summaryrefslogtreecommitdiffstats
path: root/devtools/shared/discovery
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/shared/discovery')
-rw-r--r--devtools/shared/discovery/discovery.js496
-rw-r--r--devtools/shared/discovery/moz.build11
-rw-r--r--devtools/shared/discovery/tests/unit/test_discovery.js161
-rw-r--r--devtools/shared/discovery/tests/unit/xpcshell.ini7
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]