diff options
Diffstat (limited to 'devtools/shared/discovery')
-rw-r--r-- | devtools/shared/discovery/discovery.js | 496 | ||||
-rw-r--r-- | devtools/shared/discovery/moz.build | 11 | ||||
-rw-r--r-- | devtools/shared/discovery/tests/unit/test_discovery.js | 161 | ||||
-rw-r--r-- | devtools/shared/discovery/tests/unit/xpcshell.ini | 7 |
4 files changed, 675 insertions, 0 deletions
diff --git a/devtools/shared/discovery/discovery.js b/devtools/shared/discovery/discovery.js new file mode 100644 index 000000000..d0b49f129 --- /dev/null +++ b/devtools/shared/discovery/discovery.js @@ -0,0 +1,496 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * This implements a UDP mulitcast device discovery protocol that: + * * Is optimized for mobile devices + * * Doesn't require any special schema for service info + * + * To ensure it works well on mobile devices, there is no heartbeat or other + * recurring transmission. + * + * Devices are typically in one of two groups: scanning for services or + * providing services (though they may be in both groups as well). + * + * Scanning devices listen on UPDATE_PORT for UDP multicast traffic. When the + * scanning device wants to force an update of the services available, it sends + * a status packet to SCAN_PORT. + * + * Service provider devices listen on SCAN_PORT for any packets from scanning + * devices. If one is recevied, the provider device sends a status packet + * (listing the services it offers) to UPDATE_PORT. + * + * Scanning devices purge any previously known devices after REPLY_TIMEOUT ms + * from that start of a scan if no reply is received during the most recent + * scan. + * + * When a service is registered, is supplies a regular object with any details + * about itself (a port number, for example) in a service-defined format, which + * is then available to scanning devices. + */ + +const { Cu, CC, Cc, Ci } = require("chrome"); +const EventEmitter = require("devtools/shared/event-emitter"); +const Services = require("Services"); + +const UDPSocket = CC("@mozilla.org/network/udp-socket;1", + "nsIUDPSocket", + "init"); + +const SCAN_PORT = 50624; +const UPDATE_PORT = 50625; +const ADDRESS = "224.0.0.115"; +const REPLY_TIMEOUT = 5000; + +const { XPCOMUtils } = Cu.import("resource://gre/modules/XPCOMUtils.jsm", {}); + +XPCOMUtils.defineLazyGetter(this, "converter", () => { + let conv = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. + createInstance(Ci.nsIScriptableUnicodeConverter); + conv.charset = "utf8"; + return conv; +}); + +XPCOMUtils.defineLazyGetter(this, "sysInfo", () => { + return Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2); +}); + +XPCOMUtils.defineLazyGetter(this, "libcutils", function () { + let { libcutils } = Cu.import("resource://gre/modules/systemlibs.js", {}); + return libcutils; +}); + +var logging = Services.prefs.getBoolPref("devtools.discovery.log"); +function log(msg) { + if (logging) { + console.log("DISCOVERY: " + msg); + } +} + +/** + * Each Transport instance owns a single UDPSocket. + * @param port integer + * The port to listen on for incoming UDP multicast packets. + */ +function Transport(port) { + EventEmitter.decorate(this); + try { + this.socket = new UDPSocket(port, false, Services.scriptSecurityManager.getSystemPrincipal()); + this.socket.joinMulticast(ADDRESS); + this.socket.asyncListen(this); + } catch (e) { + log("Failed to start new socket: " + e); + } +} + +Transport.prototype = { + + /** + * Send a object to some UDP port. + * @param object object + * Object which is the message to send + * @param port integer + * UDP port to send the message to + */ + send: function (object, port) { + if (logging) { + log("Send to " + port + ":\n" + JSON.stringify(object, null, 2)); + } + let message = JSON.stringify(object); + let rawMessage = converter.convertToByteArray(message); + try { + this.socket.send(ADDRESS, port, rawMessage, rawMessage.length); + } catch (e) { + log("Failed to send message: " + e); + } + }, + + destroy: function () { + this.socket.close(); + }, + + // nsIUDPSocketListener + + onPacketReceived: function (socket, message) { + let messageData = message.data; + let object = JSON.parse(messageData); + object.from = message.fromAddr.address; + let port = message.fromAddr.port; + if (port == this.socket.port) { + log("Ignoring looped message"); + return; + } + if (logging) { + log("Recv on " + this.socket.port + ":\n" + + JSON.stringify(object, null, 2)); + } + this.emit("message", object); + }, + + onStopListening: function () {} + +}; + +/** + * Manages the local device's name. The name can be generated in serveral + * platform-specific ways (see |_generate|). The aim is for each device on the + * same local network to have a unique name. If the Settings API is available, + * the name is saved there to persist across reboots. + */ +function LocalDevice() { + this._name = LocalDevice.UNKNOWN; + if ("@mozilla.org/settingsService;1" in Cc) { + this._settings = + Cc["@mozilla.org/settingsService;1"].getService(Ci.nsISettingsService); + Services.obs.addObserver(this, "mozsettings-changed", false); + } + this._get(); // Trigger |_get| to load name eagerly +} + +LocalDevice.SETTING = "devtools.discovery.device"; +LocalDevice.UNKNOWN = "unknown"; + +LocalDevice.prototype = { + + _get: function () { + if (!this._settings) { + // Without Settings API, just generate a name and stop, since the value + // can't be persisted. + this._generate(); + return; + } + // Initial read of setting value + this._settings.createLock().get(LocalDevice.SETTING, { + handle: (_, name) => { + if (name && name !== LocalDevice.UNKNOWN) { + this._name = name; + log("Device: " + this._name); + return; + } + // No existing name saved, so generate one. + this._generate(); + }, + handleError: () => log("Failed to get device name setting") + }); + }, + + /** + * Generate a new device name from various platform-specific properties. + * Triggers the |name| setter to persist if needed. + */ + _generate: function () { + if (Services.appinfo.widgetToolkit == "gonk") { + // For Firefox OS devices, create one from the device name plus a little + // randomness. The goal is just to distinguish devices in an office + // environment where many people may have the same device model for + // testing purposes (which would otherwise all report the same name). + let name = libcutils.property_get("ro.product.device"); + // Pick a random number from [0, 2^32) + let randomID = Math.floor(Math.random() * Math.pow(2, 32)); + // To hex and zero pad + randomID = ("00000000" + randomID.toString(16)).slice(-8); + this.name = name + "-" + randomID; + } else if (Services.appinfo.widgetToolkit == "android") { + // For Firefox for Android, use the device's model name. + // TODO: Bug 1180997: Find the right way to expose an editable name + this.name = sysInfo.get("device"); + } else { + this.name = sysInfo.get("host"); + } + }, + + /** + * Observe any changes that might be made via the Settings app + */ + observe: function (subject, topic, data) { + if (topic !== "mozsettings-changed") { + return; + } + if ("wrappedJSObject" in subject) { + subject = subject.wrappedJSObject; + } + if (subject.key !== LocalDevice.SETTING) { + return; + } + this._name = subject.value; + log("Device: " + this._name); + }, + + get name() { + return this._name; + }, + + set name(name) { + if (!this._settings) { + this._name = name; + log("Device: " + this._name); + return; + } + // Persist to Settings API + // The new value will be seen and stored by the observer above + this._settings.createLock().set(LocalDevice.SETTING, name, { + handle: () => {}, + handleError: () => log("Failed to set device name setting") + }); + } + +}; + +function Discovery() { + EventEmitter.decorate(this); + + this.localServices = {}; + this.remoteServices = {}; + this.device = new LocalDevice(); + this.replyTimeout = REPLY_TIMEOUT; + + // Defaulted to Transport, but can be altered by tests + this._factories = { Transport: Transport }; + + this._transports = { + scan: null, + update: null + }; + this._expectingReplies = { + from: new Set() + }; + + this._onRemoteScan = this._onRemoteScan.bind(this); + this._onRemoteUpdate = this._onRemoteUpdate.bind(this); + this._purgeMissingDevices = this._purgeMissingDevices.bind(this); +} + +Discovery.prototype = { + + /** + * Add a new service offered by this device. + * @param service string + * Name of the service + * @param info object + * Arbitrary data about the service to announce to scanning devices + */ + addService: function (service, info) { + log("ADDING LOCAL SERVICE"); + if (Object.keys(this.localServices).length === 0) { + this._startListeningForScan(); + } + this.localServices[service] = info; + }, + + /** + * Remove a service offered by this device. + * @param service string + * Name of the service + */ + removeService: function (service) { + delete this.localServices[service]; + if (Object.keys(this.localServices).length === 0) { + this._stopListeningForScan(); + } + }, + + /** + * Scan for service updates from other devices. + */ + scan: function () { + this._startListeningForUpdate(); + this._waitForReplies(); + // TODO Bug 1027457: Use timer to debounce + this._sendStatusTo(SCAN_PORT); + }, + + /** + * Get a list of all remote devices currently offering some service.:w + */ + getRemoteDevices: function () { + let devices = new Set(); + for (let service in this.remoteServices) { + for (let device in this.remoteServices[service]) { + devices.add(device); + } + } + return [...devices]; + }, + + /** + * Get a list of all remote devices currently offering a particular service. + */ + getRemoteDevicesWithService: function (service) { + let devicesWithService = this.remoteServices[service] || {}; + return Object.keys(devicesWithService); + }, + + /** + * Get service info (any details registered by the remote device) for a given + * service on a device. + */ + getRemoteService: function (service, device) { + let devicesWithService = this.remoteServices[service] || {}; + return devicesWithService[device]; + }, + + _waitForReplies: function () { + clearTimeout(this._expectingReplies.timer); + this._expectingReplies.from = new Set(this.getRemoteDevices()); + this._expectingReplies.timer = + setTimeout(this._purgeMissingDevices, this.replyTimeout); + }, + + get Transport() { + return this._factories.Transport; + }, + + _startListeningForScan: function () { + if (this._transports.scan) { + return; // Already listening + } + log("LISTEN FOR SCAN"); + this._transports.scan = new this.Transport(SCAN_PORT); + this._transports.scan.on("message", this._onRemoteScan); + }, + + _stopListeningForScan: function () { + if (!this._transports.scan) { + return; // Not listening + } + this._transports.scan.off("message", this._onRemoteScan); + this._transports.scan.destroy(); + this._transports.scan = null; + }, + + _startListeningForUpdate: function () { + if (this._transports.update) { + return; // Already listening + } + log("LISTEN FOR UPDATE"); + this._transports.update = new this.Transport(UPDATE_PORT); + this._transports.update.on("message", this._onRemoteUpdate); + }, + + _stopListeningForUpdate: function () { + if (!this._transports.update) { + return; // Not listening + } + this._transports.update.off("message", this._onRemoteUpdate); + this._transports.update.destroy(); + this._transports.update = null; + }, + + _restartListening: function () { + if (this._transports.scan) { + this._stopListeningForScan(); + this._startListeningForScan(); + } + if (this._transports.update) { + this._stopListeningForUpdate(); + this._startListeningForUpdate(); + } + }, + + /** + * When sending message, we can use either transport, so just pick the first + * one currently alive. + */ + get _outgoingTransport() { + if (this._transports.scan) { + return this._transports.scan; + } + if (this._transports.update) { + return this._transports.update; + } + return null; + }, + + _sendStatusTo: function (port) { + let status = { + device: this.device.name, + services: this.localServices + }; + this._outgoingTransport.send(status, port); + }, + + _onRemoteScan: function () { + // Send my own status in response + log("GOT SCAN REQUEST"); + this._sendStatusTo(UPDATE_PORT); + }, + + _onRemoteUpdate: function (e, update) { + log("GOT REMOTE UPDATE"); + + let remoteDevice = update.device; + let remoteHost = update.from; + + // Record the reply as received so it won't be purged as missing + this._expectingReplies.from.delete(remoteDevice); + + // First, loop over the known services + for (let service in this.remoteServices) { + let devicesWithService = this.remoteServices[service]; + let hadServiceForDevice = !!devicesWithService[remoteDevice]; + let haveServiceForDevice = service in update.services; + // If the remote device used to have service, but doesn't any longer, then + // it was deleted, so we remove it here. + if (hadServiceForDevice && !haveServiceForDevice) { + delete devicesWithService[remoteDevice]; + log("REMOVED " + service + ", DEVICE " + remoteDevice); + this.emit(service + "-device-removed", remoteDevice); + } + } + + // Second, loop over the services in the received update + for (let service in update.services) { + // Detect if this is a new device for this service + let newDevice = !this.remoteServices[service] || + !this.remoteServices[service][remoteDevice]; + + // Look up the service info we may have received previously from the same + // remote device + let devicesWithService = this.remoteServices[service] || {}; + let oldDeviceInfo = devicesWithService[remoteDevice]; + + // Store the service info from the remote device + let newDeviceInfo = Cu.cloneInto(update.services[service], {}); + newDeviceInfo.host = remoteHost; + devicesWithService[remoteDevice] = newDeviceInfo; + this.remoteServices[service] = devicesWithService; + + // If this is a new service for the remote device, announce the addition + if (newDevice) { + log("ADDED " + service + ", DEVICE " + remoteDevice); + this.emit(service + "-device-added", remoteDevice, newDeviceInfo); + } + + // If we've seen this service from the remote device, but the details have + // changed, announce the update + if (!newDevice && + JSON.stringify(oldDeviceInfo) != JSON.stringify(newDeviceInfo)) { + log("UPDATED " + service + ", DEVICE " + remoteDevice); + this.emit(service + "-device-updated", remoteDevice, newDeviceInfo); + } + } + }, + + _purgeMissingDevices: function () { + log("PURGING MISSING DEVICES"); + for (let service in this.remoteServices) { + let devicesWithService = this.remoteServices[service]; + for (let remoteDevice in devicesWithService) { + // If we're still expecting a reply from a remote device when it's time + // to purge, then the service is removed. + if (this._expectingReplies.from.has(remoteDevice)) { + delete devicesWithService[remoteDevice]; + log("REMOVED " + service + ", DEVICE " + remoteDevice); + this.emit(service + "-device-removed", remoteDevice); + } + } + } + } + +}; + +var discovery = new Discovery(); + +module.exports = discovery; diff --git a/devtools/shared/discovery/moz.build b/devtools/shared/discovery/moz.build new file mode 100644 index 000000000..9aeaba45e --- /dev/null +++ b/devtools/shared/discovery/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini'] + +DevToolsModules( + 'discovery.js', +) diff --git a/devtools/shared/discovery/tests/unit/test_discovery.js b/devtools/shared/discovery/tests/unit/test_discovery.js new file mode 100644 index 000000000..c31340b08 --- /dev/null +++ b/devtools/shared/discovery/tests/unit/test_discovery.js @@ -0,0 +1,161 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +var Cu = Components.utils; + +const { require } = + Cu.import("resource://devtools/shared/Loader.jsm", {}); +const Services = require("Services"); +const promise = require("promise"); +const defer = require("devtools/shared/defer"); +const EventEmitter = require("devtools/shared/event-emitter"); +const discovery = require("devtools/shared/discovery/discovery"); +const { setTimeout, clearTimeout } = require("sdk/timers"); + +Services.prefs.setBoolPref("devtools.discovery.log", true); + +do_register_cleanup(() => { + Services.prefs.clearUserPref("devtools.discovery.log"); +}); + +function log(msg) { + do_print("DISCOVERY: " + msg); +} + +// Global map of actively listening ports to TestTransport instances +var gTestTransports = {}; + +/** + * Implements the same API as Transport in discovery.js. Here, no UDP sockets + * are used. Instead, messages are delivered immediately. + */ +function TestTransport(port) { + EventEmitter.decorate(this); + this.port = port; + gTestTransports[this.port] = this; +} + +TestTransport.prototype = { + + send: function (object, port) { + log("Send to " + port + ":\n" + JSON.stringify(object, null, 2)); + if (!gTestTransports[port]) { + log("No listener on port " + port); + return; + } + let message = JSON.stringify(object); + gTestTransports[port].onPacketReceived(null, message); + }, + + destroy: function () { + delete gTestTransports[this.port]; + }, + + // nsIUDPSocketListener + + onPacketReceived: function (socket, message) { + let object = JSON.parse(message); + object.from = "localhost"; + log("Recv on " + this.port + ":\n" + JSON.stringify(object, null, 2)); + this.emit("message", object); + }, + + onStopListening: function (socket, status) {} + +}; + +// Use TestTransport instead of the usual Transport +discovery._factories.Transport = TestTransport; + +// Ignore name generation on b2g and force a fixed value +Object.defineProperty(discovery.device, "name", { + get: function () { + return "test-device"; + } +}); + +function run_test() { + run_next_test(); +} + +add_task(function* () { + // At startup, no remote devices are known + deepEqual(discovery.getRemoteDevicesWithService("devtools"), []); + deepEqual(discovery.getRemoteDevicesWithService("penguins"), []); + + discovery.scan(); + + // No services added yet, still empty + deepEqual(discovery.getRemoteDevicesWithService("devtools"), []); + deepEqual(discovery.getRemoteDevicesWithService("penguins"), []); + + discovery.addService("devtools", { port: 1234 }); + + // Changes not visible until next scan + deepEqual(discovery.getRemoteDevicesWithService("devtools"), []); + deepEqual(discovery.getRemoteDevicesWithService("penguins"), []); + + yield scanForChange("devtools", "added"); + + // Now we see the new service + deepEqual(discovery.getRemoteDevicesWithService("devtools"), ["test-device"]); + deepEqual(discovery.getRemoteDevicesWithService("penguins"), []); + + discovery.addService("penguins", { tux: true }); + yield scanForChange("penguins", "added"); + + deepEqual(discovery.getRemoteDevicesWithService("devtools"), ["test-device"]); + deepEqual(discovery.getRemoteDevicesWithService("penguins"), ["test-device"]); + deepEqual(discovery.getRemoteDevices(), ["test-device"]); + + deepEqual(discovery.getRemoteService("devtools", "test-device"), + { port: 1234, host: "localhost" }); + deepEqual(discovery.getRemoteService("penguins", "test-device"), + { tux: true, host: "localhost" }); + + discovery.removeService("devtools"); + yield scanForChange("devtools", "removed"); + + discovery.addService("penguins", { tux: false }); + yield scanForChange("penguins", "updated"); + + // Scan again, but nothing should be removed + yield scanForNoChange("penguins", "removed"); + + // Split the scanning side from the service side to simulate the machine with + // the service becoming unreachable + gTestTransports = {}; + + discovery.removeService("penguins"); + yield scanForChange("penguins", "removed"); +}); + +function scanForChange(service, changeType) { + let deferred = defer(); + let timer = setTimeout(() => { + deferred.reject(new Error("Reply never arrived")); + }, discovery.replyTimeout + 500); + discovery.on(service + "-device-" + changeType, function onChange() { + discovery.off(service + "-device-" + changeType, onChange); + clearTimeout(timer); + deferred.resolve(); + }); + discovery.scan(); + return deferred.promise; +} + +function scanForNoChange(service, changeType) { + let deferred = defer(); + let timer = setTimeout(() => { + deferred.resolve(); + }, discovery.replyTimeout + 500); + discovery.on(service + "-device-" + changeType, function onChange() { + discovery.off(service + "-device-" + changeType, onChange); + clearTimeout(timer); + deferred.reject(new Error("Unexpected change occurred")); + }); + discovery.scan(); + return deferred.promise; +} diff --git a/devtools/shared/discovery/tests/unit/xpcshell.ini b/devtools/shared/discovery/tests/unit/xpcshell.ini new file mode 100644 index 000000000..b13d4a222 --- /dev/null +++ b/devtools/shared/discovery/tests/unit/xpcshell.ini @@ -0,0 +1,7 @@ +[DEFAULT] +tags = devtools +head = +tail = +firefox-appdir = browser + +[test_discovery.js] |