// CustomSettingsApp.js -- manages Settings between a Client script and connected "settings" tablet WebView page // see browser/BridgedSettings.js for the webView side // example: // var button = tablet.addButton({ text: 'My Settings' }); // var mySettingsApp = new CustomSettingsApp({ // namespace: 'mySettingsGroup', // url: Script.resolvePath('myapp.html'), // uuid: button.uuid, // tablet: tablet // }); // // // settings are automatically sync'd from the web page back to Interface; to be notified when that happens, use: // myAppSettings.valueUpdated.connect(function(name, value, oldValue, origin) { // print('setting updated from web page', name, value, oldValue, origin); // }); // // // settings are also automatically sync'd from Interface back to the web page; to manually sync a value, use: // myAppSettings.syncValue(fullSettingsKey, value); /* eslint-env commonjs */ "use strict"; CustomSettingsApp.version = '0.0.0'; module.exports = CustomSettingsApp; var _utils = require('../_utils.js'); Object.assign = Object.assign || _utils.assign; function assert(truthy, message) { return _utils.assert.call(this, truthy, 'CustomSettingsApp | ' + message); } function _debugPrint() { print('CustomSettingsApp | ' + [].slice.call(arguments).join(' ')); } var debugPrint = function() {}; function CustomSettingsApp(options) { assert('url' in options, 'expected options.url'); if (options.debug) { debugPrint = _debugPrint; } this.url = options.url; this.namespace = options.namespace || 'BridgedSettings'; this.uuid = options.uuid || Uuid.generate(); this.recheckInterval = options.recheckInterval || 1000; this.settingsScreenVisible = false; this.isActive = false; this.extraParams = Object.assign(options.extraParams || {}, { customSettingsVersion: CustomSettingsApp.version+'', protocolVersion: Window.protocolSignature && Window.protocolSignature() }); var params = { namespace: this.namespace, uuid: this.uuid, debug: options.debug || undefined }; // encode PARAMS into '?key=value&...' var query = Object.keys(params).map(function encodeValue(key) { var value = encodeURIComponent(params[key] === undefined ? '' : params[key]); return [ key, value ].join('='); }).join('&'); this.url += '?&' + query; this.isActiveChanged = _utils.signal(function(isActive) {}); this.valueUpdated = _utils.signal(function(key, value, oldValue, origin) {}); this.settingsAPI = options.settingsAPI || Settings; // keep track of accessed settings so they can be kept in sync if changed externally this._activeSettings = { sent: {}, received: {}, remote: {} }; if (options.tablet) { this._initialize(options.tablet); } } CustomSettingsApp.prototype = { tablet: null, resolve: function(key) { if (0 === key.indexOf('.') || ~key.indexOf('/')) { // key is already qualified under a group; return as-is return key; } // nest under the current namespace return [ this.namespace, key ].join('/'); }, sendEvent: function(msg) { assert(this.tablet, '!this.tablet'); msg.ns = msg.ns || this.namespace; msg.uuid = msg.uuid || this.uuid; this.tablet.emitScriptEvent(JSON.stringify(msg)); }, getValue: function(key, defaultValue) { key = this.resolve(key); return key in this._activeSettings.remote ? this._activeSettings.remote[key] : defaultValue; }, setValue: function(key, value) { key = this.resolve(key); var current = this.getValue(key); if (current !== value) { return this.syncValue(key, value, 'CustomSettingsApp.setValue'); } return false; }, syncValue: function(key, value, origin) { key = this.resolve(key); var oldValue = this._activeSettings.remote[key]; assert(value !== null, 'CustomSettingsApp.syncValue value is null'); this.sendEvent({ id: 'valueUpdated', params: [key, value, oldValue, origin] }); this._activeSettings.sent[key] = value; this._activeSettings.remote[key] = value; this.valueUpdated(key, value, oldValue, (origin ? origin+':' : '') + 'CustomSettingsApp.syncValue'); }, onScreenChanged: function onScreenChanged(type, url) { this.settingsScreenVisible = (url === this.url); debugPrint('===> onScreenChanged', type, url, 'settingsScreenVisible: ' + this.settingsScreenVisible); if (this.isActive && !this.settingsScreenVisible) { this.isActiveChanged(this.isActive = false); } }, _apiGetValue: function(key, defaultValue) { // trim rooted keys like "/desktopTabletBecomesToolbar" => "desktopTabletBecomesToolbar" key = key.replace(/^\//,''); return this.settingsAPI.getValue(key, defaultValue); }, _apiSetValue: function(key, value) { key = key.replace(/^\//,''); return this.settingsAPI.setValue(key, value); }, _setValue: function(key, value, oldValue, origin) { var current = this._apiGetValue(key), lastRemoteValue = this._activeSettings.remote[key]; debugPrint('.setValue(' + JSON.stringify({key: key, value: value, current: current, lastRemoteValue: lastRemoteValue })+')'); this._activeSettings.received[key] = value; this._activeSettings.remote[key] = value; var result; if (lastRemoteValue !== value) { this.valueUpdated(key, value, lastRemoteValue, 'CustomSettingsApp.tablet'); } if (current !== value) { result = this._apiSetValue(key, value); } return result; }, _handleValidatedMessage: function(obj, msg) { var tablet = this.tablet; if (!tablet) { throw new Error('_handleValidatedMessage called when not connected to tablet...'); } var params = Array.isArray(obj.params) ? obj.params : [obj.params]; var parts = (obj.method||'').split('.'), api = parts[0], method = parts[1]; switch(api) { case 'valueUpdated': obj.result = this._setValue.apply(this, params); break; case 'Settings': if (method && params[0]) { var key = this.resolve(params[0]), value = params[1]; debugPrint('>>>>', method, key, value); switch (method) { case 'getValue': obj.result = this._apiGetValue(key, value); this._activeSettings.sent[key] = obj.result; this._activeSettings.remote[key] = obj.result; break; case 'setValue': obj.result = this._setValue(key, value, params[2], params[3]); break; default: obj.error = 'unmapped Settings method: ' + method; throw new Error(obj.error); } break; } default: if (this.onUnhandledMessage) { this.onUnhandledMessage(obj, msg); } else { obj.error = 'unmapped method call: ' + msg; } } if (obj.id) { // if message has an id, reply with the same message obj which now has a .result or .error field // note: a small delay is needed because of an apparent race condition between ScriptEngine and Tablet WebViews Script.setTimeout(_utils.bind(this, function() { this.sendEvent(obj); }), 100); } else if (obj.error) { throw new Error(obj.error); } }, onWebEventReceived: function onWebEventReceived(msg) { debugPrint('onWebEventReceived', msg); var tablet = this.tablet; if (!tablet) { throw new Error('onWebEventReceived called when not connected to tablet...'); } if (msg === this.url) { if (this.isActive) { // user (or page) refreshed the web view; trigger !isActive so client script can perform cleanup this.isActiveChanged(this.isActive = false); } this.isActiveChanged(this.isActive = true); // reply to initial HTML page ACK with any extraParams that were specified this.sendEvent({ id: 'extraParams', extraParams: this.extraParams }); return; } try { var obj = assert(JSON.parse(msg)); } catch (e) { return; } if (obj.ns === this.namespace && obj.uuid === this.uuid) { debugPrint('valid onWebEventReceived', msg); this._handleValidatedMessage(obj, msg); } }, _initialize: function(tablet) { if (this.tablet) { throw new Error('CustomSettingsApp._initialize called but this.tablet already has a value'); } this.tablet = tablet; tablet.webEventReceived.connect(this, 'onWebEventReceived'); tablet.screenChanged.connect(this, 'onScreenChanged'); this.onAPIValueUpdated = function(key, newValue, oldValue, origin) { if (this._activeSettings.remote[key] !== newValue) { _debugPrint('onAPIValueUpdated: ' + key + ' = ' + JSON.stringify(newValue), '(was: ' + JSON.stringify(oldValue) +')'); this.syncValue(key, newValue, (origin ? origin+':' : '') + 'CustomSettingsApp.onAPIValueUpdated'); } }; this.isActiveChanged.connect(this, function(isActive) { this._activeSettings.remote = {}; // reset assumptions about remote values isActive ? this.$startMonitor() : this.$stopMonitor(); }); debugPrint('CustomSettingsApp...initialized', this.namespace); }, $syncSettings: function() { for (var p in this._activeSettings.sent) { var value = this._apiGetValue(p), lastValue = this._activeSettings.remote[p]; if (value !== undefined && value !== null && value !== '' && value !== lastValue) { _debugPrint('CustomSettingsApp... detected external settings change', p, value); this.syncValue(p, value, 'Settings'); this.valueUpdated(p, value, lastValue, 'CustomSettingsApp.$syncSettings'); } } }, $startMonitor: function() { if (!(this.recheckInterval > 0)) { _debugPrint('$startMonitor -- recheckInterval <= 0; not starting settings monitor thread'); return false; } if (this.interval) { this.$stopMonitor(); } if (this.settingsAPI.valueUpdated) { _debugPrint('settingsAPI supports valueUpdated -- binding to detect settings changes', this.settingsAPI); this.settingsAPI.valueUpdated.connect(this, 'onAPIValueUpdated'); } this.interval = Script.setInterval(_utils.bind(this, '$syncSettings'), this.recheckInterval); _debugPrint('STARTED MONITORING THREAD'); }, $stopMonitor: function() { if (this.interval) { Script.clearInterval(this.interval); this.interval = 0; if (this.settingsAPI.valueUpdated) { this.settingsAPI.valueUpdated.disconnect(this, 'onAPIValueUpdated'); } _debugPrint('stopped monitoring thread'); return true; } }, cleanup: function() { if (!this.tablet) { return _debugPrint('CustomSettingsApp...cleanup called when not initialized'); } var tablet = this.tablet; tablet.webEventReceived.disconnect(this, 'onWebEventReceived'); tablet.screenChanged.disconnect(this, 'onScreenChanged'); this.$stopMonitor(); if (this.isActive) { try { this.isActiveChanged(this.isActive = false); } catch (e) { _debugPrint('ERROR: cleanup error during isActiveChanged(false)', e); } } this.toggle(false); this.settingsScreenVisible = false; this.tablet = null; debugPrint('cleanup completed', this.namespace); }, toggle: function(show) { if (!this.tablet) { return _debugPrint('CustomSettingsApp...cleanup called when not initialized'); } if (typeof show !== 'boolean') { show = !this.settingsScreenVisible; } if (this.settingsScreenVisible && !show) { this.tablet.gotoHomeScreen(); } else if (!this.settingsScreenVisible && show) { Script.setTimeout(_utils.bind(this, function() { // Interface sometimes crashes if not for adding a small timeout here :( this.tablet.gotoWebScreen(this.url); }), 1); } } };