feat(call): screenshare from inside the frame where delegation is missing; host stops reading the call frame (#43)
CI / Build & Quality Checks (push) Successful in 4m32s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 16m32s

Firefox, Safari and the WebKitGTK desktop app can't hand the user's click to
the call frame (no Capability Delegation), and getDisplayMedia needs it. The
host used to click EC's hidden footer button through the DOM instead, which
dies with same-origin. Now (pins element-call-embedded 0.25.0-lotus.20):

- those engines get `lotusFrameScreenshare`, the fork shows EC's own
  screenshare button in the frame, and the call bar and status bar hide
  theirs once controls_state reports `frameScreenshare`; the
  screenshare-audio mute stays;
- the room's call policy is pushed with io.lotus.set_frame_screenshare, so
  the frame button hides where the server would refuse a share, like ours;
- Chromium keeps the delegated io.lotus.set_screenshare from the host bar.

Removed the fallbacks for forks older than lotus.14, which read or clicked
EC's DOM: the screenshare/layout/settings/reactions/leave button lookups
and their MutationObservers, the frame-window hotkey binding, and the
speaking/muted tile scrape in useCallSpeakers (io.lotus.call_state is the
only source now). getCallDocument is gone; the host's only handle on the
frame is postMessage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-26 22:04:54 -04:00
co-authored by Claude Opus 5.5
parent ba48e95993
commit 49dec686f1
11 changed files with 156 additions and 504 deletions
+62 -194
View File
@@ -2,7 +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';
import { canDelegateCapability } from './utils';
export enum CallControlEvent {
StateUpdate = 'state_update',
@@ -54,16 +54,6 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
};
}
/**
* Capability Delegation (`postMessage(msg, { delegate })`) ships only in
* Chromium; other engines silently ignore the option, so the frame would get
* no activation. `navigator.userAgentData` is likewise Chromium-only, which
* makes it the practical feature test.
*/
function canDelegateCapability(): boolean {
return typeof navigator !== 'undefined' && 'userAgentData' in navigator;
}
export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState;
@@ -71,14 +61,6 @@ export class CallControl extends EventEmitter implements CallControlState {
private iframe: HTMLIFrameElement;
private bodyMutationObserver: MutationObserver;
private controlMutationObserver: MutationObserver;
// C-H3: coalesces bursts of body-subtree mutations into a single debounced
// re-observe pass so a busy EC re-render doesn't thrash the control observer.
private bodyMutationTimer?: ReturnType<typeof setTimeout>;
// [Gitea #56] Tracks the participant currently pinned via focusCameraParticipant(),
// so callers (MemberGlance) can render a "Focus camera" / "Unfocus camera" toggle
// instead of a one-way pin with no way back. null == no manual pin (speaker-follows).
@@ -107,68 +89,12 @@ export class CallControl extends EventEmitter implements CallControlState {
// timeout — io.lotus toWidget actions must only be sent after call-join).
private joined = false;
private get document(): Document | undefined {
return getCallDocument(this.iframe);
}
private get screenshareButton(): HTMLElement | undefined {
const screenshareBtn = this.document?.querySelector(
'[data-testid="incall_screenshare"]',
) as HTMLElement | null;
return screenshareBtn ?? undefined;
}
private get leaveButton(): Element | undefined {
const leaveBtn = this.document?.querySelector('[data-testid="incall_leave"]');
return leaveBtn ?? undefined;
}
private get settingsButton(): HTMLElement | undefined {
// EC 0.20.1: settings button moved to bottom-left; fall back to bottom-center.
const settingsButtonLeft = this.document?.querySelector(
'[data-testid="settings-bottom-left"]',
) as HTMLButtonElement | undefined;
const settingsButtonCenter = this.document?.querySelector(
'[data-testid="settings-bottom-center"]',
) as HTMLButtonElement | undefined;
return settingsButtonLeft ?? settingsButtonCenter ?? undefined;
}
private get reactionsButton(): HTMLElement | undefined {
// EC 0.20.1: reactions/raise-hand button sits just before the leave button.
const reactionsButton = this.leaveButton?.previousElementSibling as HTMLElement | null;
return reactionsButton ?? undefined;
}
private get spotlightButton(): HTMLInputElement | undefined {
const spotlightButton = this.document?.querySelector(
'input[value="spotlight"]',
) as HTMLInputElement | null;
return spotlightButton ?? undefined;
}
private get gridButton(): HTMLInputElement | undefined {
const gridButton = this.document?.querySelector(
'input[value="grid"]',
) as HTMLInputElement | null;
return gridButton ?? undefined;
}
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
super();
this.state = state;
this.call = call;
this.iframe = iframe;
this.bodyMutationObserver = new MutationObserver(this.onBodyMutation.bind(this));
this.controlMutationObserver = new MutationObserver(this.onControlMutation.bind(this));
}
public getState(): CallControlState {
@@ -225,6 +151,8 @@ export class CallControl extends EventEmitter implements CallControlState {
this.joined = true;
this.sendDeafenState();
this.sendQuality();
this.sendHotkeyCodes();
this.sendFrameScreenshareAllowed();
}
/**
@@ -240,55 +168,7 @@ export class CallControl extends EventEmitter implements CallControlState {
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
this.sendHotkeyCodes();
}
public startObserving() {
if (!this.document) return;
// C-H3: watch the whole body subtree (not just direct children) so we
// re-bind the control observer when EC re-renders its controls deeper in the
// tree. Debounced via onBodyMutation() to avoid thrashing on busy renders.
this.bodyMutationObserver.observe(this.document.body, {
childList: true,
subtree: true,
});
this.applyBodyMutation();
}
private onBodyMutation() {
// C-H3: coalesce a burst of subtree mutations into one debounced pass.
if (this.bodyMutationTimer !== undefined) return;
this.bodyMutationTimer = setTimeout(() => {
this.bodyMutationTimer = undefined;
this.applyBodyMutation();
}, 100);
}
private applyBodyMutation() {
if (!this.document) return;
// Hiding EC's footer and the transparent background are the fork's job now
// (lotusHostControls / lotusTransparent URL flags, Gitea #43).
this.observeControls();
}
private observeControls() {
this.controlMutationObserver.disconnect();
const screenshareBtn = this.screenshareButton;
if (screenshareBtn) {
this.controlMutationObserver.observe(screenshareBtn, {
attributes: true,
attributeFilter: ['data-kind'],
});
}
const spotlightBtn = this.spotlightButton;
if (spotlightBtn) {
this.controlMutationObserver.observe(spotlightBtn, {
attributes: true,
});
}
this.onControlMutation();
this.sendFrameScreenshareAllowed();
}
private async setMediaState(state: ElementMediaStatePayload) {
@@ -369,39 +249,52 @@ export class CallControl extends EventEmitter implements CallControlState {
}
}
// [Gitea #43] Set once the fork reports io.lotus.controls_state: from then on
// layout / settings / reactions go over the widget API and screenshare +
// layout state come from that report, not from EC's DOM. Older forks never
// send it and keep the DOM path below.
private forkControls = false;
// [Gitea #43] The fork has reported io.lotus.controls_state, so its Lotus
// action handlers are mounted. Screenshare and layout state come from that
// report; the host never reads or clicks EC's DOM.
private forkReady = false;
// [Gitea #43] The fork handles io.lotus.set_screenshare (reported in
// controls_state). Used only where Capability Delegation exists.
private forkScreenshare = false;
// [Gitea #43] The fork shows EC's own screenshare button inside the frame
// (lotusFrameScreenshare: no Capability Delegation here), so the host bar
// hides its own.
private _frameScreenshare = false;
// [Gitea #43] The fork reports call hotkeys pressed inside its frame
// (io.lotus.set_hotkeys → io.lotus.hotkey), so the host stops adding key
// listeners to the frame's window.
private forkHotkeys = false;
private frameScreenshareListeners = new Set<() => void>();
// [Gitea #43] Whether the room's call policy allows screensharing; the fork
// hides its in-frame button when it doesn't.
private frameScreenshareAllowed = true;
private hotkeyCodes = new Map<string, string[]>();
private hotkeyListeners = new Set<(report: ForkHotkeyReport) => void>();
private forkHotkeysListeners = new Set<() => void>();
public get forkHandlesHotkeys(): boolean {
return this.forkHotkeys;
/** EC's own screenshare button is shown in the frame instead of ours. */
public get frameScreenshare(): boolean {
return this._frameScreenshare;
}
/** Subscribe to `forkHandlesHotkeys` turning on. Returns an unsubscribe. */
public onForkHotkeysChange(cb: () => void): () => void {
this.forkHotkeysListeners.add(cb);
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */
public onFrameScreenshareChange(cb: () => void): () => void {
this.frameScreenshareListeners.add(cb);
return () => {
this.forkHotkeysListeners.delete(cb);
this.frameScreenshareListeners.delete(cb);
};
}
/** Room call policy for the in-frame screenshare button. */
public setFrameScreenshareAllowed(allowed: boolean): void {
this.frameScreenshareAllowed = allowed;
this.sendFrameScreenshareAllowed();
}
private sendFrameScreenshareAllowed(): void {
if (!this.joined || !this.forkReady || !this._frameScreenshare) return;
this.sendForkAction('io.lotus.set_frame_screenshare', {
visible: this.frameScreenshareAllowed,
});
}
/** Key codes `source` (e.g. 'ptt', 'deafen') wants reported from the frame. */
public setHotkeyCodes(source: string, codes: string[]): void {
this.hotkeyCodes.set(source, codes);
@@ -409,7 +302,7 @@ export class CallControl extends EventEmitter implements CallControlState {
}
private sendHotkeyCodes(): void {
if (!this.joined || !this.forkHotkeys) return;
if (!this.joined || !this.forkReady) return;
const codes = [...new Set([...this.hotkeyCodes.values()].flat())];
this.call.transport.send('io.lotus.set_hotkeys', { codes }).catch(() => undefined);
}
@@ -431,18 +324,21 @@ export class CallControl extends EventEmitter implements CallControlState {
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
public onControlsState(data: unknown) {
if (typeof data !== 'object' || data === null) return;
const { screensharing, layout, screenshareAction, hotkeys } = data as {
const { screensharing, layout, frameScreenshare } = data as {
screensharing?: unknown;
layout?: unknown;
screenshareAction?: unknown;
hotkeys?: unknown;
frameScreenshare?: unknown;
};
this.forkControls = true;
this.forkScreenshare = screenshareAction === true;
if (hotkeys === true && !this.forkHotkeys) {
this.forkHotkeys = true;
const firstReport = !this.forkReady;
this.forkReady = true;
const frame = frameScreenshare === true;
if (frame !== this._frameScreenshare) {
this._frameScreenshare = frame;
this.frameScreenshareListeners.forEach((l) => l());
}
if (firstReport) {
this.sendHotkeyCodes();
this.forkHotkeysListeners.forEach((l) => l());
this.sendFrameScreenshareAllowed();
}
this.applyControls(
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
@@ -450,13 +346,6 @@ export class CallControl extends EventEmitter implements CallControlState {
);
}
private onControlMutation() {
if (this.forkControls) return;
const screenshare: boolean = this.screenshareButton?.getAttribute('data-kind') === 'primary';
const spotlight: boolean = this.spotlightButton?.checked ?? false;
this.applyControls(screenshare, spotlight);
}
private applyControls(screenshare: boolean, spotlight: boolean) {
const wasScreensharing = this.screenshare;
@@ -560,16 +449,17 @@ export class CallControl extends EventEmitter implements CallControlState {
* Must be called synchronously inside the user's click: starting a share
* calls getDisplayMedia in the frame, which needs that click. Where the
* browser can hand the click over (Capability Delegation, Chromium incl.
* WebView2) this goes over the widget API; elsewhere (Firefox, Safari,
* WebKitGTK desktop) it still clicks EC's hidden button, which needs
* same-origin access to the frame.
* WebView2) it is sent with delegation. Elsewhere the host bar hides its
* button and EC's own shows in the frame (`frameScreenshare`); a plain send
* still stops a share, which needs no click.
*/
public toggleScreenshare() {
if (this.forkScreenshare && canDelegateCapability()) {
this.sendDelegated('io.lotus.set_screenshare', { on: !this.screenshare }, 'display-capture');
const data = { on: !this.screenshare };
if (canDelegateCapability()) {
this.sendDelegated('io.lotus.set_screenshare', data, 'display-capture');
return;
}
this.screenshareButton?.click();
this.sendForkAction('io.lotus.set_screenshare', data);
}
/**
@@ -602,34 +492,18 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public toggleSpotlight() {
if (this.forkControls) {
this.sendForkAction('io.lotus.set_layout', {
layout: this.spotlight ? 'grid' : 'spotlight',
});
return;
}
if (this.spotlight) {
this.gridButton?.click();
return;
}
this.spotlightButton?.click();
this.sendForkAction('io.lotus.set_layout', {
layout: this.spotlight ? 'grid' : 'spotlight',
});
}
public toggleReactions() {
if (this.forkControls) {
this.sendForkAction('io.lotus.toggle_reactions', {});
return;
}
this.reactionsButton?.click();
this.sendForkAction('io.lotus.toggle_reactions', {});
}
public toggleSettings() {
if (this.forkControls) {
// Same as clicking EC's settings button: opens the modal.
this.sendForkAction('io.lotus.open_settings', { open: true });
return;
}
this.settingsButton?.click();
// Same as clicking EC's settings button: opens the modal.
this.sendForkAction('io.lotus.open_settings', { open: true });
}
private sendForkAction(action: string, data: Record<string, unknown>): void {
@@ -731,14 +605,8 @@ export class CallControl extends EventEmitter implements CallControlState {
}
public dispose() {
if (this.bodyMutationTimer !== undefined) {
clearTimeout(this.bodyMutationTimer);
this.bodyMutationTimer = undefined;
}
// [Gitea #56] Don't let a manual focus pin outlive the call.
this.clearFocusParticipant();
this.bodyMutationObserver.disconnect();
this.controlMutationObserver.disconnect();
}
private emitStateUpdate() {
+8 -12
View File
@@ -28,7 +28,7 @@ import {
import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { getCallDocument } from './utils';
import { canDelegateCapability } 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
@@ -218,6 +218,11 @@ export class CallEmbed {
// [Gitea #43] The fork hides its own footer (we draw the call bar) and
// sets its root color-scheme from the theme, instead of us injecting CSS.
lotusHostControls: 'true',
// [Gitea #43] Engines without Capability Delegation (Firefox, Safari,
// WebKitGTK) can't start a share from our call bar: getDisplayMedia
// needs the click inside the frame. There the fork shows EC's own
// screenshare button and we hide ours.
...(canDelegateCapability() ? {} : { lotusFrameScreenshare: 'true' }),
// [lotus #3 / P5-15] Arm the fork's audio-inject handler so the in-call
// soundboard can publish clips into the call. Dormant until the host
// sends io.lotus.inject_audio (only on an explicit user click), so
@@ -311,10 +316,6 @@ export class CallEmbed {
const controlState = initialControlState ?? new CallControlState(true, false, true);
this.control = new CallControl(controlState, call, iframe);
this.initialState = controlState;
this.control.startObserving();
iframe.onload = () => {
this.control.startObserving();
};
// If the iframe document itself fails to load, fail fast.
iframe.onerror = () => {
this.settleLoad('iframe');
@@ -348,10 +349,6 @@ export class CallEmbed {
return this.room.roomId;
}
get document(): Document | undefined {
return getCallDocument(this.iframe);
}
public setTheme(theme: ElementCallThemeKind) {
this.themeKind = theme;
return this.call.transport
@@ -573,13 +570,12 @@ export class CallEmbed {
private onCallJoined(): void {
this.settleLoad();
this.control.startObserving();
// C-H1: EC fires JoinCall again on an EC reconnect (this action has no
// once-guard). forceState() would reset live mic/video/deafen back to the
// join-time snapshot, so only run it on the FIRST join. On a rejoin we just
// re-apply styles/observers (above) and re-push the sticky fork state
// (deafen/quality), leaving the user's live media state untouched.
// re-push the sticky fork state (deafen/quality), leaving the user's live
// media state untouched.
if (this.joined) {
this.control.resendForkState();
return;
+18
View File
@@ -73,3 +73,21 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
);
return useSyncExternalStore(subscribe, () => callEmbed?.getMicLevel() ?? 0);
};
/**
* [Gitea #43] True when EC's own screenshare button is shown inside the call
* frame (no Capability Delegation in this engine), so the host bar hides its.
*/
export const useFrameScreenshare = (control: CallControl | undefined): boolean => {
const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false);
useEffect(() => {
if (!control) {
setFrame(false);
return undefined;
}
const sync = () => setFrame(control.frameScreenshare);
sync();
return control.onFrameScreenshareChange(sync);
}, [control]);
return frame;
};
+6 -13
View File
@@ -118,17 +118,10 @@ export function getCallCapabilities(
}
/**
* 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".
* Capability Delegation (`postMessage(msg, { delegate })`) ships only in
* Chromium; other engines silently ignore the option, so the frame would get
* no activation. `navigator.userAgentData` is likewise Chromium-only, which
* makes it the practical feature test.
*/
export const getCallDocument = (iframe: HTMLIFrameElement): Document | undefined => {
try {
return iframe.contentDocument ?? iframe.contentWindow?.document ?? undefined;
} catch {
return undefined;
}
};
export const canDelegateCapability = (): boolean =>
typeof navigator !== 'undefined' && 'userAgentData' in navigator;