blob: e0bc6862d9aac34c611726687b483102e530842b [file] [log] [blame]
/**
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
*/
var fs = require('fs'),
path = require('path'),
crypto = require('crypto'),
Q = require('q'),
shelljs = require('shelljs'),
JSZip = require('jszip');
var IGNORE_PATH_FOR_PUSH_REGEXP = /www\/(?:plugins\/|cordova\.js)/;
function getDerivedWwwDir(dir, platformId) {
if (platformId == 'android') {
return path.join(dir, 'platforms', platformId, 'assets', 'www');
} else if (platformId == 'ios') {
return path.join(dir, 'platforms', platformId, 'www');
}
throw new Error('Platform not supported: ' + platformId);
}
function getDerivedConfigXmlPath(dir, platformId) {
if (platformId == 'android') {
return path.join(dir, 'platforms', platformId, 'res', 'xml', 'config.xml');
} else if (platformId == 'ios') {
var base = path.join(dir, 'platforms', platformId);
var ret = null;
if (fs.existsSync(base)) {
fs.readdirSync(base).forEach(function(a) {
if (a != 'www' && a != 'cordova' && a != 'CordovaLib') {
var fullPath = path.join(base, a, 'config.xml');
if (fs.existsSync(fullPath)) {
ret = fullPath;
}
}
});
}
return ret;
}
throw new Error('Platform not supported: ' + platformId);
}
function discoverAppId(dir) {
var configXmlPath = path.join(dir, 'config.xml');
if (!fs.existsSync(configXmlPath)) {
configXmlPath = path.join(dir, 'www', 'config.xml');
}
if (!fs.existsSync(configXmlPath)) {
throw new Error('Not a Cordova project: ' + dir);
}
var configData = fs.readFileSync(configXmlPath, 'utf8');
var m = /\bid="(.*?)"/.exec(configData);
var appId = m && m[1];
if (!appId) {
throw new Error('Could not find app ID within: ' + configXmlPath);
}
return appId;
}
function calculateMd5(fileName) {
var BUF_LENGTH = 64*1024;
var buf = new Buffer(BUF_LENGTH);
var bytesRead = BUF_LENGTH;
var pos = 0;
var fdr = fs.openSync(fileName, 'r');
var md5sum = crypto.createHash('md5');
try {
while (bytesRead === BUF_LENGTH) {
bytesRead = fs.readSync(fdr, buf, 0, BUF_LENGTH, pos);
pos += bytesRead;
md5sum.update(buf.slice(0, bytesRead));
}
} finally {
fs.closeSync(fdr);
}
return md5sum.digest('hex');
}
function buildAssetMap(dir, configXmlPath) {
var fileList = shelljs.find(dir).filter(function(a) {
return !IGNORE_PATH_FOR_PUSH_REGEXP.exec(a) && !fs.statSync(a).isDirectory();
});
var ret = Object.create(null);
for (var i = 0; i < fileList.length; ++i) {
// TODO: convert windows slash to unix slash here.
var appPath = 'www/' + fileList[i].slice(dir == '.' ? 0 : dir.length + 1);
ret[appPath] = {
dstPath: appPath,
realPath: fileList[i],
etag: calculateMd5(fileList[i]),
};
}
if (configXmlPath) {
ret['config.xml'] = {
dstPath: 'config.xml',
realPath: configXmlPath,
etag: calculateMd5(configXmlPath)
};
}
return ret;
}
function buildDeleteList(existingAssetManifest, assetMap) {
var toDelete = [];
for (var k in existingAssetManifest) {
// Don't delete top-level files ever.
if (k.slice(0, 4) != 'www/') {
continue;
}
if (!assetMap[k]) {
toDelete.push(k);
}
}
return toDelete;
}
function buildPushList(existingAssetManifest, assetMap) {
var ret = [];
for (var k in assetMap) {
var entry = assetMap[k];
if (entry.etag != existingAssetManifest[k]) {
if (entry.dstPath == 'config.xml') {
ret.unshift(entry);
} else {
ret.push(entry);
}
}
}
return ret;
}
function buildZipAssetManifest(pushList) {
var ret = Object.create(null);
// TODO: as of v0.6.2-dev of harness, this can be: {path:etag}.
for (var i = 0; i < pushList.length; ++i) {
ret[pushList[i].dstPath] = pushList[i].etag;
}
return ret;
}
function calculatePushBytes(pushList) {
var ret = 0;
for (var i = 0; i < pushList.length; ++i) {
ret += fs.statSync(pushList[i].realPath).size;
}
return ret;
}
function zipDir(dir, zip) {
var contents = fs.readdirSync(dir);
contents.forEach(function(f) {
var fullPath = path.join(dir, f);
if (!IGNORE_PATH_FOR_PUSH_REGEXP.exec(fullPath)) {
if (fs.statSync(fullPath).isDirectory()) {
var inner = zip.folder(f);
zipDir(path.join(dir, f), inner);
} else {
// 'binary' actually means binary string.
// Using optimizedBinaryString speeds things up *a lot*.
zip.file(f, fs.readFileSync(fullPath, 'binary'), { binary: true, optimizedBinaryString: true });
}
}
});
}
function PushSession(harnessClient, dir) {
this.launchAfterPush = true;
this.harnessClient_ = harnessClient;
this.appType_ = null;
this.rootDir_ = dir;
this.wwwDir_ = null;
this.appId_ = null;
this.platformId_ = null;
this.assetManifest_ = null; // { "www/foo": "etag" }
this.assetManifestEtag_ = null;
var self = this;
this.boundClearAssetManifestFn_ = function(e) {
self.assetManifest_ = null;
self.assetManifestEtag_ = null;
return Q.reject(e);
};
}
PushSession.prototype.initialize = function(opts) {
opts = opts || {};
this.appId_ = opts.appId || discoverAppId(this.rootDir_);
var self = this;
return this.harnessClient_.assetmanifest(this.appId_)
.then(function(result) {
self.platformId_ = result.body['platform'];
self.assetManifest_ = result.body['assetManifest'];
self.assetManifestEtag_ = result.body['assetManifestEtag'];
self.wwwDir_ = opts.wwwDir || getDerivedWwwDir(self.rootDir_, self.platformId_);
self.wwwDir_ = self.wwwDir_.replace(/\/$/, '');
self.configXmlPath_ = (typeof opts.configXmlPath == 'undefined') ? getDerivedConfigXmlPath(self.rootDir_, self.platformId_) : opts.configXmlPath;
self.cordovaPluginsPath_ = !opts.skipCordovaPlugins && path.join(self.wwwDir_, 'cordova_plugins.js');
self.appType_ = opts.appType || 'cordova';
});
};
PushSession.prototype.push = function(forceLaunch) {
var self = this;
return Q.when(this.platformId_ || this.initialize())
.then(function() {
if (self.configXmlPath_ && !fs.existsSync(self.configXmlPath_)) {
throw new Error('Could not find: ' + self.configXmlPath_ + ' you probably need to run: cordova platform add ' + self.platformId_);
}
if (self.cordovaPluginsPath_ && !fs.existsSync(self.cordovaPluginsPath_)) {
throw new Error('Could not find: ' + self.cordovaPluginsPath_);
}
var startTime = new Date();
var assetMap = buildAssetMap(self.wwwDir_, self.configXmlPath_);
var deleteList = buildDeleteList(self.assetManifest_ || {}, assetMap);
var pushList = buildPushList(self.assetManifest_ || {}, assetMap);
if (deleteList.length === 0 && pushList.length === 0) {
console.log('Application already up-to-date.');
// The app hasn't changed, so resolve the promise saying so.
// It won't be launched unless `forceLaunch` is true.
return false;
}
var p;
// TODO: It might be faster to use Zip even when some files exist.
if (self.assetManifest_) {
p = self.doPerFileSync_(pushList, startTime);
} else {
p = self.doZipPush_(pushList, startTime);
}
return p.then(function() {
return self.deleteFiles_(deleteList);
}).then(function() {
// The app has changed, so resolve the promise saying so. The app will be launched with the changes.
return true;
});
}).then(function(appChanged) {
// Determine whether we should launch the app.
// We want to launch the app if we've been told to force it or if the app has changed.
var shouldLaunch = forceLaunch || (self.launchAfterPush && appChanged);
if (shouldLaunch) {
return self.harnessClient_.launch(self.appId_);
}
});
};
PushSession.prototype.doPerFileSync_ = function(pushList, startTime) {
var totalPushBytes = calculatePushBytes(pushList);
console.log('Changes calculated (' + (new Date() - startTime) + ')');
var origPushListLen = pushList.length;
var self = this;
return Q.when().then(function pushNextFile() {
if (pushList.length === 0) {
return;
}
var firstRequest = pushList.length === origPushListLen;
var curPushEntry = pushList.shift();
var payload = fs.readFileSync(curPushEntry.realPath);
// TODO handle 409 responses.
return self.harnessClient_.pushFile(self.appId_, self.appType_, payload, curPushEntry.etag, curPushEntry.dstPath, firstRequest && totalPushBytes, firstRequest && self.assetManifestEtag_)
.then(function(newAssetManifestEtag) {
self.assetManifestEtag_ = newAssetManifestEtag;
self.assetManifest_[curPushEntry.dstPath] = curPushEntry.etag;
}, self.boundClearAssetManifestFn_)
.then(pushNextFile);
});
};
PushSession.prototype.deleteFiles_ = function(deleteList) {
if (deleteList.length === 0) {
return Q.when();
}
var self = this;
return this.harnessClient_.deleteFiles(this.appId_, deleteList, this.assetManifestEtag_)
.then(function(newAssetManifestEtag) {
for (var i = 0; i < deleteList.length; ++i) {
delete self.assetManifest_[deleteList[i]];
}
self.assetManifestEtag_ = newAssetManifestEtag;
}, this.boundClearAssetManifestFn_);
};
PushSession.prototype.doZipPush_ = function(pushList, startTime) {
console.log('Changes calculated (' + (new Date() - startTime) + ')');
var zip = new JSZip();
zipDir(this.wwwDir_, zip.folder('www'));
if (this.configXmlPath_) {
zip.file('config.xml', fs.readFileSync(this.configXmlPath_, 'binary'), { binary: true });
}
var zipAssetManifest = buildZipAssetManifest(pushList);
zip.file('zipassetmanifest.json', JSON.stringify(zipAssetManifest));
var zipData = zip.generate({ type: 'nodebuffer' });
var self = this;
return this.harnessClient_.pushZip(this.appId_, this.appType_, zipData, null, this.assetManifestEtag_)
.then(function(newAssetManifestEtag) {
self.assetManifestEtag_ = newAssetManifestEtag;
self.assetManifest_ = zipAssetManifest;
}, this.boundClearAssetManifestFn_);
};
module.exports = PushSession;