mirror of
https://github.com/overte-org/overte.git
synced 2025-04-27 18:15:55 +02:00
562 lines
21 KiB
JavaScript
562 lines
21 KiB
JavaScript
// _utils.js -- misc. helper classes/functions
|
|
|
|
"use strict";
|
|
/* eslint-env commonjs, hifi */
|
|
/* eslint-disable comma-dangle, no-empty */
|
|
/* global HIRES_CLOCK, Desktop */
|
|
// 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.1b' + (USE_HIRES_CLOCK ? '-hires' : ''),
|
|
bind: bind,
|
|
signal: signal,
|
|
assign: assign,
|
|
sortedAssign: sortedAssign,
|
|
sign: sign,
|
|
assert: assert,
|
|
getSystemMetadata: getSystemMetadata,
|
|
makeDebugRequire: makeDebugRequire,
|
|
DeferredUpdater: DeferredUpdater,
|
|
KeyListener: KeyListener,
|
|
getRuntimeSeconds: getRuntimeSeconds,
|
|
createAnimationStepper: createAnimationStepper,
|
|
reloadClientScript: reloadClientScript,
|
|
|
|
normalizeStackTrace: normalizeStackTrace,
|
|
BrowserUtils: BrowserUtils,
|
|
BSOD: BSOD, // exception reporter
|
|
_overlayWebWindow: _overlayWebWindow,
|
|
};
|
|
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') {
|
|
// 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 <anonymous> (file://.../filename.js:45:65)"
|
|
// * Interface: " at <anonymous() file://.../filename.js:45"
|
|
// * normalized: " at <anonymous> 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) {
|
|
this.console.log.apply(this.console, ['browserUtils | ' + msg].concat([].slice.call(arguments, 1)));
|
|
},
|
|
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);
|
|
try {
|
|
global.EventBridge.scriptEventReceived.connect.exists;
|
|
// this.log('openEventBridge| EventBridge already exists... -- invoking callback', 'typeof EventBridge == ' + typeof global.EventBridge);
|
|
return callback(global.EventBridge);
|
|
} catch (e) {
|
|
this.log('EventBridge does not yet exist -- attempting to instrument via qt.webChannelTransport');
|
|
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) {
|
|
global.EventBridge = channel.objects.eventBridgeWrapper.eventBridge;
|
|
global.EventBridge.$WebChannel = channel;
|
|
this.log('openEventBridge opened -- invoking callback', 'typeof EventBridge === ' + typeof global.EventBridge);
|
|
callback(global.EventBridge);
|
|
}));
|
|
}
|
|
},
|
|
};
|
|
}
|
|
// ----------------------------------------------------------------------------
|
|
// queue pending updates so the exact order of application can be varied
|
|
// (currently Interface exists sporadic jitter that seems to depend on whether
|
|
// Camera or MyAvatar gets updated first)
|
|
function DeferredUpdater(target, options) {
|
|
options = options || {};
|
|
// define _meta as a non-enumerable instance property (so it doesn't show up in for(var p in ...) loops)
|
|
Object.defineProperty(this, '_meta', { 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,
|
|
// lastValue = meta.lastValue,
|
|
// dedupe = meta.dedupe,
|
|
self = this,
|
|
submitted = {};
|
|
|
|
self.submit = getRuntimeSeconds();
|
|
Object.keys(self).forEach(function(property) {
|
|
var newValue = self[property];
|
|
// if (dedupe) {
|
|
// var stringified = JSON.stringify(newValue);
|
|
// var last = lastValue[property];
|
|
// if (stringified === last) {
|
|
// return;
|
|
// }
|
|
// lastValue[property] = stringified;
|
|
// }
|
|
// if (0) {
|
|
// var tmp = lastValue['_'+property];
|
|
// if (typeof tmp === 'object') {
|
|
// if ('w' in tmp) {
|
|
// newValue = Quat.normalize(Quat.slerp(tmp, newValue, 0.95));
|
|
// } else if ('z' in tmp) {
|
|
// newValue = Vec3.mix(tmp, newValue, 0.95);
|
|
// }
|
|
// } else if (typeof tmp === 'number') {
|
|
// newValue = (newValue + tmp)/2.0;
|
|
// }
|
|
// lastValue['_'+property] = newValue;
|
|
// }
|
|
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;
|
|
};
|
|
|
|
// ----------------------------------------------------------------------------
|
|
|
|
// monotonic 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);
|
|
}
|
|
|
|
|
|
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 only gets
|
|
// called when the specified event.text matches the input options
|
|
// 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 this.log('KeyListener -- different modifiers, disregarding keystroke', JSON.stringify({
|
|
expected: this.modifiers,
|
|
received: modifiers,
|
|
},0,2));
|
|
}
|
|
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 show a verbose exception report in a BSOD-like poup window, in some cases enabling users to
|
|
// report specfic feedback without having to manually scan through their local debug logs
|
|
|
|
// creates an OverlayWebWindow using inline HTML content
|
|
function _overlayWebWindow(options) {
|
|
options = Object.assign({
|
|
title: '_overlayWebWindow',
|
|
width: Overlays.width() * 2 / 3,
|
|
height: Overlays.height() * 2 / 3,
|
|
content: '(empty)',
|
|
}, options||{}, {
|
|
source: 'about:blank',
|
|
});
|
|
var window = new OverlayWebWindow(options);
|
|
window.options = options;
|
|
options.content && window.setURL('data:text/html;text,' + encodeURIComponent(options.content));
|
|
return window;
|
|
}
|
|
|
|
function BSOD(options, callback) {
|
|
var buttonHTML = Array.isArray(options.buttons) && [
|
|
'<div onclick="EventBridge.emitWebEvent(arguments[0].target.innerText)">',
|
|
options.buttons.map(function(innerText) {
|
|
return '<button>' + innerText + '</button>';
|
|
}).join(', '),
|
|
'</div>'].join('\n');
|
|
|
|
var HTML = [
|
|
'<style>body { background:#0000aa; color:#ffffff; font-family:courier; font-size:8pt; margin:10px; }</style>',
|
|
buttonHTML,
|
|
'<pre style="white-space:pre-wrap;">',
|
|
'<strong>' + options.error + '</strong>',
|
|
normalizeStackTrace(options.error, {
|
|
wrapLineNumbersWith: ['<b style=color:lime>','</b>'],
|
|
wrapFilesWith: ['<b style=color:#f99>','</b>'],
|
|
}),
|
|
'</pre>',
|
|
'<hr />DEBUG INFO:<br />',
|
|
'<pre>' + JSON.stringify(Object.assign({ date: new Date }, options.debugInfo), 0, 2) + '</pre>',
|
|
].filter(Boolean).join('\n');
|
|
|
|
var popup = _overlayWebWindow({
|
|
title: options.title || 'PC LOAD LETTER',
|
|
content: HTML,
|
|
});
|
|
popup.webEventReceived.connect(function(message) {
|
|
print('popup.webEventReceived', message);
|
|
try {
|
|
callback(null, message);
|
|
} finally {
|
|
popup.close();
|
|
}
|
|
});
|
|
return popup;
|
|
}
|
|
|
|
function getSystemMetadata() {
|
|
var tablet = Tablet.getTablet('com.highfidelity.interface.tablet.system');
|
|
return {
|
|
mode: {
|
|
hmd: HMD.active,
|
|
desktop: !HMD.active,
|
|
toolbar: Uuid.isNull(HMD.tabletID),
|
|
tablet: !Uuid.isNull(HMD.tabletID),
|
|
},
|
|
tablet: {
|
|
toolbarMode: tablet.toolbarMode,
|
|
desktopScale: Settings.getValue('desktopTabletScale'),
|
|
hmdScale: Settings.getValue('hmdTabletScale'),
|
|
},
|
|
avatar: {
|
|
pitchSpeed: MyAvatar.pitchSpeed,
|
|
yawSpeed: MyAvatar.yawSpeed,
|
|
density: MyAvatar.density,
|
|
scale: MyAvatar.scale,
|
|
},
|
|
overlays: {
|
|
width: Overlays.width(),
|
|
height: Overlays.height(),
|
|
},
|
|
window: {
|
|
width: Window.innerWidth,
|
|
height: Window.innerHeight,
|
|
},
|
|
desktop: {
|
|
width: Desktop.width,
|
|
height: Desktop.height,
|
|
},
|
|
};
|
|
}
|
|
|
|
function reloadClientScript(filename) {
|
|
function log() {
|
|
print('reloadClientScript | ', [].slice.call(arguments).join(' '));
|
|
}
|
|
log('reloading', filename);
|
|
var result = ScriptDiscoveryService.stopScript(filename, true);
|
|
log('...stopScript', filename, result);
|
|
if (!result) {
|
|
var matches = ScriptDiscoveryService.getRunning().filter(function(script) {
|
|
// log(script.path, script.url);
|
|
return 0 === script.path.indexOf(filename);
|
|
});
|
|
log('...matches', JSON.stringify(matches,0,2));
|
|
var path = matches[0] && matches[0].path;
|
|
if (path) {
|
|
log('...stopScript', path);
|
|
result = ScriptDiscoveryService.stopScript(path, true);
|
|
log('///stopScript', result);
|
|
}
|
|
}
|
|
return result;
|
|
}
|