@d2d/cordova-plugin-ionic
Version:
Ionic Cordova SDK
785 lines (679 loc) • 25.7 kB
text/typescript
/// <reference types="cordova" />
import {
CallbackFunction,
CheckForUpdateResponse,
IAppInfo,
ICurrentConfig,
IDeployConfig,
IPluginBaseAPI,
ISnapshotInfo,
ISyncOptions,
} from './IonicCordova';
declare const cordova: Cordova;
const channel = cordova.require('cordova/channel');
channel.createSticky('onIonicProReady');
channel.waitForInitialization('onIonicProReady');
declare const Ionic: any;
declare const WEBVIEW_SERVER_URL: string;
declare const Capacitor: any;
enum UpdateMethod {
BACKGROUND = 'background',
AUTO = 'auto',
NONE = 'none',
}
enum UpdateState {
Available = 'available',
Pending = 'pending',
Ready = 'ready',
}
import {
FetchManifestResp, IAvailableUpdate,
ISavedPreferences,
ManifestFileEntry,
} from './definitions';
import {
isPluginConfig
} from './guards';
class Path {
static join(...paths: string[]): string {
let fullPath: string = paths.shift() || '';
for (const path of paths) {
if (fullPath && fullPath.slice(-1) !== '/') {
fullPath += '/';
}
fullPath = path.slice(0, 1) !== '/' ? fullPath + path : fullPath + path.slice(1);
}
return fullPath;
}
}
/**
* LIVE UPDATE API
*
* The plugin API for the live updates feature.
*/
class IonicDeployImpl {
private readonly appInfo: IAppInfo;
private _savedPreferences: ISavedPreferences;
private _fileManager: FileManager = new FileManager();
private SNAPSHOT_CACHE = 'ionic_built_snapshots';
private MANIFEST_FILE = 'pro-manifest.json';
public PLUGIN_VERSION = '5.4.7';
constructor(appInfo: IAppInfo, preferences: ISavedPreferences) {
this.appInfo = appInfo;
this._savedPreferences = preferences;
}
async _handleInitialPreferenceState() {
// make sure we're not going to redirect to a stale version
await this.cleanCurrentVersionIfStale();
const isOnline = navigator && navigator.onLine;
if (!isOnline) {
console.warn('The device appears to be offline. Loading last available version and skipping update checks.');
this.reloadApp();
return;
}
const updateMethod = this._savedPreferences.updateMethod;
switch (updateMethod) {
case UpdateMethod.AUTO:
// NOTE: call sync with background as override to avoid sync
// reloading the app and manually reload always once sync has
// set the correct currentVersionId
console.log('calling _sync');
try {
await this.sync({updateMethod: UpdateMethod.BACKGROUND});
} catch (e) {
console.warn(e);
console.warn('Sync failed. Defaulting to last available version.');
}
console.log('calling _reload');
await this.reloadApp();
console.log('done _reloading');
break;
case UpdateMethod.NONE:
this.reloadApp();
break;
default:
// NOTE: default anything that doesn't explicitly match to background updates
await this.reloadApp();
try {
this.sync({updateMethod: UpdateMethod.BACKGROUND});
} catch (e) {
console.warn(e);
console.warn('Background sync failed. Unable to check for new updates.');
}
return;
}
}
getSnapshotCacheDir(versionId: string): string {
return new URL(Path.join(this.appInfo.dataDirectory, this.SNAPSHOT_CACHE, versionId)).pathname;
}
getBundledAppDir(): string {
let folder = 'www';
if (typeof (Capacitor) !== 'undefined') {
folder = 'public';
}
return folder;
}
private async _savePrefs(prefs: ISavedPreferences): Promise<ISavedPreferences> {
return new Promise<ISavedPreferences>(async (resolve, reject) => {
try {
cordova.exec(async (savedPrefs: ISavedPreferences) => {
resolve(savedPrefs);
}, reject, 'IonicCordovaCommon', 'setPreferences', [prefs]);
} catch (e) {
reject(e.message);
}
});
}
async configure(config: IDeployConfig) {
if (!isPluginConfig(config)) {
throw new Error('Invalid Config Object');
}
await new Promise((resolve, reject) => {
cordova.exec(resolve, reject, 'IonicCordovaCommon', 'configure', [config]);
});
Object.assign(this._savedPreferences, config);
this._savePrefs(this._savedPreferences);
}
async checkForUpdate(): Promise<CheckForUpdateResponse> {
const isOnline = navigator && navigator.onLine;
if (!isOnline) {
throw new Error('The device is offline.');
}
const prefs = this._savedPreferences;
const appInfo = this.appInfo;
const endpoint = `${prefs.host}/apps/${prefs.appId}/channels/check-device`;
const device_details = {
binary_version: prefs.binaryVersionName,
device_id: appInfo.device || null,
platform: appInfo.platform,
platform_version: appInfo.platformVersion,
snapshot: prefs.currentVersionId,
build: prefs.currentBuildId
};
const body = {
channel_name: prefs.channel,
app_id: prefs.appId,
device: device_details,
plugin_version: this.PLUGIN_VERSION,
manifest: true
};
const timeout = new Promise( (resolve, reject) => {
setTimeout(reject, 5000, 'Request timed out. The device maybe offline.');
});
const request = fetch(endpoint, {
method: 'POST',
headers: new Headers({
'Content-Type': 'application/json'
}),
body: JSON.stringify(body)
});
const resp = await (Promise.race([timeout, request]) as Promise<Response>);
let jsonResp;
if (resp.status < 500) {
jsonResp = await resp.json();
}
if (resp.ok) {
const checkForUpdateResp: CheckForUpdateResponse = jsonResp.data;
if (checkForUpdateResp.available && checkForUpdateResp.url && checkForUpdateResp.snapshot && checkForUpdateResp.build) {
prefs.availableUpdate = {
binaryVersionCode: prefs.binaryVersionCode,
binaryVersionName: prefs.binaryVersionName,
channel: prefs.channel,
state: UpdateState.Available,
lastUsed: new Date().toISOString(),
url: checkForUpdateResp.url,
versionId: checkForUpdateResp.snapshot,
buildId: checkForUpdateResp.build
};
await this._savePrefs(prefs);
}
return checkForUpdateResp;
}
throw new Error(`Error Status ${resp.status}: ${jsonResp ? jsonResp.error.message : await resp.text()}`);
}
async downloadUpdate(progress?: CallbackFunction<number>): Promise<boolean> {
const prefs = this._savedPreferences;
if (prefs.availableUpdate && prefs.availableUpdate.state === UpdateState.Available) {
const { fileBaseUrl, manifestJson } = await this._fetchManifest(prefs.availableUpdate.url);
const diffedManifest = await this._diffManifests(manifestJson);
await this.prepareUpdateDirectory(prefs.availableUpdate.versionId);
await this._downloadFilesFromManifest(fileBaseUrl, diffedManifest, prefs.availableUpdate.versionId, progress);
prefs.availableUpdate.state = UpdateState.Pending;
await this._savePrefs(prefs);
return true;
}
return false;
}
private async _downloadFilesFromManifest(baseUrl: string, manifest: ManifestFileEntry[], versionId: string, progress?: CallbackFunction<number>) {
console.log('Downloading update...');
let size = 0, downloaded = 0;
manifest.forEach(i => {
size += i.size;
});
const beforeDownloadTimer = new Timer('downloadTimer');
const downloadFile = async (file: ManifestFileEntry) => {
const base = new URL(baseUrl);
const newUrl = new URL(file.href, baseUrl);
newUrl.search = base.search;
const filePath = Path.join(this.getSnapshotCacheDir(versionId), file.href);
await this._fileManager.downloadAndWriteFile(newUrl.toString(), filePath);
// Update progress
downloaded += file.size;
if (progress) {
progress(Math.floor((downloaded / size) * 100));
}
};
let downloads = [];
let count = 0;
console.log(`About to download ${manifest.length} new files for update.`);
const maxBatch = 20;
let numberBatches = Math.round(manifest.length / maxBatch);
if (manifest.length % maxBatch !== 0) {
numberBatches = numberBatches + 1;
}
for (const entry of manifest) {
if (downloads.length >= maxBatch) {
count++;
await Promise.all(downloads);
beforeDownloadTimer.diff(`downloaded batch ${count} of ${numberBatches} downloads. Done downloading ${count * maxBatch} of ${manifest.length} files`);
downloads = [];
}
downloads.push(downloadFile(entry));
}
if (downloads.length) {
count++;
await Promise.all(downloads);
beforeDownloadTimer.diff(`downloaded batch ${count} of ${numberBatches} downloads. Done downloading all ${manifest.length} files`);
}
beforeDownloadTimer.end(`Downloaded ${manifest.length} files`);
}
private async _fetchManifest(url: string): Promise<FetchManifestResp> {
const resp = await fetch(url, {
method: 'GET',
redirect: 'follow',
});
return {
fileBaseUrl: resp.url,
manifestJson: await resp.json()
};
}
private async _diffManifests(newManifest: ManifestFileEntry[]) {
try {
const manifestResp = await fetch(`${WEBVIEW_SERVER_URL}/${this.MANIFEST_FILE}`);
const bundledManifest: ManifestFileEntry[] = await manifestResp.json();
const bundleManifestStrings = bundledManifest.map(entry => JSON.stringify(entry));
return newManifest.filter(entry => bundleManifestStrings.indexOf(JSON.stringify(entry)) === -1);
} catch (e) {
return newManifest;
}
}
private async prepareUpdateDirectory(versionId: string) {
await this._cleanSnapshotDir(versionId);
console.log('Cleaned version directory');
await this._copyBaseAppDir(versionId);
console.log('Copied base app resources');
}
async extractUpdate(progress?: CallbackFunction<number>): Promise<boolean> {
const prefs = this._savedPreferences;
if (!prefs.availableUpdate || prefs.availableUpdate.state !== UpdateState.Pending) {
return false;
}
if (progress) {
progress(100);
}
prefs.availableUpdate.state = UpdateState.Ready;
prefs.updates[prefs.availableUpdate.versionId] = prefs.availableUpdate;
await this._savePrefs(prefs);
return true;
}
async reloadApp(): Promise<boolean> {
const prefs = this._savedPreferences;
// Save the current update if it's ready
if (prefs.availableUpdate && prefs.availableUpdate.state === UpdateState.Ready) {
prefs.currentVersionId = prefs.availableUpdate.versionId;
prefs.currentBuildId = prefs.availableUpdate.buildId;
delete prefs.availableUpdate;
await this._savePrefs(prefs);
}
// Is there a non-binary version deployed?
if (prefs.currentVersionId) {
// Are we already running the deployed version?
if (await this._isRunningVersion(prefs.currentVersionId)) {
console.log(`Already running version ${prefs.currentVersionId}`);
await this._savePrefs(prefs);
channel.onIonicProReady.fire();
Ionic.WebView.persistServerBasePath();
await this.cleanupVersions();
return false;
}
// Is the current version on the device?
if (!(prefs.currentVersionId in prefs.updates)) {
console.error(`Missing version ${prefs.currentVersionId}`);
channel.onIonicProReady.fire();
return false;
}
// Reload the webview
const newLocation = this.getSnapshotCacheDir(prefs.currentVersionId);
Ionic.WebView.setServerBasePath(newLocation);
return true;
}
channel.onIonicProReady.fire();
return false;
}
// compare an update to the current version using both name & code
private isCurrentVersion(update: IAvailableUpdate) {
const currentVersionCode = this._savedPreferences.binaryVersionCode;
const currentVersionName = this._savedPreferences.binaryVersionName;
console.log(`Current: versionCode: ${currentVersionCode} versionName: ${currentVersionName}`);
console.log(`update: versionCode: ${update.binaryVersionCode} versionName: ${update.binaryVersionName}`);
return update.binaryVersionName === currentVersionName && update.binaryVersionCode === currentVersionCode;
}
private async cleanCurrentVersionIfStale() {
const prefs = this._savedPreferences;
// Is the current version built from a previous binary?
if (prefs.currentVersionId) {
if (!this.isCurrentVersion(prefs.updates[prefs.currentVersionId]) && !(await this._isRunningVersion(prefs.currentVersionId))) {
console.log(
`Update ${prefs.currentVersionId} was built for different binary version removing update from device` +
`Update binaryVersionName: ${prefs.updates[prefs.currentVersionId].binaryVersionName}, Device binaryVersionName ${prefs.binaryVersionName}` +
`Update binaryVersionCode: ${prefs.updates[prefs.currentVersionId].binaryVersionCode}, Device binaryVersionCode ${prefs.binaryVersionCode}`
);
const versionId = prefs.currentVersionId;
// NOTE: deleting pref.currentVersionId here to fool deleteVersionById into deleting it
delete prefs.currentVersionId;
await this.deleteVersionById(versionId);
}
}
}
private async _isRunningVersion(versionId: string) {
const currentPath = await this._getServerBasePath();
return currentPath.includes(versionId);
}
private async _getServerBasePath(): Promise<string> {
return new Promise<string>( async (resolve, reject) => {
try {
Ionic.WebView.getServerBasePath(resolve);
} catch (e) {
reject(e);
}
});
}
private async _cleanSnapshotDir(versionId: string) {
const timer = new Timer('CleanSnapshotDir');
const snapshotDir = this.getSnapshotCacheDir(versionId);
try {
await this._fileManager.remove(snapshotDir);
timer.end();
} catch (e) {
console.log('No directory found for snapshot no need to delete');
timer.end();
}
}
private async _copyBaseAppDir(versionId: string) {
const timer = new Timer('CopyBaseApp');
await this._fileManager.copyTo({
source: {
path: this.getBundledAppDir(),
directory: 'APPLICATION',
},
target: this.getSnapshotCacheDir(versionId),
});
timer.end();
}
async getCurrentVersion(): Promise<ISnapshotInfo | undefined> {
const versionId = this._savedPreferences.currentVersionId;
if (typeof versionId === 'string') {
return this.getVersionById(versionId);
}
return;
}
async getVersionById(versionId: string): Promise<ISnapshotInfo | undefined> {
const update = this._savedPreferences.updates[versionId];
if (!update) {
return;
}
return this._convertToSnapshotInfo(update);
}
private _convertToSnapshotInfo(update: IAvailableUpdate): ISnapshotInfo {
return {
deploy_uuid: update.versionId,
versionId: update.versionId,
buildId: update.buildId,
channel: update.channel,
binary_version: update.binaryVersionName,
binaryVersion: update.binaryVersionName,
binaryVersionCode: update.binaryVersionCode,
binaryVersionName: update.binaryVersionName
};
}
async getAvailableVersions(): Promise<ISnapshotInfo[]> {
return Object.keys(this._savedPreferences.updates).map(k => this._convertToSnapshotInfo(this._savedPreferences.updates[k]));
}
async deleteVersionById(versionId: string): Promise<boolean> {
const prefs = this._savedPreferences;
if (prefs.currentVersionId === versionId) {
throw Error(`Can't delete version with id: ${versionId} as it is the current version.`);
}
delete prefs.updates[versionId];
await this._savePrefs(prefs);
// delete snapshot directory
await this._cleanSnapshotDir(versionId);
return true;
}
private getStoredUpdates() {
// get an array of stored updates minus current deployed one
const prefs = this._savedPreferences;
const updates = [];
for (const versionId of Object.keys(prefs.updates)) {
// don't clean up the current version
if (versionId !== prefs.currentVersionId) {
updates.push(prefs.updates[versionId]);
}
}
return updates;
}
private async cleanupVersions() {
const prefs = this._savedPreferences;
let updates = this.getStoredUpdates();
// First clean stale versions
for (const update of updates) {
if (!this.isCurrentVersion(update)) {
console.log(
`Update ${update.versionId} was built for different binary version removing update from device` +
`Update binaryVersionName: ${update.binaryVersionName}, Device binaryVersionName ${prefs.binaryVersionName}` +
`Update binaryVersionCode: ${update.binaryVersionCode}, Device binaryVersionCode ${prefs.binaryVersionCode}`
);
await this.deleteVersionById(update.versionId);
}
}
// clean down to Max Updates stored
updates = this.getStoredUpdates();
updates = updates.sort((a, b) => a.lastUsed.localeCompare(b.lastUsed));
updates = updates.reverse();
updates = updates.slice(prefs.maxVersions);
for (const update of updates) {
await this.deleteVersionById(update.versionId);
}
}
async sync(syncOptions: ISyncOptions = {}, progress?: CallbackFunction<number>): Promise<ISnapshotInfo | undefined> {
const prefs = this._savedPreferences;
// TODO: Get API override if present?
const updateMethod = syncOptions.updateMethod || prefs.updateMethod;
const wrappedProgress = progress ? (complete?: number) => {
progress(complete);
} : undefined;
await this.checkForUpdate();
if (prefs.availableUpdate) {
if (prefs.availableUpdate.state === UpdateState.Available) {
await this.downloadUpdate(wrappedProgress);
}
if (prefs.availableUpdate.state === UpdateState.Pending) {
// ignore progress from this since it's trivial
await this.extractUpdate();
}
if (prefs.availableUpdate.state === UpdateState.Ready && updateMethod === UpdateMethod.AUTO) {
await this.reloadApp();
}
}
if (prefs.currentVersionId && prefs.currentBuildId) {
return {
deploy_uuid: prefs.currentVersionId,
versionId: prefs.currentVersionId,
buildId: prefs.currentBuildId,
channel: prefs.channel,
binary_version: prefs.binaryVersionName,
binaryVersion: prefs.binaryVersionName,
binaryVersionCode: prefs.binaryVersionCode,
binaryVersionName: prefs.binaryVersionName
};
}
return;
}
}
class FileManager {
async copyTo(options: { source: { directory: string; path: string; } , target: string}) {
return new Promise<void>( (resolve, reject) => {
cordova.exec(resolve, reject, 'IonicCordovaCommon', 'copyTo', [options]);
});
}
async remove(path: string) {
return new Promise<void>( (resolve, reject) => {
cordova.exec(resolve, reject, 'IonicCordovaCommon', 'remove', [{target: path}]);
});
}
async downloadAndWriteFile(url: string, path: string) {
return new Promise<void>( (resolve, reject) => {
cordova.exec(resolve, reject, 'IonicCordovaCommon', 'downloadFile', [{url, target: path}]);
});
}
}
class IonicDeploy implements IDeployPluginAPI {
private parent: IPluginBaseAPI;
private delegate: Promise<IonicDeployImpl>;
private fetchIsAvailable: boolean;
private lastPause = 0;
private minBackgroundDuration = 10;
private disabled = false;
constructor(parent: IPluginBaseAPI) {
this.parent = parent;
this.delegate = this.initialize();
this.fetchIsAvailable = typeof(fetch) === 'function';
document.addEventListener('deviceready', this.onLoad.bind(this));
}
async initialize() {
const preferences = await this._initPreferences();
this.minBackgroundDuration = preferences.minBackgroundDuration;
this.disabled = preferences.disabled || !this.fetchIsAvailable;
const appInfo = await this.parent.getAppDetails();
const delegate = new IonicDeployImpl(appInfo, preferences);
// Only initialize start the plugin if fetch is available and DisableDeploy preference is false
if (this.disabled) {
let disabledMessage = 'cordova-plugin-ionic has been disabled.';
if (!this.fetchIsAvailable) {
disabledMessage = 'Fetch is unavailable so ' + disabledMessage;
}
console.warn(disabledMessage);
channel.onIonicProReady.fire();
} else {
await delegate._handleInitialPreferenceState();
}
return delegate;
}
async onLoad() {
document.addEventListener('pause', this.onPause.bind(this));
document.addEventListener('resume', this.onResume.bind(this));
await this.onResume();
}
async onPause() {
this.lastPause = Date.now();
}
async onResume() {
if (!this.disabled && this.lastPause && this.minBackgroundDuration && Date.now() - this.lastPause > this.minBackgroundDuration * 1000) {
await (await this.delegate)._handleInitialPreferenceState();
}
}
async _initPreferences(): Promise<ISavedPreferences> {
return new Promise<ISavedPreferences>(async (resolve, reject) => {
try {
channel.onNativeReady.subscribe(async () => {
// timeout to let browser proxy to init
window.setTimeout(function () {
cordova.exec(async (prefs: ISavedPreferences) => {
resolve(prefs);
}, reject, 'IonicCordovaCommon', 'getPreferences');
}, 0);
});
} catch (e) {
channel.onIonicProReady.fire();
reject(e.message);
}
});
}
async checkForUpdate(): Promise<CheckForUpdateResponse> {
if (!this.disabled) {
return (await this.delegate).checkForUpdate();
}
return {available: false, compatible: false, partial: false};
}
async configure(config: IDeployConfig): Promise<void> {
if (!this.disabled) return (await this.delegate).configure(config);
}
async getConfiguration(): Promise<ICurrentConfig> {
return new Promise<ICurrentConfig>(async (resolve, reject) => {
try {
cordova.exec(async (prefs: ISavedPreferences) => {
if (prefs.availableUpdate) {
delete prefs.availableUpdate;
}
if (prefs.updates) {
delete (prefs as any).updates;
}
resolve(prefs);
}, reject, 'IonicCordovaCommon', 'getPreferences');
} catch (e) {
reject(e.message);
}
});
}
async deleteVersionById(version: string): Promise<boolean> {
if (!this.disabled) return (await this.delegate).deleteVersionById(version);
return true;
}
async downloadUpdate(progress?: CallbackFunction<number>): Promise<boolean> {
if (!this.disabled) return (await this.delegate).downloadUpdate(progress);
return false;
}
async extractUpdate(progress?: CallbackFunction<number>): Promise<boolean> {
if (!this.disabled) return (await this.delegate).extractUpdate(progress);
return false;
}
async getAvailableVersions(): Promise<ISnapshotInfo[]> {
if (!this.disabled) return (await this.delegate).getAvailableVersions();
return [];
}
async getCurrentVersion(): Promise<ISnapshotInfo | undefined> {
if (!this.disabled) return (await this.delegate).getCurrentVersion();
return;
}
async getVersionById(versionId: string): Promise<ISnapshotInfo | undefined> {
if (!this.disabled) return (await this.delegate).getVersionById(versionId);
return;
}
async reloadApp(): Promise<boolean> {
if (!this.disabled) return (await this.delegate).reloadApp();
return false;
}
async sync(syncOptions: ISyncOptions = {}, progress?: CallbackFunction<number>): Promise<ISnapshotInfo | undefined> {
if (!this.disabled) return (await this.delegate).sync(syncOptions, progress);
return;
}
}
/**
* BASE API
*
* All features of the Ionic Cordova plugin are registered here, along with some low level error tracking features used
* by the monitoring service.
*/
class IonicCordova implements IPluginBaseAPI {
public deploy: IDeployPluginAPI;
constructor() {
this.deploy = new IonicDeploy(this);
}
getAppInfo(success: CallbackFunction<IAppInfo>, failure: CallbackFunction<string>) {
console.warn('This function has been deprecated in favor of IonicCordova.getAppDetails.');
this.getAppDetails().then(
result => success(result),
err => {
typeof err === 'string' ? failure(err) : failure(err.message);
}
);
}
async getAppDetails(): Promise<IAppInfo> {
return new Promise<IAppInfo>( (resolve, reject) => {
cordova.exec(resolve, reject, 'IonicCordovaCommon', 'getAppInfo');
});
}
}
class Timer {
name: string;
startTime: Date;
lastTime: Date;
constructor(name: string) {
this.name = name;
this.startTime = new Date();
this.lastTime = new Date();
console.log(`Starting IonicTimer ${this.name}`);
}
end(extraLog?: string) {
console.log(`Finished IonicTimer ${this.name} in ${(new Date().getTime() - this.startTime.getTime()) / 1000} seconds.`);
if (extraLog) {
console.log(`IonicTimer extra ${extraLog}`);
}
}
diff(message?: string) {
console.log(`Message: ${message} Diff IonicTimer ${this.name} in ${(new Date().getTime() - this.lastTime.getTime()) / 1000} seconds.`);
this.lastTime = new Date();
}
}
const instance = new IonicCordova();
export = instance;