UNPKG

react-native-codepush-sdk

Version:

A React Native CodePush SDK for over-the-air updates

526 lines (450 loc) 17.1 kB
import AsyncStorage from '@react-native-async-storage/async-storage'; import DeviceInfo from 'react-native-device-info'; import RNFS from 'react-native-fs'; import { unzip } from 'react-native-zip-archive'; import { Platform, NativeModules } from 'react-native'; export interface CodePushConfiguration { serverUrl: string; deploymentKey: string; appName: string; checkFrequency?: 'ON_APP_START' | 'ON_APP_RESUME' | 'MANUAL'; installMode?: 'IMMEDIATE' | 'ON_NEXT_RESTART' | 'ON_NEXT_RESUME'; minimumBackgroundDuration?: number; } export interface UpdatePackage { packageHash: string; label: string; appVersion: string; description: string; isMandatory: boolean; packageSize: number; downloadUrl: string; rollout?: number; isDisabled?: boolean; timestamp: number; } export interface SyncStatus { status: 'CHECKING_FOR_UPDATE' | 'DOWNLOADING_PACKAGE' | 'INSTALLING_UPDATE' | 'UP_TO_DATE' | 'UPDATE_INSTALLED' | 'UPDATE_IGNORED' | 'UNKNOWN_ERROR' | 'AWAITING_USER_ACTION'; progress?: number; downloadedBytes?: number; totalBytes?: number; } export interface LocalPackage extends UpdatePackage { localPath: string; isFirstRun: boolean; failedInstall: boolean; } export type SyncStatusCallback = (status: SyncStatus) => void; export type DownloadProgressCallback = (progress: { receivedBytes: number; totalBytes: number }) => void; class CustomCodePush { private config: CodePushConfiguration; private currentPackage: LocalPackage | null = null; // Promise that resolves when directories are initialized and current package loaded private readyPromise: Promise<void>; private pendingUpdate: UpdatePackage | null = null; private isCheckingForUpdate = false; private isDownloading = false; private isInstalling = false; // Storage keys private static readonly CURRENT_PACKAGE_KEY = 'CustomCodePush_CurrentPackage'; private static readonly PENDING_UPDATE_KEY = 'CustomCodePush_PendingUpdate'; private static readonly FAILED_UPDATES_KEY = 'CustomCodePush_FailedUpdates'; private static readonly UPDATE_METADATA_KEY = 'CustomCodePush_UpdateMetadata'; // File paths private static readonly UPDATES_FOLDER = `${RNFS.DocumentDirectoryPath}/CustomCodePush`; private static readonly BUNDLES_FOLDER = `${CustomCodePush.UPDATES_FOLDER}/bundles`; private static readonly DOWNLOADS_FOLDER = `${CustomCodePush.UPDATES_FOLDER}/downloads`; constructor(config: CodePushConfiguration) { this.config = { checkFrequency: 'ON_APP_START', installMode: 'ON_NEXT_RESTART', minimumBackgroundDuration: 0, ...config, }; // Initialize directories and load stored package before SDK use this.readyPromise = (async () => { await this.initializeDirectories(); await this.loadCurrentPackage(); })(); } /** * Wait for SDK initialization (directories + stored package loaded) */ public async initialize(): Promise<void> { return this.readyPromise; } private async initializeDirectories(): Promise<void> { try { await RNFS.mkdir(CustomCodePush.UPDATES_FOLDER); await RNFS.mkdir(CustomCodePush.BUNDLES_FOLDER); await RNFS.mkdir(CustomCodePush.DOWNLOADS_FOLDER); } catch (error) { console.warn('Failed to create directories:', error); } } private async loadCurrentPackage(): Promise<void> { try { const packageData = await AsyncStorage.getItem(CustomCodePush.CURRENT_PACKAGE_KEY); if (packageData) { this.currentPackage = JSON.parse(packageData); } } catch (error) { console.warn('Failed to load current package:', error); } } private async saveCurrentPackage(packageInfo: LocalPackage): Promise<void> { try { await AsyncStorage.setItem(CustomCodePush.CURRENT_PACKAGE_KEY, JSON.stringify(packageInfo)); this.currentPackage = packageInfo; } catch (error) { console.warn('Failed to save current package:', error); } } private async getDeviceInfo(): Promise<any> { return { platform: Platform.OS, platformVersion: Platform.Version, appVersion: await DeviceInfo.getVersion(), deviceId: await DeviceInfo.getUniqueId(), deviceModel: await DeviceInfo.getModel(), clientUniqueId: await DeviceInfo.getUniqueId(), currentPackageHash: this.currentPackage?.packageHash || null, }; } public async checkForUpdate(): Promise<UpdatePackage | null> { if (this.isCheckingForUpdate) { throw new Error('Already checking for update'); } this.isCheckingForUpdate = true; try { const deviceInfo = await this.getDeviceInfo(); const response = await fetch(`${this.config.serverUrl}/v0.1/public/codepush/update_check`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ deploymentKey: this.config.deploymentKey, appVersion: deviceInfo.appVersion || '1.0.0', packageHash: this.currentPackage?.packageHash, clientUniqueId: deviceInfo.clientUniqueId, label: this.currentPackage?.label, }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (data.updateInfo) { const updatePackage: UpdatePackage = { packageHash: data.updateInfo.packageHash, label: data.updateInfo.label, appVersion: data.updateInfo.appVersion, description: data.updateInfo.description || '', isMandatory: data.updateInfo.isMandatory || false, packageSize: data.updateInfo.size || 0, downloadUrl: data.updateInfo.downloadUrl, rollout: data.updateInfo.rollout, isDisabled: data.updateInfo.isDisabled, timestamp: Date.now(), }; this.pendingUpdate = updatePackage; return updatePackage; } return null; } catch (error) { console.log('Error checking for update:', error); console.error('Error checking for update:', error); throw error; } finally { this.isCheckingForUpdate = false; } } public async downloadUpdate( updatePackage: UpdatePackage, progressCallback?: DownloadProgressCallback ): Promise<LocalPackage> { if (this.isDownloading) { throw new Error('Already downloading update'); } this.isDownloading = true; try { // Check if it's a JavaScript file (demo bundles) const isJsFile = updatePackage.downloadUrl.endsWith('.js'); const fileExtension = isJsFile ? 'js' : 'zip'; const downloadPath = `${CustomCodePush.DOWNLOADS_FOLDER}/${updatePackage.packageHash}.${fileExtension}`; // Clean up any existing download if (await RNFS.exists(downloadPath)) { await RNFS.unlink(downloadPath); } const downloadResult = await RNFS.downloadFile({ fromUrl: updatePackage.downloadUrl, toFile: downloadPath, progress: (res) => { if (progressCallback) { progressCallback({ receivedBytes: res.bytesWritten, totalBytes: res.contentLength, }); } }, }).promise; if (downloadResult.statusCode !== 200) { throw new Error(`Download failed with status ${downloadResult.statusCode}`); } // Verify file size (approximate for JS files) const fileStats = await RNFS.stat(downloadPath); if (isJsFile) { // For JS files, just check if file exists and has content if (fileStats.size === 0) { throw new Error('Downloaded JavaScript file is empty'); } } else { // For zip files, check exact size if (fileStats.size !== updatePackage.packageSize) { throw new Error('Downloaded file size mismatch'); } } let localPath: string; if (isJsFile) { // For JavaScript files, create a simple structure localPath = `${CustomCodePush.BUNDLES_FOLDER}/${updatePackage.packageHash}`; await RNFS.mkdir(localPath); // Copy the JS file to the bundle location const bundlePath = `${localPath}/index.bundle`; await RNFS.copyFile(downloadPath, bundlePath); // Clean up download file await RNFS.unlink(downloadPath); } else { // For zip files, extract as before localPath = `${CustomCodePush.BUNDLES_FOLDER}/${updatePackage.packageHash}`; await RNFS.mkdir(localPath); await unzip(downloadPath, localPath); // Clean up download file await RNFS.unlink(downloadPath); } const localPackage: LocalPackage = { ...updatePackage, localPath: localPath, isFirstRun: false, failedInstall: false, }; // Save update metadata await AsyncStorage.setItem( `${CustomCodePush.UPDATE_METADATA_KEY}_${updatePackage.packageHash}`, JSON.stringify(localPackage) ); return localPackage; } catch (error) { console.error('Error downloading update:', error); throw error; } finally { this.isDownloading = false; } } public async installUpdate(localPackage: LocalPackage): Promise<void> { if (this.isInstalling) { throw new Error('Already installing update'); } this.isInstalling = true; try { // Validate the package const bundlePath = `${localPackage.localPath}/index.bundle`; if (!(await RNFS.exists(bundlePath))) { throw new Error('Bundle file not found in update package'); } // Mark as current package await this.saveCurrentPackage(localPackage); // Clear pending update this.pendingUpdate = null; await AsyncStorage.removeItem(CustomCodePush.PENDING_UPDATE_KEY); // Log installation await this.logUpdateInstallation(localPackage, true); } catch (error) { console.error('Error installing update:', error); await this.logUpdateInstallation(localPackage, false); throw error; } finally { this.isInstalling = false; } } private async logUpdateInstallation(localPackage: LocalPackage, success: boolean): Promise<void> { try { const deviceInfo = await this.getDeviceInfo(); await fetch(`${this.config.serverUrl}/v0.1/public/codepush/report_status/deploy`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ deploymentKey: this.config.deploymentKey, label: localPackage.label, status: success ? 'Deployed' : 'Failed', clientUniqueId: deviceInfo.clientUniqueId, }), }); } catch (error) { console.warn('Failed to log update installation:', error); } } public async sync( options: { installMode?: 'IMMEDIATE' | 'ON_NEXT_RESTART' | 'ON_NEXT_RESUME'; mandatoryInstallMode?: 'IMMEDIATE' | 'ON_NEXT_RESTART' | 'ON_NEXT_RESUME'; updateDialog?: boolean; rollbackRetryOptions?: { delayInHours?: number; maxRetryAttempts?: number; }; } = {}, statusCallback?: SyncStatusCallback, downloadProgressCallback?: DownloadProgressCallback ): Promise<boolean> { try { // Check for update statusCallback?.({ status: 'CHECKING_FOR_UPDATE' }); const updatePackage = await this.checkForUpdate(); if (!updatePackage) { statusCallback?.({ status: 'UP_TO_DATE' }); return false; } // Show update dialog if needed if (options.updateDialog && updatePackage.isMandatory) { statusCallback?.({ status: 'AWAITING_USER_ACTION' }); // In a real implementation, you would show a native dialog here // For now, we'll proceed automatically } // Download update statusCallback?.({ status: 'DOWNLOADING_PACKAGE', progress: 0 }); const localPackage = await this.downloadUpdate(updatePackage, (progress) => { const progressPercent = (progress.receivedBytes / progress.totalBytes) * 100; statusCallback?.({ status: 'DOWNLOADING_PACKAGE', progress: progressPercent, downloadedBytes: progress.receivedBytes, totalBytes: progress.totalBytes, }); downloadProgressCallback?.(progress); }); // Install update statusCallback?.({ status: 'INSTALLING_UPDATE' }); await this.installUpdate(localPackage); const installMode = updatePackage.isMandatory ? (options.mandatoryInstallMode || 'IMMEDIATE') : (options.installMode || this.config.installMode || 'ON_NEXT_RESTART'); if (installMode === 'IMMEDIATE') { // Restart the app immediately this.restartApp(); } statusCallback?.({ status: 'UPDATE_INSTALLED' }); return true; } catch (error) { console.error('Sync error:', error); statusCallback?.({ status: 'UNKNOWN_ERROR' }); return false; } } public async getCurrentPackage(): Promise<LocalPackage | null> { return this.currentPackage; } public async getUpdateMetadata(): Promise<LocalPackage | null> { return this.currentPackage; } public async clearUpdates(): Promise<void> { try { // Clear storage await AsyncStorage.multiRemove([ CustomCodePush.CURRENT_PACKAGE_KEY, CustomCodePush.PENDING_UPDATE_KEY, CustomCodePush.FAILED_UPDATES_KEY, ]); // Clear files if (await RNFS.exists(CustomCodePush.UPDATES_FOLDER)) { await RNFS.unlink(CustomCodePush.UPDATES_FOLDER); } // Reinitialize await this.initializeDirectories(); this.currentPackage = null; this.pendingUpdate = null; } catch (error) { console.error('Error clearing updates:', error); throw error; } } public async rollback(): Promise<void> { if (!this.currentPackage) { throw new Error('No current package to rollback from'); } try { // Remove current package const packagePath = this.currentPackage.localPath; if (await RNFS.exists(packagePath)) { await RNFS.unlink(packagePath); } // Clear current package await AsyncStorage.removeItem(CustomCodePush.CURRENT_PACKAGE_KEY); this.currentPackage = null; // Log rollback await this.logRollback(); // Restart app to use original bundle this.restartApp(); } catch (error) { console.error('Error during rollback:', error); throw error; } } private async logRollback(): Promise<void> { try { const deviceInfo = await this.getDeviceInfo(); await fetch(`${this.config.serverUrl}/v0.1/public/codepush/report_status/deploy`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ deploymentKey: this.config.deploymentKey, label: this.currentPackage?.label || 'unknown', status: 'Rollback', clientUniqueId: deviceInfo.clientUniqueId, }), }); } catch (error) { console.warn('Failed to log rollback:', error); } } private restartApp(): void { // In a real implementation, you would use a native module to restart the app // For now, we'll just reload the React Native bundle if (Platform.OS === 'android') { // Android restart implementation NativeModules.DevSettings?.reload(); } else { // iOS restart implementation NativeModules.DevSettings?.reload(); } } public getBundleUrl(): string | null { if (this.currentPackage && this.currentPackage.localPath) { return `file://${this.currentPackage.localPath}/index.bundle`; } return null; } // Static methods for easy integration public static configure(config: CodePushConfiguration): CustomCodePush { return new CustomCodePush(config); } public static async checkForUpdate(instance: CustomCodePush): Promise<UpdatePackage | null> { return instance.checkForUpdate(); } public static async sync( instance: CustomCodePush, options?: any, statusCallback?: SyncStatusCallback, downloadProgressCallback?: DownloadProgressCallback ): Promise<boolean> { return instance.sync(options, statusCallback, downloadProgressCallback); } } export default CustomCodePush;