import AsyncStorage from '@react-native-async-storage/async-storage' import { Linking, Platform } from 'react-native' import { apiRequest, awaitInitialization, bytesToBase64, downloadBytes, getConfig, getDeviceInfo, getUserId, sha256Hex, type DownloadProgress, } from '@xuqm/rn-common' import { NativeBundle, type NativeReleaseModule } from './NativeBundle' import { NativeAppUpdate } from './NativeAppUpdate' import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion' import { planReleaseSet, type InstalledPluginModule, type PluginModuleType, type PluginReleaseCandidate, type ReleaseSetPlan, } from './releaseSet' import { shouldSkipUpdateForLogin } from './updateLoginPolicy' export interface PluginRegistration { moduleId: string type: PluginModuleType appVersionRange: string builtAgainstNativeBaselineId?: string commonVersionRange?: string minNativeApiLevel?: number } export interface AppUpdateInfo { needsUpdate: boolean versionName?: string versionCode?: number downloadUrl?: string changeLog?: string forceUpdate?: boolean appStoreUrl?: string marketUrl?: string requiresLogin?: boolean alreadyDownloaded?: boolean sha256?: string skippedReason?: 'LOGIN_REQUIRED' } export type UpdateDownloadProgress = DownloadProgress export type StartupUpdateResult = | { kind: 'app'; update: AppUpdateInfo } | { kind: 'plugins'; plan: ReleaseSetPlan } | { kind: 'none' } export class StaleReleaseSetError extends Error { readonly name = 'StaleReleaseSetError' constructor(public readonly moduleId: string) { super(`[UpdateSDK] Plugin state changed after update check: ${moduleId}. Check again.`) } } interface RawAppUpdateInfo extends Omit { sha256?: string apkHash?: string | null } interface RawPluginReleaseCandidate { moduleId: string type: PluginModuleType version?: string latestVersion?: string downloadUrl: string sha256: string appVersionRange: string builtAgainstNativeBaselineId?: string commonVersionRange?: string minNativeApiLevel?: number note?: string forceUpdate?: boolean changeLog?: string } interface RawPluginReleaseSet { releaseId?: string modules?: RawPluginReleaseCandidate[] candidates?: RawPluginReleaseCandidate[] } const pluginRegistry = new Map() const confirmedReleaseModules = new Set() const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache' const UPDATE_IGNORED_VERSION_KEY = 'xuqm_update_ignored_version_code' const QUARANTINED_RELEASES_KEY = 'xuqm_update_quarantined_releases' const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000 let sessionGeneration = 0 let sessionNonce = `${Date.now()}-${Math.random().toString(36).slice(2)}` let automaticLoginCheckGeneration = -1 let activeSessionAbort = new AbortController() const automaticResultListeners = new Set<(result: StartupUpdateResult) => void>() function sessionCacheKey(base: string): string { const userId = getUserId()?.trim() return userId ? `${base}:user:${userId}:session:${sessionNonce}` : `${base}:anonymous` } function currentSessionSignal(signal?: AbortSignal): AbortSignal { return signal ? AbortSignal.any([signal, activeSessionAbort.signal]) : activeSessionAbort.signal } function isLoginRequiredWithoutSession(): boolean { return shouldSkipUpdateForLogin(getConfig().updateRequiresLogin, getUserId()) } async function clearSessionUpdateState(): Promise { const previousCacheKey = sessionCacheKey(UPDATE_APP_CACHE_KEY) activeSessionAbort.abort() activeSessionAbort = new AbortController() sessionGeneration += 1 sessionNonce = `${Date.now()}-${Math.random().toString(36).slice(2)}` automaticLoginCheckGeneration = -1 await AsyncStorage.removeItem(previousCacheKey).catch(() => undefined) } async function getIgnoredVersionCode(): Promise { const raw = await AsyncStorage.getItem(UPDATE_IGNORED_VERSION_KEY).catch(() => null) if (!raw) return null const value = Number.parseInt(raw, 10) return Number.isFinite(value) ? value : null } 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 } return Date.now() - cached.ts < UPDATE_CACHE_TTL_MS ? cached.data : null } catch { return null } } async function writeUpdateCache(key: string, data: T): Promise { await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })).catch(() => undefined) } async function readQuarantinedReleases(): Promise> { try { const raw = await AsyncStorage.getItem(QUARANTINED_RELEASES_KEY) return raw ? (JSON.parse(raw) as Record) : {} } catch { return {} } } async function quarantineRelease(releaseId: string, moduleIds: readonly string[]): Promise { const quarantined = await readQuarantinedReleases() for (const moduleId of moduleIds) quarantined[moduleId] = releaseId await AsyncStorage.setItem(QUARANTINED_RELEASES_KEY, JSON.stringify(quarantined)).catch( () => undefined, ) } async function clearUpdateStorage(): Promise { const keys = await AsyncStorage.getAllKeys().catch(() => []) const updateKeys = keys.filter(key => key.startsWith('xuqm_update_')) if (updateKeys.length > 0) await AsyncStorage.removeMany(updateKeys) } 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 { // Preserve a non-standard server URL; the download layer reports a deterministic error. } } return rawUrl } function requireRegistration(moduleId: string): PluginRegistration { const registration = pluginRegistry.get(moduleId) if (!registration) { throw new Error( `[UpdateSDK] Plugin "${moduleId}" is not registered. Register every common/app/buz module once.`, ) } return registration } function registeredNativeBaselineId( registrations: readonly PluginRegistration[], ): string | undefined { const baselines = new Set( registrations .map(registration => registration.builtAgainstNativeBaselineId?.trim()) .filter((value): value is string => Boolean(value)), ) if (baselines.size > 1) { throw new Error('[UpdateSDK] Registered plugins disagree on native baseline') } return baselines.values().next().value } async function installedModule(registration: PluginRegistration): Promise { const state = await NativeBundle.getState(registration.moduleId) return { moduleId: registration.moduleId, type: registration.type, version: state.activeVersion ?? '0.0.0', appVersionRange: state.activeAppVersionRange ?? registration.appVersionRange, builtAgainstNativeBaselineId: state.activeBuiltAgainstNativeBaselineId ?? registration.builtAgainstNativeBaselineId, commonVersionRange: state.activeCommonVersionRange ?? registration.commonVersionRange, minNativeApiLevel: state.activeMinNativeApiLevel ?? registration.minNativeApiLevel, } } function asCandidate(info: RawPluginReleaseCandidate): PluginReleaseCandidate { const version = info.version ?? info.latestVersion if (!version) throw new Error(`[UpdateSDK] Release candidate ${info.moduleId} has no version`) return { moduleId: info.moduleId, type: info.type, version, downloadUrl: info.downloadUrl, sha256: info.sha256, commonVersionRange: info.commonVersionRange, appVersionRange: info.appVersionRange, builtAgainstNativeBaselineId: info.builtAgainstNativeBaselineId, minNativeApiLevel: info.minNativeApiLevel, forceUpdate: info.forceUpdate, changeLog: info.changeLog, } } function assertSha256(bytes: Uint8Array | string, expected: string, label: string): void { if (!/^[a-f0-9]{64}$/i.test(expected)) { throw new Error(`[UpdateSDK] ${label} response does not contain a valid SHA-256`) } const actual = sha256Hex(bytes) if (actual.toLowerCase() !== expected.toLowerCase()) { throw new Error(`[UpdateSDK] ${label} SHA-256 mismatch: expected ${expected}, got ${actual}`) } } export const UpdateSDK = { developer: { /** Debug 开发页单次检查;不改变后续启动的默认关闭策略。 */ async checkStartupOnce(): Promise { await awaitInitialization() if (!getConfig().debug) { throw new Error('[UpdateSDK] developer.checkStartupOnce is only available in Debug.') } return UpdateSDK.checkStartupUpdate() }, /** Debug 开发页单次检查并安装目标插件。 */ async checkAndInstallPluginOnce( moduleId: string, options: { signal?: AbortSignal checkTimeoutMs?: number onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void } = {}, ): Promise { await awaitInitialization() if (!getConfig().debug) { throw new Error( '[UpdateSDK] developer.checkAndInstallPluginOnce is only available in Debug.', ) } return UpdateSDK.checkAndInstallPlugin(moduleId, options) }, }, registerPlugins(plugins: PluginRegistration[]): void { for (const plugin of plugins) UpdateSDK.registerPlugin(plugin) }, registerPlugin(plugin: PluginRegistration): void { if (!/^[a-z][a-z0-9-]*$/.test(plugin.moduleId)) { throw new Error(`[UpdateSDK] Invalid moduleId: ${plugin.moduleId}`) } if (plugin.type !== 'common' && !plugin.commonVersionRange?.trim()) { throw new Error(`[UpdateSDK] ${plugin.moduleId} must declare commonVersionRange`) } if (!plugin.appVersionRange?.trim()) { throw new Error(`[UpdateSDK] ${plugin.moduleId} must declare appVersionRange`) } const existing = pluginRegistry.get(plugin.moduleId) if (existing && JSON.stringify(existing) !== JSON.stringify(plugin)) { throw new Error(`[UpdateSDK] Conflicting registration for ${plugin.moduleId}`) } pluginRegistry.set(plugin.moduleId, { ...plugin }) }, getRegisteredPlugins(): PluginRegistration[] { return [...pluginRegistry.values()].map(plugin => ({ ...plugin })) }, async checkAppUpdate(bypassIgnore = false): Promise { await awaitInitialization() if (isLoginRequiredWithoutSession()) { return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' } } const applyIgnore = async (info: AppUpdateInfo): Promise => { if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info const ignored = await getIgnoredVersionCode() return ignored !== null && info.versionCode === ignored ? { ...info, needsUpdate: false } : info } if (!bypassIgnore) { const cached = await readUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY)) if (cached) return applyIgnore(cached) } const config = getConfig() const device = await getDeviceInfo() const params: Record = { appKey: config.appKey, platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', currentVersionCode: String(getAppVersionCode()), currentVersionName: getAppVersionName() ?? '', deviceId: device.deviceId, model: device.model, osVersion: device.osVersion, vendor: device.pushVendor, } const userId = getUserId()?.trim() if (userId) params.userId = userId if (bypassIgnore) params.bypassIgnore = 'true' const raw = await apiRequest('/api/v1/updates/app/check', { skipAuth: true, params, signal: activeSessionAbort.signal, }) const normalized: AppUpdateInfo = { ...raw, downloadUrl: normalizeDownloadUrl(raw.downloadUrl), sha256: raw.sha256 ?? raw.apkHash ?? undefined, } await writeUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY), normalized) return applyIgnore(normalized) }, async ignoreAppVersion(versionCode: number): Promise { if (Number.isFinite(versionCode)) { await AsyncStorage.setItem(UPDATE_IGNORED_VERSION_KEY, String(versionCode)) } }, clearIgnoredAppVersion(): Promise { return AsyncStorage.removeItem(UPDATE_IGNORED_VERSION_KEY) }, getIgnoredAppVersion(): Promise { return getIgnoredVersionCode() }, async openStore(appStoreUrl?: string, marketUrl?: string): Promise { const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl if (url) await Linking.openURL(url) }, async downloadApk( updateInfo: AppUpdateInfo, options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {}, ): Promise { if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl') const bytes = await downloadBytes(updateInfo.downloadUrl, { ...options, signal: currentSessionSignal(options.signal), }) if (updateInfo.sha256) assertSha256(bytes, updateInfo.sha256, 'APK') return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer }, async downloadAndInstallApp( updateInfo: AppUpdateInfo, options: { signal?: AbortSignal retryCount?: number onProgress?: (progress: UpdateDownloadProgress) => void } = {}, ): Promise { if (!updateInfo.downloadUrl || !updateInfo.versionCode) { throw new Error('[UpdateSDK] App update requires downloadUrl and versionCode') } await NativeAppUpdate.downloadAndInstall( { downloadUrl: updateInfo.downloadUrl, versionCode: updateInfo.versionCode, sha256: updateInfo.sha256, }, { ...options, signal: currentSessionSignal(options.signal) }, ) }, /** Checks only the requested app/buz entry and its common dependency closure. */ async checkPluginRelease( targetModuleId: string, options: { signal?: AbortSignal; timeoutMs?: number } = {}, ): Promise { await awaitInitialization() if (isLoginRequiredWithoutSession()) return null const target = requireRegistration(targetModuleId) if (target.type === 'common') { throw new Error( '[UpdateSDK] common is resolved as a dependency and cannot be an entry target', ) } const registrations = UpdateSDK.getRegisteredPlugins() const installed = await Promise.all( registrations.map(registration => installedModule(registration)), ) const common = registrations.find(registration => registration.type === 'common') if (!common) throw new Error('[UpdateSDK] Exactly one common module must be registered') if (registrations.filter(registration => registration.type === 'common').length !== 1) { throw new Error('[UpdateSDK] Exactly one common module must be registered') } const config = getConfig() const userId = getUserId()?.trim() const raw = await apiRequest('/api/v1/rn/release-set/check', { method: 'POST', skipAuth: true, signal: currentSessionSignal(options.signal), timeoutMs: options.timeoutMs, body: { appKey: config.appKey, targetModuleId, platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', userId: userId || undefined, installedModules: installed, }, }) const allowed = new Set([common.moduleId, targetModuleId]) const candidates = (raw.modules ?? raw.candidates ?? []).map(candidate => { if (!allowed.has(candidate.moduleId)) { throw new Error( `[UpdateSDK] Release set for ${targetModuleId} contains unrelated module ${candidate.moduleId}`, ) } const registration = requireRegistration(candidate.moduleId) return asCandidate({ ...candidate, type: registration.type, downloadUrl: normalizeDownloadUrl(candidate.downloadUrl) ?? candidate.downloadUrl, commonVersionRange: candidate.commonVersionRange ?? registration.commonVersionRange, minNativeApiLevel: candidate.minNativeApiLevel ?? registration.minNativeApiLevel, }) }) const plan = planReleaseSet({ installed, candidates, nativeApiLevel: await NativeBundle.getNativeApiLevel(), nativeBaselineId: registeredNativeBaselineId(registrations), appVersion: getAppVersionName() ?? '0.0.0', releaseId: raw.releaseId, }) if (plan && (await readQuarantinedReleases())[targetModuleId] === plan.releaseId) { return null } return plan }, /** Installs a previously checked plan without performing another network version check. */ async installPluginRelease( plan: ReleaseSetPlan, options: { signal?: AbortSignal onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void } = {}, ): Promise { const authorizedGeneration = sessionGeneration const registrations = UpdateSDK.getRegisteredPlugins() const installed = await Promise.all( registrations.map(registration => installedModule(registration)), ) for (const module of installed) { if (plan.baseVersions[module.moduleId] !== module.version) { throw new StaleReleaseSetError(module.moduleId) } } const verifiedPlan = planReleaseSet({ installed, candidates: plan.modules, nativeApiLevel: await NativeBundle.getNativeApiLevel(), nativeBaselineId: registeredNativeBaselineId(registrations), appVersion: getAppVersionName() ?? '0.0.0', releaseId: plan.releaseId, }) if (!verifiedPlan) return null const nativeModules: NativeReleaseModule[] = [] for (const module of verifiedPlan.modules) { const archive = await downloadBytes(module.downloadUrl, { signal: currentSessionSignal(options.signal), onProgress: progress => options.onProgress?.(module.moduleId, progress), }) if (authorizedGeneration !== sessionGeneration) { throw new Error('[UpdateSDK] User session changed while downloading a release.') } assertSha256(archive, module.sha256, `${module.moduleId} plugin package`) nativeModules.push({ moduleId: module.moduleId, type: module.type, version: module.version, archiveBase64: bytesToBase64(archive), sha256: module.sha256, commonVersionRange: module.commonVersionRange, appVersionRange: module.appVersionRange!, builtAgainstNativeBaselineId: module.builtAgainstNativeBaselineId, minNativeApiLevel: module.minNativeApiLevel, }) } await NativeBundle.stageReleaseSet(verifiedPlan.releaseId, nativeModules) await NativeBundle.activateReleaseSet(verifiedPlan.releaseId) confirmedReleaseModules.clear() return verifiedPlan }, /** Convenience API for automatic entry flows such as opening a buz plugin. */ async checkAndInstallPlugin( targetModuleId: string, options: { signal?: AbortSignal checkTimeoutMs?: number onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void } = {}, ): Promise { const plan = await UpdateSDK.checkPluginRelease(targetModuleId, { signal: options.signal, timeoutMs: options.checkTimeoutMs, }) return plan ? UpdateSDK.installPluginRelease(plan, options) : null }, /** Pure check: full package has priority and no download or activation happens here. */ async checkStartupUpdate(): Promise { const appUpdate = await UpdateSDK.checkAppUpdate() if (appUpdate.needsUpdate) return { kind: 'app', update: appUpdate } const app = UpdateSDK.getRegisteredPlugins().find(plugin => plugin.type === 'app') if (!app) throw new Error('[UpdateSDK] Exactly one app module must be registered') const plan = await UpdateSDK.checkPluginRelease(app.moduleId) if (!plan) return { kind: 'none' } return { kind: 'plugins', plan } }, getLaunchBundlePath(moduleId: string): Promise { return NativeBundle.getLaunchPath(moduleId) }, async confirmPluginLaunch(moduleId: string): Promise { const pending = await NativeBundle.getPendingRelease() if (!pending || !pending.modules.includes(moduleId)) return confirmedReleaseModules.add(moduleId) if (pending.modules.every(id => confirmedReleaseModules.has(id))) { await NativeBundle.confirmReleaseSet(pending.releaseId) confirmedReleaseModules.clear() } }, async confirmPendingRelease(): Promise { const pending = await NativeBundle.getPendingRelease() if (pending) await NativeBundle.confirmReleaseSet(pending.releaseId) confirmedReleaseModules.clear() }, async reportPluginLaunchFailure(moduleId: string, reason?: string): Promise { const pending = await NativeBundle.getPendingRelease() if (pending) { await quarantineRelease(pending.releaseId, pending.modules) await NativeBundle.rollbackReleaseSet(pending.releaseId) } confirmedReleaseModules.clear() if (reason) console.warn(`[UpdateSDK] Rolled back release after ${moduleId} failed: ${reason}`) }, preparePendingRelease(): Promise { confirmedReleaseModules.clear() return NativeBundle.preparePendingRelease() }, /** * 最终恢复页专用:清理所有 Update SDK 插件与更新判定状态,随后按 APK 内置版本恢复。 * 不触碰宿主登录、业务缓存、BugCollect 或签名 SDK 数据。 */ async resetToEmbedded(): Promise { activeSessionAbort.abort() activeSessionAbort = new AbortController() confirmedReleaseModules.clear() await NativeBundle.resetToEmbedded() await UpdateSDK._reconcileNativeReset() }, /** @internal common/app 初始化后补齐最小原生恢复页留下的 JS 状态清理。 */ async _reconcileNativeReset(): Promise { const [generation, confirmed] = await Promise.all([ NativeBundle.getResetGeneration(), NativeBundle.getConfirmedResetGeneration(), ]) if (generation <= confirmed) return await clearUpdateStorage() await NativeBundle.confirmResetGeneration(generation) }, getAppVersionCode, getAppVersionName, _devSetAppVersion(versionCode: number, versionName?: string): void { _devSetAppVersion(versionCode, versionName) }, /** 订阅登录后自动补检结果;SDK 只返回数据,不展示更新 UI。 */ onAutomaticCheck(listener: (result: StartupUpdateResult) => void): () => void { automaticResultListeners.add(listener) return () => automaticResultListeners.delete(listener) }, /** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */ async _handleSessionChange(loggedIn: boolean): Promise { await clearSessionUpdateState() if (!loggedIn || !getConfig().updateRequiresLogin || getConfig().debug) return if (automaticLoginCheckGeneration === sessionGeneration) return automaticLoginCheckGeneration = sessionGeneration try { const result = await UpdateSDK.checkStartupUpdate() for (const listener of automaticResultListeners) listener(result) } catch { // 后台补检失败必须与宿主登录隔离。 } }, }