@bazumax/capacitor-codepush
Version:
CodePush Plugin for Capacitor
543 lines (466 loc) • 25.1 kB
text/typescript
import { Directory, Filesystem, GetUriOptions } from "@capacitor/filesystem";
import { AcquisitionStatus } from "code-push/script/acquisition-sdk";
import { Callback, ErrorCallback, SuccessCallback } from "./callbackUtil";
import { CodePushUtil } from "./codePushUtil";
import { FileUtil } from "./fileUtil";
import { InstallMode } from "./installMode";
import { InstallOptions } from "./installOptions";
import { NativeAppInfo } from "./nativeAppInfo";
import { CodePush as NativeCodePush } from "./nativeCodePushPlugin";
import { ILocalPackage, IPackageInfoMetadata, Package } from "./package";
import { Sdk } from "./sdk";
/**
* Defines the JSON format of the package diff manifest file.
*/
interface IDiffManifest {
deletedFiles: string[];
}
/**
* Defines the result of LocalPackage.handleDeployment execution.
*/
interface DeploymentResult {
deployDir: string;
isDiffUpdate: boolean;
}
/**
* Defines a local package.
*
* !! THIS TYPE IS READ FROM NATIVE CODE AS WELL. ANY CHANGES TO THIS INTERFACE NEEDS TO BE UPDATED IN NATIVE CODE !!
*/
export class LocalPackage extends Package implements ILocalPackage {
public static RootDir: string = "codepush";
public static DownloadDir: string = LocalPackage.RootDir + "/download";
private static DownloadUnzipDir: string = LocalPackage.DownloadDir + "/unzipped";
private static DeployDir: string = LocalPackage.RootDir + "/deploy";
private static VersionsDir: string = LocalPackage.DeployDir + "/versions";
public static PackageUpdateFileName: string = "update.zip";
public static PackageInfoFile: string = "currentPackage.json";
public static OldPackageInfoFile: string = "oldPackage.json";
private static DiffManifestFile: string = "hotcodepush.json";
private static DefaultInstallOptions: InstallOptions;
/**
* The local storage path where this package is located.
*/
localPath: string;
/**
* Indicates if the current application run is the first one after the package was applied.
*/
isFirstRun: boolean;
/**
* Applies this package to the application. The application will be reloaded with this package and on every application launch this package will be loaded.
* On the first run after the update, the application will wait for a codePush.notifyApplicationReady() call. Once this call is made, the install operation is considered a success.
* Otherwise, the install operation will be marked as failed, and the application is reverted to its previous version on the next run.
*
* @param installOptions Optional parameter used for customizing the installation behavior.
*/
public async install(installOptions?: InstallOptions): Promise<InstallMode> {
return new Promise<InstallMode>(async (resolve, reject) => {
try {
CodePushUtil.logMessage("Installing update");
if (!installOptions) {
installOptions = LocalPackage.getDefaultInstallOptions();
} else {
CodePushUtil.copyUnassignedMembers(LocalPackage.getDefaultInstallOptions(), installOptions);
}
var installError: ErrorCallback = (error: Error): void => {
CodePushUtil.invokeErrorCallback(error, reject);
Sdk.reportStatusDeploy(this, AcquisitionStatus.DeploymentFailed, this.deploymentKey);
};
let unzipDir;
try {
unzipDir = await FileUtil.cleanDataDirectory(LocalPackage.DownloadUnzipDir);
} catch (error) {
installError(error);
return;
}
try {
await NativeCodePush.unzip({zipFile: this.localPath, targetDirectory: unzipDir});
} catch (unzipError) {
installError(new Error("Could not unzip package" + CodePushUtil.getErrorMessage(unzipError)));
return;
}
try {
const newPackageLocation = LocalPackage.VersionsDir + "/" + this.packageHash;
const deploymentResult = await LocalPackage.handleDeployment(newPackageLocation);
await this.verifyPackage(deploymentResult);
this.localPath = deploymentResult.deployDir;
this.finishInstall(deploymentResult.deployDir, installOptions, resolve, installError);
} catch (error) {
installError(error);
}
} catch (e) {
installError && installError(new Error("An error occured while installing the package. " + CodePushUtil.getErrorMessage(e)));
}
});
}
private verifyPackage(deploymentResult: DeploymentResult): Promise<void> {
return new Promise((resolve, reject) => {
var deployDir = deploymentResult.deployDir;
var verificationFail: ErrorCallback = (error: Error) => {
reject(error);
};
var verify = (isSignatureVerificationEnabled: boolean, isSignatureAppearedInBundle: boolean, publicKey: string, signature: string) => {
if (isSignatureVerificationEnabled) {
if (isSignatureAppearedInBundle) {
this.verifyHash(deployDir, this.packageHash, verificationFail, () => {
this.verifySignature(deployDir, this.packageHash, publicKey, signature, verificationFail, resolve);
});
} else {
var errorMessage =
"Error! Public key was provided but there is no JWT signature within app bundle to verify. " +
"Possible reasons, why that might happen: \n" +
"1. You've been released CodePush bundle update using version of CodePush CLI that is not support code signing.\n" +
"2. You've been released CodePush bundle update without providing --privateKeyPath option.";
reject(new Error(errorMessage));
}
} else {
if (isSignatureAppearedInBundle) {
CodePushUtil.logMessage(
"Warning! JWT signature exists in codepush update but code integrity check couldn't be performed because there is no public key configured. " +
"Please ensure that public key is properly configured within your application."
);
// verifyHash
this.verifyHash(deployDir, this.packageHash, verificationFail, resolve);
} else {
if (deploymentResult.isDiffUpdate){
// verifyHash
this.verifyHash(deployDir, this.packageHash, verificationFail, resolve);
} else {
resolve();
}
}
}
};
if (deploymentResult.isDiffUpdate){
CodePushUtil.logMessage("Applying diff update");
} else {
CodePushUtil.logMessage("Applying full update");
}
var isSignatureVerificationEnabled: boolean, isSignatureAppearedInBundle: boolean;
var publicKey: string;
this.getPublicKey((error, publicKeyResult) => {
if (error) {
reject(new Error("Error reading public key. " + error));
return;
}
publicKey = publicKeyResult;
isSignatureVerificationEnabled = !!publicKey;
this.getSignatureFromUpdate(deploymentResult.deployDir, (error, signature) => {
if (error) {
reject(new Error("Error reading signature from update. " + error));
return;
}
isSignatureAppearedInBundle = !!signature;
verify(isSignatureVerificationEnabled, isSignatureAppearedInBundle, publicKey, signature);
});
});
});
}
private getPublicKey(callback: Callback<string>) {
var success = (publicKey: string) => {
callback(null, publicKey);
};
var fail = (error: Error) => {
callback(error, null);
};
NativeCodePush.getPublicKey().then(result => success(result.value || null), fail);
}
private async getSignatureFromUpdate(deployDir: string, callback: Callback<string>){
const filePath = deployDir + "/public/.codepushrelease";
if (!await FileUtil.fileExists(Directory.Data, filePath)) {
// signature absents in the bundle
callback(null, null);
return;
}
try {
const signature = await FileUtil.readFile(Directory.Data, filePath);
callback(null, signature);
} catch (error) {
// error reading signature file from bundle
callback(error, null);
}
}
private verifyHash(deployDir: string, newUpdateHash: string, errorCallback: ErrorCallback, successCallback: SuccessCallback<void>){
var packageHashSuccess = (computedHash: string) => {
if (computedHash !== newUpdateHash) {
errorCallback(new Error("The update contents failed the data integrity check."));
return;
}
CodePushUtil.logMessage("The update contents succeeded the data integrity check.");
successCallback();
};
var packageHashFail = (error: Error) => {
errorCallback(new Error("Unable to compute hash for package: " + error));
};
CodePushUtil.logMessage("Verifying hash for folder path: " + deployDir);
NativeCodePush.getPackageHash({path: deployDir}).then(result => packageHashSuccess(result.value), packageHashFail);
}
private verifySignature(deployDir: string, newUpdateHash: string, publicKey: string, signature: string, errorCallback: ErrorCallback, successCallback: SuccessCallback<void>){
var decodeSignatureSuccess = (contentHash: string) => {
if (contentHash !== newUpdateHash) {
errorCallback(new Error("The update contents failed the code signing check."));
return;
}
CodePushUtil.logMessage("The update contents succeeded the code signing check.");
successCallback();
};
var decodeSignatureFail = (error: Error) => {
errorCallback(new Error("Unable to verify signature for package: " + error));
};
CodePushUtil.logMessage("Verifying signature for folder path: " + deployDir);
NativeCodePush.decodeSignature({publicKey, signature}).then(result => decodeSignatureSuccess(result.value), decodeSignatureFail);
}
private finishInstall(deployDir: string, installOptions: InstallOptions, installSuccess: SuccessCallback<InstallMode>, installError: ErrorCallback): void {
async function backupPackageInformationFileIfNeeded(backupIfNeededDone: Callback<void>) {
const pendingUpdate = await NativeAppInfo.isPendingUpdate();
if (pendingUpdate) {
// Don't back up the currently installed update since it hasn't been "confirmed"
backupIfNeededDone(null, null);
} else {
try {
await LocalPackage.backupPackageInformationFile();
backupIfNeededDone(null, null);
} catch (err) {
backupIfNeededDone(err, null);
}
}
}
LocalPackage.getCurrentOrDefaultPackage().then((oldPackage: LocalPackage) => {
backupPackageInformationFileIfNeeded((backupError: Error) => {
/* continue on error, current package information is missing if this is the first update */
this.writeNewPackageMetadata().then(() => {
var invokeSuccessAndInstall = () => {
CodePushUtil.logMessage("Install succeeded.");
var installModeToUse: InstallMode = this.isMandatory ? installOptions.mandatoryInstallMode : installOptions.installMode;
if (installModeToUse === InstallMode.IMMEDIATE) {
/* invoke success before navigating */
installSuccess && installSuccess(installModeToUse);
/* no need for callbacks, the javascript context will reload */
NativeCodePush.install({
startLocation: deployDir,
installMode: installModeToUse,
minimumBackgroundDuration: installOptions.minimumBackgroundDuration
});
} else {
NativeCodePush.install({
startLocation: deployDir,
installMode: installModeToUse,
minimumBackgroundDuration: installOptions.minimumBackgroundDuration
}).then(() => { installSuccess && installSuccess(installModeToUse); }, () => { installError && installError(); });
}
};
var preInstallSuccess = () => {
/* package will be cleaned up after success, on the native side */
invokeSuccessAndInstall();
};
var preInstallFailure = (preInstallError?: any) => {
CodePushUtil.logError("Preinstall failure.", preInstallError);
var error = new Error("An error has occured while installing the package. " + CodePushUtil.getErrorMessage(preInstallError));
installError && installError(error);
};
NativeCodePush.preInstall({startLocation: deployDir}).then(preInstallSuccess, preInstallFailure);
}, (writeMetadataError: Error) => {
installError && installError(writeMetadataError);
});
});
}, installError);
}
private static async handleDeployment(newPackageLocation: string): Promise<DeploymentResult> {
const manifestFile: GetUriOptions = {
directory: Directory.Data,
path: LocalPackage.DownloadUnzipDir + "/" + LocalPackage.DiffManifestFile
};
const isDiffUpdate = await FileUtil.fileExists(manifestFile.directory, manifestFile.path);
if (!(await FileUtil.directoryExists(Directory.Data, LocalPackage.VersionsDir))) {
// If directory not exists, create recursive folder
await Filesystem.mkdir({
path: LocalPackage.VersionsDir,
directory: Directory.Data,
recursive: true
});
}
if (isDiffUpdate) {
await LocalPackage.handleDiffDeployment(newPackageLocation, manifestFile);
} else {
await LocalPackage.handleCleanDeployment(newPackageLocation);
}
return {deployDir: newPackageLocation, isDiffUpdate};
}
private async writeNewPackageMetadata(): Promise<void> {
const timestamp = await NativeAppInfo.getApplicationBuildTime().catch(buildTimeError => {
CodePushUtil.logError("Could not get application build time. " + buildTimeError);
});
const appVersion = await NativeAppInfo.getApplicationVersion().catch(appVersionError => {
CodePushUtil.logError("Could not get application version." + appVersionError);
});
const currentPackageMetadata: IPackageInfoMetadata = {
nativeBuildTime: timestamp as string,
localPath: this.localPath,
appVersion: appVersion as string,
deploymentKey: this.deploymentKey,
description: this.description,
isMandatory: this.isMandatory,
packageSize: this.packageSize,
label: this.label,
packageHash: this.packageHash,
isFirstRun: false,
failedInstall: false,
install: undefined
};
return new Promise<void>((resolve, reject) => {
LocalPackage.writeCurrentPackageInformation(currentPackageMetadata, error => error ? reject(error) : resolve());
});
}
private static async handleCleanDeployment(newPackageLocation: string): Promise<void> {
// no diff manifest
const source: GetUriOptions = {directory: Directory.Data, path: LocalPackage.DownloadUnzipDir};
const target: GetUriOptions = {directory: Directory.Data, path: newPackageLocation};
// TODO: create destination directory if it doesn't exist
return FileUtil.copyDirectoryEntriesTo(source, target);
}
private static async copyCurrentPackage(newPackageLocation: string, ignoreList: string[]): Promise<void> {
const currentPackagePath = await new Promise<string | void>(resolve => {
LocalPackage.getPackage(LocalPackage.PackageInfoFile, (currentPackage: LocalPackage) => resolve(currentPackage.localPath), () => resolve());
});
newPackageLocation = currentPackagePath ? newPackageLocation : newPackageLocation + "/public";
// https://github.com/ionic-team/capacitor/pull/2514 Directory.Application variable was removed. (TODO - for check)
const source: GetUriOptions = currentPackagePath ? {directory: Directory.Data, path: currentPackagePath} : {directory: Directory.Data, path: "public"};
const target: GetUriOptions = {directory: Directory.Data, path: newPackageLocation};
return FileUtil.copyDirectoryEntriesTo(source, target, ignoreList);
}
private static async handleDiffDeployment(newPackageLocation: string, diffManifest: GetUriOptions): Promise<void> {
let manifest: IDiffManifest;
try {
await LocalPackage.copyCurrentPackage(newPackageLocation, [".codepushrelease"]);
await LocalPackage.handleCleanDeployment(newPackageLocation);
/* delete files mentioned in the manifest */
const content = await FileUtil.readFile(diffManifest.directory, diffManifest.path);
manifest = JSON.parse(content);
await FileUtil.deleteEntriesFromDataDirectory(newPackageLocation, manifest.deletedFiles);
} catch (error) {
throw new Error("Cannot perform diff-update.");
}
}
/**
* Writes the given local package information to the current package information file.
* @param packageInfoMetadata The object to serialize.
* @param callback In case of an error, this function will be called with the error as the fist parameter.
*/
public static writeCurrentPackageInformation(packageInfoMetadata: IPackageInfoMetadata, callback: Callback<void>): void {
var content = JSON.stringify(packageInfoMetadata);
FileUtil.writeStringToDataFile(content, LocalPackage.RootDir + "/" + LocalPackage.PackageInfoFile, true, callback);
}
/**
* Backs up the current package information to the old package information file.
* This file is used for recovery in case of an update going wrong.
* @param callback In case of an error, this function will be called with the error as the fist parameter.
*/
public static async backupPackageInformationFile(): Promise<void> {
const source: GetUriOptions = {
directory: Directory.Data,
path: LocalPackage.RootDir + "/" + LocalPackage.PackageInfoFile
};
const destination: GetUriOptions = {
directory: Directory.Data,
path: LocalPackage.RootDir + "/" + LocalPackage.OldPackageInfoFile
};
return FileUtil.copy(source, destination);
}
/**
* Get the previous package information.
*
* @param packageSuccess Callback invoked with the old package information.
* @param packageError Optional callback invoked in case of an error.
*/
public static getOldPackage(packageSuccess: SuccessCallback<LocalPackage>, packageError?: ErrorCallback): void {
LocalPackage.getPackage(LocalPackage.OldPackageInfoFile, packageSuccess, packageError);
}
/**
* Reads package information from a given file.
*
* @param packageFile The package file name.
* @param packageSuccess Callback invoked with the package information.
* @param packageError Optional callback invoked in case of an error.
*/
public static async getPackage(packageFile: string, packageSuccess: SuccessCallback<LocalPackage>, packageError?: ErrorCallback): Promise<void> {
var handleError = (e: Error) => {
packageError && packageError(new Error("Cannot read package information. " + CodePushUtil.getErrorMessage(e)));
};
try {
const content = await FileUtil.readDataFile(LocalPackage.RootDir + "/" + packageFile);
const packageInfo: IPackageInfoMetadata = JSON.parse(content);
LocalPackage.getLocalPackageFromMetadata(packageInfo).then(packageSuccess, packageError);
} catch (e) {
handleError(e);
}
}
private static async getLocalPackageFromMetadata(metadata: IPackageInfoMetadata): Promise<LocalPackage> {
if (!metadata) {
throw new Error("Invalid package metadata.");
}
const installFailed = await NativeAppInfo.isFailedUpdate(metadata.packageHash);
const isFirstRun = await NativeAppInfo.isFirstRun(metadata.packageHash);
const localPackage = new LocalPackage();
localPackage.appVersion = metadata.appVersion;
localPackage.deploymentKey = metadata.deploymentKey;
localPackage.description = metadata.description;
localPackage.isMandatory = metadata.isMandatory;
localPackage.failedInstall = installFailed;
localPackage.isFirstRun = isFirstRun;
localPackage.label = metadata.label;
localPackage.localPath = metadata.localPath;
localPackage.packageHash = metadata.packageHash;
localPackage.packageSize = metadata.packageSize;
return localPackage;
}
public static getCurrentOrDefaultPackage(): Promise<LocalPackage> {
return LocalPackage.getPackageInfoOrDefault(LocalPackage.PackageInfoFile);
}
public static async getOldOrDefaultPackage(): Promise<LocalPackage> {
return LocalPackage.getPackageInfoOrDefault(LocalPackage.OldPackageInfoFile);
}
public static async getPackageInfoOrDefault(packageFile: string): Promise<LocalPackage> {
return new Promise<LocalPackage>((resolve, reject) => {
const packageFailure = async () => {
/**
* For the default package we need the app version,
* and ideally the hash of the binary contents.
*/
let appVersion;
try {
appVersion = await NativeAppInfo.getApplicationVersion();
} catch (appVersionError) {
CodePushUtil.logError("Could not get application version." + appVersionError);
reject(appVersionError);
return;
}
const defaultPackage: LocalPackage = new LocalPackage();
defaultPackage.appVersion = appVersion;
try {
defaultPackage.packageHash = await NativeAppInfo.getBinaryHash();
} catch (binaryHashError) {
CodePushUtil.logError("Could not get binary hash." + binaryHashError);
}
resolve(defaultPackage);
};
LocalPackage.getPackage(packageFile, resolve as any, packageFailure);
});
}
public static getPackageInfoOrNull(packageFile: string, packageSuccess: SuccessCallback<LocalPackage>, packageError?: ErrorCallback): void {
LocalPackage.getPackage(packageFile, packageSuccess, packageSuccess.bind(null, null));
}
/**
* Returns the default options for the CodePush install operation.
* If the options are not defined yet, the static DefaultInstallOptions member will be instantiated.
*/
private static getDefaultInstallOptions(): InstallOptions {
if (!LocalPackage.DefaultInstallOptions) {
LocalPackage.DefaultInstallOptions = {
installMode: InstallMode.ON_NEXT_RESTART,
minimumBackgroundDuration: 0,
mandatoryInstallMode: InstallMode.IMMEDIATE
};
}
return LocalPackage.DefaultInstallOptions;
}
}