123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486 |
- /* 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 == "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;
|