emulator-index
Version:
Handles emulator index releated operations
572 lines (514 loc) • 20.2 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;
};
}
var fs = require("fs");
var path = require("path");
var uuid = require('node-uuid');
var CRC32 = require("crc-32");
var walk = require('walk');
var DOMParser = require('xmldom').DOMParser;
var xpath = require('xpath');
var reAsset = new RegExp("Assets\\" + path.sep + "\\w+\\.(?:imageset|appiconset|launchimage)\\" + path.sep);
var _contentsJSONCache = {};
/**
* This callback is provied 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
* @proerty {Object} screen.px - Sceen pixel values
* @proerty {number} screen.px.height - Sceen height pixel value
* @proerty {number} screen.px.width - Sceen width pixel value
* @proerty {Object} screen.dp - Sceen dp values
* @proerty {number} screen.dp.height - Sceen height dp value
* @proerty {number} screen.dp.width - Sceen width dp value
* @proerty {Object} screen.pt - Sceen pt values
* @proerty {number} screen.pt.height - Sceen height pt value
* @proerty {number} screen.pt.width - Sceen width pt value
* @proerty {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) {
var projectJSON = fs.readFileSync(this.projectJSONPath, "utf8");
projectJSON = JSON.parse(projectJSON);
_contentsJSONCache = {}; //needs reset
var me = this;
var taskCount = 4;
var index = {
projectID: this.getProjectID(true),
info: projectJSON.info,
files: {}
};
walkFolder(this.assetsPath, function walk_callback_assets(files) {
processFolder(index, files, "asset", done);
});
walkFolder(this.scriptsPath, function walk_callback_scripts(files) {
processFolder(index, files, "script", done);
});
fs.stat(this.fontConfigPath, function(err, stat) {
if (err) {
console.log("There is an error while processing FontConfig.xml file");
return;
}
fs.readFile(me.fontConfigPath, "utf8", function fileContentFontConfigXML(err, data) {
if (err)
throw err;
var doc = new DOMParser().parseFromString(data);
var query = "/Fonts/Font/@Name";
if (device.os === "Android")
query = "/Fonts/Font/@Name[../@AndroidPublish = 'Y']";
else if (device.os === "iOS") {
query = "/Fonts/Font/@Name[../@iOSPublish = 'Y']";
}
var nodes = xpath.select(query, doc);
nodes.forEach(function(element, idx, array) {
var regexSpace = / /g;
var fontName = element.value.replace(regexSpace, ".");
taskCount++;
var fontFolder = path.join(path.dirname(me.fontConfigPath), "Fonts", fontName);
walkFolder(fontFolder, function walk_callback_fonts(files) {
processFolder(index, files, "font", done);
});
});
done();
});
});
var otherMapping = [{
path: path.join(this.configPath, "defaults.xml"),
scheme: "config",
relativeTo: this.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();
function handleImages() {
if (device.os === "iOS") {
handleImages_iOS();
}
else if (device.os === "Android") {
handleImages_Android();
}
}
handleImages();
function handleImages_iOS() {
var order = [2, 3, 1];
if (device.brandName.indexOf("Plus") > -1) {
order = [3, 2, 1];
}
else {
var nonRetinaDevices = ["iPhone", "iPhone 2G", "iPhone 3G",
"iPhone 3GS", "iPod Touch (1 Gen)", "iPod Touch (2 Gen)",
"iPod Touch (3 Gen)", "iPad", "iPad 3G", "iPad 2", "iPad Mini"
];
if (nonRetinaDevices.indexOf(device.brandName) > -1) {
order = [1, 2, 3];
}
}
var iOSImagesFolder = path.join(me.imagesPath, "iOS");
walkFolder(iOSImagesFolder, function walk_callback_iOSImages(files) {
var filesArray = Object.keys(files);
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);
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, {
os: "iOS"
});
});
}
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,
multiplier: 1
};
case 2:
case 3:
return {
name: imgName.substr(0, name.length - 3),
multiplier: multiplier
};
default:
throw Error("unhandeled image naming for iOS");
}
}
}
function getContentsJSON(name) {
var contentsJSONPath = path.join(path.dirname(name), "Contents.json");
if (!_contentsJSONCache[contentsJSONPath]) {
_contentsJSONCache[contentsJSONPath] = JSON.parse(
fs.readFileSync(contentsJSONPath, "utf8"));
}
return _contentsJSONCache[contentsJSONPath];
}
function handleImages_Android() {
var androidImagesFolder = path.join(me.imagesPath, "Android");
walkFolder(androidImagesFolder, function walk_callback_AndroidImages(files) {
var filesArray = Object.keys(files).filter(function(value) {
return path.relative(androidImagesFolder, value).split(path.sep).length === 2;
}, filesArray);
var images = {};
var newFiles = {};
filesArray.forEach(function(element, idx, array) {
var imgInfo = getAndroidImageInfo(element);
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, {
os: "Android"
});
});
}
function getAndroidImageInfo(fullPath) {
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 done() {
taskCount--;
if (taskCount !== 0)
return;
index = sort(index);
callback(index);
}
}
function sort(obj) {
if (typeof obj !== "object")
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: false
});
walker.name = folder;
walker.on("file", fileHandler);
walker.on("end", endHandler);
function fileHandler(root, fileStat, next) {
var fullPath = path.join(root, fileStat.name);
var relativePath = path.relative(folder, fullPath);
relativePath = 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 || {};
index = index || {};
index.files = index.files || {};
var taskCount = 0,
me = this,
filesArray = Object.keys(files);
for (var i = 0; i < filesArray.length; i++) {
taskCount += 2;
getFileStats(filesArray[i]);
getCRC(filesArray[i]);
}
function finalize() {
if (taskCount !== 0)
return;
callback.call(me, index);
}
function getFileStats(file) {
fs.stat(file, function fStat(err, stats) {
if (err) {
throw err;
}
var uri = getURI(file);
index.files[uri] = index.files[uri] || {};
index.files[uri].date = stats.ctime;
taskCount--;
finalize();
});
}
function getCRC(file) {
fs.readFile(file, "binary", function(err, data) {
if (err) {
throw err;
}
var uri = getURI(file);
index.files[uri] = index.files[uri] || {};
index.files[uri].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[path.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];
}
}
/**
* 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 path.join(options.path, "config", "project.json")
* @property {string} scriptsPath - Path of the scripts folder relative to workspace. Defaults to path.join(options.path, "scripts")
* @property {string} imagesPath - Path of the scripts folder relative to workspace. Defaults to path.join(options.path, "images")
* @property {string} assetsPath - Path of the scripts folder relative to workspace. Defaults to path.join(options.path, "assets")
* @property {string} fontConfigPath - Path of the FontConfig.xml file relative to workspace. Defaults to path.join(options.path, "config", "FontConfig.xml")
* @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
*/
/**
* 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 = path.join(this.path, options.projectJSONPath || path.join("config", "project.json"));
/** @type {string} */
this.scriptsPath = path.join(this.path, options.scriptsPath || "scripts");
/** @type {string} */
this.imagesPath = path.join(this.path, options.imagesPath || "images");
/** @type {string} */
this.assetsPath = path.join(this.path, options.assetsPath || "assets");
/** @type {string} */
this.configPath = path.join(this.path, options.configPath || "config");
/** @type {string} */
this.fontConfigPath = path.join(this.path, options.fontConfigPath || path.join("config", "FontConfig.xml"));
/** @type {string} */
this.projectID = options.projectID;
}
Workspace.prototype.getProjectID = getProjectID;
Workspace.prototype.getIndex = getIndex;
module.exports = Workspace;