UNPKG

@smartface/emulator-dispatcher

Version:

Handles Emulator Dispatcher Part of SmartfaceCloud

832 lines (745 loc) 28.8 kB
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;