diff --git a/package.json b/package.json index 3ab523ee9a..e66d0aabcf 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "linkifyjs": "^2.1.9", "lodash": "^4.17.19", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-widget-api": "^0.1.0-beta.2", "minimist": "^1.2.5", "pako": "^1.0.11", "parse5": "^5.1.1", diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index bca2e4eac5..5b368016b6 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -75,7 +75,8 @@ import {base32} from "rfc4648"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import WidgetStore from "./stores/WidgetStore"; -import ActiveWidgetStore from "./stores/ActiveWidgetStore"; +import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore"; +import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions"; // until we ts-ify the js-sdk voip code type Call = any; @@ -503,10 +504,10 @@ export default class CallHandler { const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type)); jitsiWidgets.forEach(w => { - const messaging = ActiveWidgetStore.getWidgetMessaging(w.id); + const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id); if (!messaging) return; // more "should never happen" words - messaging.hangup(); + messaging.transport.send(ElementWidgetActions.HangupCall, {}); }); } } diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js deleted file mode 100644 index d5d7c08d50..0000000000 --- a/src/FromWidgetPostMessageApi.js +++ /dev/null @@ -1,275 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 Travis Ralston -Copyright 2019 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. -*/ - -import URL from 'url'; -import dis from './dispatcher/dispatcher'; -import WidgetMessagingEndpoint from './WidgetMessagingEndpoint'; -import ActiveWidgetStore from './stores/ActiveWidgetStore'; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import RoomViewStore from "./stores/RoomViewStore"; -import {IntegrationManagers} from "./integrations/IntegrationManagers"; -import SettingsStore from "./settings/SettingsStore"; -import {Capability} from "./widgets/WidgetApi"; -import {objectClone} from "./utils/objects"; - -const WIDGET_API_VERSION = '0.0.2'; // Current API version -const SUPPORTED_WIDGET_API_VERSIONS = [ - '0.0.1', - '0.0.2', -]; -const INBOUND_API_NAME = 'fromWidget'; - -// Listen for and handle incoming requests using the 'fromWidget' postMessage -// API and initiate responses -export default class FromWidgetPostMessageApi { - constructor() { - this.widgetMessagingEndpoints = []; - this.widgetListeners = {}; // {action: func[]} - - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - /** - * Adds a listener for a given action - * @param {string} action The action to listen for. - * @param {Function} callbackFn A callback function to be called when the action is - * encountered. Called with two parameters: the interesting request information and - * the raw event received from the postMessage API. The raw event is meant to be used - * for sendResponse and similar functions. - */ - addListener(action, callbackFn) { - if (!this.widgetListeners[action]) this.widgetListeners[action] = []; - this.widgetListeners[action].push(callbackFn); - } - - /** - * Removes a listener for a given action. - * @param {string} action The action that was subscribed to. - * @param {Function} callbackFn The original callback function that was used to subscribe - * to updates. - */ - removeListener(action, callbackFn) { - if (!this.widgetListeners[action]) return; - - const idx = this.widgetListeners[action].indexOf(callbackFn); - if (idx !== -1) this.widgetListeners[action].splice(idx, 1); - } - - /** - * Register a widget endpoint for trusted postMessage communication - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - */ - addEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl); - return; - } - - const origin = u.protocol + '//' + u.host; - const endpoint = new WidgetMessagingEndpoint(widgetId, origin); - if (this.widgetMessagingEndpoints.some(function(ep) { - return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl); - })) { - // Message endpoint already registered - console.warn('Add FromWidgetPostMessageApi - Endpoint already registered'); - return; - } else { - console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint); - this.widgetMessagingEndpoints.push(endpoint); - } - } - - /** - * De-register a widget endpoint from trusted communication sources - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host) - * @return {boolean} True if endpoint was successfully removed - */ - removeEndpoint(widgetId, endpointUrl) { - const u = URL.parse(endpointUrl); - if (!u || !u.protocol || !u.host) { - console.warn('Remove widget messaging endpoint - Invalid origin'); - return; - } - - const origin = u.protocol + '//' + u.host; - if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) { - const length = this.widgetMessagingEndpoints.length; - this.widgetMessagingEndpoints = this.widgetMessagingEndpoints - .filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin); - return (length > this.widgetMessagingEndpoints.length); - } - return false; - } - - /** - * Handle widget postMessage events - * Messages are only handled where a valid, registered messaging endpoints - * @param {Event} event Event to handle - * @return {undefined} - */ - onPostMessage(event) { - if (!event.origin) { // Handle chrome - event.origin = event.originalEvent.origin; - } - - // Event origin is empty string if undefined - if ( - event.origin.length === 0 || - !this.trustedEndpoint(event.origin) || - event.data.api !== INBOUND_API_NAME || - !event.data.widgetId - ) { - return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise - } - - // Call any listeners we have registered - if (this.widgetListeners[event.data.action]) { - for (const fn of this.widgetListeners[event.data.action]) { - fn(event.data, event); - } - } - - // Although the requestId is required, we don't use it. We'll be nice and process the message - // if the property is missing, but with a warning for widget developers. - if (!event.data.requestId) { - console.warn("fromWidget action '" + event.data.action + "' does not have a requestId"); - } - - const action = event.data.action; - const widgetId = event.data.widgetId; - if (action === 'content_loaded') { - console.log('Widget reported content loaded for', widgetId); - dis.dispatch({ - action: 'widget_content_loaded', - widgetId: widgetId, - }); - this.sendResponse(event, {success: true}); - } else if (action === 'supported_api_versions') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - supported_versions: SUPPORTED_WIDGET_API_VERSIONS, - }); - } else if (action === 'api_version') { - this.sendResponse(event, { - api: INBOUND_API_NAME, - version: WIDGET_API_VERSION, - }); - } else if (action === 'm.sticker') { - // console.warn('Got sticker message from widget', widgetId); - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId}); - } else if (action === 'integration_manager_open') { - // Close the stickerpicker - dis.dispatch({action: 'stickerpicker_close'}); - // Open the integration manager - // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually - const data = event.data.data || event.data.widgetData; - const integType = (data && data.integType) ? data.integType : null; - const integId = (data && data.integId) ? data.integId : null; - - // TODO: Open the right integration manager for the widget - if (SettingsStore.getValue("feature_many_integration_managers")) { - IntegrationManagers.sharedInstance().openAll( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } else { - IntegrationManagers.sharedInstance().getPrimaryManager().open( - MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()), - `type_${integType}`, - integId, - ); - } - } else if (action === 'set_always_on_screen') { - // This is a new message: there is no reason to support the deprecated widgetData here - const data = event.data.data; - const val = data.value; - - if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) { - ActiveWidgetStore.setWidgetPersistence(widgetId, val); - } - } else if (action === 'get_openid') { - // Handled by caller - } else { - console.warn('Widget postMessage event unhandled'); - this.sendError(event, {message: 'The postMessage was unhandled'}); - } - } - - /** - * Check if message origin is registered as trusted - * @param {string} origin PostMessage origin to check - * @return {boolean} True if trusted - */ - trustedEndpoint(origin) { - if (!origin) { - return false; - } - - return this.widgetMessagingEndpoints.some((endpoint) => { - // TODO / FIXME -- Should this also check the widgetId? - return endpoint.endpointUrl === origin; - }); - } - - /** - * Send a postmessage response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {Object} res Response data - */ - sendResponse(event, res) { - const data = objectClone(event.data); - data.response = res; - event.source.postMessage(data, event.origin); - } - - /** - * Send an error response to a postMessage request - * @param {Event} event The original postMessage request event - * @param {string} msg Error message - * @param {Error} nestedError Nested error event (optional) - */ - sendError(event, msg, nestedError) { - console.error('Action:' + event.data.action + ' failed with message: ' + msg); - const data = objectClone(event.data); - data.response = { - error: { - message: msg, - }, - }; - if (nestedError) { - data.response.error._error = nestedError; - } - event.source.postMessage(data, event.origin); - } -} diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js deleted file mode 100644 index 00309d252c..0000000000 --- a/src/ToWidgetPostMessageApi.js +++ /dev/null @@ -1,84 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -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. -*/ - -// const OUTBOUND_API_NAME = 'toWidget'; - -// Initiate requests using the "toWidget" postMessage API and handle responses -// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a -// response field -export default class ToWidgetPostMessageApi { - constructor(timeoutMs) { - this._timeoutMs = timeoutMs || 5000; // default to 5s timer - this._counter = 0; - this._requestMap = { - // $ID: {resolve, reject} - }; - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - this.onPostMessage = this.onPostMessage.bind(this); - } - - start() { - window.addEventListener('message', this.onPostMessage); - } - - stop() { - window.removeEventListener('message', this.onPostMessage); - } - - onPostMessage(ev) { - // THIS IS ALL UNSAFE EXECUTION. - // We do not verify who the sender of `ev` is! - const payload = ev.data; - // NOTE: Workaround for running in a mobile WebView where a - // postMessage immediately triggers this callback even though it is - // not the response. - if (payload.response === undefined) { - return; - } - const promise = this._requestMap[payload.requestId]; - if (!promise) { - return; - } - delete this._requestMap[payload.requestId]; - promise.resolve(payload); - } - - // Initiate outbound requests (toWidget) - exec(action, targetWindow, targetOrigin) { - targetWindow = targetWindow || window.parent; // default to parent window - targetOrigin = targetOrigin || "*"; - this._counter += 1; - action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter; - - return new Promise((resolve, reject) => { - this._requestMap[action.requestId] = {resolve, reject}; - targetWindow.postMessage(action, targetOrigin); - - if (this._timeoutMs > 0) { - setTimeout(() => { - if (!this._requestMap[action.requestId]) { - return; - } - console.error("postMessage request timed out. Sent object: " + JSON.stringify(action), - this._requestMap); - this._requestMap[action.requestId].reject(new Error("Timed out")); - delete this._requestMap[action.requestId]; - }, this._timeoutMs); - } - }); - } -} diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js deleted file mode 100644 index 9394abf025..0000000000 --- a/src/WidgetMessaging.js +++ /dev/null @@ -1,223 +0,0 @@ -/* -Copyright 2017 New Vector Ltd -Copyright 2019 Travis Ralston - -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. -*/ - -/* -* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for -* spec. details / documentation. -*/ - -import FromWidgetPostMessageApi from './FromWidgetPostMessageApi'; -import ToWidgetPostMessageApi from './ToWidgetPostMessageApi'; -import Modal from "./Modal"; -import {MatrixClientPeg} from "./MatrixClientPeg"; -import SettingsStore from "./settings/SettingsStore"; -import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog"; -import WidgetUtils from "./utils/WidgetUtils"; -import {KnownWidgetActions} from "./widgets/WidgetApi"; - -if (!global.mxFromWidgetMessaging) { - global.mxFromWidgetMessaging = new FromWidgetPostMessageApi(); - global.mxFromWidgetMessaging.start(); -} -if (!global.mxToWidgetMessaging) { - global.mxToWidgetMessaging = new ToWidgetPostMessageApi(); - global.mxToWidgetMessaging.start(); -} - -const OUTBOUND_API_NAME = 'toWidget'; - -export default class WidgetMessaging { - /** - * @param {string} widgetId The widget's ID - * @param {string} wurl The raw URL of the widget as in the event (the 'wURL') - * @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL - * or a different URL of the clients choosing if it is using its own impl). - * @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget - * @param {object} target Where widget messages should be sent (eg. the iframe object) - */ - constructor(widgetId, wurl, renderedUrl, isUserWidget, target) { - this.widgetId = widgetId; - this.wurl = wurl; - this.renderedUrl = renderedUrl; - this.isUserWidget = isUserWidget; - this.target = target; - this.fromWidget = global.mxFromWidgetMessaging; - this.toWidget = global.mxToWidgetMessaging; - this._onOpenIdRequest = this._onOpenIdRequest.bind(this); - this.start(); - } - - messageToWidget(action) { - action.widgetId = this.widgetId; // Required to be sent for all outbound requests - - return this.toWidget.exec(action, this.target).then((data) => { - // Check for errors and reject if found - if (data.response === undefined) { // null is valid - throw new Error("Missing 'response' field"); - } - if (data.response && data.response.error) { - const err = data.response.error; - const msg = String(err.message ? err.message : "An error was returned"); - if (err._error) { - console.error(err._error); - } - // Potential XSS attack if 'msg' is not appropriately sanitized, - // as it is untrusted input by our parent window (which we assume is Element). - // We can't aggressively sanitize [A-z0-9] since it might be a translation. - throw new Error(msg); - } - // Return the response field for the request - return data.response; - }); - } - - /** - * Tells the widget that the client is ready to handle further widget requests. - * @returns {Promise<*>} Resolves after the widget has acknowledged the ready message. - */ - flagReadyToContinue() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.ClientReady, - }); - } - - /** - * Tells the widget that it should terminate now. - * @returns {Promise<*>} Resolves when widget has acknowledged the message. - */ - terminate() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.Terminate, - }); - } - - /** - * Tells the widget to hang up on its call. - * @returns {Promise<*>} Resolves when the widget has acknowledged the message. - */ - hangup() { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: KnownWidgetActions.Hangup, - }); - } - - /** - * Request a screenshot from a widget - * @return {Promise} To be resolved with screenshot data when it has been generated - */ - getScreenshot() { - console.log('Requesting screenshot for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "screenshot", - }) - .catch((error) => new Error("Failed to get screenshot: " + error.message)) - .then((response) => response.screenshot); - } - - /** - * Request capabilities required by the widget - * @return {Promise} To be resolved with an array of requested widget capabilities - */ - getCapabilities() { - console.log('Requesting capabilities for', this.widgetId); - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "capabilities", - }).then((response) => { - console.log('Got capabilities for', this.widgetId, response.capabilities); - return response.capabilities; - }); - } - - sendVisibility(visible) { - return this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "visibility", - visible, - }) - .catch((error) => { - console.error("Failed to send visibility: ", error); - }); - } - - start() { - this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.addListener("get_openid", this._onOpenIdRequest); - } - - stop() { - this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl); - this.fromWidget.removeListener("get_openid", this._onOpenIdRequest); - } - - async _onOpenIdRequest(ev, rawEv) { - if (ev.widgetId !== this.widgetId) return; // not interesting - - const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget); - - const settings = SettingsStore.getValue("widgetOpenIDPermissions"); - if (settings.deny && settings.deny.includes(widgetSecurityKey)) { - this.fromWidget.sendResponse(rawEv, {state: "blocked"}); - return; - } - if (settings.allow && settings.allow.includes(widgetSecurityKey)) { - const responseBody = {state: "allowed"}; - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - this.fromWidget.sendResponse(rawEv, responseBody); - return; - } - - // Confirm that we received the request - this.fromWidget.sendResponse(rawEv, {state: "request"}); - - // Actually ask for permission to send the user's data - Modal.createTrackedDialog("OpenID widget permissions", '', - WidgetOpenIDPermissionsDialog, { - widgetUrl: this.wurl, - widgetId: this.widgetId, - isUserWidget: this.isUserWidget, - - onFinished: async (confirm) => { - const responseBody = { - // Legacy (early draft) fields - success: confirm, - - // New style MSC1960 fields - state: confirm ? "allowed" : "blocked", - original_request_id: ev.requestId, // eslint-disable-line camelcase - }; - if (confirm) { - const credentials = await MatrixClientPeg.get().getOpenIdToken(); - Object.assign(responseBody, credentials); - } - this.messageToWidget({ - api: OUTBOUND_API_NAME, - action: "openid_credentials", - data: responseBody, - }).catch((error) => { - console.error("Failed to send OpenID credentials: ", error); - }); - }, - }, - ); - } -} diff --git a/src/WidgetMessagingEndpoint.js b/src/WidgetMessagingEndpoint.js deleted file mode 100644 index 9114e12137..0000000000 --- a/src/WidgetMessagingEndpoint.js +++ /dev/null @@ -1,37 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -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. -*/ - - -/** - * Represents mapping of widget instance to URLs for trusted postMessage communication. - */ -export default class WidgetMessageEndpoint { - /** - * Mapping of widget instance to URL for trusted postMessage communication. - * @param {string} widgetId Unique widget identifier - * @param {string} endpointUrl Widget wurl origin. - */ - constructor(widgetId, endpointUrl) { - if (!widgetId) { - throw new Error("No widgetId specified in widgetMessageEndpoint constructor"); - } - if (!endpointUrl) { - throw new Error("No endpoint specified in widgetMessageEndpoint constructor"); - } - this.widgetId = widgetId; - this.endpointUrl = endpointUrl; - } -} diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 6aaeab060f..3945eaa763 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -18,11 +18,9 @@ limitations under the License. */ import url from 'url'; -import qs from 'qs'; import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import WidgetMessaging from '../../../WidgetMessaging'; import AccessibleButton from './AccessibleButton'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; @@ -34,37 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils'; import dis from '../../../dispatcher/dispatcher'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import PersistedElement from "./PersistedElement"; import {WidgetType} from "../../../widgets/WidgetType"; -import {Capability} from "../../../widgets/WidgetApi"; -import {sleep} from "../../../utils/promise"; import {SettingLevel} from "../../../settings/SettingLevel"; import WidgetStore from "../../../stores/WidgetStore"; import {Action} from "../../../dispatcher/actions"; - -const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; -const ENABLE_REACT_PERF = false; - -/** - * Does template substitution on a URL (or any string). Variables will be - * passed through encodeURIComponent. - * @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'. - * @param {Object} variables The key/value pairs to replace the template - * variables with. E.g. { '$bar': 'baz' }. - * @return {string} The result of replacing all template variables e.g. '/foo/baz'. - */ -function uriFromTemplate(uriTemplate, variables) { - let out = uriTemplate; - for (const [key, val] of Object.entries(variables)) { - out = out.replace( - '$' + key, encodeURIComponent(val), - ); - } - return out; -} +import {StopGapWidget} from "../../../stores/widgets/StopGapWidget"; +import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions"; +import {MatrixCapabilities} from "matrix-widget-api"; export default class AppTile extends React.Component { constructor(props) { @@ -72,11 +49,13 @@ export default class AppTile extends React.Component { // The key used for PersistedElement this._persistKey = 'widget_' + this.props.app.id; + this._sgWidget = new StopGapWidget(this.props); + this._sgWidget.on("ready", this._onWidgetReady); + this.iframe = null; // ref to the iframe (callback style) this.state = this._getNewState(props); this._onAction = this._onAction.bind(this); - this._onLoaded = this._onLoaded.bind(this); this._onEditClick = this._onEditClick.bind(this); this._onDeleteClick = this._onDeleteClick.bind(this); this._onRevokeClicked = this._onRevokeClicked.bind(this); @@ -89,7 +68,6 @@ export default class AppTile extends React.Component { this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); this._contextMenuButton = createRef(); - this._appFrame = createRef(); this._menu_bar = createRef(); } @@ -108,12 +86,10 @@ export default class AppTile extends React.Component { return !!currentlyAllowedWidgets[newProps.app.eventId]; }; - const PersistedElement = sdk.getComponent("elements.PersistedElement"); return { initialising: true, // True while we are mangling the widget URL // True while the iframe content is loading loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey), - widgetUrl: this._addWurlParams(newProps.app.url), // Assume that widget has permission to load if we are the user who // added it to the room, or if explicitly granted by the user hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(), @@ -124,43 +100,6 @@ export default class AppTile extends React.Component { }; } - /** - * Does the widget support a given capability - * @param {string} capability Capability to check for - * @return {Boolean} True if capability supported - */ - _hasCapability(capability) { - return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability); - } - - /** - * Add widget instance specific parameters to pass in wUrl - * Properties passed to widget instance: - * - widgetId - * - origin / parent URL - * @param {string} urlString Url string to modify - * @return {string} - * Url string with parameters appended. - * If url can not be parsed, it is returned unmodified. - */ - _addWurlParams(urlString) { - try { - const parsed = new URL(urlString); - - // TODO: Replace these with proper widget params - // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833 - parsed.searchParams.set('widgetId', this.props.app.id); - parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]); - - // Replace the encoded dollar signs back to dollar signs. They have no special meaning - // in HTTP, but URL parsers encode them anyways. - return parsed.toString().replace(/%24/g, '$'); - } catch (e) { - console.error("Failed to add widget URL params:", e); - return urlString; - } - } - isMixedContent() { const parentContentProtocol = window.location.protocol; const u = url.parse(this.props.app.url); @@ -176,7 +115,7 @@ export default class AppTile extends React.Component { componentDidMount() { // Only fetch IM token on mount if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } // Widget action listeners @@ -190,93 +129,44 @@ export default class AppTile extends React.Component { // if it's not remaining on screen, get rid of the PersistedElement container if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); - const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); } + + if (this._sgWidget) { + this._sgWidget.stop(); + } } - // TODO: Generify the name of this function. It's not just scalar tokens. - /** - * Adds a scalar token to the widget URL, if required - * Component initialisation is only complete when this function has resolved - */ - setScalarToken() { - if (!WidgetUtils.isScalarUrl(this.props.app.url)) { - console.warn('Widget does not match integration manager, refusing to set auth token', url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; + _resetWidget(newProps) { + if (this._sgWidget) { + this._sgWidget.stop(); } + this._sgWidget = new StopGapWidget(newProps); + this._sgWidget.on("ready", this._onWidgetReady); + this._startWidget(); + } - const managers = IntegrationManagers.sharedInstance(); - if (!managers.hasManager()) { - console.warn("No integration manager - not setting scalar token", url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; - } - - // TODO: Pick the right manager for the widget - - const defaultManager = managers.getPrimaryManager(); - if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) { - console.warn('Unknown integration manager, refusing to set auth token', url); - this.setState({ - error: null, - widgetUrl: this._addWurlParams(this.props.app.url), - initialising: false, - }); - return; - } - - // Fetch the token before loading the iframe as we need it to mangle the URL - if (!this._scalarClient) { - this._scalarClient = defaultManager.getScalarClient(); - } - this._scalarClient.getScalarToken().then((token) => { - // Append scalar_token as a query param if not already present - this._scalarClient.scalarToken = token; - const u = url.parse(this._addWurlParams(this.props.app.url)); - const params = qs.parse(u.query); - if (!params.scalar_token) { - params.scalar_token = encodeURIComponent(token); - // u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options - u.search = undefined; - u.query = params; - } - - this.setState({ - error: null, - widgetUrl: u.format(), - initialising: false, - }); - - // Fetch page title from remote content if not already set - if (!this.state.widgetPageTitle && params.url) { - this._fetchWidgetTitle(params.url); - } - }, (err) => { - console.error("Failed to get scalar_token", err); - this.setState({ - error: err.message, - initialising: false, - }); + _startWidget() { + this._sgWidget.prepare().then(() => { + this.setState({initialising: false}); }); } + _iframeRefChange = (ref) => { + this.iframe = ref; + if (ref) { + this._sgWidget.start(ref); + } else { + this._resetWidget(this.props); + } + }; + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase if (nextProps.app.url !== this.props.app.url) { this._getNewState(nextProps); - // Fetch IM token for new URL if we're showing and have permission to load if (this.props.show && this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._resetWidget(nextProps); } } @@ -287,9 +177,9 @@ export default class AppTile extends React.Component { loading: true, }); } - // Fetch IM token now that we're showing if we already have permission to load + // Start the widget now that we're showing if we already have permission to load if (this.state.hasPermissionToLoad) { - this.setScalarToken(); + this._startWidget(); } } @@ -319,7 +209,14 @@ export default class AppTile extends React.Component { } _onSnapshotClick() { - WidgetUtils.snapshotWidget(this.props.app); + this._sgWidget.widgetApi.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); } /** @@ -327,35 +224,24 @@ export default class AppTile extends React.Component { * @private * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed. */ - _endWidgetActions() { - let terminationPromise; - - if (this._hasCapability(Capability.ReceiveTerminate)) { - // Wait for widget to terminate within a timeout - const timeout = 2000; - const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id); - terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]); - } else { - terminationPromise = Promise.resolve(); + async _endWidgetActions() { // widget migration dev note: async to maintain signature + // HACK: This is a really dirty way to ensure that Jitsi cleans up + // its hold on the webcam. Without this, the widget holds a media + // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 + if (this.iframe) { + // In practice we could just do `+= ''` to trick the browser + // into thinking the URL changed, however I can foresee this + // being optimized out by a browser. Instead, we'll just point + // the iframe at a page that is reasonably safe to use in the + // event the iframe doesn't wink away. + // This is relative to where the Element instance is located. + this.iframe.src = 'about:blank'; } - return terminationPromise.finally(() => { - // HACK: This is a really dirty way to ensure that Jitsi cleans up - // its hold on the webcam. Without this, the widget holds a media - // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351 - if (this._appFrame.current) { - // In practice we could just do `+= ''` to trick the browser - // into thinking the URL changed, however I can foresee this - // being optimized out by a browser. Instead, we'll just point - // the iframe at a page that is reasonably safe to use in the - // event the iframe doesn't wink away. - // This is relative to where the Element instance is located. - this._appFrame.current.src = 'about:blank'; - } + // Delete the widget from the persisted store for good measure. + PersistedElement.destroyElement(this._persistKey); - // Delete the widget from the persisted store for good measure. - PersistedElement.destroyElement(this._persistKey); - }); + this._sgWidget.stop(); } /* If user has permission to modify widgets, delete the widget, @@ -409,73 +295,18 @@ export default class AppTile extends React.Component { this._revokeWidgetPermission(); } - /** - * Called when widget iframe has finished loading - */ - _onLoaded() { - // Destroy the old widget messaging before starting it back up again. Some widgets - // have startup routines that run when they are loaded, so we just need to reinitialize - // the messaging for them. - ActiveWidgetStore.delWidgetMessaging(this.props.app.id); - this._setupWidgetMessaging(); - - ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId); + _onWidgetReady = () => { this.setState({loading: false}); - } - - _setupWidgetMessaging() { - // FIXME: There's probably no reason to do this here: it should probably be done entirely - // in ActiveWidgetStore. - const widgetMessaging = new WidgetMessaging( - this.props.app.id, - this.props.app.url, - this._getRenderedUrl(), - this.props.userWidget, - this._appFrame.current.contentWindow, - ); - ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging); - widgetMessaging.getCapabilities().then((requestedCapabilities) => { - console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities); - requestedCapabilities = requestedCapabilities || []; - - // Allow whitelisted capabilities - let requestedWhitelistCapabilies = []; - - if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) { - requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) { - return this.indexOf(e)>=0; - }, this.props.whitelistCapabilities); - - if (requestedWhitelistCapabilies.length > 0 ) { - console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` + - requestedWhitelistCapabilies, - ); - } - } - - // TODO -- Add UI to warn about and optionally allow requested capabilities - - ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies); - - if (this.props.onCapabilityRequest) { - this.props.onCapabilityRequest(requestedCapabilities); - } - - // We only tell Jitsi widgets that we're ready because they're realistically the only ones - // using this custom extension to the widget API. - if (WidgetType.JITSI.matches(this.props.app.type)) { - widgetMessaging.flagReadyToContinue(); - } - }).catch((err) => { - console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err); - }); - } + if (WidgetType.JITSI.matches(this.props.app.type)) { + this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {}); + } + }; _onAction(payload) { if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': - if (this._hasCapability('m.sticker')) { + if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) { dis.dispatch({action: 'post_sticker_message', data: payload.data}); } else { console.warn('Ignoring sticker message. Invalid capability'); @@ -493,20 +324,6 @@ export default class AppTile extends React.Component { } } - /** - * Set remote content title on AppTile - * @param {string} url Url to check for title - */ - _fetchWidgetTitle(url) { - this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => { - if (widgetPageTitle) { - this.setState({widgetPageTitle: widgetPageTitle}); - } - }, (err) =>{ - console.error("Failed to get page title", err); - }); - } - _grantWidgetPermission() { const roomId = this.props.room.roomId; console.info("Granting permission for widget to load: " + this.props.app.eventId); @@ -516,7 +333,7 @@ export default class AppTile extends React.Component { this.setState({hasPermissionToLoad: true}); // Fetch a token for the integration manager, now that we're allowed to - this.setScalarToken(); + this._startWidget(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -535,6 +352,7 @@ export default class AppTile extends React.Component { ActiveWidgetStore.destroyPersistentWidget(this.props.app.id); const PersistedElement = sdk.getComponent("elements.PersistedElement"); PersistedElement.destroyElement(this._persistKey); + this._sgWidget.stop(); }).catch(err => { console.error(err); // We don't really need to do anything about this - the user will just hit the button again. @@ -572,40 +390,6 @@ export default class AppTile extends React.Component { } } - /** - * Replace the widget template variables in a url with their values - * - * @param {string} u The URL with template variables - * @param {string} widgetType The widget's type - * - * @returns {string} url with temlate variables replaced - */ - _templatedUrl(u, widgetType: string) { - const targetData = {}; - if (WidgetType.JITSI.matches(widgetType)) { - targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded - } - const myUserId = MatrixClientPeg.get().credentials.userId; - const myUser = MatrixClientPeg.get().getUser(myUserId); - const vars = Object.assign(targetData, this.props.app.data, { - 'matrix_user_id': myUserId, - 'matrix_room_id': this.props.room.roomId, - 'matrix_display_name': myUser ? myUser.displayName : myUserId, - 'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '', - - // TODO: Namespace themes through some standard - 'theme': SettingsStore.getValue("theme"), - }); - - if (vars.conferenceId === undefined) { - // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets - const parsedUrl = new URL(this.props.app.url); - vars.conferenceId = parsedUrl.searchParams.get("confId"); - } - - return uriFromTemplate(u, vars); - } - /** * Whether we're using a local version of the widget rather than loading the * actual widget URL @@ -615,67 +399,11 @@ export default class AppTile extends React.Component { return WidgetType.JITSI.matches(this.props.app.type); } - /** - * Get the URL used in the iframe - * In cases where we supply our own UI for a widget, this is an internal - * URL different to the one used if the widget is popped out to a separate - * tab / browser - * - * @returns {string} url - */ - _getRenderedUrl() { - let url; - - if (WidgetType.JITSI.matches(this.props.app.type)) { - console.log("Replacing Jitsi widget URL with local wrapper"); - url = WidgetUtils.getLocalJitsiWrapperUrl({ - forLocalRender: true, - auth: this.props.app.data ? this.props.app.data.auth : null, - }); - url = this._addWurlParams(url); - } else { - url = this._getSafeUrl(this.state.widgetUrl); - } - return this._templatedUrl(url, this.props.app.type); - } - - _getPopoutUrl() { - if (WidgetType.JITSI.matches(this.props.app.type)) { - return this._templatedUrl( - WidgetUtils.getLocalJitsiWrapperUrl({ - forLocalRender: false, - auth: this.props.app.data ? this.props.app.data.auth : null, - }), - this.props.app.type, - ); - } else { - // use app.url, not state.widgetUrl, because we want the one without - // the wURL params for the popped-out version. - return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type); - } - } - - _getSafeUrl(u) { - const parsedWidgetUrl = url.parse(u, true); - if (ENABLE_REACT_PERF) { - parsedWidgetUrl.search = null; - parsedWidgetUrl.query.react_perf = true; - } - let safeWidgetUrl = ''; - if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) { - safeWidgetUrl = url.format(parsedWidgetUrl); - } - - // Replace all the dollar signs back to dollar signs as they don't affect HTTP at all. - // We also need the dollar signs in-tact for variable substitution. - return safeWidgetUrl.replace(/%24/g, '$'); - } - _getTileTitle() { const name = this.formatAppTileName(); const titleSpacer =  - ; let title = ''; - if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) { + if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) { title = this.state.widgetPageTitle; } @@ -698,9 +426,9 @@ export default class AppTile extends React.Component { // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop). if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) { this._endWidgetActions().then(() => { - if (this._appFrame.current) { + if (this.iframe) { // Reload iframe - this._appFrame.current.src = this._getRenderedUrl(); + this.iframe.src = this._sgWidget.embedUrl; this.setState({}); } }); @@ -708,13 +436,13 @@ export default class AppTile extends React.Component { // Using Object.assign workaround as the following opens in a new window instead of a new tab. // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); Object.assign(document.createElement('a'), - { target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click(); + { target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click(); } _onReloadWidgetClick() { // Reload iframe in this way to avoid cross-origin restrictions // eslint-disable-next-line no-self-assign - this._appFrame.current.src = this._appFrame.current.src; + this.iframe.src = this.iframe.src; } _onContextMenuClick = () => { @@ -760,7 +488,7 @@ export default class AppTile extends React.Component { @@ -785,11 +513,11 @@ export default class AppTile extends React.Component { { this.state.loading && loadingElement }