@@ -62,6 +62,20 @@ import { parseUrl } from "../../../utils/UrlUtils";
6262import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts" ;
6363import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts" ;
6464
65+ // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
66+ // because that would allow the iframe to programmatically remove the sandbox attribute, but
67+ // this would only be for content hosted on the same origin as the element client: anything
68+ // hosted on the same origin as the client will get the same access as if you clicked
69+ // a link to it.
70+ const sandboxFlags =
71+ "allow-forms allow-popups allow-popups-to-escape-sandbox " +
72+ "allow-same-origin allow-scripts allow-presentation allow-downloads" ;
73+
74+ // Additional iframe feature permissions
75+ // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
76+ const iframeFeatures =
77+ "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; clipboard-read;" ;
78+
6579interface IProps {
6680 app : IWidget | IApp ;
6781 // If room is not specified then it is an account level widget
@@ -138,7 +152,7 @@ export default class AppTile extends React.Component<IProps, IState> {
138152 } ;
139153
140154 private contextMenuButton = createRef < any > ( ) ;
141- private iframe ?: HTMLIFrameElement ; // ref to the iframe (callback style)
155+ private iframeParent : HTMLElement | null = null ; // parent div of the iframe
142156 private allowedWidgetsWatchRef ?: string ;
143157 private persistKey : string ;
144158 private sgWidget ?: StopGapWidget ;
@@ -397,18 +411,46 @@ export default class AppTile extends React.Component<IProps, IState> {
397411 } ) ;
398412 }
399413
414+ /**
415+ * Creates the widget iframe and opens communication with the widget.
416+ */
400417 private startMessaging ( ) : void {
401- try {
402- this . sgWidget ?. startMessaging ( this . iframe ! ) ;
403- } catch ( e ) {
404- logger . error ( "Failed to start widget" , e ) ;
405- }
418+ // We create the iframe ourselves rather than leaving the job to React,
419+ // because we need the lifetime of the messaging and the iframe to be
420+ // the same; we don't want strict mode, for instance, to cause the
421+ // messaging to restart (lose its state) without also killing the widget
422+ const iframe = document . createElement ( "iframe" ) ;
423+ iframe . title = WidgetUtils . getWidgetName ( this . props . app ) ;
424+ iframe . allow = iframeFeatures ;
425+ iframe . src = this . sgWidget ! . embedUrl ;
426+ iframe . allowFullscreen = true ;
427+ iframe . sandbox = sandboxFlags ;
428+ this . iframeParent ! . appendChild ( iframe ) ;
429+ // In order to start the widget messaging we need iframe.contentWindow
430+ // to exist. Waiting until the next layout gives the browser a chance to
431+ // initialize it.
432+ requestAnimationFrame ( ( ) => {
433+ // Handle the race condition (seen in strict mode) where the element
434+ // is added and then removed before we enter this callback
435+ if ( iframe . parentElement === null ) return ;
436+ try {
437+ this . sgWidget ?. startMessaging ( iframe ) ;
438+ } catch ( e ) {
439+ logger . error ( "Failed to start widget" , e ) ;
440+ }
441+ } ) ;
406442 }
407443
408- private iframeRefChange = ( ref : HTMLIFrameElement ) : void => {
409- this . iframe = ref ;
444+ /**
445+ * Callback ref for the parent div of the iframe.
446+ */
447+ private iframeParentRef = ( element : HTMLElement | null ) : void => {
448+ // Detach the existing iframe (if any) from the document so we know not
449+ // to do anything further with it, like starting up the messaging
450+ this . iframeParent ?. querySelector ( "iframe" ) ?. remove ( ) ;
451+ this . iframeParent = element ;
410452 if ( this . unmounted ) return ;
411- if ( ref ) {
453+ if ( element && this . sgWidget ) {
412454 this . startMessaging ( ) ;
413455 } else {
414456 this . resetWidget ( this . props ) ;
@@ -426,24 +468,8 @@ export default class AppTile extends React.Component<IProps, IState> {
426468
427469 /**
428470 * Ends all widget interaction, such as cancelling calls and disabling webcams.
429- * @private
430- * @returns {Promise<*> } Resolves when the widget is terminated, or timeout passed.
431471 */
432- private async endWidgetActions ( ) : Promise < void > {
433- // widget migration dev note: async to maintain signature
434- // HACK: This is a really dirty way to ensure that Jitsi cleans up
435- // its hold on the webcam. Without this, the widget holds a media
436- // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
437- if ( this . iframe ) {
438- // In practice we could just do `+= ''` to trick the browser
439- // into thinking the URL changed, however I can foresee this
440- // being optimized out by a browser. Instead, we'll just point
441- // the iframe at a page that is reasonably safe to use in the
442- // event the iframe doesn't wink away.
443- // This is relative to where the Element instance is located.
444- this . iframe . src = "about:blank" ;
445- }
446-
472+ private endWidgetActions ( ) : void {
447473 if ( WidgetType . JITSI . matches ( this . props . app . type ) && this . props . room ) {
448474 LegacyCallHandler . instance . hangupCallApp ( this . props . room . roomId ) ;
449475 }
@@ -457,6 +483,7 @@ export default class AppTile extends React.Component<IProps, IState> {
457483
458484 this . sgWidget ?. stopMessaging ( { forceDestroy : true } ) ;
459485 }
486+
460487 private onWidgetReady = ( ) : void => {
461488 this . setState ( { loading : false } ) ;
462489 } ;
@@ -554,16 +581,11 @@ export default class AppTile extends React.Component<IProps, IState> {
554581 }
555582
556583 private reload ( ) : void {
557- this . endWidgetActions ( ) . then ( ( ) => {
558- // reset messaging
559- this . resetWidget ( this . props ) ;
560- this . startMessaging ( ) ;
561-
562- if ( this . iframe && this . sgWidget ) {
563- // Reload iframe
564- this . iframe . src = this . sgWidget . embedUrl ;
565- }
566- } ) ;
584+ this . endWidgetActions ( ) ;
585+ // reset messaging
586+ this . resetWidget ( this . props ) ;
587+ this . iframeParent ?. querySelector ( "iframe" ) ?. remove ( ) ;
588+ this . startMessaging ( ) ;
567589 }
568590
569591 // TODO replace with full screen interactions
@@ -621,20 +643,6 @@ export default class AppTile extends React.Component<IProps, IState> {
621643 public render ( ) : React . ReactNode {
622644 let appTileBody : JSX . Element | undefined ;
623645
624- // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin
625- // because that would allow the iframe to programmatically remove the sandbox attribute, but
626- // this would only be for content hosted on the same origin as the element client: anything
627- // hosted on the same origin as the client will get the same access as if you clicked
628- // a link to it.
629- const sandboxFlags =
630- "allow-forms allow-popups allow-popups-to-escape-sandbox " +
631- "allow-same-origin allow-scripts allow-presentation allow-downloads" ;
632-
633- // Additional iframe feature permissions
634- // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/)
635- const iframeFeatures =
636- "microphone; camera; encrypted-media; autoplay; display-capture; clipboard-write; " + "clipboard-read;" ;
637-
638646 const appTileBodyClass = classNames ( {
639647 "mx_AppTileBody" : true ,
640648 "mx_AppTileBody--large" : ! this . props . miniMode ,
@@ -654,8 +662,6 @@ export default class AppTile extends React.Component<IProps, IState> {
654662 </ div >
655663 ) ;
656664
657- const widgetTitle = WidgetUtils . getWidgetName ( this . props . app ) ;
658-
659665 if ( this . sgWidget === null ) {
660666 appTileBody = (
661667 < div className = { appTileBodyClass } style = { appTileBodyStyles } >
@@ -692,16 +698,8 @@ export default class AppTile extends React.Component<IProps, IState> {
692698 } else if ( this . sgWidget ) {
693699 appTileBody = (
694700 < >
695- < div className = { appTileBodyClass } style = { appTileBodyStyles } >
701+ < div className = { appTileBodyClass } style = { appTileBodyStyles } ref = { this . iframeParentRef } >
696702 { this . state . loading && loadingElement }
697- < iframe
698- title = { widgetTitle }
699- allow = { iframeFeatures }
700- ref = { this . iframeRefChange }
701- src = { this . sgWidget . embedUrl }
702- allowFullScreen = { true }
703- sandbox = { sandboxFlags }
704- />
705703 </ div >
706704 { this . props . overlay }
707705 </ >
0 commit comments