feat(remotecontrol): multi monitor support

This commit is contained in:
hristoterezov 2017-06-29 14:06:38 -05:00
parent a94732fff5
commit f9f5f7692b
12 changed files with 297 additions and 172 deletions

View file

@ -10,6 +10,10 @@ from config.js
```bash ```bash
npm install npm install
``` ```
Since node_addons/sourceId2Coordinates add-on is local dependency, every code change requires increasing the version in its package.json. To rebuild the add-on if it is already installed execute:
```bash
npm update
```
## Statring the application ## Statring the application
```bash ```bash

View file

@ -2,12 +2,5 @@ module.exports = {
/** /**
* The URL of the Jitsi Meet deployment that will be used. * The URL of the Jitsi Meet deployment that will be used.
*/ */
jitsiMeetURL: "https://meet.jit.si/", jitsiMeetURL: "https://meet.jit.si/"
/**
* If true, the authorization of remote control will be handled by jitsi
* meet electron app. Otherwise the authorization will be handled in Jitsi
* Meet.
*/
handleRemoteControlAuthorization: false
}; };

View file

@ -1,8 +1,8 @@
module.exports = { module.exports = {
/** /**
* Types of remote-control-event events. * Types of remote-control events.
*/ */
EVENT_TYPES: { EVENTS: {
mousemove: "mousemove", mousemove: "mousemove",
mousedown: "mousedown", mousedown: "mousedown",
mouseup: "mouseup", mouseup: "mouseup",
@ -10,22 +10,19 @@ module.exports = {
mousescroll: "mousescroll", mousescroll: "mousescroll",
keydown: "keydown", keydown: "keydown",
keyup: "keyup", keyup: "keyup",
permissions: "permissions",
stop: "stop", stop: "stop",
supported: "supported" supported: "supported"
}, },
/** /**
* Actions for the remote control permission events. * Types of remote-control requests.
*/ */
PERMISSIONS_ACTIONS: { REQUESTS: {
request: "request", start: "start"
grant: "grant",
deny: "deny"
}, },
/** /**
* The name of remote control events sent trough the API module. * The name of remote control messages.
*/ */
REMOTE_CONTROL_EVENT_NAME: "remote-control-event" REMOTE_CONTROL_MESSAGE_NAME: "remote-control"
}; };

View file

@ -1,6 +1,8 @@
let robot = require("robotjs"); let robot = require("robotjs");
const sourceId2Coordinates = require("sourceId2Coordinates");
const electron = require("electron");
const constants = require("../../modules/remotecontrol/constants"); const constants = require("../../modules/remotecontrol/constants");
const {EVENT_TYPES, PERMISSIONS_ACTIONS, REMOTE_CONTROL_EVENT_NAME} = constants; const { EVENTS, REQUESTS, REMOTE_CONTROL_MESSAGE_NAME } = constants;
/** /**
* Attaching to the window for debug purposes. * Attaching to the window for debug purposes.
@ -9,13 +11,8 @@ const {EVENT_TYPES, PERMISSIONS_ACTIONS, REMOTE_CONTROL_EVENT_NAME} = constants;
window.robot = robot; window.robot = robot;
/** /**
* Width/Heught for the screen. * Mouse button mapping between the values in remote control mouse event and
*/ * robotjs methods.
const {width, height} = robot.getScreenSize();
/**
* Mouse button mapping between the values in remote-control-event and robotjs
* methods.
*/ */
const MOUSE_BUTTONS = { const MOUSE_BUTTONS = {
1: "left", 1: "left",
@ -24,8 +21,8 @@ const MOUSE_BUTTONS = {
}; };
/** /**
* Mouse actions mapping between the values in remote-control-event and robotjs * Mouse actions mapping between the values in remote control mouse event and
* methods. * robotjs methods.
*/ */
const MOUSE_ACTIONS_FROM_EVENT_TYPE = { const MOUSE_ACTIONS_FROM_EVENT_TYPE = {
"mousedown": "down", "mousedown": "down",
@ -33,8 +30,8 @@ const MOUSE_ACTIONS_FROM_EVENT_TYPE = {
}; };
/** /**
* Key actions mapping between the values in remote-control-event and robotjs * Key actions mapping between the values in remote control key event and
* methods. * robotjs methods.
*/ */
const KEY_ACTIONS_FROM_EVENT_TYPE = { const KEY_ACTIONS_FROM_EVENT_TYPE = {
"keydown": "down", "keydown": "down",
@ -50,35 +47,25 @@ const KEY_ACTIONS_FROM_EVENT_TYPE = {
let mouseButtonStatus = "up"; let mouseButtonStatus = "up";
/** /**
* Parses the remote-control-events and executes them via robotjs. * Parses the remote control events and executes them via robotjs.
*/ */
class RemoteControl { class RemoteControl {
/** /**
* Construcs new instance. * Constructs new instance and initializes the remote control functionality.
* @constructor *
* @param {Postis} channel the postis channel.
*/ */
constructor() { constructor(channel) {
this.started = false; // TODO: if no channel is passed, create one.
}
/**
* Initializes the remote control functionality.
*/
init(channel, windowManager, handleAuthorization) {
this.handleAuthorization = handleAuthorization;
this.windowManager = windowManager;
this.channel = channel; this.channel = channel;
this.channel.ready(() => { this.channel.ready(() => {
this.channel.listen('message', message => { this.channel.listen('message', message => {
const event = message.data; const { name } = message.data;
if(event.name === REMOTE_CONTROL_EVENT_NAME) { if(name === REMOTE_CONTROL_MESSAGE_NAME) {
this.onRemoteControlEvent(event); this.onRemoteControlMessage(message);
} }
}); });
this.sendEvent({type: EVENT_TYPES.supported}); this.sendEvent({ type: EVENTS.supported });
if (!handleAuthorization) {
this.start();
}
}); });
} }
@ -86,114 +73,137 @@ class RemoteControl {
* Disposes the remote control functionality. * Disposes the remote control functionality.
*/ */
dispose() { dispose() {
this.windowManager = null;
this.channel = null; this.channel = null;
this.stop(); this.stop();
} }
/** /**
* Handles permission requests from Jitsi Meet. * Handles remote control start messages.
* @param {object} userInfo - information about the user that has requested *
* permissions: * @param {number} id - the id of the request that will be used for the
* @param {string} userInfo.displayName - display name * response.
* @param {string} userInfo.userJID - the JID of the user. * @param {string} sourceId - The source id of the desktop sharing stream.
* @param {string} userInfo.userId - the user id (the resource of the JID)
* @param {boolean} userInfo.screenSharing - true if the screen sharing
* is started.
*/ */
handlePermissionRequest(userInfo) { start(id, sourceId) {
this.windowManager.requestRemoteControlPermissions(userInfo) const displays = electron.screen.getAllDisplays();
.then(result => {
this.sendEvent({
type: EVENT_TYPES.permissions,
action: result ? PERMISSIONS_ACTIONS.grant
: PERMISSIONS_ACTIONS.deny,
userId: userInfo.userId
});
if(result) {
this.start();
}
}).catch(e => {
console.error(e);
});
}
/** switch(displays.length) {
* Starts processing the events. case 0:
*/ this.display = undefined;
start() { break;
this.started = true; case 1:
// On Linux probably we'll end up here even if there are
// multiple monitors.
this.display = displays[0];
break;
// eslint-disable-next-line no-case-declarations
default: // > 1 display
const coordinates = sourceId2Coordinates(sourceId);
if(coordinates) {
// Currently sourceId2Coordinates will return undefined for
// any OS except Windows. This code will be executed only on
// Windows.
const { x, y } = coordinates;
this.display = electron.screen.getDisplayNearestPoint({
x: x + 1,
y: y + 1
});
} else {
// On Mac OS the sourceId = 'screen' + displayId.
// Try to match displayId with sourceId.
const displayId = Number(sourceId.replace('screen:', ''));
this.display
= displays.find(display => display.id === displayId);
}
}
const response = {
id,
type: 'response'
};
if(this.display) {
response.result = true;
} else {
response.error
= 'Error: Can\'t detect the display that is currently shared';
}
this.sendMessage(response);
} }
/** /**
* Stops processing the events. * Stops processing the events.
*/ */
stop() { stop() {
this.started = false; this.display = undefined;
} }
/** /**
* Executes the passed event. * Executes the passed message.
* @param {Object} event the remote-control-event. * @param {Object} message the remote control message.
*/ */
onRemoteControlEvent(event) { onRemoteControlMessage(message) {
if(!this.started && event.type !== EVENT_TYPES.permissions) { const { id, data } = message;
// If we haven't set the display prop. We haven't received the remote
// control start message or there was an error associating a display.
if(!this.display
&& data.type != REQUESTS.start) {
return; return;
} }
switch(event.type) { switch(data.type) {
case EVENT_TYPES.mousemove: { case EVENTS.mousemove: {
const x = event.x * width, y = event.y * height; const { width, height, x, y } = this.display.workArea;
const destX = data.x * width + x;
const destY = data.y * height + y;
if(mouseButtonStatus === "down") { if(mouseButtonStatus === "down") {
robot.dragMouse(x, y); robot.dragMouse(destX, destY);
} else { } else {
robot.moveMouse(x, y); robot.moveMouse(destX, destY);
} }
break; break;
} }
case EVENT_TYPES.mousedown: case EVENTS.mousedown:
case EVENT_TYPES.mouseup: { case EVENTS.mouseup: {
mouseButtonStatus = MOUSE_ACTIONS_FROM_EVENT_TYPE[event.type]; mouseButtonStatus
= MOUSE_ACTIONS_FROM_EVENT_TYPE[data.type];
robot.mouseToggle( robot.mouseToggle(
mouseButtonStatus, mouseButtonStatus,
(event.button ? MOUSE_BUTTONS[event.button] : undefined)); (data.button
? MOUSE_BUTTONS[data.button] : undefined));
break; break;
} }
case EVENT_TYPES.mousedblclick: { case EVENTS.mousedblclick: {
robot.mouseClick( robot.mouseClick(
(event.button ? MOUSE_BUTTONS[event.button] : undefined), (data.button
? MOUSE_BUTTONS[data.button] : undefined),
true); true);
break; break;
} }
case EVENT_TYPES.mousescroll:{ case EVENTS.mousescroll:{
//FIXME: implement horizontal scrolling //FIXME: implement horizontal scrolling
if(event.y !== 0) { if(data.y !== 0) {
robot.scrollMouse( robot.scrollMouse(
Math.abs(event.y), Math.abs(data.y),
event.y > 0 ? "down" : "up" data.y > 0 ? "down" : "up"
); );
} }
break; break;
} }
case EVENT_TYPES.keydown: case EVENTS.keydown:
case EVENT_TYPES.keyup: { case EVENTS.keyup: {
robot.keyToggle(event.key, robot.keyToggle(
KEY_ACTIONS_FROM_EVENT_TYPE[event.type], event.modifiers); data.key,
KEY_ACTIONS_FROM_EVENT_TYPE[data.type],
data.modifiers);
break; break;
} }
case EVENT_TYPES.permissions: { case REQUESTS.start: {
if(event.action === PERMISSIONS_ACTIONS.request this.start(id, data.sourceId);
&& this.handleAuthorization) {
// Open Dialog and answer
this.handlePermissionRequest({
userId: event.userId,
userJID: event.userJID,
displayName: event.displayName,
screenSharing: event.screenSharing
});
}
break; break;
} }
case EVENT_TYPES.stop: { case EVENTS.stop: {
this.stop(); this.stop();
break; break;
} }
@ -204,18 +214,28 @@ class RemoteControl {
/** /**
* Sends remote control event to the controlled participant. * Sends remote control event to the controlled participant.
*
* @param {Object} event the remote control event. * @param {Object} event the remote control event.
*/ */
sendEvent(event) { sendEvent(event) {
const remoteControlEvent = Object.assign( const remoteControlEvent = Object.assign(
{ name: REMOTE_CONTROL_EVENT_NAME }, { name: REMOTE_CONTROL_MESSAGE_NAME },
event event
); );
this.sendMessage({ data: remoteControlEvent });
}
/**
* Sends a message to Jitsi Meet.
*
* @param {Object} message the message to be sent.
*/
sendMessage(message) {
this.channel.send({ this.channel.send({
method: 'message', method: 'message',
params: { data: remoteControlEvent } params: message
}); });
} }
} }
module.exports = new RemoteControl(); module.exports = RemoteControl;

View file

@ -0,0 +1,27 @@
{
'targets': [{
'target_name': 'sourceId2Coordinates',
'include_dirs': [
"<!(node -e \"require('nan')\")"
],
'cflags': [
'-Wall',
'-Wparentheses',
'-Winline',
'-Wbad-function-cast',
'-Wdisabled-optimization'
],
'conditions': [
["OS=='win'", {
'defines': ['IS_WINDOWS']
}]
],
'sources': [
'src/index.cc',
'src/sourceId2Coordinates.cc'
]
}]
}

View file

@ -0,0 +1,22 @@
const sourceId2Coordinates
= require('./build/Release/sourceId2Coordinates.node').sourceId2Coordinates;
/**
* Returns the coordinates of a desktop using the passed desktop sharing source
* id.
*
* @param {string} sourceId - The desktop sharing source id.
* @returns {Object.<string, number>|undefined} - The x and y coordinates of the
* top left corner of the desktop. Currently works only for windows. Returns
* undefined for Mac OS, Linux.
*/
module.exports = function(sourceID) {
// On windows the source id will have the following format "0:desktop_id".
// we need the "desktop_id" only to get the coordinates.
const idArr = sourceID.split(":");
const id = Number(idArr.length > 1 ? idArr[1] : sourceID);
if(id) {
return sourceId2Coordinates(id);
}
return undefined;
};

View file

@ -0,0 +1,17 @@
{
"name": "sourceId2Coordinates",
"version": "0.0.3",
"description": "Native addon that returns the coordinates of desktop using the passed source id",
"main": "index.js",
"scripts": {
"install": "node-gyp rebuild"
},
"keywords": [
"Util",
"WebRTC"
],
"gypfile": true,
"dependencies": {
"nan": "^2.2.1"
}
}

View file

@ -0,0 +1,37 @@
#include <node.h>
#include <nan.h>
#include <v8.h>
#include <stdio.h>
#include "sourceId2Coordinates.h"
using namespace v8;
NAN_METHOD(sourceId2Coordinates)
{
const int sourceID = info[0]->Int32Value();
Local<Object> obj = Nan::New<Object>();
Point coordinates;
if(!sourceId2Coordinates(sourceID, &coordinates))
{ // return undefined if sourceId2Coordinates function fail.
info.GetReturnValue().Set(Nan::Undefined());
}
else
{ // return the coordinates if sourceId2Coordinates function succeed.
Nan::Set(obj, Nan::New("x").ToLocalChecked(), Nan::New(coordinates.x));
Nan::Set(obj, Nan::New("y").ToLocalChecked(), Nan::New(coordinates.y));
info.GetReturnValue().Set(obj);
}
}
NAN_MODULE_INIT(Init)
{
Nan::Set(
target,
Nan::New("sourceId2Coordinates").ToLocalChecked(),
Nan::GetFunction(Nan::New<FunctionTemplate>(sourceId2Coordinates))
.ToLocalChecked()
);
NAN_EXPORT(target, sourceId2Coordinates);
}
NODE_MODULE(sourceId2CoordinatesModule, Init)

View file

@ -0,0 +1,45 @@
#if defined(IS_WINDOWS)
#include <windows.h>
#endif
#include "sourceId2Coordinates.h"
/**
* Tries to get the coordinates of a desktop from passed sourceId
* (which identifies a desktop sharing source). Used to match the source id to a
* screen in Electron.
*
* Returns true on success and false on failure.
*
* NOTE: Works on windows only because on the other platforms there is an easier
* way to match the source id and the screen.
*/
bool sourceId2Coordinates(int sourceId, Point* res)
{
#if defined(IS_WINDOWS)
DISPLAY_DEVICE device;
device.cb = sizeof(device);
if (!EnumDisplayDevices(NULL, sourceId, &device, 0) // device not found
|| !(device.StateFlags & DISPLAY_DEVICE_ACTIVE))// device is not active
{
return false;
}
DEVMODE deviceSettings;
deviceSettings.dmSize = sizeof(deviceSettings);
deviceSettings.dmDriverExtra = 0;
if(!EnumDisplaySettingsEx(device.DeviceName, ENUM_CURRENT_SETTINGS,
&deviceSettings, 0))
{
return false;
}
res->x = deviceSettings.dmPosition.x;
res->y = deviceSettings.dmPosition.y;
return true;
#else
return false;
#endif
}

View file

@ -0,0 +1,7 @@
struct Point {
int x;
int y;
Point(): x(0), y(0) {};
};
bool sourceId2Coordinates(int sourceId, Point* res);

View file

@ -30,7 +30,8 @@
"readmeFilename": "README.md", "readmeFilename": "README.md",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"robotjs": "hristoterezov/robotjs", "sourceId2Coordinates": "file:./node_addons/sourceId2Coordinates",
"robotjs": "jitsi/robotjs#jitsi",
"postis": "^2.2.0" "postis": "^2.2.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,15 +1,19 @@
/* global process */ /* global process */
const remoteControl = require("../../modules/remotecontrol"); const RemoteControl = require("../../modules/remotecontrol");
let postis = require("postis"); let postis = require("postis");
const setupScreenSharingForWindow = require("../../modules/screensharing"); const setupScreenSharingForWindow = require("../../modules/screensharing");
const config = require("../../config.js"); const config = require("../../config.js");
const {dialog} = require('electron').remote;
/** /**
* The postis channel. * The postis channel.
*/ */
let channel; let channel;
/**
* The remote control instance.
*/
let remoteControl;
/** /**
* Cteates the iframe that will load Jitsi Meet. * Cteates the iframe that will load Jitsi Meet.
*/ */
@ -19,52 +23,6 @@ iframe.allowFullscreen = true;
iframe.onload = onload; iframe.onload = onload;
document.body.appendChild(iframe); document.body.appendChild(iframe);
/**
* Factory for dialogs.
*/
class DialogFactory {
/**
* Creates new instance
* @constructor
*/
constructor() { }
/**
* Shows message box dialog for request for remote control permissions
* @param {object} userInfo - information about the user that has sent the
* request:
* @param {string} userInfo.displayName - display name
* @param {string} userInfo.userJID - the JID of the user.
* @param {boolean} userInfo.screenSharing - true if the screen sharing
* is started.
*/
requestRemoteControlPermissions(userInfo) {
return new Promise( resolve =>
dialog.showMessageBox({
type: "question",
buttons: [
"Yes",
"No"
],
defaultId: 0,
title: "Request for permission for remote control",
message: "Would you like to allow " + userInfo.displayName
+ " to remotely control your desktop?"
+ (userInfo.screenSharing ? ""
: "\nNote: If you press \"Yes\" the screen sharing "
+ "will start!"),
detail: "userId: " + userInfo.userJID,
cancelId: 1
}, response => resolve(response === 0? true : false))
);
}
}
/**
* Dialog factory instance.
*/
const dialogFactory = new DialogFactory();
/** /**
* Handles loaded event for iframe: * Handles loaded event for iframe:
* Enables screen sharing functionality to the iframe webpage. * Enables screen sharing functionality to the iframe webpage.
@ -78,10 +36,7 @@ function onload() {
window: iframe.contentWindow, window: iframe.contentWindow,
windowForEventListening: window windowForEventListening: window
}); });
remoteControl.init( remoteControl = new RemoteControl(channel);
channel,
dialogFactory,
config.handleRemoteControlAuthorization);
} }
/** /**