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

224 行
7.3 KiB
TypeScript

import { NativeEventEmitter, NativeModules, Platform } from 'react-native'
import type { AppUpdateInfo, UpdateDownloadProgress } from './UpdateSDK'
export type AppInstallErrorCode =
| 'DOWNLOAD_FAILED'
| 'DOWNLOAD_CANCELLED'
| 'HASH_MISMATCH'
| 'PACKAGE_MISMATCH'
| 'SIGNATURE_MISMATCH'
| 'AUTHORIZATION_EXPIRED'
| 'INSTALL_PERMISSION_REQUIRED'
| 'INSTALLER_UNAVAILABLE'
| 'ANDROID_SDK_MISSING'
| 'INSTALL_FAILED'
export class AppInstallError extends Error {
readonly name = 'AppInstallError'
constructor(
public readonly code: AppInstallErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options)
}
}
interface XuqmAppUpdateModuleInterface {
syncUser(userId: string | null): Promise<void>
checkAppUpdate(userId: string | null, bypassIgnore: boolean): Promise<NativeUpdateCheckResult>
download(
downloadUrl: string,
versionCode: number,
sha256: string | null,
retryCount: number,
destination: UpdateDownloadDestination,
): Promise<DownloadedAppUpdate>
downloadAndInstall(
downloadUrl: string,
versionCode: number,
sha256: string | null,
retryCount: number,
destination: UpdateDownloadDestination,
): Promise<void>
installDownloaded(versionCode: number, sha256: string | null): Promise<void>
openStore(marketUrl: string | null, appStoreUrl: string | null): Promise<boolean>
cancelDownload(): Promise<void>
openInstallPermissionSettings(): Promise<boolean>
}
function getModule(): XuqmAppUpdateModuleInterface {
const module = NativeModules.XuqmAppUpdateModule
if (!module) {
throw new AppInstallError(
'ANDROID_SDK_MISSING',
'[UpdateSDK] Xuqm Android update bridge is not linked',
)
}
return module as XuqmAppUpdateModuleInterface
}
function asInstallError(error: unknown): AppInstallError {
if (error instanceof AppInstallError) return error
const native = error as { code?: string; message?: string }
const code = native.code as AppInstallErrorCode | undefined
const known: AppInstallErrorCode[] = [
'DOWNLOAD_FAILED',
'DOWNLOAD_CANCELLED',
'HASH_MISMATCH',
'PACKAGE_MISMATCH',
'SIGNATURE_MISMATCH',
'AUTHORIZATION_EXPIRED',
'INSTALL_PERMISSION_REQUIRED',
'INSTALLER_UNAVAILABLE',
'ANDROID_SDK_MISSING',
'INSTALL_FAILED',
]
return new AppInstallError(
code && known.includes(code) ? code : 'INSTALL_FAILED',
native.message ?? '[UpdateSDK] App installation failed',
{ cause: error },
)
}
export type UpdateDownloadDestination = 'SDK_PRIVATE' | 'PUBLIC_DOWNLOADS'
export interface DownloadedAppUpdate {
localPath: string
publicUri: string | null
}
export type NativeUpdateCheckStatus =
'UPDATE_AVAILABLE' | 'NO_UPDATE' | 'LOGIN_REQUIRED' | 'SERVICE_DISABLED' | 'FAILED'
export type NativeNetworkType =
'NONE' | 'WIFI' | 'CELLULAR' | 'CELLULAR_4G' | 'CELLULAR_5G' | 'ETHERNET' | 'OTHER'
export interface NativeUpdateCheckResult {
status: NativeUpdateCheckStatus
networkType: NativeNetworkType
isConnected: boolean
isMetered: boolean
update: AppUpdateInfo | null
errorCode: string | null
message: string | null
}
export const NativeAppUpdate = {
syncUser(userId: string | null): Promise<void> {
if (Platform.OS !== 'android') return Promise.resolve()
return getModule().syncUser(userId)
},
check(userId: string | null, bypassIgnore = false): Promise<NativeUpdateCheckResult> {
if (Platform.OS !== 'android') {
throw new AppInstallError('INSTALLER_UNAVAILABLE', 'Native app update is Android-only')
}
return getModule().checkAppUpdate(userId, bypassIgnore)
},
async download(
input: { downloadUrl: string; versionCode: number; sha256?: string },
options: {
signal?: AbortSignal
retryCount?: number
destination?: UpdateDownloadDestination
onProgress?: (progress: UpdateDownloadProgress) => void
} = {},
): Promise<DownloadedAppUpdate> {
if (Platform.OS !== 'android') {
throw new AppInstallError('INSTALLER_UNAVAILABLE', 'App download is Android-only')
}
const module = getModule()
if (!input.sha256) throw new AppInstallError('HASH_MISMATCH', 'APK SHA-256 is required')
const emitter = new NativeEventEmitter(NativeModules.XuqmAppUpdateModule)
const subscription = emitter.addListener(
'XuqmAppUpdateProgress',
(progress: UpdateDownloadProgress) => options.onProgress?.(progress),
)
const cancel = () => void module.cancelDownload()
options.signal?.addEventListener('abort', cancel, { once: true })
try {
if (options.signal?.aborted) throw new AppInstallError('DOWNLOAD_CANCELLED', 'Cancelled')
return await module.download(
input.downloadUrl,
input.versionCode,
input.sha256,
Math.max(0, Math.trunc(options.retryCount ?? 1)),
options.destination ?? 'SDK_PRIVATE',
)
} catch (error) {
throw asInstallError(error)
} finally {
subscription.remove()
options.signal?.removeEventListener('abort', cancel)
}
},
async downloadAndInstall(
input: { downloadUrl: string; versionCode: number; sha256?: string },
options: {
signal?: AbortSignal
retryCount?: number
destination?: UpdateDownloadDestination
onProgress?: (progress: UpdateDownloadProgress) => void
} = {},
): Promise<void> {
if (Platform.OS !== 'android') {
throw new AppInstallError('INSTALLER_UNAVAILABLE', 'App installation is Android-only')
}
const module = getModule()
if (!input.sha256) {
throw new AppInstallError('HASH_MISMATCH', 'APK SHA-256 is required')
}
const emitter = new NativeEventEmitter(NativeModules.XuqmAppUpdateModule)
const subscription = emitter.addListener(
'XuqmAppUpdateProgress',
(progress: UpdateDownloadProgress) => options.onProgress?.(progress),
)
const cancel = () => void module.cancelDownload()
options.signal?.addEventListener('abort', cancel, { once: true })
try {
if (options.signal?.aborted) throw new AppInstallError('DOWNLOAD_CANCELLED', 'Cancelled')
await module.downloadAndInstall(
input.downloadUrl,
input.versionCode,
input.sha256 ?? null,
Math.max(0, Math.trunc(options.retryCount ?? 1)),
options.destination ?? 'SDK_PRIVATE',
)
} catch (error) {
throw asInstallError(error)
} finally {
subscription.remove()
options.signal?.removeEventListener('abort', cancel)
}
},
async installDownloaded(input: { versionCode: number; sha256?: string }): Promise<void> {
if (Platform.OS !== 'android') {
throw new AppInstallError('INSTALLER_UNAVAILABLE', 'App installation is Android-only')
}
if (!input.sha256) throw new AppInstallError('HASH_MISMATCH', 'APK SHA-256 is required')
try {
await getModule().installDownloaded(input.versionCode, input.sha256)
} catch (error) {
throw asInstallError(error)
}
},
openStore(input: { marketUrl?: string; appStoreUrl?: string }): Promise<boolean> {
if (Platform.OS !== 'android') return Promise.resolve(false)
return getModule().openStore(input.marketUrl ?? null, input.appStoreUrl ?? null)
},
async cancel(): Promise<void> {
await getModule().cancelDownload()
},
openInstallPermissionSettings(): Promise<boolean> {
return getModule().openInstallPermissionSettings()
},
}