2026-04-24 16:16:31 +08:00
|
|
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
|
|
|
import { Linking, Platform } from 'react-native'
|
2026-07-17 13:50:30 +08:00
|
|
|
import {
|
|
|
|
|
apiRequest,
|
|
|
|
|
awaitInitialization,
|
2026-07-20 19:31:43 +08:00
|
|
|
bytesToBase64,
|
2026-07-17 13:50:30 +08:00
|
|
|
downloadBytes,
|
|
|
|
|
getConfig,
|
|
|
|
|
getDeviceInfo,
|
|
|
|
|
getUserId,
|
|
|
|
|
sha256Hex,
|
|
|
|
|
type DownloadProgress,
|
|
|
|
|
} from '@xuqm/rn-common'
|
|
|
|
|
import { NativeBundle, type NativeReleaseModule } from './NativeBundle'
|
|
|
|
|
import { NativeAppUpdate } from './NativeAppUpdate'
|
2026-04-24 16:16:31 +08:00
|
|
|
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
|
2026-07-17 13:50:30 +08:00
|
|
|
import {
|
|
|
|
|
planReleaseSet,
|
|
|
|
|
type InstalledPluginModule,
|
|
|
|
|
type PluginModuleType,
|
|
|
|
|
type PluginReleaseCandidate,
|
|
|
|
|
type ReleaseSetPlan,
|
|
|
|
|
} from './releaseSet'
|
2026-04-24 16:16:31 +08:00
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
export interface PluginRegistration {
|
2026-04-24 16:16:31 +08:00
|
|
|
moduleId: string
|
2026-07-17 13:50:30 +08:00
|
|
|
type: PluginModuleType
|
|
|
|
|
appVersionRange: string
|
|
|
|
|
builtAgainstNativeBaselineId?: string
|
|
|
|
|
commonVersionRange?: string
|
|
|
|
|
minNativeApiLevel?: number
|
2026-06-15 10:57:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
export interface AppUpdateInfo {
|
|
|
|
|
needsUpdate: boolean
|
|
|
|
|
versionName?: string
|
|
|
|
|
versionCode?: number
|
|
|
|
|
downloadUrl?: string
|
|
|
|
|
changeLog?: string
|
|
|
|
|
forceUpdate?: boolean
|
|
|
|
|
appStoreUrl?: string
|
|
|
|
|
marketUrl?: string
|
2026-06-15 01:44:20 +08:00
|
|
|
requiresLogin?: boolean
|
|
|
|
|
alreadyDownloaded?: boolean
|
2026-07-17 13:50:30 +08:00
|
|
|
sha256?: string
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
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.`)
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
interface RawAppUpdateInfo extends Omit<AppUpdateInfo, 'sha256'> {
|
|
|
|
|
sha256?: string
|
|
|
|
|
apkHash?: string | null
|
2026-06-16 12:10:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
interface RawPluginReleaseCandidate {
|
2026-04-24 16:16:31 +08:00
|
|
|
moduleId: string
|
2026-07-17 13:50:30 +08:00
|
|
|
type: PluginModuleType
|
|
|
|
|
version?: string
|
|
|
|
|
latestVersion?: string
|
|
|
|
|
downloadUrl: string
|
|
|
|
|
sha256: string
|
|
|
|
|
appVersionRange: string
|
|
|
|
|
builtAgainstNativeBaselineId?: string
|
|
|
|
|
commonVersionRange?: string
|
|
|
|
|
minNativeApiLevel?: number
|
|
|
|
|
note?: string
|
|
|
|
|
forceUpdate?: boolean
|
|
|
|
|
changeLog?: string
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
interface RawPluginReleaseSet {
|
|
|
|
|
releaseId?: string
|
|
|
|
|
modules?: RawPluginReleaseCandidate[]
|
|
|
|
|
candidates?: RawPluginReleaseCandidate[]
|
|
|
|
|
}
|
2026-06-16 12:10:28 +08:00
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
const pluginRegistry = new Map<string, PluginRegistration>()
|
|
|
|
|
const confirmedReleaseModules = new Set<string>()
|
2026-06-16 12:10:28 +08:00
|
|
|
const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache'
|
2026-06-25 17:50:29 +08:00
|
|
|
const UPDATE_IGNORED_VERSION_KEY = 'xuqm_update_ignored_version_code'
|
2026-07-17 13:50:30 +08:00
|
|
|
const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000
|
2026-06-25 17:50:29 +08:00
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
async function getIgnoredVersionCode(): Promise<number | null> {
|
2026-06-25 17:50:29 +08:00
|
|
|
const raw = await AsyncStorage.getItem(UPDATE_IGNORED_VERSION_KEY).catch(() => null)
|
|
|
|
|
if (!raw) return null
|
2026-07-17 13:50:30 +08:00
|
|
|
const value = Number.parseInt(raw, 10)
|
|
|
|
|
return Number.isFinite(value) ? value : null
|
2026-06-25 17:50:29 +08:00
|
|
|
}
|
2026-06-16 12:10:28 +08:00
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
async function readUpdateCache<T>(key: string): Promise<T | null> {
|
2026-06-16 12:10:28 +08:00
|
|
|
try {
|
|
|
|
|
const raw = await AsyncStorage.getItem(key)
|
|
|
|
|
if (!raw) return null
|
|
|
|
|
const cached = JSON.parse(raw) as { ts: number; data: T }
|
2026-07-17 13:50:30 +08:00
|
|
|
return Date.now() - cached.ts < UPDATE_CACHE_TTL_MS ? cached.data : null
|
2026-06-16 12:10:28 +08:00
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
async function writeUpdateCache<T>(key: string, data: T): Promise<void> {
|
|
|
|
|
await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })).catch(() => undefined)
|
2026-06-15 10:57:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
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}`
|
|
|
|
|
}
|
2026-07-17 13:50:30 +08:00
|
|
|
} catch {
|
|
|
|
|
// Preserve a non-standard server URL; the download layer reports a deterministic error.
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|
|
|
|
|
return rawUrl
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
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.`,
|
|
|
|
|
)
|
2026-06-15 10:57:55 +08:00
|
|
|
}
|
2026-07-17 13:50:30 +08:00
|
|
|
return registration
|
2026-06-15 10:57:55 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-20 19:31:43 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
async function installedModule(registration: PluginRegistration): Promise<InstalledPluginModule> {
|
|
|
|
|
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,
|
2026-06-15 10:57:55 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 10:57:55 +08:00
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
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}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-15 01:44:20 +08:00
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
export const UpdateSDK = {
|
2026-06-15 10:57:55 +08:00
|
|
|
registerPlugins(plugins: PluginRegistration[]): void {
|
2026-07-17 13:50:30 +08:00
|
|
|
for (const plugin of plugins) UpdateSDK.registerPlugin(plugin)
|
2026-04-24 16:16:31 +08:00
|
|
|
},
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
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 })
|
2026-06-15 10:57:55 +08:00
|
|
|
},
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
getRegisteredPlugins(): PluginRegistration[] {
|
|
|
|
|
return [...pluginRegistry.values()].map(plugin => ({ ...plugin }))
|
2026-06-15 01:44:20 +08:00
|
|
|
},
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateInfo> {
|
|
|
|
|
await awaitInitialization()
|
2026-06-25 17:50:29 +08:00
|
|
|
const applyIgnore = async (info: AppUpdateInfo): Promise<AppUpdateInfo> => {
|
|
|
|
|
if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info
|
2026-07-17 13:50:30 +08:00
|
|
|
const ignored = await getIgnoredVersionCode()
|
|
|
|
|
return ignored !== null && info.versionCode === ignored
|
2026-06-25 17:50:29 +08:00
|
|
|
? { ...info, needsUpdate: false }
|
|
|
|
|
: info
|
|
|
|
|
}
|
2026-06-16 12:10:28 +08:00
|
|
|
if (!bypassIgnore) {
|
2026-07-17 13:50:30 +08:00
|
|
|
const cached = await readUpdateCache<AppUpdateInfo>(UPDATE_APP_CACHE_KEY)
|
2026-06-25 17:50:29 +08:00
|
|
|
if (cached) return applyIgnore(cached)
|
2026-06-16 12:10:28 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
const config = getConfig()
|
2026-07-17 13:50:30 +08:00
|
|
|
const device = await getDeviceInfo()
|
2026-05-08 12:00:34 +08:00
|
|
|
const params: Record<string, string> = {
|
2026-07-17 13:50:30 +08:00
|
|
|
appKey: config.appKey,
|
|
|
|
|
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
2026-06-15 10:57:55 +08:00
|
|
|
currentVersionCode: String(getAppVersionCode()),
|
2026-06-19 01:27:56 +08:00
|
|
|
currentVersionName: getAppVersionName() ?? '',
|
2026-07-17 13:50:30 +08:00
|
|
|
deviceId: device.deviceId,
|
|
|
|
|
model: device.model,
|
|
|
|
|
osVersion: device.osVersion,
|
|
|
|
|
vendor: device.pushVendor,
|
2026-05-08 12:00:34 +08:00
|
|
|
}
|
2026-06-15 10:57:55 +08:00
|
|
|
const userId = getUserId()?.trim()
|
2026-06-15 01:44:20 +08:00
|
|
|
if (userId) params.userId = userId
|
|
|
|
|
if (bypassIgnore) params.bypassIgnore = 'true'
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', {
|
2026-04-24 16:16:31 +08:00
|
|
|
skipAuth: true,
|
2026-05-08 12:00:34 +08:00
|
|
|
params,
|
2026-04-24 16:16:31 +08:00
|
|
|
})
|
2026-07-17 13:50:30 +08:00
|
|
|
const normalized: AppUpdateInfo = {
|
|
|
|
|
...raw,
|
|
|
|
|
downloadUrl: normalizeDownloadUrl(raw.downloadUrl),
|
|
|
|
|
sha256: raw.sha256 ?? raw.apkHash ?? undefined,
|
|
|
|
|
}
|
|
|
|
|
await writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized)
|
2026-06-25 17:50:29 +08:00
|
|
|
return applyIgnore(normalized)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async ignoreAppVersion(versionCode: number): Promise<void> {
|
2026-07-17 13:50:30 +08:00
|
|
|
if (Number.isFinite(versionCode)) {
|
|
|
|
|
await AsyncStorage.setItem(UPDATE_IGNORED_VERSION_KEY, String(versionCode))
|
|
|
|
|
}
|
2026-06-25 17:50:29 +08:00
|
|
|
},
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
clearIgnoredAppVersion(): Promise<void> {
|
|
|
|
|
return AsyncStorage.removeItem(UPDATE_IGNORED_VERSION_KEY)
|
2026-06-25 17:50:29 +08:00
|
|
|
},
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
getIgnoredAppVersion(): Promise<number | null> {
|
|
|
|
|
return getIgnoredVersionCode()
|
2026-04-24 16:16:31 +08:00
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
|
|
|
|
|
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
|
|
|
|
|
if (url) await Linking.openURL(url)
|
|
|
|
|
},
|
|
|
|
|
|
2026-06-16 12:10:28 +08:00
|
|
|
async downloadApk(
|
|
|
|
|
updateInfo: AppUpdateInfo,
|
2026-07-17 13:50:30 +08:00
|
|
|
options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {},
|
2026-06-16 12:10:28 +08:00
|
|
|
): Promise<ArrayBuffer> {
|
2026-07-17 13:50:30 +08:00
|
|
|
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
|
2026-06-16 12:10:28 +08:00
|
|
|
},
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
async downloadAndInstallApp(
|
|
|
|
|
updateInfo: AppUpdateInfo,
|
|
|
|
|
options: {
|
|
|
|
|
signal?: AbortSignal
|
|
|
|
|
retryCount?: number
|
2026-06-16 12:10:28 +08:00
|
|
|
onProgress?: (progress: UpdateDownloadProgress) => void
|
2026-07-17 13:50:30 +08:00
|
|
|
} = {},
|
2026-06-15 10:57:55 +08:00
|
|
|
): Promise<void> {
|
2026-07-17 13:50:30 +08:00
|
|
|
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,
|
|
|
|
|
)
|
2026-06-15 10:57:55 +08:00
|
|
|
},
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
/** Checks only the requested app/buz entry and its common dependency closure. */
|
2026-07-20 19:31:43 +08:00
|
|
|
async checkPluginRelease(
|
|
|
|
|
targetModuleId: string,
|
|
|
|
|
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
|
|
|
|
): Promise<ReleaseSetPlan | null> {
|
2026-07-17 13:50:30 +08:00
|
|
|
await awaitInitialization()
|
|
|
|
|
const target = requireRegistration(targetModuleId)
|
|
|
|
|
if (target.type === 'common') {
|
2026-04-24 16:16:31 +08:00
|
|
|
throw new Error(
|
2026-07-17 13:50:30 +08:00
|
|
|
'[UpdateSDK] common is resolved as a dependency and cannot be an entry target',
|
2026-04-24 16:16:31 +08:00
|
|
|
)
|
|
|
|
|
}
|
2026-07-17 13:50:30 +08:00
|
|
|
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')
|
|
|
|
|
}
|
2026-06-15 10:57:55 +08:00
|
|
|
const config = getConfig()
|
|
|
|
|
const userId = getUserId()?.trim()
|
2026-07-17 13:50:30 +08:00
|
|
|
const raw = await apiRequest<RawPluginReleaseSet>('/api/v1/rn/release-set/check', {
|
|
|
|
|
method: 'POST',
|
2026-04-24 16:16:31 +08:00
|
|
|
skipAuth: true,
|
2026-07-20 19:31:43 +08:00
|
|
|
signal: options.signal,
|
|
|
|
|
timeoutMs: options.timeoutMs,
|
2026-07-17 13:50:30 +08:00
|
|
|
body: {
|
|
|
|
|
appKey: config.appKey,
|
|
|
|
|
targetModuleId,
|
|
|
|
|
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
|
|
|
|
userId: userId || undefined,
|
|
|
|
|
installedModules: installed,
|
|
|
|
|
},
|
2026-04-24 16:16:31 +08:00
|
|
|
})
|
2026-07-17 13:50:30 +08:00
|
|
|
const allowed = new Set([common.moduleId, targetModuleId])
|
|
|
|
|
const candidates = (raw.modules ?? raw.candidates ?? []).map(candidate => {
|
|
|
|
|
if (!allowed.has(candidate.moduleId)) {
|
2026-06-16 13:37:45 +08:00
|
|
|
throw new Error(
|
2026-07-17 13:50:30 +08:00
|
|
|
`[UpdateSDK] Release set for ${targetModuleId} contains unrelated module ${candidate.moduleId}`,
|
2026-06-16 13:37:45 +08:00
|
|
|
)
|
|
|
|
|
}
|
2026-07-17 13:50:30 +08:00
|
|
|
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(),
|
2026-07-20 19:31:43 +08:00
|
|
|
nativeBaselineId: registeredNativeBaselineId(registrations),
|
2026-07-17 13:50:30 +08:00
|
|
|
appVersion: getAppVersionName() ?? '0.0.0',
|
|
|
|
|
releaseId: raw.releaseId,
|
|
|
|
|
})
|
|
|
|
|
},
|
2026-06-16 13:37:45 +08:00
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
/** 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<ReleaseSetPlan | null> {
|
|
|
|
|
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)
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|
2026-07-17 13:50:30 +08:00
|
|
|
const verifiedPlan = planReleaseSet({
|
|
|
|
|
installed,
|
|
|
|
|
candidates: plan.modules,
|
|
|
|
|
nativeApiLevel: await NativeBundle.getNativeApiLevel(),
|
2026-07-20 19:31:43 +08:00
|
|
|
nativeBaselineId: registeredNativeBaselineId(registrations),
|
2026-07-17 13:50:30 +08:00
|
|
|
appVersion: getAppVersionName() ?? '0.0.0',
|
|
|
|
|
releaseId: plan.releaseId,
|
|
|
|
|
})
|
|
|
|
|
if (!verifiedPlan) return null
|
2026-04-24 16:16:31 +08:00
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
const nativeModules: NativeReleaseModule[] = []
|
|
|
|
|
for (const module of verifiedPlan.modules) {
|
2026-07-20 19:31:43 +08:00
|
|
|
const archive = await downloadBytes(module.downloadUrl, {
|
2026-07-17 13:50:30 +08:00
|
|
|
signal: options.signal,
|
|
|
|
|
onProgress: progress => options.onProgress?.(module.moduleId, progress),
|
|
|
|
|
})
|
2026-07-20 19:31:43 +08:00
|
|
|
assertSha256(archive, module.sha256, `${module.moduleId} plugin package`)
|
2026-07-17 13:50:30 +08:00
|
|
|
nativeModules.push({
|
|
|
|
|
moduleId: module.moduleId,
|
|
|
|
|
type: module.type,
|
|
|
|
|
version: module.version,
|
2026-07-20 19:31:43 +08:00
|
|
|
archiveBase64: bytesToBase64(archive),
|
2026-07-17 13:50:30 +08:00
|
|
|
sha256: module.sha256,
|
|
|
|
|
commonVersionRange: module.commonVersionRange,
|
|
|
|
|
appVersionRange: module.appVersionRange!,
|
|
|
|
|
builtAgainstNativeBaselineId: module.builtAgainstNativeBaselineId,
|
|
|
|
|
minNativeApiLevel: module.minNativeApiLevel,
|
|
|
|
|
})
|
2026-06-15 10:57:55 +08:00
|
|
|
}
|
2026-07-17 13:50:30 +08:00
|
|
|
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
|
2026-07-20 19:31:43 +08:00
|
|
|
checkTimeoutMs?: number
|
2026-07-17 13:50:30 +08:00
|
|
|
onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void
|
|
|
|
|
} = {},
|
|
|
|
|
): Promise<ReleaseSetPlan | null> {
|
2026-07-20 19:31:43 +08:00
|
|
|
const plan = await UpdateSDK.checkPluginRelease(targetModuleId, {
|
|
|
|
|
signal: options.signal,
|
|
|
|
|
timeoutMs: options.checkTimeoutMs,
|
|
|
|
|
})
|
2026-07-17 13:50:30 +08:00
|
|
|
return plan ? UpdateSDK.installPluginRelease(plan, options) : null
|
|
|
|
|
},
|
2026-06-15 01:44:20 +08:00
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
/** Pure check: full package has priority and no download or activation happens here. */
|
|
|
|
|
async checkStartupUpdate(): Promise<StartupUpdateResult> {
|
|
|
|
|
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<string> {
|
|
|
|
|
return NativeBundle.getLaunchPath(moduleId)
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async confirmPluginLaunch(moduleId: string): Promise<void> {
|
|
|
|
|
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()
|
2026-06-15 10:57:55 +08:00
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
},
|
|
|
|
|
|
2026-07-17 13:50:30 +08:00
|
|
|
async confirmPendingRelease(): Promise<void> {
|
|
|
|
|
const pending = await NativeBundle.getPendingRelease()
|
|
|
|
|
if (pending) await NativeBundle.confirmReleaseSet(pending.releaseId)
|
|
|
|
|
confirmedReleaseModules.clear()
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
async reportPluginLaunchFailure(moduleId: string, reason?: string): Promise<void> {
|
|
|
|
|
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<string[]> {
|
|
|
|
|
confirmedReleaseModules.clear()
|
|
|
|
|
return NativeBundle.preparePendingRelease()
|
|
|
|
|
},
|
2026-06-15 01:44:20 +08:00
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
getAppVersionCode,
|
|
|
|
|
getAppVersionName,
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
|
|
|
_devSetAppVersion(versionCode: number, versionName?: string): void {
|
|
|
|
|
_devSetAppVersion(versionCode, versionName)
|
|
|
|
|
},
|
2026-04-24 16:16:31 +08:00
|
|
|
}
|