| /** |
| 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; |