@smartface/emulator-dispatcher
Version:
Handles Emulator Dispatcher Part of SmartfaceCloud
832 lines (745 loc) • 28.8 kB
JavaScript
if (!String.prototype.endsWith) {
String.prototype.endsWith = function(searchString, position) {
var subjectString = this.toString();
if (typeof position !== 'number' || !isFinite(position) ||
Math.floor(position) !== position || position > subjectString.length) {
position = subjectString.length;
}
position -= searchString.length;
var lastIndex = subjectString.indexOf(searchString, position);
return lastIndex !== -1 && lastIndex === position;
};
}
const join = require('../util/join');
const fs = require("fs");
const path = require("path");
const uuid = require('node-uuid');
const CRC32 = require("crc-32");
const walk = require('walk');
const DOMParser = require('xmldom').DOMParser;
const xpath = require('xpath');
const reAsset = new RegExp("Assets\\" + path.sep + "\\w+\\.(?:imageset|appiconset|launchimage)\\" + path.sep);
const projectJSONCombiner = require("project-json-combiner");
const exec = require('child_process').exec;
const globalModulesPath = require('global-modules');
const fileQueue = require('filequeue');
const LogToConsole = require('../common/LogToConsole');
const MAX_OPEN_FILES = 5000;
const logger = new LogToConsole(false, "Workflow");
const defaultIndexOptions = {
calculateCRC: true,
addDate: true,
fileKeyType: "uri",
addScaleFactor: false
};
const Device = require("./device");
const getScaleFactor = require("./androidresourcescalefactor");
var _contentsJSONCache = {};
/**
* This callback is provided as function to getProjectID as a parameter
* @callback getProjectIDCallback
* @param {string} projectID - Retrieved from project.json
*/
/**
* Gets project unique identifier from project.json in a Smartface workspace
* @param {boolean} withSave - Optional. If projectID is missing from project.json, creates a new one, saves to project.json and retrieves it. Otherwise if missing it will throw error.
* @param {getProjectIDCallback} callback - Optional. Asynch call function as parameter. If provided, all operations will be synch.
* @returns {undefined} - If callback parameter is provided will return undefined, otherwise will return projectID
* @throws If project.json does not have projectID and withSave parameter is provided as false or not provided at all
*/
function getProjectID(withSave, callback) {
if (typeof callback === "undefined" && typeof withSave === "function") {
return getProjectID(false, withSave);
}
withSave = Boolean(withSave);
var me = this;
if (this.projectID) {
if (callback)
return callback(this.projectID);
else
return this.projectID;
}
if (callback) {
fs.readFile(this.projectJSONPath, "utf8", parseProjectJSONForID);
}
else {
try {
var data = fs.readFileSync(this.projectJSONPath, "utf8");
return parseProjectJSONForID(null, data);
}
catch (ex) {
return parseProjectJSONForID(ex, null);
}
}
var project = {};
function parseProjectJSONForID(err, data) {
if (err)
throw err;
project = JSON.parse(data);
if (!project.projectID) {
if (!withSave) {
throw Error("projectID is missing in project.json." +
"\nPlease update project.json file with a valid id or call this " +
"method withSave option");
}
else {
project.projectID = uuid.v4();
data = JSON.stringify(project, null, 4);
if (callback) {
fs.writeFile(me.projectJSONPath, data, "utf8", retrieveData);
}
else {
try {
fs.writeFileSync(me.projectJSONPath, data, "utf8");
return retrieveData(null);
}
catch (ex) {
return retrieveData(err);
}
}
}
}
else {
return retrieveData(null);
}
}
function retrieveData(err) {
if (err)
throw err;
if (callback) {
callback.call(me, project.projectID);
}
else {
return project.projectID;
}
}
}
/**
* Device info provided as device options
* @typedef {Object} Device
* @property {string} deviceID - Random GUID generated by Emulator once
* @property {string} deviceName - Name of the Device
* @property {string} brandName - Name of the device manifacturer
* @property {string} os - Operating System name of the device (iOS | Android)
* @property {string} osVersion - OS version of the device
* @property {string} smartfaceVersion - Smartface runtime version such as: 4.4.0.1
* @property {Object} screen - Device screen info
* @property {Object} screen.px - Sceen pixel values
* @property {number} screen.px.height - Sceen height pixel value
* @property {number} screen.px.width - Sceen width pixel value
* @property {Object} screen.dp - Sceen dp values
* @property {number} screen.dp.height - Sceen height dp value
* @property {number} screen.dp.width - Sceen width dp value
* @property {Object} screen.pt - Sceen pt values
* @property {number} screen.pt.height - Sceen height pt value
* @property {number} screen.pt.width - Sceen width pt value
* @property {Array.string} resourceFolderOrder - Density based resource folder names given in order
*/
/**
* Emulator Project Index
* @typedef {Object} Index
* @property {**************
*/
/**
* @callback GetIndexCallback
* @param {Index} data - Device index provided as data property of the callback
*/
/**
* Calculates index from workspace
* @param {Device} device - Required. Device information to get correct image resources
* @param {GetIndexCallback} - Required. When provided performs asynch operation
*/
function getIndex(device, callback) {
if (!(device instanceof Device))
device = new Device(device);
var me = this;
me.settings = {};
projectJSONCombiner.getProjectJSON(path.dirname(this.projectJSONPath), fs, function(err, jsonObj) {
if (err) return callback(err);
var index = Object.assign({}, jsonObj, {
files: {}
}),
taskCount = 3;
index.projectID = me.getProjectID(true);
index.files = {};
_contentsJSONCache = {};
updateScriptsPath(index);
try{
delete require.cache[me.settingsPath];
me.settings = require(me.settingsPath);
} catch(e) {
console.error(e);
throw new Error("No settings file found")
}
if (me.hashBinaries) {
taskCount++;
setBinaryHashes();
}
walkFolder(join(me.assetsPath), function walk_callback_assets(files) {
processFolder(index, files, "asset", done);
});
let jsRegExp = /\.js$|\.json$|\.jsx$/;
if(me.settings && me.settings.config && me.settings.config.paths && me.settings.config.paths.output) {
const output = me.settings.config.paths.output;
if(output.acceptedExtensions && output.acceptedExtensions.length) {
jsRegExp = new RegExp(output.acceptedExtensions.map((ext) => escapeRegExp(ext)).join('$|') + '$');
}
if (output.root) {
taskCount += 1;
walkFolder(join(me.path, output.root), function walk_callback_scripts(files) {
processFolder(index, elimaneteUnnecessaryScriptsFiles(files, jsRegExp), "script", done);
});
}
if(output.include && output.include.length) {
taskCount += output.include.length;
walkFolder(join(me.scriptsPath), function walk_callback_scripts(files) {
output.include.forEach( (includePath) => {
processFolder(index, elimaneteUnnecessaryScriptsFiles(files, jsRegExp), "script", done);
})
});
}
} else {
taskCount += 1;
walkFolder(join(me.scriptsPath), function walk_callback_scripts(files) {
processFolder(index, elimaneteUnnecessaryScriptsFiles(files, jsRegExp), "script", done);
});
}
walkFolder(join(me.fontPath), function walk_callback_fonts(files) {
processFolder(index, files, "font", done);
});
var otherMapping = [{
path: join(me.configPath, "defaults.xml"),
scheme: "config",
relativeTo: me.configPath
}];
function handleOther() {
var handled = [];
var mapping;
for (var i = 0; i < otherMapping.length; i++) {
mapping = otherMapping[i];
if (mapping.os && (mapping.os !== device.os)) {
handled.push(i);
continue;
}
fs.stat(mapping.path, function otherStatCallback(err, stats) {
if (err)
return;
taskCount++;
var fileObject = {};
fileObject[mapping.path] = path.relative(mapping.relativeTo, mapping.path);
processFolder(index, fileObject, mapping.scheme, done);
});
}
}
handleOther();
var handleImages;
if (device.os === "iOS") {
handleImages = handleImages_iOS;
}
else if (device.os === "Android") {
handleImages = handleImages_Android;
}
handleImages(device, me, index, done);
function setBinaryHashes() {
const playerFolder = join(globalModulesPath, "smartface", "bin");
var playerPath;
var playerName;
var fileHashes = {};
if (device.os === "iOS") {
playerName = "iOS_Player.zip";
}
else {
if (device.cpu === "x86") {
playerName = "SmartfacePlayer-x86.zip";
}
else {
playerName = "SmartfacePlayer.zip";
}
}
playerPath = join(playerFolder, playerName);
fileHashes[playerPath] = true;
if (index && index.build && index.build.input) {
var osKey = device.os.toLocaleLowerCase();
var pluginConfigSection = index.build.input[osKey] && index.build.input[osKey].plugins;
if (pluginConfigSection) {
for (var i in pluginConfigSection) {
var p = pluginConfigSection[i];
if (typeof p === "object") {
p = p.active && p.path;
}
if (p) {
p = join(me.path, p);
fileHashes[p] = true;
}
}
}
}
hashMD5Files(fileHashes, function(err, fileHashes) {
if (err) throw err;
index.config.rau = index.config.rau || {};
index.config.rau.binary = {
players: {},
plugins: {}
};
var plugins = index.config.rau.binary.plugins[device.os] = {};
var playerHash = fileHashes[playerPath];
var pluginList = Object.keys(fileHashes);
var idx = pluginList.indexOf(playerPath);
if (idx > -1)
pluginList.splice(idx, 1);
if (playerHash) {
index.config.rau.binary.players[playerName] = playerHash;
}
for (var i in pluginList) {
var p = pluginList[i];
var pName = path.basename(p);
plugins[pName] = fileHashes[p];
}
done();
});
}
function updateScriptsPath(index) {
var iOSScripts = index.build.input.ios.scripts,
androidScripts = index.build.input.android.scripts;
if (!iOSScripts || !androidScripts || iOSScripts != androidScripts) {
return;
}
me.scriptsPath = join(me.path, iOSScripts);
me.settingsPath = join(me.scriptsPath, 'settings.json');
}
function done() {
taskCount--;
if (taskCount !== 0)
return;
index = sort(index);
callback(null, index);
}
});
}
/**
* Calculates index from workspace
* @param {Device} device - Required. Device information to get correct image resources
* @param {String} - Optional. Filters by image name
* @param {GetIndexCallback} - Required. When provided performs asynch operation
*/
function getImage(device, imageName, callback, full) {
if (!(device instanceof Device))
device = new Device(device);
if (typeof callback === "undefined" && typeof imageName === "function") {
callback = imageName;
imageName = undefined;
}
var me = this;
full = !!full;
projectJSONCombiner.getProjectJSON(path.dirname(this.projectJSONPath), fs, function(err, jsonObj) {
if (err) return callback(err);
var index = Object.assign({}, jsonObj, {
files: {}
});
var handleImages;
if (device.os === "iOS") {
handleImages = handleImages_iOS;
if (imageName) {
var pp = path.parse(imageName);
if (pp.name.endsWith("@2x") || pp.name.endsWith("@3x")) {
pp.name = pp.name.substring(0, pp.name.length - 3);
}
imageName = [
pp.name + pp.ext,
pp.name + "@2x" + pp.ext,
pp.name + "@3x" + pp.ext
];
}
}
else if (device.os === "Android") {
handleImages = handleImages_Android;
}
handleImages(device, me, index, done, {
calculateCRC: full,
addDate: full,
fileKeyType: "path",
addScaleFactor: device,
fileNameMatch: imageName
});
function done(index) {
callback(null, index.files);
}
});
}
function handleImages_iOS(device, me, index, done, options) {
options = options || {};
//TODO add order from device
var order = device.imageExtensionOrder;
var iOSImagesFolder = join(me.imagesPath, "iOS");
walkFolder(iOSImagesFolder, function walk_callback_iOSImages(files) {
var filesArray = Object.keys(files).filter(function(value) {
var valid = true;
options.fileNameMatch && (
valid = (options.fileNameMatch.indexOf(path.parse(value).base) > -1 || options.fileNameMatch[0] === "*"));
return valid;
}, filesArray);
var images = {};
var newFiles = {};
filesArray.forEach(function(element, idx, array) {
var fileInfo = path.parse(element);
if (fileInfo.base === "Contents.json")
return; //skip Contents.json
var imgInfo = getiOSImageInfo(element, getContentsJSON);
imgInfo.fullPath = element;
imgInfo.priority = order.indexOf(imgInfo.multiplier);
if (!images[imgInfo.name])
images[imgInfo.name] = imgInfo;
else {
var other = images[imgInfo.name];
if (other.priority > imgInfo.priority)
images[imgInfo.name] = imgInfo;
}
});
for (var imgInfoName in images) {
newFiles[images[imgInfoName].fullPath] = files[images[imgInfoName].fullPath];
}
Object.defineProperty(newFiles, "__ofBaseFolder", {
enumerable: false,
configurable: true,
value: files.__ofBaseFolder
});
processFolder(index, newFiles, "image", done, Object.assign({
os: "iOS"
}, defaultIndexOptions, options));
});
}
function getiOSImageInfo(name) {
var fileInfo = path.parse(name);
var imgName = fileInfo.name;
var multiplier = 1;
reAsset.lastIndex = 0; //requires reset before any reuse
if (reAsset.test(name)) { //is an asset image
var contents = getContentsJSON(name);
var imageRecord;
var assetName = path.parse(path.dirname(name)).name;
for (var i = 0; i < contents.images.length; i++) {
imageRecord = contents.images[i];
if (imageRecord.filename === fileInfo.base) {
multiplier = Number(imageRecord.scale[0]);
var ret = {
multiplier: multiplier,
name: assetName,
assetName: assetName
};
return ret;
}
}
return {
name: "",
multiplier: Number.MIN_VALUE
};
}
else { //is not an asset image
if (imgName.endsWith("@2x"))
multiplier = 2;
else if (imgName.endsWith("@3x"))
multiplier = 3;
switch (multiplier) {
case 1:
return {
name: imgName + fileInfo.ext,
multiplier: 1
};
case 2:
case 3:
return {
name: imgName.substr(0, imgName.length - 3) + fileInfo.ext,
multiplier: multiplier
};
default:
throw Error("unhandeled image naming for iOS");
}
}
}
function getContentsJSON(name) {
var contentsJSONPath = join(path.dirname(name), "Contents.json");
if (!_contentsJSONCache[contentsJSONPath]) {
_contentsJSONCache[contentsJSONPath] = JSON.parse(
fs.readFileSync(contentsJSONPath, "utf8"));
}
return _contentsJSONCache[contentsJSONPath];
}
function handleImages_Android(device, me, index, done, options) {
options = options || {};
var androidImagesFolder = join(me.imagesPath, "Android");
walkFolder(androidImagesFolder, function walk_callback_AndroidImages(files) {
var filesArray = Object.keys(files).filter(function(value) {
var valid = path.relative(androidImagesFolder, value).split(path.sep).length === 2 || options.fileNameMatch === "*";
if (valid) {
if (options.fileNameMatch) {
valid = path.parse(value).base === options.fileNameMatch;
}
}
return valid;
}, filesArray);
var images = {};
var newFiles = {};
filesArray.forEach(function(element, idx, array) {
var imgInfo = getAndroidImageInfo(element, device);
imgInfo.fullPath = element;
if (!images[imgInfo.name])
images[imgInfo.name] = imgInfo;
else {
var other = images[imgInfo.name];
if (other.priority > imgInfo.priority)
images[imgInfo.name] = imgInfo;
}
});
for (var imgInfo in images) {
newFiles[images[imgInfo].fullPath] = path.parse(files[images[imgInfo].fullPath]).base;
}
Object.defineProperty(newFiles, "__ofBaseFolder", {
enumerable: false,
configurable: true,
value: files.__ofBaseFolder
});
processFolder(index, newFiles, "image", done, Object.assign({
os: "Android"
}, defaultIndexOptions, options));
});
}
function getAndroidImageInfo(fullPath, device) {
var fileInfo = path.parse(fullPath);
var density = path.parse(path.dirname(fullPath)).name;
var priority = device.resourceFolderOrder.indexOf(density);
return {
name: fileInfo.name,
density: density,
fullPath: fullPath,
priority: priority === -1 ? Number.MAX_VALUE : priority
};
}
function sort(obj) {
if (typeof obj !== "object" || typeof obj === 'undefined' || !obj)
return obj;
var props = Object.keys(obj).sort();
var newObject = {};
var i, p;
for (i = 0; i < props.length; i++) {
p = props[i];
newObject[p] = sort(obj[p]);
}
if (props.length === 0)
return obj;
return newObject;
}
function walkFolder(folder, callback) {
var files = {};
Object.defineProperty(files, "__ofBaseFolder", {
enumerable: false,
configurable: true,
value: folder
});
var walker = walk.walk(folder, {
followLinks: true
});
walker.name = folder;
walker.on("file", fileHandler);
walker.on("end", endHandler);
function fileHandler(root, fileStat, next) {
var fullPath = join(root, fileStat.name);
var relativePath = path.relative(folder, fullPath);
relativePath = join(relativePath.split(path.sep).join("/"));
files[fullPath] = relativePath;
if (typeof next === "function")
next();
}
function endHandler() {
callback(files);
}
}
function processFolder(index, files, schema, callback, options) {
options = options || defaultIndexOptions;
index = index || {};
index.files = index.files || {};
var taskCount = 0,
me = this,
filesArray = Object.keys(files),
fq = new fileQueue(MAX_OPEN_FILES);
for (var i = 0; i < filesArray.length; i++) {
options.addDate && taskCount++;
options.calculateCRC && taskCount++;
var fileKey, file = filesArray[i];
if (options.fileKeyType == "uri") {
fileKey = getURI(file);
}
else if (options.fileKeyType == "path") {
fileKey = file;
}
index.files[fileKey] = index.files[fileKey] || {};
if(schema === 'script') {
index.files[fileKey].fullPath = file;
}
options.addDate && getFileStats(file, fileKey);
options.calculateCRC && getCRC(file, fileKey);
options.addScaleFactor && addScaleFactor(index, file, fileKey, options.addScaleFactor);
}
if (taskCount === 0) {
finalize();
}
function finalize() {
if (taskCount !== 0)
return;
callback.call(me, index);
}
function getFileStats(file, fileKey) {
fq.stat(file, function fStat(err, stats) {
if (err) {
throw err;
}
index.files[fileKey].date = stats.ctime;
taskCount--;
finalize();
});
}
function getCRC(file, fileKey) {
fq.readFile(file, "binary", function(err, data) {
if (err) {
throw err;
}
index.files[fileKey].crc = CRC32.buf(data);
taskCount--;
finalize();
});
}
function getURI(file) {
reAsset.lastIndex = 0;
if (options.os === "Android") {
var density = path.parse(path.dirname(file)).name;
return schema + "://" + files[file] + "?density=" + density;
}
else if (options.os === "iOS") {
if (schema === "image" && reAsset.test(files[file])) {
var fileInfo = path.parse(files[file]);
var assetInfo = path.parse(fileInfo.dir);
var contentJSONImages = _contentsJSONCache[join(path.dirname(file), "Contents.json")].images;
for (var i = 0; i < contentJSONImages.length; i++) {
if (contentJSONImages[i].filename === fileInfo.base) {
return schema + "://" + assetInfo.name +
(contentJSONImages[i].scale === "1x" ? "" : "@" + contentJSONImages[i].scale) + fileInfo.ext + "?path=" + encodeURIComponent(files[file]);
}
}
throw Error("No file found in Xcode asset content.json");
}
else {
}
}
return schema + "://" + files[file];
}
function addScaleFactor(index, file, fileKey, device) {
var imgInfo;
var targetObject = index.files[fileKey];
if (device.os === "iOS") {
imgInfo = getiOSImageInfo(file);
targetObject.imageScaleFactor = imgInfo.multiplier;
}
else if (device.os === "Android") {
imgInfo = getAndroidImageInfo(file, device);
targetObject.imageScaleFactor = getScaleFactor(imgInfo.density);
}
targetObject.scaleWith = device.scaleFactor / targetObject.imageScaleFactor;
isNaN(targetObject.scaleWith) && (targetObject.scaleWith = 1);
}
}
/**
* Workspace constructor options parameter object
* @typedef {Object} workspaceOptions
* @property {string} path - Path of the workspace.
* @property {string} projectJSONPath - Path of the project.json file relative to workspace. Defaults to join(options.path, "config", "project.json")
* @property {string} scriptsPath - Path of the scripts folder relative to workspace. Defaults to join(options.path, "scripts")
* @property {string} imagesPath - Path of the scripts folder relative to workspace. Defaults to join(options.path, "images")
* @property {string} assetsPath - Path of the scripts folder relative to workspace. Defaults to join(options.path, "assets")
* @property {string} fontPath - Path of the scripts folder relative to workspace. Defaults to join(options.path, "config", "Fonts")
* @property {string} projectID - Optinal. Unique Identifier of ProjectID. If not provided a UUID.v4 will be added to project.json and it will be used. Otherwise provided identifier will be used
* @property {string} hashBinaries - Optinal. Should hash binaries within the index
*/
/**
* Creates a new workspace instance with options
* @class
* @param {workspaceOptions} options - Required. Creates a workspace with required options
*/
function Workspace(options) {
if (!(this instanceof Workspace))
return new Workspace(options);
if (!options) {
throw Error("Options are required");
}
/** @type {string} */
this.path = options.path || "/home/ubuntu/workspace/";
/** @type {string} */
this.projectJSONPath = join(this.path, options.projectJSONPath || join("config", "project.json"));
/** @type {string} */
this.scriptsPath = join(this.path, options.scriptsPath || "scripts");
/** @type {string} */
this.settingsPath = join(this.scriptsPath, "settings.json");
/** @type {string} */
this.imagesPath = join(this.path, options.imagesPath || "images");
/** @type {string} */
this.assetsPath = join(this.path, options.assetsPath || "assets");
/** @type {string} */
this.configPath = join(this.path, options.configPath || "config");
/** @type {string} */
this.fontPath = join(this.path, join("config", "Fonts"));
/** @type {string} */
this.projectID = options.projectID;
/** @type {bool} */
this.hashBinaries = options.hashBinaries;
}
function hashMD5(file, callback) {
exec('md5sum ' + file, (err, stdout, stderr) => {
if (err) {
console.error(err);
callback(err);
return;
}
var data = stdout.substr(0, stdout.indexOf(" "));
callback(null, data);
});
}
function hashMD5Files(fileList, callback) {
var files = Object.keys(fileList);
var file = files.pop();
function getMD5File(err, md5Value) {
if (err)
return console.error(err);
fileList[file] = md5Value || null;
file = files.pop();
if (file) {
hashMD5(file, getMD5File);
}
else {
callback(null, fileList);
}
}
if (file) {
hashMD5(file, getMD5File);
}
else {
callback(null, fileList);
}
}
function escapeRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&');
}
function elimaneteUnnecessaryScriptsFiles(files, jsRegExp, userRegExp) {
const resultFiles = {};
Object.keys(files).forEach( (key) => {
if(userRegExp && !userRegExp.test(key)){
// Skip
return;
}
if(jsRegExp.test(key)) {
resultFiles[key] = files[key];
}
});
return resultFiles;
}
Workspace.prototype.getProjectID = getProjectID;
Workspace.prototype.getIndex = getIndex;
Workspace.prototype.getImage = getImage;
module.exports = Workspace;