/* 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 EventEmitter = require(
"resource://devtools/shared/event-emitter.js");
const UDPSocket = Components.Constructor(
"@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;
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(object, port) {
if (logging) {
log(
"Send to " + port +
":\n" + JSON.stringify(object,
null, 2));
}
const message = JSON.stringify(object);
const rawMessage = Uint8Array.from(message, x => x.charCodeAt(0));
try {
this.socket.send(ADDRESS, port, rawMessage, rawMessage.length);
}
catch (e) {
log(
"Failed to send message: " + e);
}
},
destroy() {
this.socket.close();
},
// nsIUDPSocketListener
onPacketReceived(socket, message) {
const messageData = message.data;
const object = JSON.parse(messageData);
object.from = message.fromAddr.address;
const 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() {},
};
/**
* 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.
*/
function LocalDevice() {
this._name = LocalDevice.UNKNOWN;
// Trigger |_get| to load name eagerly
this._get();
}
LocalDevice.UNKNOWN =
"unknown";
LocalDevice.prototype = {
_get() {
// Without Settings API, just generate a name and stop, since the value
// can't be persisted.
this._generate();
},
/**
* Generate a new device name from various platform-specific properties.
* Triggers the |name| setter to persist if needed.
*/
_generate() {
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 = Services.sysinfo.get(
"device");
}
else {
this.name = Services.dns.myHostName;
}
},
get name() {
return this._name;
},
set name(name) {
this._name = name;
log(
"Device: " +
this._name);
},
};
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 };
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(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(service) {
delete this.localServices[service];
if (Object.keys(
this.localServices).length === 0) {
this._stopListeningForScan();
}
},
/**
* Scan for service updates from other devices.
*/
scan() {
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.
*/
getRemoteDevices() {
const devices =
new Set();
for (
const service in
this.remoteServices) {
for (
const device in
this.remoteServices[service]) {
devices.add(device);
}
}
return [...devices];
},
/**
* Get a list of all remote devices currently offering a particular service.
*/
getRemoteDevicesWithService(service) {
const 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(service, device) {
const devicesWithService =
this.remoteServices[service] || {};
return devicesWithService[device];
},
_waitForReplies() {
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() {
if (
this._transports.scan) {
// Already listening
return;
}
log(
"LISTEN FOR SCAN");
this._transports.scan =
new this.Transport(SCAN_PORT);
this._transports.scan.on(
"message",
this._onRemoteScan);
},
_stopListeningForScan() {
if (!
this._transports.scan) {
// Not listening
return;
}
this._transports.scan.off(
"message",
this._onRemoteScan);
this._transports.scan.destroy();
this._transports.scan =
null;
},
_startListeningForUpdate() {
if (
this._transports.update) {
// Already listening
return;
}
log(
"LISTEN FOR UPDATE");
this._transports.update =
new this.Transport(UPDATE_PORT);
this._transports.update.on(
"message",
this._onRemoteUpdate);
},
_stopListeningForUpdate() {
if (!
this._transports.update) {
// Not listening
return;
}
this._transports.update.off(
"message",
this._onRemoteUpdate);
this._transports.update.destroy();
this._transports.update =
null;
},
_restartListening() {
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(port) {
const status = {
device:
this.device.name,
services:
this.localServices,
};
this._outgoingTransport.send(status, port);
},
_onRemoteScan() {
// Send my own status in response
log(
"GOT SCAN REQUEST");
this._sendStatusTo(UPDATE_PORT);
},
_onRemoteUpdate(update) {
log(
"GOT REMOTE UPDATE");
const remoteDevice = update.device;
const 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 (
const service in
this.remoteServices) {
const devicesWithService =
this.remoteServices[service];
const hadServiceForDevice = !!devicesWithService[remoteDevice];
const 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 (
const service in update.services) {
// Detect if this is a new device for this service
const newDevice =
!
this.remoteServices[service] ||
!
this.remoteServices[service][remoteDevice];
// Look up the service info we may have received previously from the same
// remote device
const devicesWithService =
this.remoteServices[service] || {};
const oldDeviceInfo = devicesWithService[remoteDevice];
// Store the service info from the remote device
const 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() {
log(
"PURGING MISSING DEVICES");
for (
const service in
this.remoteServices) {
const devicesWithService =
this.remoteServices[service];
for (
const 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;