react-native-codepush-sdk
Version:
A React Native CodePush SDK for over-the-air updates
406 lines (405 loc) • 18 kB
JavaScript
;
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;