XuqmGroup-RNSDK/packages/update/src/UpdateSDK.ts

766 行
27 KiB
TypeScript

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 {
verifySignedPluginManifest,
type SignedPluginManifest,
} from '@xuqm/rn-common/internal-security'
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'
import { assertUpdateEnabled, UpdateDisabledError } from './updateAvailability'
import { createReleaseSetCheckBody } from './releaseSetRequest'
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<AppUpdateInfo, 'sha256'> {
sha256?: string
apkHash?: string | null
}
interface RawPluginReleaseCandidate {
appKey: string
packageName: string
platform: 'ANDROID' | 'IOS'
moduleId: string
type: PluginModuleType
version: string
downloadUrl: string
sha256: string
appVersionRange: string
builtAgainstNativeBaselineId: string
buildId: string
bundleFormat: 'hermes-bytecode' | 'javascript'
bundleSha256: string
commonVersionRange?: string | null
minNativeApiLevel: number
keyId: string
signature: string
note?: string
forceUpdate?: boolean
changeLog?: string
}
interface RawPluginReleaseSet {
releaseId?: string
modules?: RawPluginReleaseCandidate[]
candidates?: RawPluginReleaseCandidate[]
}
const pluginRegistry = new Map<string, PluginRegistration>()
const confirmedReleaseModules = new Set<string>()
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())
}
function requireUpdateEnabled(): void {
assertUpdateEnabled(getConfig().updateEnabled)
}
function requireAppVersion(operation: string): string {
const appVersion = getAppVersionName()?.trim()
if (!appVersion) {
throw new Error(`[UpdateSDK] Full-app version is required for plugin ${operation}.`)
}
return appVersion
}
async function clearSessionUpdateState(): Promise<void> {
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<number | null> {
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<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 }
return Date.now() - cached.ts < UPDATE_CACHE_TTL_MS ? cached.data : null
} catch {
return null
}
}
async function writeUpdateCache<T>(key: string, data: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })).catch(() => undefined)
}
async function readQuarantinedReleases(): Promise<Record<string, string>> {
try {
const raw = await AsyncStorage.getItem(QUARANTINED_RELEASES_KEY)
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
} catch {
return {}
}
}
async function quarantineRelease(releaseId: string, moduleIds: readonly string[]): Promise<void> {
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<void> {
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<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,
}
}
function asCandidate(info: RawPluginReleaseCandidate): PluginReleaseCandidate {
return {
appKey: info.appKey,
packageName: info.packageName,
platform: info.platform,
moduleId: info.moduleId,
type: info.type,
version: info.version,
buildId: info.buildId,
bundleFormat: info.bundleFormat,
bundleSha256: info.bundleSha256,
keyId: info.keyId,
signature: info.signature,
downloadUrl: info.downloadUrl,
sha256: info.sha256,
commonVersionRange: info.commonVersionRange ?? undefined,
appVersionRange: info.appVersionRange,
builtAgainstNativeBaselineId: info.builtAgainstNativeBaselineId,
minNativeApiLevel: info.minNativeApiLevel,
forceUpdate: info.forceUpdate,
changeLog: info.changeLog,
}
}
function signedManifest(candidate: PluginReleaseCandidate): SignedPluginManifest {
const manifest: SignedPluginManifest = {
appKey: candidate.appKey,
packageName: candidate.packageName,
platform: candidate.platform,
moduleId: candidate.moduleId,
type: candidate.type,
version: candidate.version,
appVersionRange: candidate.appVersionRange ?? '',
builtAgainstNativeBaselineId: candidate.builtAgainstNativeBaselineId ?? '',
buildId: candidate.buildId,
bundleFormat: candidate.bundleFormat,
minNativeApiLevel: candidate.minNativeApiLevel ?? 0,
bundleSha256: candidate.bundleSha256,
archiveSha256: candidate.sha256,
}
// Config V2 canonical 规则省略 null;common 不允许构造不存在的依赖范围字段。
if (candidate.type !== 'common' && candidate.commonVersionRange) {
manifest.commonVersionRange = candidate.commonVersionRange
}
return manifest
}
function verifyCandidate(
candidate: PluginReleaseCandidate,
expected: { appKey: string; packageName: string; platform: 'ANDROID' | 'IOS' },
): void {
if (
candidate.appKey !== expected.appKey ||
candidate.packageName !== expected.packageName ||
candidate.platform !== expected.platform
) {
throw new Error(
`[UpdateSDK] Signed plugin identity does not match this host: ${candidate.moduleId}`,
)
}
verifySignedPluginManifest(signedManifest(candidate), candidate.keyId, candidate.signature)
}
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<StartupUpdateResult> {
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<ReleaseSetPlan | null> {
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<AppUpdateInfo> {
await awaitInitialization()
requireUpdateEnabled()
if (isLoginRequiredWithoutSession()) {
return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' }
}
const applyIgnore = async (info: AppUpdateInfo): Promise<AppUpdateInfo> => {
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<AppUpdateInfo>(sessionCacheKey(UPDATE_APP_CACHE_KEY))
if (cached) return applyIgnore(cached)
}
const config = getConfig()
const device = await getDeviceInfo()
const params: Record<string, string> = {
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<RawAppUpdateInfo>('/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<void> {
if (Number.isFinite(versionCode)) {
await AsyncStorage.setItem(UPDATE_IGNORED_VERSION_KEY, String(versionCode))
}
},
clearIgnoredAppVersion(): Promise<void> {
return AsyncStorage.removeItem(UPDATE_IGNORED_VERSION_KEY)
},
getIgnoredAppVersion(): Promise<number | null> {
return getIgnoredVersionCode()
},
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
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<ArrayBuffer> {
await awaitInitialization()
requireUpdateEnabled()
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<void> {
await awaitInitialization()
requireUpdateEnabled()
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<ReleaseSetPlan | null> {
await awaitInitialization()
requireUpdateEnabled()
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()
if (!config.packageName) {
throw new Error('[UpdateSDK] Signed host packageName is required for plugin update checks.')
}
const platform = Platform.OS === 'android' ? 'ANDROID' : 'IOS'
const userId = getUserId()?.trim()
const nativeApiLevel = await NativeBundle.getNativeApiLevel()
const nativeBaselineId = registeredNativeBaselineId(registrations) ?? ''
const appVersion = requireAppVersion('update checks')
const raw = await apiRequest<RawPluginReleaseSet>('/api/v1/rn/release-set/check', {
method: 'POST',
skipAuth: true,
signal: currentSessionSignal(options.signal),
timeoutMs: options.timeoutMs,
body: createReleaseSetCheckBody({
appKey: config.appKey,
targetModuleId,
platform,
userId: userId || undefined,
appVersion,
nativeApiLevel,
nativeBaselineId,
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)
const normalized = asCandidate({
...candidate,
downloadUrl: normalizeDownloadUrl(candidate.downloadUrl) ?? candidate.downloadUrl,
})
if (normalized.type !== registration.type) {
throw new Error(
`[UpdateSDK] Signed plugin type does not match registration: ${candidate.moduleId}`,
)
}
verifyCandidate(normalized, {
appKey: config.appKey,
packageName: config.packageName,
platform,
})
return normalized
})
const plan = planReleaseSet({
installed,
candidates,
nativeApiLevel,
nativeBaselineId,
appVersion,
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<ReleaseSetPlan | null> {
await awaitInitialization()
requireUpdateEnabled()
const authorizedGeneration = sessionGeneration
const config = getConfig()
if (!config.packageName) {
throw new Error('[UpdateSDK] Signed host packageName is required for plugin installation.')
}
const platform = Platform.OS === 'android' ? 'ANDROID' : 'IOS'
for (const candidate of plan.modules) {
verifyCandidate(candidate, {
appKey: config.appKey,
packageName: config.packageName,
platform,
})
}
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: requireAppVersion('installation'),
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,
buildId: module.buildId,
bundleFormat: module.bundleFormat,
bundleSha256: module.bundleSha256,
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<ReleaseSetPlan | null> {
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<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()
}
},
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 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<string[]> {
confirmedReleaseModules.clear()
return NativeBundle.preparePendingRelease()
},
/**
* 最终恢复页专用:清理所有 Update SDK 插件与更新判定状态,随后按 APK 内置版本恢复。
* 不触碰宿主登录、业务缓存、BugCollect 或签名 SDK 数据。
*/
async resetToEmbedded(): Promise<void> {
activeSessionAbort.abort()
activeSessionAbort = new AbortController()
confirmedReleaseModules.clear()
await NativeBundle.resetToEmbedded()
await UpdateSDK._reconcileNativeReset()
},
/** @internal common/app 初始化后补齐最小原生恢复页留下的 JS 状态清理。 */
async _reconcileNativeReset(): Promise<void> {
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<void> {
await clearSessionUpdateState()
if (
!loggedIn ||
!getConfig().updateEnabled ||
!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 {
// 后台补检失败必须与宿主登录隔离。
}
},
}
export { UpdateDisabledError }