Merge pull request #4234 from matrix-org/travis/wrapped-jitsi

Use a local wrapper for Jitsi calls
This commit is contained in:
Travis Ralston 2020-03-19 11:52:14 -06:00 committed by GitHub
commit 20d0e8a888
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 225 additions and 49 deletions

31
docs/jitsi.md Normal file
View file

@ -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.

View file

@ -64,7 +64,6 @@ import SdkConfig from './SdkConfig';
import { showUnknownDeviceDialogForCalls } from './cryptodevices'; import { showUnknownDeviceDialogForCalls } from './cryptodevices';
import WidgetUtils from './utils/WidgetUtils'; import WidgetUtils from './utils/WidgetUtils';
import WidgetEchoStore from './stores/WidgetEchoStore'; import WidgetEchoStore from './stores/WidgetEchoStore';
import {IntegrationManagers} from "./integrations/IntegrationManagers";
import SettingsStore, { SettingLevel } from './settings/SettingsStore'; import SettingsStore, { SettingLevel } from './settings/SettingsStore';
global.mxCalls = { global.mxCalls = {
@ -395,32 +394,6 @@ function _onAction(payload) {
} }
async function _startCallApp(roomId, type) { 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({ dis.dispatch({
action: 'appsDrawer', action: 'appsDrawer',
show: true, show: true,
@ -460,27 +433,16 @@ async function _startCallApp(roomId, type) {
// the event. It's just a random string to make the Jitsi URLs unique. // the event. It's just a random string to make the Jitsi URLs unique.
const widgetSessionId = Math.random().toString(36).substring(2); const widgetSessionId = Math.random().toString(36).substring(2);
const confId = room.roomId.replace(/[^A-Za-z0-9]/g, '') + widgetSessionId; 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 const jitsiDomain = SdkConfig.get()['jitsi']['preferredDomain'];
// (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; const widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl();
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 widgetData = { widgetSessionId }; const widgetData = {
widgetSessionId, // TODO: Remove this eventually
conferenceId: confId,
isAudioOnly: type === 'voice',
domain: jitsiDomain,
};
const widgetId = ( const widgetId = (
'jitsi_' + 'jitsi_' +

View file

@ -26,6 +26,13 @@ export const DEFAULTS: ConfigOptions = {
integrations_rest_url: "https://scalar.vector.im/api", integrations_rest_url: "https://scalar.vector.im/api",
// Where to send bug reports. If not specified, bugs cannot be sent. // Where to send bug reports. If not specified, bugs cannot be sent.
bug_report_endpoint_url: null, 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 { export default class SdkConfig {

View file

@ -520,7 +520,13 @@ export default class AppTile extends React.Component {
parsedWidgetUrl.query.react_perf = true; parsedWidgetUrl.query.react_perf = true;
} }
let safeWidgetUrl = ''; 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); safeWidgetUrl = url.format(parsedWidgetUrl);
} }
return safeWidgetUrl; return safeWidgetUrl;

View file

@ -45,8 +45,6 @@
"VoIP is unsupported": "VoIP is unsupported", "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 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.", "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", "Call in Progress": "Call in Progress",
"A call is currently being placed!": "A call is currently being placed!", "A call is currently being placed!": "A call is currently being placed!",
"A call is already in progress!": "A call is already in progress!", "A call is already in progress!": "A call is already in progress!",

View file

@ -430,6 +430,11 @@ export default class WidgetUtils {
app.waitForIframeLoad = (app.data.waitForIframeLoad === 'false' ? false : true); 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); app.url = encodeUri(app.url, params);
return app; return app;
@ -468,4 +473,31 @@ export default class WidgetUtils {
return encodeURIComponent(`${widgetLocation}::${widgetUrl}`); 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
src/widgets/WidgetApi.ts Normal file
View file

@ -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);
});
}
}