2026-04-24 16:16:31 +08:00
|
|
|
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
|
|
|
|
import { Linking, Platform } from 'react-native'
|
2026-06-15 10:57:55 +08:00
|
|
|
|
import { apiRequest, getConfig, getUserId, _registerUserInfoHandler } from '@xuqm/rn-common'
|
2026-04-24 16:16:31 +08:00
|
|
|
|
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
// ─── Types (public) ────────────────────────────────────────────────────────────
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
/** 插件注册(版本号由 SDK 自动获取,不需要 App 传入) */
|
|
|
|
|
|
export interface PluginRegistration {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
moduleId: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
/** @deprecated 使用 PluginRegistration(移除 version 字段) */
|
|
|
|
|
|
export interface PluginMeta extends PluginRegistration {
|
|
|
|
|
|
version?: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
|
|
apkHash?: string | null
|
2026-04-24 16:16:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
export interface PluginUpdateInfo {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
needsUpdate: boolean
|
|
|
|
|
|
latestVersion: string
|
2026-06-15 10:57:55 +08:00
|
|
|
|
currentVersion: string
|
2026-04-24 16:16:31 +08:00
|
|
|
|
downloadUrl: string
|
|
|
|
|
|
md5: string
|
|
|
|
|
|
minCommonVersion: string
|
|
|
|
|
|
note: string
|
2026-06-15 10:57:55 +08:00
|
|
|
|
forceUpdate?: boolean
|
|
|
|
|
|
changeLog?: string
|
2026-04-24 16:16:31 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 12:10:28 +08:00
|
|
|
|
export type UpdateDownloadProgress = {
|
|
|
|
|
|
bytesDownloaded: number
|
|
|
|
|
|
totalBytes: number
|
|
|
|
|
|
/** 0-100 */
|
|
|
|
|
|
percent: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
export type { CachedRnBundle }
|
|
|
|
|
|
interface CachedRnBundle {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
moduleId: string
|
|
|
|
|
|
version: string
|
|
|
|
|
|
md5: string
|
|
|
|
|
|
downloadedAt: string
|
|
|
|
|
|
source: string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
// ─── Internal state ────────────────────────────────────────────────────────────
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
const _pluginRegistry = new Set<string>()
|
|
|
|
|
|
|
2026-06-16 12:10:28 +08:00
|
|
|
|
// ─── 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<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 }
|
|
|
|
|
|
if (Date.now() - cached.ts < UPDATE_CACHE_TTL_MS) return cached.data
|
|
|
|
|
|
return null
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function _writeUpdateCache<T>(key: string, data: T): Promise<void> {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data }))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// cache write failure is non-fatal
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
let _writeBundleCallback: ((moduleId: string, source: string) => Promise<void>) | null = null
|
|
|
|
|
|
let _reloadBundleCallback: ((moduleId: string) => Promise<void>) | null = null
|
2026-04-24 16:16:31 +08:00
|
|
|
|
|
|
|
|
|
|
function bundleCacheKey(moduleId: string) {
|
|
|
|
|
|
return `@xuqm:bundle:${moduleId}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
async function _getCachedVersion(moduleId: string): Promise<string> {
|
|
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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}`
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
return rawUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
async function _downloadText(url: string, onProgress?: (p: number) => void): Promise<string> {
|
|
|
|
|
|
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<ArrayBuffer> {
|
|
|
|
|
|
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(),此处无需额外操作
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
// ─── UpdateSDK ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
export const UpdateSDK = {
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
// ── 宿主注入(插件化写入/重载能力)──────────────────────────────────────────
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
/**
|
2026-06-15 10:57:55 +08:00
|
|
|
|
* 注入 bundle 写入和重载回调。
|
|
|
|
|
|
* 宿主在初始化时调用(如 src/core/updater.ts),注入 BundleRuntime 能力。
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @example
|
2026-06-15 10:57:55 +08:00
|
|
|
|
* UpdateSDK.setBundleCallbacks({
|
|
|
|
|
|
* writeBundle: writeBundleFile,
|
|
|
|
|
|
* reloadBundle: loadBundle,
|
|
|
|
|
|
* })
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*/
|
2026-06-15 10:57:55 +08:00
|
|
|
|
setBundleCallbacks(callbacks: {
|
|
|
|
|
|
writeBundle: (moduleId: string, source: string) => Promise<void>
|
|
|
|
|
|
reloadBundle: (moduleId: string) => Promise<void>
|
|
|
|
|
|
}): void {
|
|
|
|
|
|
_writeBundleCallback = callbacks.writeBundle
|
|
|
|
|
|
_reloadBundleCallback = callbacks.reloadBundle
|
2026-04-24 16:16:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
// ── 插件注册 ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
/**
|
2026-06-15 10:57:55 +08:00
|
|
|
|
* 批量注册插件。版本号由 SDK 自动从本地缓存获取,不需要 App 传入。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @example
|
|
|
|
|
|
* UpdateSDK.registerPlugins([{ moduleId: 'buz1' }, { moduleId: 'buz2' }])
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*/
|
2026-06-15 10:57:55 +08:00
|
|
|
|
registerPlugins(plugins: PluginRegistration[]): void {
|
|
|
|
|
|
for (const p of plugins) {
|
|
|
|
|
|
_pluginRegistry.add(p.moduleId)
|
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-15 10:57:55 +08:00
|
|
|
|
* 注册单个插件(向后兼容;version 字段已废弃,SDK 自动获取)。
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*/
|
2026-06-15 10:57:55 +08:00
|
|
|
|
registerPlugin(meta: PluginRegistration): void {
|
|
|
|
|
|
_pluginRegistry.add(meta.moduleId)
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
getRegisteredPlugins(): string[] {
|
|
|
|
|
|
return Array.from(_pluginRegistry)
|
2026-06-15 01:44:20 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ── App 整包更新 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
async checkAppUpdate(bypassIgnore?: boolean): Promise<AppUpdateInfo> {
|
2026-06-16 12:10:28 +08:00
|
|
|
|
// Return cached result if within TTL (cache is skipped when bypassIgnore is set)
|
|
|
|
|
|
if (!bypassIgnore) {
|
|
|
|
|
|
const cached = await _readUpdateCache<AppUpdateInfo>(UPDATE_APP_CACHE_KEY)
|
|
|
|
|
|
if (cached) return cached
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
const config = getConfig()
|
2026-05-08 12:00:34 +08:00
|
|
|
|
const params: Record<string, string> = {
|
2026-06-15 10:57:55 +08:00
|
|
|
|
appKey: config.appKey,
|
|
|
|
|
|
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
|
|
|
|
|
currentVersionCode: String(getAppVersionCode()),
|
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-04-24 16:16:31 +08:00
|
|
|
|
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
|
|
|
|
|
|
skipAuth: true,
|
2026-05-08 12:00:34 +08:00
|
|
|
|
params,
|
2026-04-24 16:16:31 +08:00
|
|
|
|
})
|
2026-06-16 12:10:28 +08:00
|
|
|
|
const normalized = { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
|
|
|
|
|
|
await _writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized)
|
|
|
|
|
|
return normalized
|
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
|
|
|
|
/**
|
|
|
|
|
|
* 下载 APK 文件,返回 ArrayBuffer。
|
|
|
|
|
|
* 支持进度回调(UpdateDownloadProgress),向下兼容(options 可选)。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async downloadApk(
|
|
|
|
|
|
updateInfo: AppUpdateInfo,
|
|
|
|
|
|
options?: {
|
|
|
|
|
|
onProgress?: (progress: UpdateDownloadProgress) => void
|
|
|
|
|
|
},
|
|
|
|
|
|
): Promise<ArrayBuffer> {
|
|
|
|
|
|
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<string> {
|
|
|
|
|
|
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()
|
|
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Android APK 直接下载并调起系统安装器。
|
|
|
|
|
|
*/
|
|
|
|
|
|
async downloadAndInstallApk(
|
|
|
|
|
|
downloadUrl: string,
|
|
|
|
|
|
options?: {
|
|
|
|
|
|
onProgress?: (progress: number) => void
|
|
|
|
|
|
sha256?: string
|
|
|
|
|
|
},
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
if (Platform.OS !== 'android') throw new Error('[UpdateSDK] APK install is Android-only.')
|
|
|
|
|
|
// NativeApkInstall 由宿主提供(TurboModule),若未注入则 fallback 到 Linking
|
|
|
|
|
|
try {
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
|
|
|
|
const { XuqmApkInstall } = require('./NativeApkInstall') as { XuqmApkInstall: { installApk(url: string, sha256?: string | null): Promise<void> } }
|
|
|
|
|
|
await XuqmApkInstall.installApk(downloadUrl, options?.sha256 ?? null)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Native module not available — fallback to Linking
|
|
|
|
|
|
await Linking.openURL(downloadUrl)
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// ── 插件(Bundle)热更新 ──────────────────────────────────────────────────
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
2026-04-24 16:16:31 +08:00
|
|
|
|
/**
|
2026-06-15 01:44:20 +08:00
|
|
|
|
* 检查指定插件的更新。
|
|
|
|
|
|
*
|
2026-06-15 10:57:55 +08:00
|
|
|
|
* 版本号由 SDK 自动从本地 AsyncStorage 缓存读取(首次为 0.0.0)。
|
|
|
|
|
|
* 插件必须先通过 registerPlugin / registerPlugins 注册。
|
2026-04-24 16:16:31 +08:00
|
|
|
|
*/
|
2026-06-15 01:44:20 +08:00
|
|
|
|
async checkPluginUpdate(moduleId: string): Promise<PluginUpdateInfo> {
|
2026-06-15 10:57:55 +08:00
|
|
|
|
if (!_pluginRegistry.has(moduleId)) {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
throw new Error(
|
|
|
|
|
|
`[UpdateSDK] Plugin "${moduleId}" not registered. ` +
|
2026-06-15 10:57:55 +08:00
|
|
|
|
'Call UpdateSDK.registerPlugins([{ moduleId }]) first.',
|
2026-04-24 16:16:31 +08:00
|
|
|
|
)
|
|
|
|
|
|
}
|
2026-06-16 12:10:28 +08:00
|
|
|
|
|
|
|
|
|
|
// Return cached result if within TTL
|
|
|
|
|
|
const cacheKey = `${UPDATE_PLUGIN_CACHE_KEY_PREFIX}${moduleId}`
|
|
|
|
|
|
const cached = await _readUpdateCache<PluginUpdateInfo>(cacheKey)
|
|
|
|
|
|
if (cached) return cached
|
|
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
const config = getConfig()
|
|
|
|
|
|
const currentVersion = await _getCachedVersion(moduleId)
|
|
|
|
|
|
const userId = getUserId()?.trim()
|
|
|
|
|
|
const params: Record<string, string> = {
|
|
|
|
|
|
appKey: config.appKey,
|
|
|
|
|
|
moduleId,
|
|
|
|
|
|
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
|
|
|
|
|
currentVersion,
|
|
|
|
|
|
}
|
|
|
|
|
|
if (userId) params.userId = userId
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
const result = await apiRequest<PluginUpdateInfo>('/api/v1/rn/update/check', {
|
2026-04-24 16:16:31 +08:00
|
|
|
|
skipAuth: true,
|
2026-06-15 10:57:55 +08:00
|
|
|
|
params,
|
2026-04-24 16:16:31 +08:00
|
|
|
|
})
|
2026-06-16 12:10:28 +08:00
|
|
|
|
const normalized = {
|
2026-06-15 10:57:55 +08:00
|
|
|
|
...result,
|
|
|
|
|
|
currentVersion,
|
|
|
|
|
|
downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl,
|
2026-06-15 02:36:11 +08:00
|
|
|
|
}
|
2026-06-16 12:10:28 +08:00
|
|
|
|
await _writeUpdateCache(cacheKey, normalized)
|
|
|
|
|
|
return normalized
|
2026-04-24 16:16:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
2026-06-15 01:44:20 +08:00
|
|
|
|
/**
|
2026-06-15 10:57:55 +08:00
|
|
|
|
* 执行插件更新(一步完成)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* SDK 内部自动:检查版本 → 下载 bundle → 写入文件系统 → 触发重载(silent = false 时)。
|
|
|
|
|
|
*
|
|
|
|
|
|
* 需先调用 setBundleCallbacks 注入写入/重载能力。
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param silent true = 静默更新,下次启动生效;false(默认)= 立即重载
|
2026-06-15 01:44:20 +08:00
|
|
|
|
*/
|
2026-06-15 10:57:55 +08:00
|
|
|
|
async updatePlugin(
|
|
|
|
|
|
moduleId: string,
|
|
|
|
|
|
options?: {
|
|
|
|
|
|
onProgress?: (progress: number) => void
|
|
|
|
|
|
silent?: boolean
|
|
|
|
|
|
},
|
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
|
const info = await UpdateSDK.checkPluginUpdate(moduleId)
|
|
|
|
|
|
if (!info.needsUpdate) return
|
|
|
|
|
|
|
|
|
|
|
|
const source = await _downloadText(info.downloadUrl, options?.onProgress)
|
|
|
|
|
|
|
|
|
|
|
|
// 写入 AsyncStorage 缓存(版本记录)
|
|
|
|
|
|
const cachePayload: CachedRnBundle = {
|
|
|
|
|
|
moduleId,
|
|
|
|
|
|
version: info.latestVersion,
|
|
|
|
|
|
md5: info.md5,
|
|
|
|
|
|
source,
|
2026-04-24 16:16:31 +08:00
|
|
|
|
downloadedAt: new Date().toISOString(),
|
|
|
|
|
|
}
|
2026-06-15 10:57:55 +08:00
|
|
|
|
await AsyncStorage.setItem(bundleCacheKey(moduleId), JSON.stringify(cachePayload))
|
2026-04-24 16:16:31 +08:00
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
// 写入宿主文件系统(需注入 setBundleCallbacks)
|
|
|
|
|
|
if (_writeBundleCallback) {
|
|
|
|
|
|
await _writeBundleCallback(moduleId, source)
|
|
|
|
|
|
}
|
2026-06-15 01:44:20 +08:00
|
|
|
|
|
2026-06-15 10:57:55 +08:00
|
|
|
|
// 触发重载(非静默模式)
|
|
|
|
|
|
if (!options?.silent && _reloadBundleCallback) {
|
|
|
|
|
|
await _reloadBundleCallback(moduleId)
|
|
|
|
|
|
}
|
2026-04-24 16:16:31 +08:00
|
|
|
|
},
|
|
|
|
|
|
|
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
|
|
|
|
}
|