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

323 行
11 KiB
TypeScript

import AsyncStorage from '@react-native-async-storage/async-storage'
import { Linking, Platform } from 'react-native'
import { apiRequest, getConfig, getUserId, _registerUserInfoHandler } from '@xuqm/rn-common'
import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion'
// ─── Types (public) ────────────────────────────────────────────────────────────
/** 插件注册(版本号由 SDK 自动获取,不需要 App 传入) */
export interface PluginRegistration {
moduleId: string
}
/** @deprecated 使用 PluginRegistration移除 version 字段) */
export interface PluginMeta extends PluginRegistration {
version?: string
}
export interface AppUpdateInfo {
needsUpdate: boolean
versionName?: string
versionCode?: number
downloadUrl?: string
changeLog?: string
forceUpdate?: boolean
appStoreUrl?: string
marketUrl?: string
requiresLogin?: boolean
alreadyDownloaded?: boolean
apkHash?: string | null
}
export interface PluginUpdateInfo {
needsUpdate: boolean
latestVersion: string
currentVersion: string
downloadUrl: string
md5: string
minCommonVersion: string
note: string
forceUpdate?: boolean
changeLog?: string
}
export type { CachedRnBundle }
interface CachedRnBundle {
moduleId: string
version: string
md5: string
downloadedAt: string
source: string
}
// ─── Internal state ────────────────────────────────────────────────────────────
const _pluginRegistry = new Set<string>()
let _writeBundleCallback: ((moduleId: string, source: string) => Promise<void>) | null = null
let _reloadBundleCallback: ((moduleId: string) => Promise<void>) | null = null
function bundleCacheKey(moduleId: string) {
return `@xuqm:bundle:${moduleId}`
}
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'
}
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
}
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
}
// ─── 订阅 setUserInfouserId 用于定向更新) ──────────────────────────────────
_registerUserInfoHandler(() => {
// updatePlugin 检查更新时会实时调用 getUserId(),此处无需额外操作
})
// ─── UpdateSDK ────────────────────────────────────────────────────────────────
export const UpdateSDK = {
// ── 宿主注入(插件化写入/重载能力)──────────────────────────────────────────
/**
* bundle
* 宿 src/core/updater.ts BundleRuntime
*
* @example
* UpdateSDK.setBundleCallbacks({
* writeBundle: writeBundleFile,
* reloadBundle: loadBundle,
* })
*/
setBundleCallbacks(callbacks: {
writeBundle: (moduleId: string, source: string) => Promise<void>
reloadBundle: (moduleId: string) => Promise<void>
}): void {
_writeBundleCallback = callbacks.writeBundle
_reloadBundleCallback = callbacks.reloadBundle
},
// ── 插件注册 ──────────────────────────────────────────────────────────────
/**
* SDK App
*
* @example
* UpdateSDK.registerPlugins([{ moduleId: 'buz1' }, { moduleId: 'buz2' }])
*/
registerPlugins(plugins: PluginRegistration[]): void {
for (const p of plugins) {
_pluginRegistry.add(p.moduleId)
}
},
/**
* version SDK
*/
registerPlugin(meta: PluginRegistration): void {
_pluginRegistry.add(meta.moduleId)
},
getRegisteredPlugins(): string[] {
return Array.from(_pluginRegistry)
},
// ── App 整包更新 ──────────────────────────────────────────────────────────
async checkAppUpdate(bypassIgnore?: boolean): Promise<AppUpdateInfo> {
const config = getConfig()
const params: Record<string, string> = {
appKey: config.appKey,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
currentVersionCode: String(getAppVersionCode()),
}
const userId = getUserId()?.trim()
if (userId) params.userId = userId
if (bypassIgnore) params.bypassIgnore = 'true'
const result = await apiRequest<AppUpdateInfo>('/api/v1/updates/app/check', {
skipAuth: true,
params,
})
return { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
},
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
if (url) await Linking.openURL(url)
},
/**
* 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热更新 ──────────────────────────────────────────────────
/**
*
*
* SDK AsyncStorage 0.0.0
* registerPlugin / registerPlugins
*/
async checkPluginUpdate(moduleId: string): Promise<PluginUpdateInfo> {
if (!_pluginRegistry.has(moduleId)) {
throw new Error(
`[UpdateSDK] Plugin "${moduleId}" not registered. ` +
'Call UpdateSDK.registerPlugins([{ moduleId }]) first.',
)
}
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
const result = await apiRequest<PluginUpdateInfo>('/api/v1/rn/update/check', {
skipAuth: true,
params,
})
return {
...result,
currentVersion,
downloadUrl: normalizeDownloadUrl(result.downloadUrl) ?? result.downloadUrl,
}
},
/**
*
*
* SDK bundle silent = false
*
* setBundleCallbacks /
*
* @param silent true = false=
*/
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,
downloadedAt: new Date().toISOString(),
}
await AsyncStorage.setItem(bundleCacheKey(moduleId), JSON.stringify(cachePayload))
// 写入宿主文件系统(需注入 setBundleCallbacks
if (_writeBundleCallback) {
await _writeBundleCallback(moduleId, source)
}
// 触发重载(非静默模式)
if (!options?.silent && _reloadBundleCallback) {
await _reloadBundleCallback(moduleId)
}
},
// ── 版本信息 ──────────────────────────────────────────────────────────────
getAppVersionCode,
getAppVersionName,
_devSetAppVersion(versionCode: number, versionName?: string): void {
_devSetAppVersion(versionCode, versionName)
},
}