// _utils.js -- misc. helper classes/functions "use strict"; /* eslint-env commonjs, hifi */ /* eslint-disable comma-dangle, no-empty */ /* global HIRES_CLOCK, Desktop, OverlayWebWindow */ // var HIRES_CLOCK = (typeof Window === 'object' && Window && Window.performance) && Window.performance.now; var USE_HIRES_CLOCK = typeof HIRES_CLOCK === 'function'; var exports = { version: '0.0.1c' + (USE_HIRES_CLOCK ? '-hires' : ''), bind: bind, signal: signal, assign: assign, sortedAssign: sortedAssign, sign: sign, assert: assert, makeDebugRequire: makeDebugRequire, DeferredUpdater: DeferredUpdater, KeyListener: KeyListener, getRuntimeSeconds: getRuntimeSeconds, createAnimationStepper: createAnimationStepper, reloadClientScript: reloadClientScript, normalizeStackTrace: normalizeStackTrace, BrowserUtils: BrowserUtils, }; try { module.exports = exports; // Interface / Node.js } catch (e) { this._utils = assign(this._utils || {}, exports); // browser } // ---------------------------------------------------------------------------- function makeDebugRequire(relativeTo) { return function boundDebugRequire(id) { return debugRequire(id, relativeTo); }; } function debugRequire(id, relativeTo) { if (typeof Script === 'object') { relativeTo = (relativeTo||Script.resolvePath('.')).replace(/\/+$/, ''); // hack-around for use during local development / testing that forces every require to re-fetch the script from the server var modulePath = Script._requireResolve(id, relativeTo+'/') + '?' + new Date().getTime().toString(36); print('========== DEBUGREQUIRE:' + modulePath); Script.require.cache[modulePath] = Script.require.cache[id] = undefined; Script.require.__qt_data__[modulePath] = Script.require.__qt_data__[id] = true; return Script.require(modulePath); } else { return require(id); } } // examples: // assert(function assertion() { return (conditions === true) }, 'assertion failed!') // var neededValue = assert(idString, 'idString not specified!'); // assert(false, 'unexpected state'); function assert(truthy, message) { message = message || 'Assertion Failed:'; if (typeof truthy === 'function' && truthy.name === 'assertion') { // extract function body to display with the assertion message var assertion = (truthy+'').replace(/[\r\n]/g, ' ') .replace(/^[^{]+\{|\}$|^\s*|\s*$/g, '').trim() .replace(/^return /,'').replace(/\s[\r\n\t\s]+/g, ' '); message += ' ' + JSON.stringify(assertion); try { truthy = truthy(); } catch (e) { message += '(exception: ' + e +')'; } } if (!truthy) { message += ' ('+truthy+')'; throw new Error(message); } return truthy; } // ---------------------------------------------------------------------------- function sign(x) { x = +x; if (x === 0 || isNaN(x)) { return Number(x); } return x > 0 ? 1 : -1; } // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill /* eslint-disable */ function assign(target, varArgs) { // .length of function is 2 'use strict'; if (target == null) { // TypeError if undefined or null throw new TypeError('Cannot convert undefined or null to object'); } var to = Object(target); for (var index = 1; index < arguments.length; index++) { var nextSource = arguments[index]; if (nextSource != null) { // Skip over if undefined or null for (var nextKey in nextSource) { // Avoid bugs when hasOwnProperty is shadowed if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { to[nextKey] = nextSource[nextKey]; } } } } return to; } /* eslint-enable */ // //https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill // hack to sort keys in v8 for prettier JSON exports function sortedAssign(target, sources) { var allParams = assign.apply(this, [{}].concat([].slice.call(arguments))); for (var p in target) { delete target[p]; } Object.keys(allParams).sort(function(a,b) { function swapCase(ch) { return /[A-Z]/.test(ch) ? ch.toLowerCase() : ch.toUpperCase(); } a = a.replace(/^./, swapCase); b = b.replace(/^./, swapCase); a = /Version/.test(a) ? 'AAAA'+a : a; b = /Version/.test(b) ? 'AAAA'+b : b; return a < b ? -1 : a > b ? 1 : 0; }).forEach(function(key) { target[key] = allParams[key]; }); return target; } // ---------------------------------------------------------------------------- // @function - bind a function to a `this` context // @param {Object} - the `this` context // @param {Function|String} - function or method name bind.debug = true; function bind(thiz, method) { var methodName = typeof method === 'string' ? method : method.name; method = thiz[method] || method; if (bind.debug && methodName) { methodName = methodName.replace(/[^A-Za-z0-9_$]/g, '_'); var debug = {}; debug[methodName] = method; return eval('1,function bound'+methodName+'() { return debug.'+methodName+'.apply(thiz, arguments); }'); } return function() { return method.apply(thiz, arguments); }; } // @function - Qt signal polyfill function signal(template) { var callbacks = []; return Object.defineProperties(function() { var args = [].slice.call(arguments); callbacks.forEach(function(obj) { obj.handler.apply(obj.scope, args); }); }, { connect: { value: function(scope, handler) { callbacks.push({scope: scope, handler: scope[handler] || handler || scope}); }}, disconnect: { value: function(scope, handler) { var match = {scope: scope, handler: scope[handler] || handler || scope}; callbacks = callbacks.filter(function(obj) { return !(obj.scope === match.scope && obj.handler === match.handler); }); }} }); } // ---------------------------------------------------------------------------- function normalizeStackTrace(err, options) { options = options || {}; // * Chromium: " at (file://.../filename.js:45:65)" // * Interface: " at filename.js:45" var output = err.stack ? err.stack.replace( /((?:https?|file):[/][/].*?):(\d+)(?::\d+)?([)]|\s|$)/g, function(_, url, lineNumber, suffix) { var fileref = url.split(/[?#]/)[0].split('/').pop(); if (Array.isArray(options.wrapFilesWith)) { fileref = options.wrapFilesWith[0] + fileref + options.wrapFilesWith[1]; } if (Array.isArray(options.wrapLineNumbersWith)) { lineNumber = options.wrapLineNumbersWith[0] + lineNumber + options.wrapLineNumbersWith[1]; } return fileref + ':' + lineNumber + suffix; } ).replace(/[(]([-\w.%:]+[.](?:html|js))[)]/g, '$1') : err.message; return ' '+output; } // utilities specific to use from web browsers / embedded Interface web windows function BrowserUtils(global) { global = global || (1,eval)('this'); return { global: global, console: global.console, log: function(msg) { return this.console.log('browserUtils | ' + [].slice.call(arguments).join(' ')); }, makeConsoleWorkRight: function(console, forcePatching) { if (console.$patched || !(forcePatching || global.qt)) { return console; } var patched = ['log','debug','info','warn','error'].reduce(function(output, method) { output[method] = function() { return console[method]([].slice.call(arguments).join(' ')); }; return output; }, { $patched: console }); for (var p in console) { if (typeof console[p] === 'function' && !(p in patched)) { patched[p] = console[p].bind(console); } } patched.__proto__ = console; // let scope chain find constants and other non-function values return patched; }, parseQueryParams: function(querystring) { return this.extendWithQueryParams({}, querystring); }, extendWithQueryParams: function(obj, querystring) { querystring = querystring || global.location.href; querystring.replace(/\b(\w+)=([^&?#]+)/g, function(_, key, value) { value = unescape(value); obj[key] = value; }); return obj; }, // openEventBridge handles the cluster of scenarios Interface has imposed on webviews for making EventBridge connections openEventBridge: function openEventBridge(callback) { this.log('openEventBridge |', 'typeof global.EventBridge == ' + [typeof global.EventBridge, global.EventBridge ]); var error; try { global.EventBridge.toString = function() { return '[global.EventBridge at startup]'; }; global.EventBridge.scriptEventReceived.connect.exists; // this.log('openEventBridge| EventBridge already exists... -- invoking callback', 'typeof EventBridge == ' + typeof global.EventBridge); try { return callback(global.EventBridge); } catch(e) { error = e; } } catch (e) { this.log('EventBridge not found in a usable state -- attempting to instrument via qt.webChannelTransport', Object.keys(global.EventBridge||{})); var QWebChannel = assert(global.QWebChannel, 'expected global.QWebChannel to exist'), qt = assert(global.qt, 'expected global.qt to exist'); assert(qt.webChannelTransport, 'expected global.qt.webChannelTransport to exist'); new QWebChannel(qt.webChannelTransport, bind(this, function (channel) { var objects = channel.objects; if (global.EventBridge) { log('>>> global.EventBridge was unavailable at page load, but has spontaneously materialized; ' + [ typeof global.EventBridge, global.EventBridge ]); } var eventBridge = objects.eventBridge || (objects.eventBridgeWrapper && objects.eventBridgeWrapper.eventBridge); eventBridge.toString = function() { return '[window.EventBridge per QWebChannel]'; }; assert(!global.EventBridge || global.EventBridge === eventBridge, 'global.EventBridge !== QWebChannel eventBridge\n' + [global.EventBridge, eventBridge]); global.EventBridge = eventBridge; global.EventBridge.$WebChannel = channel; this.log('openEventBridge opened -- invoking callback', 'typeof EventBridge === ' + typeof global.EventBridge); callback(global.EventBridge); })); } if (error) { throw error; } }, }; } // ---------------------------------------------------------------------------- // queue property/method updates to target so that they can be applied all-at-once function DeferredUpdater(target, options) { options = options || {}; // define _meta as a non-enumerable (so it doesn't show up in for (var p in ...) loops) Object.defineProperty(this, '_meta', { enumerable: false, value: { target: target, lastValue: {}, dedupe: options.dedupe, }}); } DeferredUpdater.prototype = { reset: function() { var self = this; Object.keys(this).forEach(function(property) { delete self[property]; }); this._meta.lastValue = {}; }, submit: function() { var meta = this._meta, target = meta.target, self = this, submitted = {}; self.submit = getRuntimeSeconds(); Object.keys(self).forEach(function(property) { var newValue = self[property]; submitted[property] = newValue; if (typeof target[property] === 'function') { target[property](newValue); } else { target[property] = newValue; } delete self[property]; }); return submitted; } }; // create a group of deferred updaters eg: DeferredUpdater.createGroup({ MyAvatar: MyAvatar, Camera: Camera }) DeferredUpdater.createGroup = function(items, options) { var result = { __proto__: { reset: function() { Object.keys(this).forEach(bind(this, function(item) { this[item].reset(); })); }, submit: function() { var submitted = {}; Object.keys(this).forEach(bind(this, function(item) { submitted[item] = this[item].submit(); })); return submitted; } } }; Object.keys(items).forEach(function(item) { result[item] = new DeferredUpdater(items[item], options); }); return result; }; // ---------------------------------------------------------------------------- // session runtime in seconds getRuntimeSeconds.EPOCH = getRuntimeSeconds(0); function getRuntimeSeconds(since) { since = since === undefined ? getRuntimeSeconds.EPOCH : since; var now = USE_HIRES_CLOCK ? new HIRES_CLOCK() : +new Date; return ((now / 1000.0) - since); } // requestAnimationFrame emulation function createAnimationStepper(options) { options = options || {}; var fps = options.fps || 30, waitMs = 1000 / fps, getTime = options.getRuntimeSeconds || getRuntimeSeconds, lastUpdateTime = -1e-6, timeout = 0; requestAnimationFrame.fps = fps; requestAnimationFrame.reset = function() { if (timeout) { Script.clearTimeout(timeout); timeout = 0; } }; function requestAnimationFrame(update) { requestAnimationFrame.reset(); timeout = Script.setTimeout(function() { timeout = 0; update(getTime(lastUpdateTime)); lastUpdateTime = getTime(); }, waitMs ); } return requestAnimationFrame; } // ---------------------------------------------------------------------------- // KeyListener provides a scoped wrapper where options.onKeyPressEvent gets // called when a key event matches the specified event.text / key spec // example: var listener = new KeyListener({ text: 'SPACE', isShifted: false, onKeyPressEvent: function(event) { ... } }); // Script.scriptEnding.connect(listener, 'disconnect'); function KeyListener(options) { assert(typeof options === 'object' && 'text' in options && 'onKeyPressEvent' in options); this._options = options; assign(this, { modifiers: this._getEventModifiers(options, true) }, options); this.log = options.log || function log() { print('KeyListener | ', [].slice.call(arguments).join(' ')); }; this.log('created KeyListener', JSON.stringify(this.text), this.modifiers); this.connect(); } KeyListener.prototype = { _getEventModifiers: function(event, trueOnly) { return '(' + [ 'Control', 'Meta', 'Alt', 'Super', 'Menu', 'Shifted' ].map(function(mod) { var isMod = 'is' + mod, value = event[isMod], found = (trueOnly ? value : typeof value === 'boolean'); return found && isMod + ' == ' + value; }).filter(Boolean).sort().join(' | ') + ')'; }, handleEvent: function(event, target) { if (event.text === this.text) { var modifiers = this._getEventModifiers(event, true); if (modifiers !== this.modifiers) { return; } return this[target](event); } }, connect: function() { return this.$bindEvents(true); }, disconnect: function() { return this.$bindEvents(false); }, $onKeyPressEvent: function(event) { return this.handleEvent(event, 'onKeyPressEvent'); }, $onKeyReleaseEvent: function(event) { return this.handleEvent(event, 'onKeyReleaseEvent'); }, $bindEvents: function(connect) { if (this.onKeyPressEvent) { Controller.keyPressEvent[connect ? 'connect' : 'disconnect'](this, '$onKeyPressEvent'); } if (this.onKeyReleaseEvent) { Controller.keyReleaseEvent[connect ? 'connect' : 'disconnect'](this, '$onKeyReleaseEvent'); } Controller[(connect ? 'capture' : 'release') + 'KeyEvents'](this._options); } }; // helper to reload a client script reloadClientScript._findRunning = function(filename) { return ScriptDiscoveryService.getRunning().filter(function(script) { return 0 === script.path.indexOf(filename); }); }; function reloadClientScript(filename) { function log() { print('reloadClientScript | ', [].slice.call(arguments).join(' ')); } log('attempting to reload using stopScript(..., true):', filename); var result = ScriptDiscoveryService.stopScript(filename, true); if (!result) { var matches = reloadClientScript._findRunning(filename), path = matches[0] && matches[0].path; if (path) { log('attempting to reload using matched getRunning path: ' + path); result = ScriptDiscoveryService.stopScript(path, true); } } log('///result:' + result); return result; }