UNPKG

react-native-codepush-sdk

Version:

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

406 lines (405 loc) 18 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const async_storage_1 = __importDefault(require("@react-native-async-storage/async-storage")); const react_native_device_info_1 = __importDefault(require("react-native-device-info")); const react_native_fs_1 = __importDefault(require("react-native-fs")); const react_native_zip_archive_1 = require("react-native-zip-archive"); const react_native_1 = require("react-native"); class CustomCodePush { constructor(config) { this.currentPackage = null; this.pendingUpdate = null; this.isCheckingForUpdate = false; this.isDownloading = false; this.isInstalling = false; this.config = Object.assign({ 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) */ async initialize() { return this.readyPromise; } async initializeDirectories() { try { await react_native_fs_1.default.mkdir(CustomCodePush.UPDATES_FOLDER); await react_native_fs_1.default.mkdir(CustomCodePush.BUNDLES_FOLDER); await react_native_fs_1.default.mkdir(CustomCodePush.DOWNLOADS_FOLDER); } catch (error) { console.warn('Failed to create directories:', error); } } async loadCurrentPackage() { try { const packageData = await async_storage_1.default.getItem(CustomCodePush.CURRENT_PACKAGE_KEY); if (packageData) { this.currentPackage = JSON.parse(packageData); } } catch (error) { console.warn('Failed to load current package:', error); } } async saveCurrentPackage(packageInfo) { try { await async_storage_1.default.setItem(CustomCodePush.CURRENT_PACKAGE_KEY, JSON.stringify(packageInfo)); this.currentPackage = packageInfo; } catch (error) { console.warn('Failed to save current package:', error); } } async getDeviceInfo() { var _a; return { platform: react_native_1.Platform.OS, platformVersion: react_native_1.Platform.Version, appVersion: await react_native_device_info_1.default.getVersion(), deviceId: await react_native_device_info_1.default.getUniqueId(), deviceModel: await react_native_device_info_1.default.getModel(), clientUniqueId: await react_native_device_info_1.default.getUniqueId(), currentPackageHash: ((_a = this.currentPackage) === null || _a === void 0 ? void 0 : _a.packageHash) || null, }; } async checkForUpdate() { var _a, _b; 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: (_a = this.currentPackage) === null || _a === void 0 ? void 0 : _a.packageHash, clientUniqueId: deviceInfo.clientUniqueId, label: (_b = this.currentPackage) === null || _b === void 0 ? void 0 : _b.label, }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (data.updateInfo) { const 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; } } async downloadUpdate(updatePackage, progressCallback) { 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 react_native_fs_1.default.exists(downloadPath)) { await react_native_fs_1.default.unlink(downloadPath); } const downloadResult = await react_native_fs_1.default.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 react_native_fs_1.default.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; if (isJsFile) { // For JavaScript files, create a simple structure localPath = `${CustomCodePush.BUNDLES_FOLDER}/${updatePackage.packageHash}`; await react_native_fs_1.default.mkdir(localPath); // Copy the JS file to the bundle location const bundlePath = `${localPath}/index.bundle`; await react_native_fs_1.default.copyFile(downloadPath, bundlePath); // Clean up download file await react_native_fs_1.default.unlink(downloadPath); } else { // For zip files, extract as before localPath = `${CustomCodePush.BUNDLES_FOLDER}/${updatePackage.packageHash}`; await react_native_fs_1.default.mkdir(localPath); await (0, react_native_zip_archive_1.unzip)(downloadPath, localPath); // Clean up download file await react_native_fs_1.default.unlink(downloadPath); } const localPackage = Object.assign(Object.assign({}, updatePackage), { localPath: localPath, isFirstRun: false, failedInstall: false }); // Save update metadata await async_storage_1.default.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; } } async installUpdate(localPackage) { if (this.isInstalling) { throw new Error('Already installing update'); } this.isInstalling = true; try { // Validate the package const bundlePath = `${localPackage.localPath}/index.bundle`; if (!(await react_native_fs_1.default.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 async_storage_1.default.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; } } async logUpdateInstallation(localPackage, success) { 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); } } async sync(options = {}, statusCallback, downloadProgressCallback) { try { // Check for update statusCallback === null || statusCallback === void 0 ? void 0 : statusCallback({ status: 'CHECKING_FOR_UPDATE' }); const updatePackage = await this.checkForUpdate(); if (!updatePackage) { statusCallback === null || statusCallback === void 0 ? void 0 : statusCallback({ status: 'UP_TO_DATE' }); return false; } // Show update dialog if needed if (options.updateDialog && updatePackage.isMandatory) { statusCallback === null || statusCallback === void 0 ? void 0 : 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 === null || statusCallback === void 0 ? void 0 : statusCallback({ status: 'DOWNLOADING_PACKAGE', progress: 0 }); const localPackage = await this.downloadUpdate(updatePackage, (progress) => { const progressPercent = (progress.receivedBytes / progress.totalBytes) * 100; statusCallback === null || statusCallback === void 0 ? void 0 : statusCallback({ status: 'DOWNLOADING_PACKAGE', progress: progressPercent, downloadedBytes: progress.receivedBytes, totalBytes: progress.totalBytes, }); downloadProgressCallback === null || downloadProgressCallback === void 0 ? void 0 : downloadProgressCallback(progress); }); // Install update statusCallback === null || statusCallback === void 0 ? void 0 : 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 === null || statusCallback === void 0 ? void 0 : statusCallback({ status: 'UPDATE_INSTALLED' }); return true; } catch (error) { console.error('Sync error:', error); statusCallback === null || statusCallback === void 0 ? void 0 : statusCallback({ status: 'UNKNOWN_ERROR' }); return false; } } async getCurrentPackage() { return this.currentPackage; } async getUpdateMetadata() { return this.currentPackage; } async clearUpdates() { try { // Clear storage await async_storage_1.default.multiRemove([ CustomCodePush.CURRENT_PACKAGE_KEY, CustomCodePush.PENDING_UPDATE_KEY, CustomCodePush.FAILED_UPDATES_KEY, ]); // Clear files if (await react_native_fs_1.default.exists(CustomCodePush.UPDATES_FOLDER)) { await react_native_fs_1.default.unlink(CustomCodePush.UPDATES_FOLDER); } // Reinitialize await this.initializeDirectories(); this.currentPackage = null; this.pendingUpdate = null; } catch (error) { console.error('Error clearing updates:', error); throw error; } } async rollback() { if (!this.currentPackage) { throw new Error('No current package to rollback from'); } try { // Remove current package const packagePath = this.currentPackage.localPath; if (await react_native_fs_1.default.exists(packagePath)) { await react_native_fs_1.default.unlink(packagePath); } // Clear current package await async_storage_1.default.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; } } async logRollback() { var _a; 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: ((_a = this.currentPackage) === null || _a === void 0 ? void 0 : _a.label) || 'unknown', status: 'Rollback', clientUniqueId: deviceInfo.clientUniqueId, }), }); } catch (error) { console.warn('Failed to log rollback:', error); } } restartApp() { var _a, _b; // 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 (react_native_1.Platform.OS === 'android') { // Android restart implementation (_a = react_native_1.NativeModules.DevSettings) === null || _a === void 0 ? void 0 : _a.reload(); } else { // iOS restart implementation (_b = react_native_1.NativeModules.DevSettings) === null || _b === void 0 ? void 0 : _b.reload(); } } getBundleUrl() { if (this.currentPackage && this.currentPackage.localPath) { return `file://${this.currentPackage.localPath}/index.bundle`; } return null; } // Static methods for easy integration static configure(config) { return new CustomCodePush(config); } static async checkForUpdate(instance) { return instance.checkForUpdate(); } static async sync(instance, options, statusCallback, downloadProgressCallback) { return instance.sync(options, statusCallback, downloadProgressCallback); } } // Storage keys CustomCodePush.CURRENT_PACKAGE_KEY = 'CustomCodePush_CurrentPackage'; CustomCodePush.PENDING_UPDATE_KEY = 'CustomCodePush_PendingUpdate'; CustomCodePush.FAILED_UPDATES_KEY = 'CustomCodePush_FailedUpdates'; CustomCodePush.UPDATE_METADATA_KEY = 'CustomCodePush_UpdateMetadata'; // File paths CustomCodePush.UPDATES_FOLDER = `${react_native_fs_1.default.DocumentDirectoryPath}/CustomCodePush`; CustomCodePush.BUNDLES_FOLDER = `${CustomCodePush.UPDATES_FOLDER}/bundles`; CustomCodePush.DOWNLOADS_FOLDER = `${CustomCodePush.UPDATES_FOLDER}/downloads`; exports.default = CustomCodePush;