diff --git a/src/engine/block-utility.js b/src/engine/block-utility.js index cfe18a22735..7ad9c61ce9b 100644 --- a/src/engine/block-utility.js +++ b/src/engine/block-utility.js @@ -7,6 +7,8 @@ const Timer = require('../util/timer'); * runtime, thread, target, and convenient methods. */ +let lastInstance; + class BlockUtility { constructor (sequencer = null, thread = null) { /** @@ -25,6 +27,8 @@ class BlockUtility { this._nowObj = { now: () => this.sequencer.runtime.currentMSecs }; + + lastInstance = this; } /** @@ -235,6 +239,14 @@ class BlockUtility { return devObject[func].apply(devObject, args); } } + + /** + * Returns BlockUtility instance to use same instance in runtime and extension. + * @return {BlockUtility} The BlockUtility instance. + */ + static lastInstance () { + return lastInstance; + } } module.exports = BlockUtility; diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 908a30821d7..14e3978cc1f 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -1488,6 +1488,20 @@ class Runtime extends EventEmitter { return isConnected; } + /** + * Returns the connected message. + * @param {string} extensionId - the id of the extension. + * @return {string|null} - the connected message. + */ + getPeripheralConnectedMessage (extensionId) { + if (this.getPeripheralIsConnected(extensionId) && + this.peripheralExtensions[extensionId] && + this.peripheralExtensions[extensionId].connectedMessage) { + return this.peripheralExtensions[extensionId].connectedMessage(); + } + return null; + } + /** * Emit an event to indicate that the microphone is being used to stream audio. * @param {boolean} listening - true if the microphone is currently listening. diff --git a/src/engine/target.js b/src/engine/target.js index 6ed5747b561..0f2467e9ae8 100644 --- a/src/engine/target.js +++ b/src/engine/target.js @@ -305,6 +305,20 @@ class Target extends EventEmitter { } } + /** + * Sets the variable value with the given id to newValue. + * @param {string} id Id of variable to set value. + * @param {object} newValue New value for the variable. + */ + setVariableValue (id, newValue) { + if (this.variables.hasOwnProperty(id)) { + const variable = this.variables[id]; + if (variable.id === id) { + variable.value = newValue; + } + } + } + /** * Renames the variable with the given id to newName. * @param {string} id Id of variable to rename. diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index 7cb556c5d0d..a01d1334455 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -23,7 +23,8 @@ const builtinExtensions = { ev3: () => require('../extensions/scratch3_ev3'), makeymakey: () => require('../extensions/scratch3_makeymakey'), boost: () => require('../extensions/scratch3_boost'), - gdxfor: () => require('../extensions/scratch3_gdx_for') + gdxfor: () => require('../extensions/scratch3_gdx_for'), + mesh: () => require('../extensions/scratch3_mesh') }; /** diff --git a/src/extensions/scratch3_mesh/index.js b/src/extensions/scratch3_mesh/index.js new file mode 100644 index 00000000000..3deaa1a3427 --- /dev/null +++ b/src/extensions/scratch3_mesh/index.js @@ -0,0 +1,339 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); +const log = require('../../util/log'); +const formatMessage = require('format-message'); +const uuidv4 = require('uuid/v4'); +uuidv4(); +const Variable = require('../../engine/variable'); +const MeshService = require('./mesh-service'); +const MeshHost = require('./mesh-host'); +const MeshPeer = require('./mesh-peer'); + +/** + * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. + * @type {string} + */ +// eslint-disable-next-line max-len +const blockIconURI = 'data:image/png;base64,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'; + +const MESH_HOST_PERIPHERAL_ID = 'mesh_host'; + +/** + * Host for the Mesh-related blocks + * @param {Runtime} runtime - the runtime instantiating this block package. + * @constructor + */ +class Scratch3MeshBlocks { + /** + * @return {string} - the name of this extension. + */ + static get EXTENSION_NAME () { + return 'Mesh'; + } + + /** + * @return {string} - the ID of this extension. + */ + static get EXTENSION_ID () { + return 'mesh'; + } + + constructor (runtime) { + /** + * The runtime instantiating this block package. + * @type {Runtime} + */ + this.runtime = runtime; + + /** + * Mesh ID + * @type {string} + */ + this.meshId = uuidv4(); + + /** + * Mesh Object + * @type {MeshHost|MeshPeer} + */ + this.meshService = new MeshService(this, this.meshId, null); + + this.setOpcodeFunctionHOC(); + this.setVariableFunctionHOC(); + + this.runtime.registerPeripheralExtension(Scratch3MeshBlocks.EXTENSION_ID, this); + } + + /** + * @returns {object} metadata for this extension and its blocks. + */ + getInfo () { + return { + id: Scratch3MeshBlocks.EXTENSION_ID, + name: Scratch3MeshBlocks.EXTENSION_NAME, + blockIconURI: blockIconURI, + showStatusButton: true, + blocks: [ + { + opcode: 'getSensorValue', + text: formatMessage({ + id: 'mesh.sensorValue', + default: '[NAME] sensor value', + description: 'Any global variables from other projects' + }), + blockType: BlockType.REPORTER, + arguments: { + NAME: { + type: ArgumentType.STRING, + menu: 'variableNames', + defaultValue: '' + } + } + } + ], + menus: { + variableNames: { + acceptReporters: true, + items: 'getVariableNamesMenuItems' + } + } + }; + } + + getSensorValue (args) { + return this.meshService.getVariable(args.NAME); + } + + getVariableNamesMenuItems () { + return [' '].concat(this.meshService.variableNames); + } + + /** + * Called by the runtime when user wants to scan for a peripheral. + */ + scan () { + this.meshService.scan(MESH_HOST_PERIPHERAL_ID); + } + + /** + * Called by the runtime when user wants to connect to a certain peripheral. + * @param {string} meshId - the Mesh ID of the peripheral to connect to. + */ + connect (meshId) { + if (meshId === MESH_HOST_PERIPHERAL_ID) { + this.meshService = new MeshHost(this, this.meshId, this.meshService.webSocket); + this.meshService.connect(); + } else { + this.meshService = new MeshPeer(this, this.meshId, this.meshService.webSocket); + this.meshService.connect(meshId); + } + } + + /** + * Disconnect from the Mesh. + */ + disconnect () { + this.meshService.requestDisconnect(); + } + + /** + * Return true if connected to the Mesh + * @return {boolean} - whether the Mesh is connected. + */ + isConnected () { + return this.meshService.isConnected(); + } + + /** + * Return connected message if connected to the Mesh + * @return {string} - connected message. + */ + connectedMessage () { + let message; + if (this.meshService.isHost) { + message = formatMessage({ + id: 'mesh.registeredHost', + default: 'Registered Host Mesh [{ MESH_ID }]', + description: 'label for registered Host Mesh in connect modal for Mesh extension' + }, {MESH_ID: this.makeMeshIdLabel(this.meshService.meshId)}); + } else { + message = formatMessage({ + id: 'mesh.joinedMesh', + default: 'Joined Mesh [{ MESH_ID }]', + description: 'label for joined Mesh in connect modal for Mesh extension' + }, {MESH_ID: this.makeMeshIdLabel(this.meshService.hostMeshId)}); + } + return message; + } + + makeMeshIdLabel (meshId) { + return meshId.slice(0, 6); + } + + setOpcodeFunctionHOC () { + this.opcodeFunctions = { + event_broadcast: this.runtime.getOpcodeFunction('event_broadcast'), + event_broadcastandwait: this.runtime.getOpcodeFunction('event_broadcastandwait'), + data_setvariableto: this.runtime.getOpcodeFunction('data_setvariableto'), + data_changevariableby: this.runtime.getOpcodeFunction('data_changevariableby') + }; + + this.runtime._primitives.event_broadcast = this.broadcast.bind(this); + this.runtime._primitives.event_broadcastandwait = this.broadcastAndWait.bind(this); + this.runtime._primitives.data_setvariableto = this.setVariableTo.bind(this); + this.runtime._primitives.data_changevariableby = this.changeVariableBy.bind(this); + } + + broadcast (args, util) { + try { + log.log('event_broadcast in mesh'); + + this.opcodeFunctions.event_broadcast(args, util); + this.meshService.sendRTCBroadcastMessage(args.BROADCAST_OPTION.name); + } catch (error) { + log.error(`Failed to execute event_broadcast: ${error}`); + } + } + + broadcastAndWait (args, util) { + try { + log.log('event_broadcastandwait in mesh'); + + const first = !util.stackFrame.startedThreads; + this.opcodeFunctions.event_broadcastandwait(args, util); + if (first) { + this.meshService.sendRTCBroadcastMessage(args.BROADCAST_OPTION.name); + } + } catch (error) { + log.error(`Failed to execute event_broadcastandwait: ${error}`); + } + } + + setVariableTo (args, util) { + try { + log.log('data_setvariableto in mesh'); + + this.opcodeFunctions.data_setvariableto(args, util); + this.sendVariableByOpcodeFunction(args); + } catch (error) { + log.error(`Failed to execute data_setvariableto: ${error}`); + } + } + + changeVariableBy (args, util) { + try { + log.log('data_changevariableby in mesh'); + + this.opcodeFunctions.data_changevariableby(args, util); + this.sendVariableByOpcodeFunction(args); + } catch (error) { + log.error(`Failed to execute data_changevariableby: ${error}`); + } + } + + sendVariableByOpcodeFunction (args) { + const stage = this.runtime.getTargetForStage(); + let variable = stage.lookupVariableById(args.VARIABLE.id); + if (!variable) { + variable = stage.lookupVariableByNameAndType(args.VARIABLE.name, Variable.SCALAR_TYPE); + } + if (!variable) { + return; + } + + this.meshService.sendRTCVariableMessage(variable.name, variable.value); + } + + setVariableFunctionHOC () { + const stage = this.runtime.getTargetForStage(); + this.variableFunctions = { + runtime: { + createNewGlobalVariable: this.runtime.createNewGlobalVariable.bind(this.runtime) + }, + stage: { + lookupOrCreateVariable: stage.lookupOrCreateVariable.bind(stage), + createVariable: stage.createVariable.bind(stage), + setVariableValue: stage.setVariableValue.bind(stage), + renameVariable: stage.renameVariable.bind(stage) + } + }; + + this.runtime.createNewGlobalVariable = this.createNewGlobalVariable.bind(this); + + stage.lookupOrCreateVariable = this.lookupOrCreateVariable.bind(this); + stage.createVariable = this.createVariable.bind(this); + stage.setVariableValue = this.setVariableValue.bind(this); + stage.renameVariable = this.renameVariable.bind(this); + } + + createNewGlobalVariable (variableName, optVarId, optVarType) { + log.log('runtime.createNewGlobalVariable in mesh'); + + const variable = this.variableFunctions.runtime.createNewGlobalVariable(variableName, optVarId, optVarType); + if (variable.type === Variable.SCALAR_TYPE) { + this.meshService.sendRTCVariableMessage(variable.name, variable.value); + } + return variable; + } + + lookupOrCreateVariable (id, name) { + log.log('stage.lookupOrCreateVariable in mesh'); + + const stage = this.runtime.getTargetForStage(); + let variable = stage.lookupVariableById(id); + if (variable) return variable; + + variable = stage.lookupVariableByNameAndType(name, Variable.SCALAR_TYPE); + if (variable) return variable; + + // No variable with this name exists - create it locally. + const newVariable = new Variable(id, name, Variable.SCALAR_TYPE, false); + stage.variables[id] = newVariable; + this.meshService.sendRTCVariableMessage(newVariable.name, newVariable.value); + return newVariable; + } + + createVariable (id, name, type, isCloud) { + log.log('stage.createVariable in mesh'); + + const stage = this.runtime.getTargetForStage(); + if (!stage.variables.hasOwnProperty(id)) { + this.variableFunctions.stage.createVariable(id, name, type, isCloud); + if (type === Variable.SCALAR_TYPE) { + const variable = stage.variables[id]; + this.meshService.sendRTCVariableMessage(variable.name, variable.value); + } + } + } + + setVariableValue (id, newValue) { + log.log('stage.setVariableValue in mesh'); + + const stage = this.runtime.getTargetForStage(); + if (stage.variables.hasOwnProperty(id)) { + const variable = stage.variables[id]; + if (variable.id === id) { + this.variableFunctions.stage.setVariableValue(id, newValue); + if (variable.type === Variable.SCALAR_TYPE) { + this.meshService.sendRTCVariableMessage(variable.name, variable.value); + } + } + } + } + + renameVariable (id, newName) { + log.log('stage.renameVariable in mesh'); + + const stage = this.runtime.getTargetForStage(); + if (stage.variables.hasOwnProperty(id)) { + const variable = stage.variables[id]; + if (variable.id === id) { + this.variableFunctions.stage.renameVariable(id, newName); + if (variable.type === Variable.SCALAR_TYPE) { + this.meshService.sendRTCVariableMessage(variable.name, variable.value); + } + } + } + } +} + +module.exports = Scratch3MeshBlocks; diff --git a/src/extensions/scratch3_mesh/mesh-host.js b/src/extensions/scratch3_mesh/mesh-host.js new file mode 100644 index 00000000000..9977920c872 --- /dev/null +++ b/src/extensions/scratch3_mesh/mesh-host.js @@ -0,0 +1,199 @@ +const MeshService = require('./mesh-service'); +const MeshPeer = require('./mesh-peer'); + +const log = require('../../util/log'); +const debugLogger = require('../../util/debug-logger'); +const debug = debugLogger(true); + +const HEATBEAT_MINUTES = 4; + +class MeshHost extends MeshService { + constructor (blocks, meshId, webSocket) { + super(blocks, meshId, webSocket); + + this.isHost = true; + } + + get logPrefix () { + return 'Mesh Host'; + } + + connect () { + if (this.connectionState === 'connected') { + log.info('Already connected'); + return; + } + if (this.connectionState === 'connecting') { + log.info('Now connecting, please wait to connect.'); + return; + } + + this.setConnectionState('connecting'); + + this.sendWebSocketMessage('register', { + meshId: this.meshId + }); + + this.connectTimeoutId = + setTimeout(this.onConnectTimeout.bind(this), this.connectTimeoutSeconds * 1000); + } + + onConnectTimeout () { + this.connectTimeoutId = null; + if (!this.isConnected()) { + this.webSocket.close(); + } + } + + restartHeatbeat () { + debug(() => { + const at = new Date(); + at.setSeconds(at.getSeconds() + HEATBEAT_MINUTES * 60); + return `Heatbeat: at=<${at.toLocaleString()}>`; + }); + + clearTimeout(this.restartHeatbeatTimeoutId); + this.restartHeatbeatTimeoutId = setTimeout(() => { + if (this.connectionState === 'connected') { + this.sendWebSocketMessage('heartbeat', { + meshId: this.meshId + }); + + this.restartHeatbeat(); + } + }, HEATBEAT_MINUTES * 60 * 1000); + } + + onWebSocketClose () { + debug(() => 'WebSocket closed.'); + + clearTimeout(this.restartHeatbeatTimeoutId); + this.restartHeatbeatTimeoutId = null; + + if (this.connectionState !== 'disconnected') { + this.disconnect(); + } + } + + registerWebSocketAction (result, data) { + if (!result) { + this.setConnectionState('request_error'); + log.error(`Failed to register: reason=<${data.error}>`); + return; + } + + this.setConnectionState('connected'); + log.info('Connected as Mesh Host.'); + + this.restartHeatbeat(); + } + + offerWebSocketAction (result, data) { + const peerMeshId = data.meshId; + if (data.hostMeshId !== this.meshId) { + log.error(`Invalid Mesh ID in offer from peer:` + + ` peer=<${peerMeshId}> received=<${data.hostMeshId}> own=<${this.meshId}>`); + + this.sendWebSocketMessage('answer', { + meshId: this.meshId, + clientMeshId: peerMeshId + }, false); + return; + } + + this.changeWebRTCIPHandlingPolicy().then(() => { + const connection = this.openRTCPeerConnection(peerMeshId); + + connection.onconnectionstatechange = () => { + this.onRTCConnectionStateChange(connection, peerMeshId); + }; + connection.onicecandidate = event => { + this.onRTCICECandidate(connection, peerMeshId, event, description => { + debug(() => `Answer to Peer: peer=<${peerMeshId}> description=<\n` + + `${JSON.stringify(description, null, 2)}\n` + + `>`); + + this.sendWebSocketMessage('answer', { + meshId: this.meshId, + clientMeshId: peerMeshId, + hostDescription: description + }); + }); + }; + connection.ondatachannel = event => { + this.onRTCDataChannel(connection, peerMeshId, event); + }; + + connection.setRemoteDescription(new RTCSessionDescription(data.clientDescription)); + + connection.createAnswer().then( + desc => { + connection.setLocalDescription(desc); + }, + error => { + log.error(`Failed createAnswer: peer=<${peerMeshId}> reason=<${error}>`); + this.closeRTCPeerConnection(peerMeshId); + } + ); + }); + } + + onRTCDataChannel (connection, peerMeshId, event) { + debug(() => `WebRTC data channel by remote peer: peer=<${peerMeshId}>`); + + const dataChannel = event.channel; + + dataChannel.onopen = () => { + this.onRTCDataChannelOpen(connection, dataChannel, peerMeshId); + }; + dataChannel.onmessage = e => { + this.onRTCDataChannelMessage(connection, dataChannel, peerMeshId, e); + }; + dataChannel.onclose = () => { + this.onRTCDataChannelClose(connection, dataChannel, peerMeshId); + }; + } + + onRTCDataChannelOpen (connection, dataChannel, peerMeshId) { + MeshService.prototype.onRTCDataChannelOpen.call(this, connection, dataChannel, peerMeshId); + this.sendVariablesTo(this.variables, peerMeshId); + } + + answerWebSocketAction (result, data) { + this.restartHeatbeat(); + + if (!result) { + this.closeRTCPeerConnection(data.clientMeshId); + log.error(`Failed to answer: reason=<${data.error}>`); + return; + } + + log.info(`Answered to peer: peer=<${data.clientMeshId}>`); + } + + heartbeatWebSocketAction (result, data) { + this.restartHeatbeat(); + + debug(() => `Heartbeat: result=<${result ? 'OK' : 'NG'}>`); + + if (!result) { + log.error('Failed Heartbeat: reason='); + + this.webSocket.close(); + } + } + + broadcastRTCAction (peerMeshId, message) { + this.sendRTCMessage(message); + + MeshPeer.prototype.broadcastRTCAction.call(this, peerMeshId, message); + } + + variableRTCAction (peerMeshId, message) { + this.sendRTCMessage(message); + + MeshPeer.prototype.variableRTCAction.call(this, peerMeshId, message); + } +} + +module.exports = MeshHost; diff --git a/src/extensions/scratch3_mesh/mesh-peer.js b/src/extensions/scratch3_mesh/mesh-peer.js new file mode 100644 index 00000000000..6771bada00d --- /dev/null +++ b/src/extensions/scratch3_mesh/mesh-peer.js @@ -0,0 +1,179 @@ +const MeshService = require('./mesh-service'); +const BlockUtility = require('../../engine/block-utility.js'); + +const log = require('../../util/log'); +const debugLogger = require('../../util/debug-logger'); +const debug = debugLogger(true); + +class MeshPeer extends MeshService { + get logPrefix () { + return 'Mesh Peer'; + } + + connect (hostMeshId) { + if (this.connectionState === 'connected') { + log.info('Already connected'); + return; + } + if (this.connectionState === 'connecting') { + log.info('Now connecting, please wait to connect.'); + return; + } + + if (!hostMeshId || hostMeshId.trim() === '') { + this.setConnectionState('request_error'); + + log.error('Not select Host Mesh ID'); + return; + } + + this.hostMeshId = hostMeshId; + + this.setConnectionState('connecting'); + + this.changeWebRTCIPHandlingPolicy().then(() => { + const connection = this.openRTCPeerConnection(hostMeshId); + + connection.onconnectionstatechange = () => { + this.onRTCConnectionStateChange(connection, hostMeshId); + }; + connection.onicecandidate = event => { + this.onRTCICECandidate(connection, hostMeshId, event, description => { + debug(() => `Offer to Host: host=<${hostMeshId}> description=<\n` + + `${JSON.stringify(description, null, 2)}\n` + + `>`); + + this.sendWebSocketMessage('offer', { + meshId: this.meshId, + hostMeshId: hostMeshId, + clientDescription: description + }); + }); + }; + + const dataChannel = connection.createDataChannel('dataChannel'); + dataChannel.onopen = () => { + this.onRTCDataChannelOpen(connection, dataChannel, hostMeshId); + }; + dataChannel.onmessage = e => { + this.onRTCDataChannelMessage(connection, dataChannel, hostMeshId, e); + }; + dataChannel.onclose = () => { + this.onRTCDataChannelClose(connection, dataChannel, hostMeshId); + }; + + connection.createOffer().then( + desc => { + connection.setLocalDescription(desc); + }, + error => { + this.setConnectionState('request_error'); + log.error(`Failed createOffer: host=<${hostMeshId}> reason=<${error}>`); + } + ); + + this.connectTimeoutId = + setTimeout(this.onConnectTimeout.bind(this), this.connectTimeoutSeconds * 1000); + }); + } + + onConnectTimeout () { + this.connectTimeoutId = null; + if (!this.isConnected()) { + Object.keys(this.rtcConnections).forEach(meshId => { + this.rtcConnections[meshId].close(); + }); + this.rtcConnections = {}; + this.rtcDataChannels = {}; + + this.webSocket.close(); + } + } + + onWebSocketClose () { + debug(() => 'WebSocket closed.'); + } + + offerWebSocketAction (result, data) { + if (!result) { + this.setConnectionState('request_error'); + + log.error(`Failed to offer: reason=<${data.error}>`); + return; + } + + log.info(`Offered to host: host=<${data.hostMeshId}>`); + } + + answerWebSocketAction (result, data) { + const hostMeshId = data.meshId; + + if (this.connectionState !== 'connecting') { + log.error(`Received answer, but WebRTC not connecting: host=<${hostMeshId}>`); + return; + } + + if (data.clientMeshId !== this.meshId) { + this.disconnect(); + + log.error(`Invalid Mesh ID in answer from host:` + + ` host=<${hostMeshId}> received=<${data.clientMeshId}> own=<${this.meshId}>`); + return; + } + + const connection = this.rtcConnections[hostMeshId]; + connection.setRemoteDescription(new RTCSessionDescription(data.hostDescription)); + + log.info(`Received answer and set host description: host=<${hostMeshId}>`); + } + + onRTCDataChannelOpen (connection, dataChannel, peerMeshId) { + MeshService.prototype.onRTCDataChannelOpen.call(this, connection, dataChannel, peerMeshId); + + this.setConnectionState('connected'); + } + + onRTCDataChannelClose (connection, dataChannel, peerMeshId) { + MeshService.prototype.onRTCDataChannelClose.call(this, connection, dataChannel, peerMeshId); + + if (this.connectState !== 'disconnected') { + this.disconnect(); + } + } + + broadcastRTCAction (peerMeshId, message) { + const broadcast = message.data; + + if (this.meshId === message.owner) { + debug(() => `Ignore broadcast: reason= ${JSON.stringify(broadcast)}`); + return; + } + + debug(() => `Process broadcast: name=<${broadcast.name}>`); + + const args = { + BROADCAST_OPTION: { + id: null, + name: broadcast.name + } + }; + const util = BlockUtility.lastInstance(); + if (!util.sequencer) { + util.sequencer = this.runtime.sequencer; + } + this.blocks.opcodeFunctions.event_broadcast(args, util); + } + + variableRTCAction (peerMeshId, message) { + const variable = message.data; + + if (this.meshId === message.owner) { + debug(() => `Ignore variable: reason= ${JSON.stringify(variable)}`); + return; + } + + this.setVariable(variable.name, variable.value, message.owner); + } +} + +module.exports = MeshPeer; diff --git a/src/extensions/scratch3_mesh/mesh-service.js b/src/extensions/scratch3_mesh/mesh-service.js new file mode 100644 index 00000000000..f998716558b --- /dev/null +++ b/src/extensions/scratch3_mesh/mesh-service.js @@ -0,0 +1,506 @@ +/* global chrome */ + +const formatMessage = require('format-message'); +const Variable = require('../../engine/variable'); + +const log = require('../../util/log'); +const debugLogger = require('../../util/debug-logger'); +const debug = debugLogger(true); + +const CHROME_MESH_EXTENSION_ID = 'ioaoebnfpgnbehdolokpdddomfnhpckn'; +const MESH_WSS_URL = 'wss://api.smalruby.app/mesh-signaling'; + +class MeshService { + constructor (blocks, meshId, webSocket) { + this.blocks = blocks; + + this.runtime = this.blocks.runtime; + + this.meshId = meshId; + + this.setWebSocket(webSocket); + + this.connectionState = 'disconnected'; + + this.connectTimeoutId = null; + + this.connectTimeoutSeconds = 10; + + this.rtcConnections = {}; + + this.rtcDataChannels = {}; + + this.variables = {}; + + this.variableNames = []; + + this.availablePeripherals = {}; + } + + get logPrefix () { + return 'Mesh Service'; + } + + setWebSocket (webSocket) { + this.webSocket = webSocket; + + if (this.webSocket) { + this.webSocket.onopen = this.onWebSocketOpen.bind(this); + this.webSocket.onmessage = this.onWebSocketMessage.bind(this); + this.webSocket.onclose = this.onWebSocketClose.bind(this); + this.webSocket.onerror = this.onWebSocketError.bind(this); + } + } + + isWebSocketOpened () { + return this.webSocket && this.webSocket.readyState === 1; + } + + openWebSocket () { + if (!this.webSocket || this.webSocket.readyState === 2 || this.webSocket.readyState === 3) { + this.setWebSocket(new WebSocket(MESH_WSS_URL)); + } + } + + scan (hostMeshId) { + try { + debug(() => `Scan: hostMeshId=<${hostMeshId}>`); + + this.availablePeripherals = {}; + this.availablePeripherals[hostMeshId] = { + name: formatMessage({ + id: 'mesh.hostPeripheralName', + default: 'Host Mesh [{ MESH_ID }]', + description: 'label for "Host Mesh" in connect modal for Mesh extension' + }, {MESH_ID: this.blocks.makeMeshIdLabel(this.meshId)}), + peripheralId: hostMeshId, + rssi: 0 + }; + + this.emitPeripheralEvent(this.runtime.constructor.PERIPHERAL_LIST_UPDATE); + + if (this.isWebSocketOpened()) { + this.sendWebSocketMessage('list', { + meshId: this.meshId + }); + } else { + this.openWebSocket(); + } + } catch (error) { + log.error(`Failed to scan: reason=<${error}>`); + } + } + + connect () { + } + + isConnected () { + return this.connectionState === 'connected'; + } + + requestDisconnect () { + debug(() => 'MeshService.requestDisconnect'); + + this.setConnectionState('disconnecting'); + this.disconnect(); + } + + disconnect () { + debug(() => 'MeshService.disconnect'); + + if (this.connectionState === 'disconnected') { + log.info('Already disconnected.'); + return; + } + + if (this.connectTimeoutId) { + clearTimeout(this.connectTimeoutId); + this.connectTimeoutId = null; + } + + this.webSocket.close(); + + Object.keys(this.rtcConnections).forEach(meshId => { + this.rtcConnections[meshId].close(); + }); + this.rtcConnections = {}; + this.rtcDataChannels = {}; + + this.setConnectionState('disconnected'); + } + + setConnectionState (connectionState) { + debug(() => `set connection state: from=<${this.connectionState}> to=<${connectionState}>`); + + const prevConnectionState = this.connectionState; + + this.connectionState = connectionState; + + switch (this.connectionState) { + case 'connected': + clearTimeout(this.connectTimeoutId); + this.connectTimeoutId = null; + this.emitPeripheralEvent(this.runtime.constructor.PERIPHERAL_CONNECTED); + break; + case 'disconnected': + if (prevConnectionState === 'connecting') { + this.emitPeripheralEvent(this.runtime.constructor.PERIPHERAL_REQUEST_ERROR); + } else if (prevConnectionState !== 'disconnecting' && prevConnectionState !== 'disconnected') { + this.emitPeripheralEvent(this.runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR); + } + this.emitPeripheralEvent(this.runtime.constructor.PERIPHERAL_DISCONNECTED); + break; + } + } + + onWebSocketOpen () { + try { + debug(() => 'WebSocket opened.'); + + this.sendWebSocketMessage('list', { + meshId: this.meshId + }); + } catch (error) { + log.error(`Failed in WebSocket open event handler: reason=<${error}>`); + } + } + + onWebSocketMessage (event) { + try { + debug(() => `Received WebSocket message: message=<${event.data}>`); + + const message = JSON.parse(event.data); + const {action, result, data} = message; + + const actionMethodName = `${action}WebSocketAction`; + if (this[actionMethodName]) { + log.info(`Process WebSocket message: ` + + `action=${action} result=<${result}> data=<${JSON.stringify(data)}>`); + this[actionMethodName](result, data); + } else { + log.error(`Unknown WebSocket message action: ${action}`); + } + } catch (error) { + log.error(`Failed to process WebSocket message: reason=<${error}>`); + } + } + + onWebSocketClose () { + debug(() => 'WebSocket closed.'); + } + + onWebSocketError (event) { + log.error(`Occured WebSocket error: ${event}`); + } + + listWebSocketAction (result, data) { + if (!result) { + log.error(`Failed to list: reason=<${data.error}>`); + return; + } + + const now = Math.floor(Date.now() / 1000); + data.hosts.forEach(host => { + const t = host.ttl - now; + let rssi; + if (t >= 4 * 60) { + rssi = 0; + } else if (t >= 3 * 60) { + rssi = -20; + } else if (t >= 2 * 60) { + rssi = -40; + } else if (t >= 1 * 60) { + rssi = -60; + } else { + rssi = -80; + } + this.availablePeripherals[host.meshId] = { + name: formatMessage({ + id: 'mesh.clientPeripheralName', + default: 'Join Mesh [{ MESH_ID }]', + description: 'label for "Join Mesh" in connect modal for Mesh extension' + }, {MESH_ID: this.blocks.makeMeshIdLabel(host.meshId)}), + peripheralId: host.meshId, + rssi: rssi + }; + }); + + this.emitPeripheralEvent(this.runtime.constructor.PERIPHERAL_LIST_UPDATE); + } + + sendWebSocketMessage (action, data, result) { + const message = { + action: action, + data: data + }; + if (typeof result !== 'undefined') { + message.result = result; + } + + debug(() => `Send WebSocket message: message=<${JSON.stringify(message)}>`); + + this.webSocket.send(JSON.stringify(message)); + } + + openRTCPeerConnection (meshId) { + if (this.rtcConnections[meshId]) { + log.info(`Already open WebRTC connection: peer=<${meshId}>`); + + const channel = this.rtcDataChannels[meshId]; + if (channel) { + channel.onopen = null; + channel.onmessage = null; + channel.onclose = null; + delete this.rtcDataChannels[meshId]; + } + this.closeRTCPeerConnection(meshId); + } + + debug(() => `Open WebRTC connection: peer=<${meshId}>`); + + const connection = new RTCPeerConnection({ + iceServers: [ + { + urls: [ + 'stun:stun.l.google.com:19302', + 'stun:stun1.l.google.com:19302', + 'stun:stun2.l.google.com:19302', + 'stun:stun3.l.google.com:19302', + 'stun:stun4.l.google.com:19302' + ] + } + ] + }); + this.rtcConnections[meshId] = connection; + return connection; + } + + onRTCConnectionStateChange (connection, peerMeshId) { + debug(() => `Changed WebRTC connection state: ${connection.connectionState}`); + + switch (connection.connectionState) { + case 'disconnected': + log.error(`Disconnected WebRTC connection by peer: peer=<${peerMeshId}>`); + this.closeRTCPeerConnection(peerMeshId); + break; + case 'failed': + log.error(`Failed WebRTC connection: peer=<${peerMeshId}>`); + this.closeRTCPeerConnection(peerMeshId); + break; + } + } + + onRTCICECandidate (connection, peerMeshId, event, onDescriptionCreate) { + if (event.candidate) { + debug(() => `ICE candidate: peer=<${peerMeshId}> candidate=<\n` + + `${JSON.stringify(event.candidate, null, 2)}\n` + + `>`); + } else { + onDescriptionCreate(connection.localDescription); + } + } + + closeRTCPeerConnection (meshId) { + debug(() => `Close WebRTC connection: peer=<${meshId}>`); + + const connection = this.rtcConnections[meshId]; + if (connection) { + connection.close(); + delete this.rtcConnections[meshId]; + } + } + + getGlobalVariables () { + const variables = {}; + const stage = this.runtime.getTargetForStage(); + for (const varId in stage.variables) { + const currVar = stage.variables[varId]; + if (currVar.type === Variable.SCALAR_TYPE) { + variables[currVar.name] = { + name: currVar.name, + value: currVar.value, + owner: this.meshId + }; + } + } + return variables; + } + + onRTCDataChannelOpen (connection, dataChannel, peerMeshId) { + debug(() => `Open WebRTC data channel: peer=<${peerMeshId}>`); + + this.revertWebRTCIPHandlingPolicy(); + + if (this.rtcDataChannels[peerMeshId]) { + log.error(`Already open WebRTC data channel: peer=<${peerMeshId}>`); + } + this.rtcDataChannels[peerMeshId] = dataChannel; + + this.sendVariablesTo(this.getGlobalVariables(), peerMeshId); + } + + onRTCDataChannelMessage (connection, dataChannel, peerMeshId, event) { + try { + debug(() => `Received WebRTC message: peer=<${peerMeshId}> data=<${event.data}>`); + + const message = JSON.parse(event.data); + + const {type, data} = message; + + const actionMethodName = `${type}RTCAction`; + if (this[actionMethodName]) { + log.info(`Process WebRTC message: ` + + `type=${type} peer=<${peerMeshId}> data=<${JSON.stringify(data)}>`); + + this[actionMethodName](peerMeshId, message); + } else { + log.error(`Unknown WebRTC message type: type=<${type}> peer=<${peerMeshId}>`); + } + } catch (error) { + log.error(`Failed to process WebRTC message: ${error}`); + return; + } + } + + onRTCDataChannelClose (connection, dataChannel, peerMeshId) { + debug(() => `Close WebRTC data channel: peer=<${peerMeshId}>`); + + this.revertWebRTCIPHandlingPolicy(); + + this.closeRTCPeerConnection(peerMeshId); + delete this.rtcDataChannels[peerMeshId]; + } + + sendMessageToChromeMeshExtension (action) { + debug(() => `Send message to Chrome mesh extension: action=<${action}>`); + + return new Promise((resolve, reject) => { + try { + chrome.runtime.sendMessage(CHROME_MESH_EXTENSION_ID, {action: action}, null, response => { + if (typeof chrome.runtime.lastError === 'undefined') { + debug(() => `Succeeded sending message to Chrome mesh extension: ` + + `response=<${JSON.stringify(response)}>`); + } else { + log.error(`Failed to send message to Chrome extension: ` + + `lastError=<${JSON.stringify(chrome.runtime.lastError)}>`); + } + resolve(); + }); + } catch (error) { + debug(() => `Failed to send message to Chrome extension: ${error}`); + resolve(); + } + }); + } + + changeWebRTCIPHandlingPolicy () { + debug(() => 'Change WebRTC IPHandlingPolicy to default.'); + + return this.sendMessageToChromeMeshExtension('change'); + } + + revertWebRTCIPHandlingPolicy () { + debug(() => 'Revert WebRTC IPHandlingPolicy to before default.'); + + return this.sendMessageToChromeMeshExtension('revert'); + } + + emitPeripheralEvent (event) { + debug(() => `Emit Peripheral event: event=<${event}>`); + + if (event === this.runtime.constructor.PERIPHERAL_LIST_UPDATE) { + return new Promise(() => this.runtime.emit(event, this.availablePeripherals)); + } + return new Promise(() => this.runtime.emit(event, { + extensionId: this.blocks.constructor.EXTENSION_ID + })); + } + + setVariable (name, value, owner) { + if (this.variables[name]) { + log.info(`Update variable: name=<${name}> value=<${value}> from=<${this.getVariable(name)}> ` + + `owner=<${owner}>`); + } else { + log.info(`Create variable: name=<${name}> value=<${value}> ` + + `owner=<${owner}>`); + } + + if (!this.variableNames.includes(name)) { + this.variableNames.push(name); + } + + this.variables[name] = { + name: name, + value: value, + owner: owner + }; + } + + getVariable (name) { + const variable = this.variables[name]; + if (!variable) { + return ''; + } + return variable.value; + } + + sendRTCMessage (message) { + const peers = Object.keys(this.rtcDataChannels); + + debug(() => `Send WebRTC message to all peers: ` + + `message=<${JSON.stringify(message)}> peers=<${peers.join(', ')}>`); + + try { + peers.forEach(meshId => { + const channel = this.rtcDataChannels[meshId]; + channel.send(JSON.stringify(message)); + }); + } catch (error) { + log.error(`Failed to send WebRTC message: error=<${error}> message=<${JSON.stringify(message)}>`); + } + } + + sendRTCBroadcastMessage (name) { + this.sendRTCMessage({ + owner: this.meshId, + type: 'broadcast', + data: { + name: name + } + }); + } + + sendRTCVariableMessage (name, value) { + this.sendRTCMessage({ + owner: this.meshId, + type: 'variable', + data: { + name: name, + value: value + } + }); + } + + sendVariablesTo (variables, peerMeshId) { + const channel = this.rtcDataChannels[peerMeshId]; + Object.keys(variables).forEach(name => { + const variable = variables[name]; + + const message = { + owner: variable.owner, + type: 'variable', + data: { + name: variable.name, + value: variable.value + } + }; + + debug(() => `Send WebRTC message: ` + + `message=<${JSON.stringify(message)}> peer=<${peerMeshId}>`); + + channel.send(JSON.stringify(message)); + }); + } +} + +module.exports = MeshService; diff --git a/src/util/debug-logger.js b/src/util/debug-logger.js new file mode 100644 index 00000000000..e094058dd17 --- /dev/null +++ b/src/util/debug-logger.js @@ -0,0 +1,15 @@ +const log = require('./log'); + +const debugLogger = debugFlag => { + const debug = func => { + if (debugFlag) { + const message = func(); + if (message) { + log.debug(message); + } + } + }; + return debug; +} + +module.exports = debugLogger; diff --git a/src/util/maybe-format-message.js b/src/util/maybe-format-message.js index acb5eef133f..7309876caf3 100644 --- a/src/util/maybe-format-message.js +++ b/src/util/maybe-format-message.js @@ -10,7 +10,7 @@ const formatMessage = require('format-message'); */ const maybeFormatMessage = function (maybeMessage, args, locale) { if (maybeMessage && maybeMessage.id && maybeMessage.default) { - return formatMessage(maybeMessage, args, locale); + return formatMessage(maybeMessage, args, locale); // eslint-disable-line format-message } return maybeMessage; }; diff --git a/src/virtual-machine.js b/src/virtual-machine.js index a36fe767ca9..ecd80830c5b 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -289,6 +289,15 @@ class VirtualMachine extends EventEmitter { return this.runtime.getPeripheralIsConnected(extensionId); } + /** + * Returns connected message. + * @param {string} extensionId - the id of the extension. + * @return {string} - connected message. + */ + getPeripheralConnectedMessage (extensionId) { + return this.runtime.getPeripheralConnectedMessage(extensionId); + } + /** * Load a Scratch project from a .sb, .sb2, .sb3 or json string. * @param {string | object} input A json string, object, or ArrayBuffer representing the project to load.