Use a local wrapper for Jitsi calls

Requires https://github.com/vector-im/riot-web/pull/12780
pull/21833/head
Travis Ralston 2020-03-18 15:50:05 -06:00
parent ce90cbe35e
commit 9da57817d1
6 changed files with 225 additions and 46 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 ideally 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

@ -395,32 +395,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 +434,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

@ -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(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(forLocalRender = false) {
// 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://") && !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);
});
}
}