From d12611d79cc21f5b7b3c900c3930dcffa2fa001e Mon Sep 17 00:00:00 2001 From: Christophe Hamerling Date: Wed, 10 Jun 2020 14:21:13 +0200 Subject: [PATCH] Add protocol to open conference links with the app Co-authored-by: Christophe HAMERLING Co-authored-by: Klemens Arro Co-authored-by: Goran Urukalo --- README.md | 1 + app/features/app/components/App.js | 68 +++++++++++++++- app/features/config/index.js | 6 ++ app/features/utils/functions.js | 37 +++++++++ app/features/welcome/components/Welcome.js | 29 ++----- app/preload/preload.js | 30 ++++++- main.js | 94 +++++++++++++++++++++- package.json | 11 ++- 8 files changed, 243 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 58ff8f3..9519763 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Desktop application for [Jitsi Meet] built with [Electron]. - Builtin auto-updates - Remote control - Always-On-Top window +- Support for deeplinks such as `jitsi-meet://myroom` (will open `myroom` on the configured Jitsi instance) or `jitsi-meet://jitsi.mycompany.com/myroom` (will open `myroom` on the Jitsi instance running on `jitsi.mycompany.com`) ## Installation diff --git a/app/features/app/components/App.js b/app/features/app/components/App.js index b934ddf..c34bbbf 100644 --- a/app/features/app/components/App.js +++ b/app/features/app/components/App.js @@ -4,26 +4,84 @@ import { AtlasKitThemeProvider } from '@atlaskit/theme'; import React, { Component } from 'react'; import { Route, Switch } from 'react-router'; -import { ConnectedRouter as Router } from 'react-router-redux'; +import { connect } from 'react-redux'; +import { ConnectedRouter as Router, push } from 'react-router-redux'; import { Conference } from '../../conference'; import config from '../../config'; import { history } from '../../router'; +import { createConferenceObjectFromURL } from '../../utils'; import { Welcome } from '../../welcome'; /** * Main component encapsulating the entire application. */ -export default class App extends Component<*> { +class App extends Component<*> { /** * Initializes a new {@code App} instance. * * @inheritdoc */ - constructor() { - super(); + constructor(props) { + super(props); document.title = config.appName; + + this._listenOnProtocolMessages + = this._listenOnProtocolMessages.bind(this); + } + + /** + * Implements React's {@link Component#componentDidMount()}. + * + * @returns {void} + */ + componentDidMount() { + // start listening on this events + window.jitsiNodeAPI.ipc.on('protocol-data-msg', this._listenOnProtocolMessages); + + // send notification to main process + window.jitsiNodeAPI.ipc.send('renderer-ready'); + } + + /** + * Implements React's {@link Component#componentWillUnmount()}. + * + * @returns {void} + */ + componentWillUnmount() { + // remove listening for this events + window.jitsiNodeAPI.ipc.removeListener( + 'protocol-data-msg', + this._listenOnProtocolMessages + ); + } + + _listenOnProtocolMessages: (*) => void; + + /** + * Handler when main proccess contact us. + * + * @param {Object} event - Message event. + * @param {string} inputURL - String with room name. + * + * @returns {void} + */ + _listenOnProtocolMessages(event, inputURL: string) { + // Remove trailing slash if one exists. + if (inputURL.substr(-1) === '/') { + inputURL = inputURL.substr(0, inputURL.length - 1); // eslint-disable-line no-param-reassign + } + + const conference = createConferenceObjectFromURL(inputURL); + + // Don't navigate if conference couldn't be created + if (!conference) { + return; + } + + // change route when we are notified + this.props.dispatch(push('/conference', conference)); } /** @@ -50,3 +108,5 @@ export default class App extends Component<*> { ); } } + +export default connect()(App); diff --git a/app/features/config/index.js b/app/features/config/index.js index dde9fb3..9983fe9 100644 --- a/app/features/config/index.js +++ b/app/features/config/index.js @@ -15,6 +15,12 @@ export default { */ appName: 'Jitsi Meet', + /** + * The prefix for application protocol. + * You will also need to replace this in package.json. + */ + appProtocolPrefix: 'jitsi-meet', + /** * The default server URL of Jitsi Meet Deployment that will be used. */ diff --git a/app/features/utils/functions.js b/app/features/utils/functions.js index 4c7bf5a..e8e11e2 100644 --- a/app/features/utils/functions.js +++ b/app/features/utils/functions.js @@ -54,3 +54,40 @@ export function normalizeServerURL(url: string) { export function openExternalLink(link: string) { window.jitsiNodeAPI.openExternalLink(link); } + + +/** + * Get URL, extract room name from it and create a Conference object. + * + * @param {string} inputURL - Combined server url with room separated by /. + * @returns {Object} + */ +export function createConferenceObjectFromURL(inputURL: string) { + const lastIndexOfSlash = inputURL.lastIndexOf('/'); + let room; + let serverURL; + + if (lastIndexOfSlash === -1) { + // This must be only the room name. + room = inputURL; + } else { + // Take the substring after last slash to be the room name. + room = inputURL.substring(lastIndexOfSlash + 1); + + // Take the substring before last slash to be the Server URL. + serverURL = inputURL.substring(0, lastIndexOfSlash); + + // Normalize the server URL. + serverURL = normalizeServerURL(serverURL); + } + + // Don't navigate if no room was specified. + if (!room) { + return; + } + + return { + room, + serverURL + }; +} diff --git a/app/features/welcome/components/Welcome.js b/app/features/welcome/components/Welcome.js index 4a04661..f0bb138 100644 --- a/app/features/welcome/components/Welcome.js +++ b/app/features/welcome/components/Welcome.js @@ -15,7 +15,7 @@ import { push } from 'react-router-redux'; import { Navbar } from '../../navbar'; import { Onboarding, startOnboarding } from '../../onboarding'; import { RecentList } from '../../recent-list'; -import { normalizeServerURL } from '../../utils'; +import { createConferenceObjectFromURL } from '../../utils'; import { Body, FieldWrapper, Form, Header, Label, Wrapper } from '../styled'; @@ -206,33 +206,14 @@ class Welcome extends Component { */ _onJoin() { const inputURL = this.state.url || this.state.generatedRoomname; - const lastIndexOfSlash = inputURL.lastIndexOf('/'); - let room; - let serverURL; + const conference = createConferenceObjectFromURL(inputURL); - if (lastIndexOfSlash === -1) { - // This must be only the room name. - room = inputURL; - } else { - // Take the substring after last slash to be the room name. - room = inputURL.substring(lastIndexOfSlash + 1); - - // Take the substring before last slash to be the Server URL. - serverURL = inputURL.substring(0, lastIndexOfSlash); - - // Normalize the server URL. - serverURL = normalizeServerURL(serverURL); - } - - // Don't navigate if no room was specified. - if (!room) { + // Don't navigate if conference couldn't be created + if (!conference) { return; } - this.props.dispatch(push('/conference', { - room, - serverURL - })); + this.props.dispatch(push('/conference', conference)); } _onURLChange: (*) => void; diff --git a/app/preload/preload.js b/app/preload/preload.js index cbe9aab..85064ea 100644 --- a/app/preload/preload.js +++ b/app/preload/preload.js @@ -1,11 +1,10 @@ const createElectronStorage = require('redux-persist-electron-storage'); -const { shell } = require('electron'); +const { ipcRenderer, shell } = require('electron'); const os = require('os'); const url = require('url'); const jitsiMeetElectronUtils = require('jitsi-meet-electron-utils'); - const protocolRegex = /^https?:/i; /** @@ -28,10 +27,35 @@ function openExternalLink(link) { } } +const whitelistedIpcChannels = [ 'protocol-data-msg', 'renderer-ready' ]; window.jitsiNodeAPI = { createElectronStorage, osUserInfo: os.userInfo, openExternalLink, - jitsiMeetElectronUtils + jitsiMeetElectronUtils, + shellOpenExternal: shell.openExternal, + ipc: { + on: (channel, listener) => { + if (!whitelistedIpcChannels.includes(channel)) { + return; + } + + return ipcRenderer.on(channel, listener); + }, + send: channel => { + if (!whitelistedIpcChannels.includes(channel)) { + return; + } + + return ipcRenderer.send(channel); + }, + removeListener: (channel, listener) => { + if (!whitelistedIpcChannels.includes(channel)) { + return; + } + + return ipcRenderer.removeListener(channel, listener); + } + } }; diff --git a/main.js b/main.js index 296741d..0ca6cde 100644 --- a/main.js +++ b/main.js @@ -4,6 +4,7 @@ const { BrowserWindow, Menu, app, + ipcMain, shell } = require('electron'); const contextMenu = require('electron-context-menu'); @@ -71,6 +72,14 @@ if (isDev) { */ let mainWindow = null; +/** + * Add protocol data + */ +const appProtocolSurplus = `${config.default.appProtocolPrefix}://`; +let rendererReady = false; +let protocolDataForFrontApp = null; + + /** * Sets the application menu. It is hidden on all platforms except macOS because * otherwise copy and paste functionality is not available. @@ -211,6 +220,44 @@ function createJitsiMeetWindow() { mainWindow.once('ready-to-show', () => { mainWindow.show(); }); + + /** + * This is for windows [win32] + * so when someone tries to enter something like jitsi-meet://test + * while app is closed + * it will trigger this event below + */ + if (process.platform === 'win32') { + handleProtocolCall(process.argv.pop()); + } +} + +/** + * Handler for application protocol links to initiate a conference. + */ +function handleProtocolCall(fullProtocolCall) { + // don't touch when something is bad + if ( + !fullProtocolCall + || fullProtocolCall.trim() === '' + || fullProtocolCall.indexOf(appProtocolSurplus) !== 0 + ) { + return; + } + + const inputURL = fullProtocolCall.replace(appProtocolSurplus, ''); + + if (app.isReady() && mainWindow === null) { + createJitsiMeetWindow(); + } + + protocolDataForFrontApp = inputURL; + + if (rendererReady) { + mainWindow + .webContents + .send('protocol-data-msg', inputURL); + } } /** @@ -247,7 +294,7 @@ app.on('certificate-error', app.on('ready', createJitsiMeetWindow); -app.on('second-instance', () => { +app.on('second-instance', (event, commandLine) => { /** * If someone creates second instance of the application, set focus on * existing window. @@ -255,6 +302,13 @@ app.on('second-instance', () => { if (mainWindow) { mainWindow.isMinimized() && mainWindow.restore(); mainWindow.focus(); + + /** + * This is for windows [win32] + * so when someone tries to enter something like jitsi-meet://test + * while app is opened it will trigger protocol handler. + */ + handleProtocolCall(commandLine.pop()); } }); @@ -264,3 +318,41 @@ app.on('window-all-closed', () => { app.quit(); } }); + +// remove so we can register each time as we run the app. +app.removeAsDefaultProtocolClient(config.default.appProtocolPrefix); + +// If we are running a non-packaged version of the app && on windows +if (isDev && process.platform === 'win32') { + // Set the path of electron.exe and your app. + // These two additional parameters are only available on windows. + app.setAsDefaultProtocolClient( + config.default.appProtocolPrefix, + process.execPath, + [ path.resolve(process.argv[1]) ] + ); +} else { + app.setAsDefaultProtocolClient(config.default.appProtocolPrefix); +} + +/** + * This is for mac [darwin] + * so when someone tries to enter something like jitsi-meet://test + * it will trigger this event below + */ +app.on('open-url', (event, data) => { + event.preventDefault(); + handleProtocolCall(data); +}); + +/** + * This is to notify main.js [this] that front app is ready to receive messages. + */ +ipcMain.on('renderer-ready', () => { + rendererReady = true; + if (protocolDataForFrontApp) { + mainWindow + .webContents + .send('protocol-data-msg', protocolDataForFrontApp); + } +}); diff --git a/package.json b/package.json index 0e52f08..d1fea59 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,16 @@ }, "directories": { "buildResources": "resources" - } + }, + "protocols": [ + { + "name": "jitsi-protocol", + "role": "Viewer", + "schemes": [ + "jitsi-meet" + ] + } + ] }, "pre-commit": [ "lint"