Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #4234 from matrix-org/travis/wrapped-jitsi
Browse files Browse the repository at this point in the history
Use a local wrapper for Jitsi calls
  • Loading branch information
turt2live authored Mar 19, 2020
2 parents c616107 + 9225131 commit 20d0e8a
Show file tree
Hide file tree
Showing 7 changed files with 226 additions and 50 deletions.
31 changes: 31 additions & 0 deletions docs/jitsi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Jitsi Wrapper

**Note**: These are developer docs. Please consult your client's documentation for
instructions on setting up Jitsi.

The react-sdk wraps all Jitsi call widgets in a local wrapper called `jitsi.html`
which takes several parameters:

*Query string*:
* `widgetId`: The ID of the widget. This is needed for communication back to the
react-sdk.
* `parentUrl`: The URL of the parent window. This is also needed for
communication back to the react-sdk.

*Hash/fragment (formatted as a query string)*:
* `conferenceDomain`: The domain to connect Jitsi Meet to.
* `conferenceId`: The room or conference ID to connect Jitsi Meet to.
* `isAudioOnly`: Boolean for whether this is a voice-only conference. May not
be present, should default to `false`.
* `displayName`: The display name of the user viewing the widget. May not
be present or could be null.
* `avatarUrl`: The HTTP(S) URL for the avatar of the user viewing the widget. May
not be present or could be null.
* `userId`: The MXID of the user viewing the widget. May not be present or could
be null.

The react-sdk will assume that `jitsi.html` is at the path of wherever it is currently
being served. For example, `https://riot.im/develop/jitsi.html` or `vector://webapp/jitsi.html`.

The `jitsi.html` wrapper can use the react-sdk's `WidgetApi` to communicate, making
it easier to actually implement the feature.
56 changes: 9 additions & 47 deletions src/CallHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ import SdkConfig from './SdkConfig';
import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore';
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore, { SettingLevel } from './settings/SettingsStore';

global.mxCalls = {
Expand Down Expand Up @@ -395,32 +394,6 @@ function _onAction(payload) {
}

async function _startCallApp(roomId, type) {
// check for a working integration manager. Technically we could put
// the state event in anyway, but the resulting widget would then not
// work for us. Better that the user knows before everyone else in the
// room sees it.
const managers = IntegrationManagers.sharedInstance();
let haveScalar = false;
if (managers.hasManager()) {
try {
const scalarClient = managers.getPrimaryManager().getScalarClient();
await scalarClient.connect();
haveScalar = scalarClient.hasCredentials();
} catch (e) {
// ignore
}
}

if (!haveScalar) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");

Modal.createTrackedDialog('Could not connect to the integration server', '', ErrorDialog, {
title: _t('Could not connect to the integration server'),
description: _t('A conference call could not be started because the integrations server is not available'),
});
return;
}

dis.dispatch({
action: 'appsDrawer',
show: true,
Expand Down Expand Up @@ -460,27 +433,16 @@ async function _startCallApp(roomId, type) {
// the event. It's just a random string to make the Jitsi URLs unique.
const widgetSessionId = Math.random().toString(36).substring(2);
const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId;
// NB. we can't just encodeURICompoent all of these because the $ signs need to be there
// (but currently the only thing that needs encoding is the confId)
const queryString = [
'confId='+encodeURIComponent(confId),
'isAudioConf='+(type === 'voice' ? 'true' : 'false'),
'displayName=$matrix_display_name',
'avatarUrl=$matrix_avatar_url',
'email=$matrix_user_id',
].join('&');

let widgetUrl;
if (SdkConfig.get().integrations_jitsi_widget_url) {
// Try this config key. This probably isn't ideal as a way of discovering this
// URL, but this will at least allow the integration manager to not be hardcoded.
widgetUrl = SdkConfig.get().integrations_jitsi_widget_url + '?' + queryString;
} else {
const apiUrl = IntegrationManagers.sharedInstance().getPrimaryManager().apiUrl;
widgetUrl = apiUrl + '/widgets/jitsi.html?' + queryString;
}
const jitsiDomain = SdkConfig.get()['jitsi']['preferredDomain'];

const widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();

const widgetData = { widgetSessionId };
const widgetData = {
widgetSessionId, // TODO: Remove this eventually
conferenceId: confId,
isAudioOnly: type === 'voice',
domain: jitsiDomain,
};

const widgetId = (
'jitsi_' +
Expand Down
7 changes: 7 additions & 0 deletions src/SdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export const DEFAULTS: ConfigOptions = {
integrations_rest_url: "https://scalar.vector.im/api",
// Where to send bug reports. If not specified, bugs cannot be sent.
bug_report_endpoint_url: null,
// Jitsi conference options
jitsi: {
// Default conference domain
preferredDomain: "jitsi.riot.im",
// Default Jitsi Meet API location
externalApiUrl: "https://jitsi.riot.im/libs/external_api.min.js",
},
};

export default class SdkConfig {
Expand Down
8 changes: 7 additions & 1 deletion src/components/views/elements/AppTile.js
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,13 @@ export default class AppTile extends React.Component {
parsedWidgetUrl.query.react_perf = true;
}
let safeWidgetUrl = '';
if (ALLOWED_APP_URL_SCHEMES.indexOf(parsedWidgetUrl.protocol) !== -1) {
if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol) || (
// Check if the widget URL is a Jitsi widget in Electron
parsedWidgetUrl.protocol === 'vector:'
&& parsedWidgetUrl.host === 'vector'
&& parsedWidgetUrl.pathname === '/webapp/jitsi.html'
&& this.props.type === 'jitsi'
)) {
safeWidgetUrl = url.format(parsedWidgetUrl);
}
return safeWidgetUrl;
Expand Down
2 changes: 0 additions & 2 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@
"VoIP is unsupported": "VoIP is unsupported",
"You cannot place VoIP calls in this browser.": "You cannot place VoIP calls in this browser.",
"You cannot place a call with yourself.": "You cannot place a call with yourself.",
"Could not connect to the integration server": "Could not connect to the integration server",
"A conference call could not be started because the integrations server is not available": "A conference call could not be started because the integrations server is not available",
"Call in Progress": "Call in Progress",
"A call is currently being placed!": "A call is currently being placed!",
"A call is already in progress!": "A call is already in progress!",
Expand Down
32 changes: 32 additions & 0 deletions src/utils/WidgetUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,11 @@ export default class WidgetUtils {
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true);
}

if (app.type === 'jitsi') {
console.log("Replacing Jitsi widget URL with local wrapper");
app.url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true});
}

app.url = encodeUri(app.url, params);

return app;
Expand Down Expand Up @@ -468,4 +473,31 @@ export default class WidgetUtils {

return encodeURIComponent(`${widgetLocation}::${widgetUrl}`);
}

static getLocalJitsiWrapperUrl(opts: {forLocalRender?: boolean}) {
// NB. we can't just encodeURIComponent all of these because the $ signs need to be there
const queryString = [
'conferenceDomain=$domain',
'conferenceId=$conferenceId',
'isAudioOnly=$isAudioOnly',
'displayName=$matrix_display_name',
'avatarUrl=$matrix_avatar_url',
'userId=$matrix_user_id',
].join('&');

let currentUrl = window.location.href.split('#')[0];
if (!currentUrl.startsWith("https://") && !opts.forLocalRender) {
// Use an external wrapper if we're not locally rendering the widget. This is usually
// the URL that will end up in the widget event, so we want to make sure it's relatively
// safe to send.
// We'll end up using a local render URL when we see a Jitsi widget anyways, so this is
// really just for backwards compatibility and to appease the spec.
currentUrl = "https://riot.im/app";
}
if (!currentUrl.endsWith('/')) {
currentUrl = `${currentUrl}/`;
}

return currentUrl + "jitsi.html#" + queryString;
}
}
140 changes: 140 additions & 0 deletions src/widgets/WidgetApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

// Dev note: This is largely inspired by Dimension. Used with permission.
// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts

import { randomString } from "matrix-js-sdk/src/randomstring";

export enum Capability {
Screenshot = "m.capability.screenshot",
Sticker = "m.sticker",
AlwaysOnScreen = "m.always_on_screen",
}

export enum KnownWidgetActions {
GetSupportedApiVersions = "supported_api_versions",
TakeScreenshot = "screenshot",
GetCapabilities = "capabilities",
SendEvent = "send_event",
UpdateVisibility = "visibility",
ReceiveOpenIDCredentials = "openid_credentials",
SetAlwaysOnScreen = "set_always_on_screen",
}
export type WidgetAction = KnownWidgetActions | string;

export enum WidgetApiType {
ToWidget = "toWidget",
FromWidget = "fromWidget",
}

export interface WidgetRequest {
api: WidgetApiType;
widgetId: string;
requestId: string;
data: any;
action: WidgetAction;
}

export interface ToWidgetRequest extends WidgetRequest {
api: WidgetApiType.ToWidget;
}

export interface FromWidgetRequest extends WidgetRequest {
api: WidgetApiType.FromWidget;
response: any;
}

/**
* Handles Riot <--> Widget interactions for embedded/standalone widgets.
*/
export class WidgetApi {
private origin: string;
private inFlightRequests: {[requestId: string]: (reply: FromWidgetRequest) => void} = {};
private readyPromise: Promise<any>;
private readyPromiseResolve: () => void;

constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
this.origin = new URL(currentUrl).origin;

this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);

window.addEventListener("message", event => {
if (event.origin !== this.origin) return; // ignore: invalid origin
if (!event.data) return; // invalid schema
if (event.data.widgetId !== this.widgetId) return; // not for us

const payload = <WidgetRequest>event.data;
if (payload.api === WidgetApiType.ToWidget && payload.action) {
console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`);

if (payload.action === KnownWidgetActions.GetCapabilities) {
this.onCapabilitiesRequest(<ToWidgetRequest>payload);
this.readyPromiseResolve();
} else {
console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
}
} else if (payload.api === WidgetApiType.FromWidget && this.inFlightRequests[payload.requestId]) {
console.log(`[WidgetAPI] Got reply: ${JSON.stringify(payload)}`);
const handler = this.inFlightRequests[payload.requestId];
delete this.inFlightRequests[payload.requestId];
handler(<FromWidgetRequest>payload);
} else {
console.warn(`[WidgetAPI] Unhandled payload: ${JSON.stringify(payload)}`);
}
});
}

public waitReady(): Promise<any> {
return this.readyPromise;
}

private replyToRequest(payload: ToWidgetRequest, reply: any) {
if (!window.parent) return;

const request = JSON.parse(JSON.stringify(payload));
request.response = reply;

window.parent.postMessage(request, this.origin);
}

private onCapabilitiesRequest(payload: ToWidgetRequest) {
return this.replyToRequest(payload, {capabilities: this.requestedCapabilities});
}

public callAction(action: WidgetAction, payload: any, callback: (reply: FromWidgetRequest) => void) {
if (!window.parent) return;

const request: FromWidgetRequest = {
api: WidgetApiType.FromWidget,
widgetId: this.widgetId,
action: action,
requestId: randomString(160),
data: payload,
response: {}, // Not used at this layer - it's used when the client responds
};
this.inFlightRequests[request.requestId] = callback;

console.log(`[WidgetAPI] Sending request: `, request);
window.parent.postMessage(request, "*");
}

public setAlwaysOnScreen(onScreen: boolean): Promise<any> {
return new Promise<any>(resolve => {
this.callAction(KnownWidgetActions.SetAlwaysOnScreen, {value: onScreen}, resolve);
});
}
}

0 comments on commit 20d0e8a

Please sign in to comment.