import AsyncStorage from '@react-native-async-storage/async-storage' import { Linking, Platform } from 'react-native' import { apiRequest, awaitInitialization, downloadBytes, downloadText, 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' 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 } 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 UPDATE_CACHE_TTL_MS = 30 * 60 * 1000 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) } 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 } 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 = { 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() 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(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, }) const normalized: AppUpdateInfo = { ...raw, downloadUrl: normalizeDownloadUrl(raw.downloadUrl), sha256: raw.sha256 ?? raw.apkHash ?? undefined, } await writeUpdateCache(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) 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, ) }, /** Checks only the requested app/buz entry and its common dependency closure. */ async checkPluginRelease(targetModuleId: string): Promise { await awaitInitialization() 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, 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, }) }) return planReleaseSet({ installed, candidates, nativeApiLevel: await NativeBundle.getNativeApiLevel(), appVersion: getAppVersionName() ?? '0.0.0', releaseId: raw.releaseId, }) }, /** 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 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(), appVersion: getAppVersionName() ?? '0.0.0', releaseId: plan.releaseId, }) if (!verifiedPlan) return null const nativeModules: NativeReleaseModule[] = [] for (const module of verifiedPlan.modules) { const source = await downloadText(module.downloadUrl, { signal: options.signal, onProgress: progress => options.onProgress?.(module.moduleId, progress), }) assertSha256(source, module.sha256, `${module.moduleId} bundle`) nativeModules.push({ moduleId: module.moduleId, type: module.type, version: module.version, source, 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 onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void } = {}, ): Promise { const plan = await UpdateSDK.checkPluginRelease(targetModuleId) 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 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() }, getAppVersionCode, getAppVersionName, _devSetAppVersion(versionCode: number, versionName?: string): void { _devSetAppVersion(versionCode, versionName) }, }