fix(calls): read the EC iframe document defensively when the widget failed to load
When the Element Call frame's navigation fails (offline, blocked) it becomes a cross-origin error page and `iframe.contentWindow.document` throws a SecurityError. Every DOM-driven call hook (CallControl selectors, deafen DOM fallback, useCallSpeakers/useRemoteAllMuted observers, CallEmbed theme injection) read it unguarded, so the moment the 25 s load watchdog fired the page logged a burst of uncaught SecurityErrors. Reproduced headless by aborting the EC index request. One `getCallDocument()` helper now treats "can't read" as "not loaded yet"; the watchdog overlay itself was already correct. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { getCallDocument } from '../plugins/call/utils';
|
||||||
import { isUserId } from '../utils/matrix';
|
import { isUserId } from '../utils/matrix';
|
||||||
import { nextSpeakerSet } from '../utils/speakerSet';
|
import { nextSpeakerSet } from '../utils/speakerSet';
|
||||||
import { useCallMembers, useCallSession } from './useCall';
|
import { useCallMembers, useCallSession } from './useCall';
|
||||||
@@ -38,8 +39,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDoc = (): Document | undefined =>
|
const getDoc = (): Document | undefined => getCallDocument(callEmbed.iframe);
|
||||||
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
|
|
||||||
|
|
||||||
let tileObserver: MutationObserver | undefined;
|
let tileObserver: MutationObserver | undefined;
|
||||||
|
|
||||||
@@ -163,8 +163,7 @@ export const useRemoteAllMuted = (callEmbed: CallEmbed | undefined): boolean =>
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!callEmbed) return undefined;
|
if (!callEmbed) return undefined;
|
||||||
|
|
||||||
const getDoc = (): Document | undefined =>
|
const getDoc = (): Document | undefined => getCallDocument(callEmbed.iframe);
|
||||||
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
|
|
||||||
|
|
||||||
const localUserId = callEmbed.room.client?.getUserId() ?? '';
|
const localUserId = callEmbed.room.client?.getUserId() ?? '';
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ClientWidgetApi } from 'matrix-widget-api';
|
|||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||||
|
import { getCallDocument } from './utils';
|
||||||
|
|
||||||
export enum CallControlEvent {
|
export enum CallControlEvent {
|
||||||
StateUpdate = 'state_update',
|
StateUpdate = 'state_update',
|
||||||
@@ -62,7 +63,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
private joined = false;
|
private joined = false;
|
||||||
|
|
||||||
private get document(): Document | undefined {
|
private get document(): Document | undefined {
|
||||||
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
return getCallDocument(this.iframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get screenshareButton(): HTMLElement | undefined {
|
private get screenshareButton(): HTMLElement | undefined {
|
||||||
@@ -265,7 +266,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setSound(sound: boolean): void {
|
private setSound(sound: boolean): void {
|
||||||
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
const callDocument = getCallDocument(this.iframe);
|
||||||
if (callDocument) {
|
if (callDocument) {
|
||||||
callDocument.querySelectorAll('audio').forEach((el) => {
|
callDocument.querySelectorAll('audio').forEach((el) => {
|
||||||
const isScreenshareAudio = el.getAttribute('data-lk-source') === 'screen_share_audio';
|
const isScreenshareAudio = el.getAttribute('data-lk-source') === 'screen_share_audio';
|
||||||
@@ -277,7 +278,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
private applyScreenshareAudioMuted(): void {
|
private applyScreenshareAudioMuted(): void {
|
||||||
if (!this.sound) return;
|
if (!this.sound) return;
|
||||||
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
const callDocument = getCallDocument(this.iframe);
|
||||||
if (callDocument) {
|
if (callDocument) {
|
||||||
callDocument
|
callDocument
|
||||||
.querySelectorAll<HTMLAudioElement>('audio[data-lk-source="screen_share_audio"]')
|
.querySelectorAll<HTMLAudioElement>('audio[data-lk-source="screen_share_audio"]')
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { CallControl } from './CallControl';
|
import { CallControl } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||||
|
import { getCallDocument } from './utils';
|
||||||
|
|
||||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||||
@@ -326,7 +327,7 @@ export class CallEmbed {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get document(): Document | undefined {
|
get document(): Document | undefined {
|
||||||
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
|
return getCallDocument(this.iframe);
|
||||||
}
|
}
|
||||||
|
|
||||||
public setTheme(theme: ElementCallThemeKind) {
|
public setTheme(theme: ElementCallThemeKind) {
|
||||||
|
|||||||
@@ -116,3 +116,19 @@ export function getCallCapabilities(
|
|||||||
|
|
||||||
return capabilities;
|
return capabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The EC iframe's document, or undefined when it cannot be read. The widget is
|
||||||
|
* same-origin, but when its navigation fails (offline, blocked) the frame
|
||||||
|
* becomes a cross-origin error page and `contentWindow.document` THROWS a
|
||||||
|
* SecurityError — which surfaced as page errors (and a React "Should not
|
||||||
|
* already be working" cascade) from every DOM-driven call hook the moment the
|
||||||
|
* load watchdog fired. Treat "can't read" the same as "not loaded yet".
|
||||||
|
*/
|
||||||
|
export const getCallDocument = (iframe: HTMLIFrameElement): Document | undefined => {
|
||||||
|
try {
|
||||||
|
return iframe.contentDocument ?? iframe.contentWindow?.document ?? undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user