Commit 6f290c21 authored by kelvinp's avatar kelvinp Committed by Commit bot

Implement base.IPC

In Chrome Apps, some platform APIs can only be called from the background
page (e.g. reloading a chrome.app.AppWindow).  Likewise, some chrome API's
must be initiated by user interaction, which can only be called from the
foreground.

This CL base.IPC that provides helper functions to invoke methods on
different pages using chrome.runtime.sendMessage.

BUG=452317

Review URL: https://codereview.chromium.org/877993002

Cr-Commit-Position: refs/heads/master@{#313614}
parent 9e344b9f
...@@ -59,6 +59,7 @@ ...@@ -59,6 +59,7 @@
'remoting_webapp_js_core_files': [ 'remoting_webapp_js_core_files': [
'webapp/base/js/application.js', 'webapp/base/js/application.js',
'webapp/base/js/base.js', 'webapp/base/js/base.js',
'webapp/base/js/ipc.js',
'webapp/base/js/platform.js', 'webapp/base/js/platform.js',
'webapp/crd/js/apps_v2_migration.js', 'webapp/crd/js/apps_v2_migration.js',
'webapp/crd/js/error.js', 'webapp/crd/js/error.js',
...@@ -178,6 +179,7 @@ ...@@ -178,6 +179,7 @@
'webapp/unittests/base_unittest.js', 'webapp/unittests/base_unittest.js',
'webapp/unittests/dns_blackhole_checker_unittest.js', 'webapp/unittests/dns_blackhole_checker_unittest.js',
'webapp/unittests/fallback_signal_strategy_unittest.js', 'webapp/unittests/fallback_signal_strategy_unittest.js',
'webapp/unittests/ipc_unittest.js',
'webapp/unittests/it2me_helpee_channel_unittest.js', 'webapp/unittests/it2me_helpee_channel_unittest.js',
'webapp/unittests/it2me_helper_channel_unittest.js', 'webapp/unittests/it2me_helper_channel_unittest.js',
'webapp/unittests/it2me_service_unittest.js', 'webapp/unittests/it2me_service_unittest.js',
...@@ -226,6 +228,7 @@ ...@@ -226,6 +228,7 @@
# The JavaScript files that are used in the background page. # The JavaScript files that are used in the background page.
'remoting_webapp_background_js_files': [ 'remoting_webapp_background_js_files': [
'webapp/base/js/base.js', 'webapp/base/js/base.js',
'webapp/base/js/ipc.js',
'webapp/base/js/message_window_helper.js', 'webapp/base/js/message_window_helper.js',
'webapp/base/js/message_window_manager.js', 'webapp/base/js/message_window_manager.js',
'webapp/crd/js/app_launcher.js', 'webapp/crd/js/app_launcher.js',
......
...@@ -111,6 +111,18 @@ base.values = function(dict) { ...@@ -111,6 +111,18 @@ base.values = function(dict) {
}); });
}; };
/**
* @param {*} value
* @return {*} a recursive copy of |value| or null if |value| is not copyable
* (e.g. undefined, NaN).
*/
base.deepCopy = function (value) {
try {
return JSON.parse(JSON.stringify(value));
} catch (e) {}
return null;
};
/** /**
* @type {boolean|undefined} * @type {boolean|undefined}
* @private * @private
......
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* @fileoverview
*
* In Chrome Apps, some platform APIs can only be called from the background
* page (e.g. reloading a chrome.app.AppWindow). Likewise, some chrome API's
* must be initiated by user interaction, which can only be called from the
* foreground.
*
* This class provides helper functions to invoke methods on different pages
* using chrome.runtime.sendMessage. Messages are passed in the following
* format:
* {methodName:{string}, params:{Array}}
*
* chrome.runtime.sendMessage allows multiple handlers to be registered on a
* document, but only one handler can send a response.
* This class uniquely identifies a method with the |methodName| and enforces
* that only one handler can be registered per |methodName| in the document.
*
* For example, to call method foo() in the background page from the foreground
* chrome.app.AppWindow, you can do the following.
* In the background page:
* base.Ipc.getInstance().register('my.service.name', foo);
*
* In the AppWindow document:
* base.Ipc.invoke('my.service.name', arg1, arg2, ...).then(
* function(result) {
* console.log('The result is ' + result);
* });
*
* This will invoke foo() with the arg1, arg2, ....
* The return value of foo() will be passed back to the caller in the
* form of a promise.
*/
/** @suppress {duplicate} */
var base = base || {};
(function() {
'use strict';
/**
* @constructor
* @private
*/
base.Ipc = function() {
base.debug.assert(instance_ === null);
/**
* @type {!Object.<Function>}
* @private
*/
this.handlers_ = {};
this.onMessageHandler_ = this.onMessage_.bind(this);
chrome.runtime.onMessage.addListener(this.onMessageHandler_);
};
/** @private */
base.Ipc.prototype.dispose_ = function() {
chrome.runtime.onMessage.removeListener(this.onMessageHandler_);
};
/**
* The error strings are only used for debugging purposes and are not localized.
*
* @enum {string}
*/
base.Ipc.Error = {
UNSUPPORTED_REQUEST_TYPE: 'Unsupported method name.',
INVALID_REQUEST_ORIGIN:
'base.Ipc only accept incoming requests from the same extension.'
};
/**
* @constructor
* @param {string} methodName
* @param {?Array} params
* @struct
* @private
*/
base.Ipc.Request_ = function(methodName, params) {
this.methodName = methodName;
this.params = params;
};
/**
* @param {string} methodName
* @param {function(?)} handler The handler can be invoked by calling
* base.Ipc.invoke(|methodName|, arg1, arg2, ...)
* Async handlers that return promises are currently not supported.
* @return {boolean} Whether the handler is successfully registered.
*/
base.Ipc.prototype.register = function(methodName, handler) {
if (methodName in this.handlers_) {
console.error('service ' + methodName + ' is already registered.');
return false;
}
this.handlers_[methodName] = handler;
return true;
};
/**
* @param {string} methodName
*/
base.Ipc.prototype.unregister = function(methodName) {
delete this.handlers_[methodName];
};
/**
* @param {base.Ipc.Request_} message
* @param {chrome.runtime.MessageSender} sender
* @param {function(*): void} sendResponse
*/
base.Ipc.prototype.onMessage_ = function(message, sender, sendResponse) {
var methodName = message.methodName;
if (typeof methodName !== 'string') {
return;
}
if (sender.id !== chrome.runtime.id) {
sendResponse({error: base.Ipc.Error.INVALID_REQUEST_ORIGIN});
return;
}
var remoteMethod =
/** @type {function(*):void} */ (this.handlers_[methodName]);
if (!remoteMethod) {
sendResponse({error: base.Ipc.Error.UNSUPPORTED_REQUEST_TYPE});
return;
}
try {
sendResponse(remoteMethod.apply(null, message.params));
} catch (/** @type {Error} */ e) {
sendResponse({error: e.message});
}
};
/**
* Invokes a method on a remote page
*
* @param {string} methodName
* @param {...} var_args
* @return A Promise that would resolve to the return value of the handler or
* reject if the handler throws an exception.
*/
base.Ipc.invoke = function(methodName, var_args) {
var params = Array.prototype.slice.call(arguments, 1);
var sendMessage = base.Promise.as(
chrome.runtime.sendMessage,
[null, new base.Ipc.Request_(methodName, params)]);
return sendMessage.then(
/** @param {?{error: Error}} response */
function(response) {
if (response && response.error) {
return Promise.reject(response.error);
} else {
return Promise.resolve(response);
}
});
};
/** @type {base.Ipc} */
var instance_ = null;
/** @return {base.Ipc} */
base.Ipc.getInstance = function() {
if (!instance_) {
instance_ = new base.Ipc();
}
return instance_;
};
base.Ipc.deleteInstance = function() {
if (instance_) {
instance_.dispose_();
instance_ = null;
}
};
})();
...@@ -39,9 +39,9 @@ chrome.app.window = { ...@@ -39,9 +39,9 @@ chrome.app.window = {
current: function() {}, current: function() {},
/** /**
* @param {string} id * @param {string} id
* @param {function()=} opt_callback * @return {AppWindow}
*/ */
get: function(id, opt_callback) {}, get: function(id) {},
/** /**
* @return {Array.<AppWindow>} * @return {Array.<AppWindow>}
*/ */
...@@ -88,7 +88,7 @@ chrome.runtime.connectNative = function(name) {}; ...@@ -88,7 +88,7 @@ chrome.runtime.connectNative = function(name) {};
chrome.runtime.connect = function(config) {}; chrome.runtime.connect = function(config) {};
/** /**
* @param {string} extensionId * @param {string?} extensionId
* @param {*} message * @param {*} message
* @param {Object=} opt_options * @param {Object=} opt_options
* @param {function(*)=} opt_callback * @param {function(*)=} opt_callback
...@@ -100,6 +100,10 @@ chrome.runtime.sendMessage = function( ...@@ -100,6 +100,10 @@ chrome.runtime.sendMessage = function(
chrome.runtime.MessageSender = function(){ chrome.runtime.MessageSender = function(){
/** @type {chrome.Tab} */ /** @type {chrome.Tab} */
this.tab = null; this.tab = null;
/** @type {string} */
this.id = '';
/** @type {string} */
this.url = '';
}; };
/** @constructor */ /** @constructor */
......
...@@ -41,6 +41,35 @@ test('values(obj) should return an array containing the values of |obj|', ...@@ -41,6 +41,35 @@ test('values(obj) should return an array containing the values of |obj|',
notEqual(output.indexOf('b'), -1, '"b" should be in the output'); notEqual(output.indexOf('b'), -1, '"b" should be in the output');
}); });
test('deepCopy(obj) should return null on NaN and undefined',
function() {
QUnit.equal(base.deepCopy(NaN), null);
QUnit.equal(base.deepCopy(undefined), null);
});
test('deepCopy(obj) should copy primitive types recursively',
function() {
QUnit.equal(base.deepCopy(1), 1);
QUnit.equal(base.deepCopy('hello'), 'hello');
QUnit.equal(base.deepCopy(false), false);
QUnit.equal(base.deepCopy(null), null);
QUnit.deepEqual(base.deepCopy([1, 2]), [1, 2]);
QUnit.deepEqual(base.deepCopy({'key': 'value'}), {'key': 'value'});
QUnit.deepEqual(base.deepCopy(
{'key': {'key_nested': 'value_nested'}}),
{'key': {'key_nested': 'value_nested'}}
);
QUnit.deepEqual(base.deepCopy([1, [2, [3]]]), [1, [2, [3]]]);
});
test('modify the original after deepCopy(obj) should not affect the copy',
function() {
var original = [1, 2, 3, 4];
var copy = base.deepCopy(original);
original[2] = 1000;
QUnit.deepEqual(copy, [1, 2, 3, 4]);
});
test('dispose(obj) should invoke the dispose method on |obj|', test('dispose(obj) should invoke the dispose method on |obj|',
function() { function() {
var obj = { var obj = {
......
...@@ -28,9 +28,10 @@ chromeMocks.Event.prototype.removeListener = function(callback) { ...@@ -28,9 +28,10 @@ chromeMocks.Event.prototype.removeListener = function(callback) {
} }
}; };
chromeMocks.Event.prototype.mock$fire = function(data) { chromeMocks.Event.prototype.mock$fire = function(var_args) {
var params = Array.prototype.slice.call(arguments);
this.listeners_.forEach(function(listener){ this.listeners_.forEach(function(listener){
listener(data); listener.apply(null, params);
}); });
}; };
...@@ -47,6 +48,22 @@ chromeMocks.runtime.Port = function() { ...@@ -47,6 +48,22 @@ chromeMocks.runtime.Port = function() {
chromeMocks.runtime.Port.prototype.disconnect = function() {}; chromeMocks.runtime.Port.prototype.disconnect = function() {};
chromeMocks.runtime.Port.prototype.postMessage = function() {}; chromeMocks.runtime.Port.prototype.postMessage = function() {};
chromeMocks.runtime.onMessage = new chromeMocks.Event();
chromeMocks.runtime.sendMessage = function(extensionId, message,
responseCallback) {
base.debug.assert(
extensionId === null,
'The mock only supports sending messages to the same extension.');
extensionId = chrome.runtime.id;
window.requestAnimationFrame(function() {
var message_copy = base.deepCopy(message);
chromeMocks.runtime.onMessage.mock$fire(
message_copy, {id: extensionId}, responseCallback);
});
};
chromeMocks.runtime.id = 'extensionId';
chromeMocks.storage = {}; chromeMocks.storage = {};
// Sample implementation of chrome.StorageArea according to // Sample implementation of chrome.StorageArea according to
...@@ -55,10 +72,6 @@ chromeMocks.StorageArea = function() { ...@@ -55,10 +72,6 @@ chromeMocks.StorageArea = function() {
this.storage_ = {}; this.storage_ = {};
}; };
function deepCopy(value) {
return JSON.parse(JSON.stringify(value));
}
function getKeys(keys) { function getKeys(keys) {
if (typeof keys === 'string') { if (typeof keys === 'string') {
return [keys]; return [keys];
...@@ -70,14 +83,14 @@ function getKeys(keys) { ...@@ -70,14 +83,14 @@ function getKeys(keys) {
chromeMocks.StorageArea.prototype.get = function(keys, onDone) { chromeMocks.StorageArea.prototype.get = function(keys, onDone) {
if (!keys) { if (!keys) {
onDone(deepCopy(this.storage_)); onDone(base.deepCopy(this.storage_));
return; return;
} }
var result = (typeof keys === 'object') ? keys : {}; var result = (typeof keys === 'object') ? keys : {};
getKeys(keys).forEach(function(key) { getKeys(keys).forEach(function(key) {
if (key in this.storage_) { if (key in this.storage_) {
result[key] = deepCopy(this.storage_[key]); result[key] = base.deepCopy(this.storage_[key]);
} }
}, this); }, this);
onDone(result); onDone(result);
...@@ -85,7 +98,7 @@ chromeMocks.StorageArea.prototype.get = function(keys, onDone) { ...@@ -85,7 +98,7 @@ chromeMocks.StorageArea.prototype.get = function(keys, onDone) {
chromeMocks.StorageArea.prototype.set = function(value) { chromeMocks.StorageArea.prototype.set = function(value) {
for (var key in value) { for (var key in value) {
this.storage_[key] = deepCopy(value[key]); this.storage_[key] = base.deepCopy(value[key]);
} }
}; };
......
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
(function() {
'use strict';
var ipc_;
function pass() {
ok(true);
QUnit.start();
}
function fail() {
ok(false);
QUnit.start();
}
module('base.Ipc', {
setup: function() {
chromeMocks.activate(['runtime']);
ipc_ = base.Ipc.getInstance();
},
teardown: function() {
base.Ipc.deleteInstance();
ipc_ = null;
chromeMocks.restore();
}
});
QUnit.test(
'register() should return false if the request type was already registered',
function() {
var handler1 = function() {};
var handler2 = function() {};
QUnit.equal(true, ipc_.register('foo', handler1));
QUnit.equal(false, ipc_.register('foo', handler2));
});
QUnit.asyncTest(
'send() should invoke a registered handler with the correct arguments',
function() {
var handler = sinon.spy();
var argArray = [1, 2, 3];
var argDict = {
key1: 'value1',
key2: false
};
ipc_.register('foo', handler);
base.Ipc.invoke('foo', 1, false, 'string', argArray, argDict).then(
function() {
sinon.assert.calledWith(handler, 1, false, 'string', argArray, argDict);
pass();
}, fail);
});
QUnit.asyncTest(
'send() should not invoke a handler that is unregistered',
function() {
var handler = sinon.spy();
ipc_.register('foo', handler);
ipc_.unregister('foo', handler);
base.Ipc.invoke('foo', 'hello', 'world').then(fail, function(error) {
sinon.assert.notCalled(handler);
QUnit.equal(error, base.Ipc.Error.UNSUPPORTED_REQUEST_TYPE);
pass();
});
});
QUnit.asyncTest(
'send() should raise exceptions on unknown request types',
function() {
var handler = sinon.spy();
ipc_.register('foo', handler);
base.Ipc.invoke('bar', 'hello', 'world').then(fail, function(error) {
QUnit.equal(error, base.Ipc.Error.UNSUPPORTED_REQUEST_TYPE);
pass();
});
});
QUnit.asyncTest(
'send() should raise exceptions on request from another extension',
function() {
var handler = sinon.spy();
var oldId = chrome.runtime.id;
ipc_.register('foo', handler);
chrome.runtime.id = 'foreign-extension';
base.Ipc.invoke('foo', 'hello', 'world').then(fail, function(error) {
QUnit.equal(error, base.Ipc.Error.INVALID_REQUEST_ORIGIN);
pass();
});
chrome.runtime.id = oldId;
});
QUnit.asyncTest(
'send() should pass exceptions raised by the handler to the caller',
function() {
var handler = function() {
throw new Error('Whatever can go wrong, will go wrong.');
};
ipc_.register('foo', handler);
base.Ipc.invoke('foo').then(fail, function(error) {
QUnit.equal(error, 'Whatever can go wrong, will go wrong.');
pass();
});
});
QUnit.asyncTest(
'send() should pass the return value of the handler to the caller',
function() {
var handlers = {
'boolean': function() { return false; },
'number': function() { return 12; },
'string': function() { return 'string'; },
'array': function() { return [1, 2]; },
'dict': function() { return {key1: 'value1', key2: 'value2'}; }
};
var testCases = [];
for (var ipcName in handlers) {
ipc_.register(ipcName, handlers[ipcName]);
testCases.push(base.Ipc.invoke(ipcName));
}
Promise.all(testCases).then(function(results){
QUnit.equal(results[0], false);
QUnit.equal(results[1], 12);
QUnit.equal(results[2], 'string');
QUnit.deepEqual(results[3], [1,2]);
QUnit.deepEqual(results[4], {key1: 'value1', key2: 'value2'});
pass();
}, fail);
});
})();
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment