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:
2026-09-19 00:15:37 -04:00
co-authored by Claude Opus 5
parent 4e455ae42e
commit 70620d4b43
4 changed files with 25 additions and 8 deletions
+4 -3
View File
@@ -2,6 +2,7 @@ import { ClientWidgetApi } from 'matrix-widget-api';
import { EventEmitter } from 'events';
import { CallControlState } from './CallControlState';
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
import { getCallDocument } from './utils';
export enum CallControlEvent {
StateUpdate = 'state_update',
@@ -62,7 +63,7 @@ export class CallControl extends EventEmitter implements CallControlState {
private joined = false;
private get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
return getCallDocument(this.iframe);
}
private get screenshareButton(): HTMLElement | undefined {
@@ -265,7 +266,7 @@ export class CallControl extends EventEmitter implements CallControlState {
}
private setSound(sound: boolean): void {
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
const callDocument = getCallDocument(this.iframe);
if (callDocument) {
callDocument.querySelectorAll('audio').forEach((el) => {
const isScreenshareAudio = el.getAttribute('data-lk-source') === 'screen_share_audio';
@@ -277,7 +278,7 @@ export class CallControl extends EventEmitter implements CallControlState {
private applyScreenshareAudioMuted(): void {
if (!this.sound) return;
const callDocument = this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
const callDocument = getCallDocument(this.iframe);
if (callDocument) {
callDocument
.querySelectorAll<HTMLAudioElement>('audio[data-lk-source="screen_share_audio"]')
+2 -1
View File
@@ -28,6 +28,7 @@ import {
import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { getCallDocument } from './utils';
// 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
@@ -326,7 +327,7 @@ export class CallEmbed {
}
get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
return getCallDocument(this.iframe);
}
public setTheme(theme: ElementCallThemeKind) {
+16
View File
@@ -116,3 +116,19 @@ export function getCallCapabilities(
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;
}
};