Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 b929cbf74b chore(call): pin element-call-embedded 0.25.0-lotus.22 (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m14s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 16m39s
lotus.22 draws the "Share your screen?" prompt inside the call frame on
request and drops the corner button. Verified against the published
package: no-delegation (Firefox path) cross- and same-origin — bar button,
in-frame prompt, Cancel, Share → screenshare tracks, bar Stop; Chromium
unchanged; picture-in-picture prompt fits and Share works; room policy
hides the button and refuses the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 13:32:45 -04:00
Lotus CIandClaude Opus 5.5 8520de232d feat(call): screenshare stays on the call bar in Firefox/Safari via the in-frame prompt (#43)
CI / Build & Quality Checks (pull_request) Successful in 4m18s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m27s
With a fork that reports `screensharePrompt` (element-call lotus-screenshare-
prompt), the call bar and status bar keep their screenshare button on every
engine. Where the click can't be delegated, starting asks the fork to show
"Share your screen?" inside the call frame (io.lotus.prompt_screenshare)
instead of our own confirm; its Share click starts the share. Stopping works
from the bar as before (no click needed in the frame). Chromium is unchanged.

- useScreenshareMode: hidden (older fork's corner button) | prompt | direct.
- The room's call policy is also pushed to the fork in prompt mode, so the
  prompt never opens where sharing is forbidden.
- Picture-in-picture: the "Return to call" overlay covers the frame; while
  the fork reports the prompt open it lets clicks through to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 12:48:12 -04:00
jared 773e41311e Merge pull request #243: optional Element Call origin via config.json elementCallUrl (#43)
CI / Build & Quality Checks (push) Successful in 4m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 14m40s
2026-09-27 02:24:47 -04:00
Lotus CIandClaude Opus 5.5 da78bff316 chore(call): pin element-call-embedded 0.25.0-lotus.21 (#43)
CI / Build & Quality Checks (pull_request) Successful in 5m10s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 24s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m21s
lotus.21 checks widget messages against the host's origin (works same- and
cross-origin) and accepts soundboard clip bytes. Same-origin behaviour is
unchanged; verified against the published package: join, both screenshare
paths, PTT/deafen in the frame, layout/reactions/settings, speaking and mic
level, soundboard, avatars, muted-speech warning, per-person volume, and the
foreign-frame spoof stays blocked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-27 02:05:18 -04:00
Lotus CIandClaude Opus 5.5 4dcc5176e2 feat(call): optional Element Call origin via config.json elementCallUrl (#43)
CI / Build & Quality Checks (pull_request) Successful in 3m31s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m27s
Groundwork for serving the call page from its own origin
(call.chat.lotusguild.org). Inert until config.json sets `elementCallUrl`:
without it the bundled same-origin page is used exactly as today.

- callPageUrl: resolves `elementCallUrl` — absolute https only (http only on
  localhost for development); anything else, and the desktop app, fall back
  to the bundled page so a bad value can't break calls. Set once from the
  loaded client config.
- CallEmbed builds the widget URL from it; the widget origin (used by the
  message guard and Capability Delegation) follows automatically.
- Soundboard: a host blob: URL can't be fetched from another origin, so
  io.lotus.inject_audio now also carries the clip's bytes (`audio`). Forks
  that predate it ignore the field and use `url`, so this is safe on the
  released fork.

Needs element-call's lotus-call-origin branch (host-origin message check +
inject_audio bytes) released and pinned before `elementCallUrl` is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 23:24:24 -04:00
Lotus CIandClaude Opus 5.5 df395776b4 fix(security): widget API only accepts messages from the widget's own frame
CI / Build & Quality Checks (push) Successful in 5m0s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 13m13s
matrix-widget-api's host transport handled a message from ANY window on the
page as long as it carried the widget's id; its strictOriginCheck only
compares with the host's own origin and is off by default. The call's id is
the fixed 'call-embed', so any other frame (a room widget, a URL-preview
embed) could post fromWidget actions as the call. Reproduced locally: an
opaque-origin frame posting one io.lotus.hotkey keydown for the PTT key
turned a push-to-talk user's mic on ("● Live").

restrictWidgetMessages() swaps each ClientWidgetApi transport's listener
for one that requires ev.source === the widget iframe's window and
ev.origin === the widget's origin. Applied to the call and to room widgets
(so one widget can't impersonate another). Verified: the spoof no longer
opens the mic; PTT/deafen from inside the call, screenshare, speaking
indicator and room widgets (capability prompt, send, live events) unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 22:22:57 -04:00
16 changed files with 357 additions and 64 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.20",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.20",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.20/element-call-embedded-0.25.0-lotus.20.tgz",
"integrity": "sha512-USac8cunc+nlkyivxV0dXshzJoSWTyNHc6Aq/xIpphKbkON05NzhEWBX+qNwgwdyVWacdmYAEX97z6jHA5J+Kw==",
"version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.20",
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+5 -1
View File
@@ -38,7 +38,7 @@ import {
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -882,6 +882,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView;
const pipMode = callActive && !inCallRoom;
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
const { navigateRoom } = useRoomNavigate();
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
@@ -1363,6 +1364,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
zIndex: 1,
background: 'transparent',
cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
+11 -7
View File
@@ -7,7 +7,7 @@ import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useFrameScreenshare,
useScreenshareMode,
} from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
@@ -197,10 +197,16 @@ export function CallControl({
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame.
const frameScreenshare = useFrameScreenshare(callEmbed.control);
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare);
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame;
// where the click can't be delegated, starting asks for the in-frame prompt.
const screenshareMode = useScreenshareMode(callEmbed.control);
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
const [shareConfirm, setShareConfirm] = useState(false);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(),
@@ -255,9 +261,7 @@ export function CallControl({
{!compact && showScreenshare && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
onToggle={() => handleScreenshareToggle()}
disabled={!callJoined}
/>
)}
+11 -8
View File
@@ -33,7 +33,7 @@ import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useFrameScreenshare,
useScreenshareMode,
} from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -112,9 +112,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours
// is hidden (this engine can't start a share from the host); the
// screenshare-audio mute stays.
const frameScreenshare = useFrameScreenshare(callEmbed.control);
// is hidden (the screenshare-audio mute stays); where the click can't be
// delegated, starting asks for the fork's in-frame "Share your screen?".
const screenshareMode = useScreenshareMode(callEmbed.control);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
@@ -225,12 +230,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<>
{!frameScreenshare && (
{screenshareMode !== 'hidden' && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
onToggle={() => handleScreenshareToggle()}
/>
)}
{/* Mute-screenshare-audio sits directly next to the screenshare
+6 -1
View File
@@ -115,7 +115,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
try {
const url = await resolveClipObjectUrl(mx, flat.clip.url);
const vol = (flat.clip.volume / 100) * master;
const result = await callEmbed.control.injectAudio(url, vol);
// [Gitea #43] Send the bytes too: the call page may be on another
// origin, where this blob: URL can't be fetched.
const clipBytes = await fetch(url)
.then((r) => r.arrayBuffer())
.catch(() => undefined);
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
if (!result.played) {
// [EC#13] Refused fork-side (only reason today: local mic muted) —
// don't play it locally either, or the user would think it went out.
@@ -12,6 +12,7 @@ import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-a
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
type PendingAsk = {
@@ -96,6 +97,9 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
}),
);
const clientApi = new ClientWidgetApi(current, iframe, driver);
// Only messages from this widget's own frame and origin are handled, so a
// widget can't impersonate another one (or the call).
restrictWidgetMessages(clientApi, iframe, current.origin);
clientApi.setViewedRoomId(room.roomId);
// Live room events for widgets that were allowed to see them
+6
View File
@@ -19,6 +19,12 @@ export type ClientConfig = {
hashRouter?: HashRouterConfig;
gifApiKey?: string;
/**
* [Gitea #43] Absolute https URL of the Element Call page on its own origin.
* Unset: the bundled copy on this origin. Ignored in the desktop app.
*/
elementCallUrl?: string;
};
const ClientConfigContext = createContext<ClientConfig | null>(null);
+22 -17
View File
@@ -36,6 +36,7 @@ import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
import { zIndices } from '../styles/zIndex';
import { OIDC_CALLBACK_PATH } from './paths';
import { OidcCallback } from './auth/oidc/OidcCallback';
import { resolveCallPageUrl, setCallPageUrl } from '../plugins/call/callPageUrl';
// The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on
// Windows — see SystemEmojiFeature) must sit before the generic family, or the
@@ -220,23 +221,27 @@ function App() {
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
)}
>
{(clientConfig) => (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<AppearanceEffects />
<TauriEffects />
<DesktopChrome>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</DesktopChrome>
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ClientConfigProvider>
)}
{(clientConfig) => {
// [Gitea #43] Idempotent: where the call page is loaded from.
setCallPageUrl(resolveCallPageUrl(clientConfig.elementCallUrl, isTauri()));
return (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<AppearanceEffects />
<TauriEffects />
<DesktopChrome>
<RouterProvider router={createRouter(clientConfig, screenSize)} />
</DesktopChrome>
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
</JotaiProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</ClientConfigProvider>
);
}}
</ClientConfigLoader>
</FeatureCheck>
</ScreenSizeProvider>
+71 -14
View File
@@ -261,6 +261,15 @@ export class CallControl extends EventEmitter implements CallControlState {
private frameScreenshareListeners = new Set<() => void>();
// [Gitea #43] The fork draws the "Share your screen?" prompt inside the frame
// on request (io.lotus.prompt_screenshare), so where the click can't be
// delegated our call-bar button asks for that instead of starting the share.
private _screensharePrompt = false;
// [Gitea #43] The fork's in-frame prompt is showing: anything the host lays
// over the frame (the picture-in-picture overlay) must let clicks through.
private _screensharePromptOpen = false;
// [Gitea #43] Whether the room's call policy allows screensharing; the fork
// hides its in-frame button when it doesn't.
private frameScreenshareAllowed = true;
@@ -274,7 +283,32 @@ export class CallControl extends EventEmitter implements CallControlState {
return this._frameScreenshare;
}
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */
/**
* Starting a share must go through the fork's in-frame prompt: this engine
* can't delegate the click (Firefox, Safari, WebKitGTK) and the fork has
* the prompt. Stopping never needs it.
*/
public get screenshareNeedsPrompt(): boolean {
return this._screensharePrompt && !canDelegateCapability();
}
/** The fork's in-frame "Share your screen?" prompt is showing. */
public get screensharePromptOpen(): boolean {
return this._screensharePromptOpen;
}
/**
* Ask the fork to show "Share your screen?" inside the call frame; its Share
* button starts the share with the frame's own click.
*/
public promptScreenshare(): void {
this.sendForkAction('io.lotus.prompt_screenshare', {});
}
/**
* Subscribe to `frameScreenshare` / `screenshareNeedsPrompt` /
* `screensharePromptOpen` changes. Returns an unsubscribe.
*/
public onFrameScreenshareChange(cb: () => void): () => void {
this.frameScreenshareListeners.add(cb);
return () => {
@@ -289,7 +323,8 @@ export class CallControl extends EventEmitter implements CallControlState {
}
private sendFrameScreenshareAllowed(): void {
if (!this.joined || !this.forkReady || !this._frameScreenshare) return;
if (!this.joined || !this.forkReady) return;
if (!this._frameScreenshare && !this._screensharePrompt) return;
this.sendForkAction('io.lotus.set_frame_screenshare', {
visible: this.frameScreenshareAllowed,
});
@@ -324,17 +359,31 @@ 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, frameScreenshare } = data as {
screensharing?: unknown;
layout?: unknown;
frameScreenshare?: unknown;
};
const { screensharing, layout, frameScreenshare, screensharePrompt, screensharePromptOpen } =
data as {
screensharing?: unknown;
layout?: unknown;
frameScreenshare?: unknown;
screensharePrompt?: unknown;
screensharePromptOpen?: unknown;
};
const firstReport = !this.forkReady;
this.forkReady = true;
const frame = frameScreenshare === true;
if (frame !== this._frameScreenshare) {
const prompt = screensharePrompt === true;
const promptOpen = screensharePromptOpen === true;
if (promptOpen !== this._screensharePromptOpen) {
this._screensharePromptOpen = promptOpen;
if (frame === this._frameScreenshare && prompt === this._screensharePrompt) {
this.frameScreenshareListeners.forEach((l) => l());
}
}
if (frame !== this._frameScreenshare || prompt !== this._screensharePrompt) {
const promptTurnedOn = prompt && !this._screensharePrompt;
this._frameScreenshare = frame;
this._screensharePrompt = prompt;
this.frameScreenshareListeners.forEach((l) => l());
if (promptTurnedOn && !firstReport) this.sendFrameScreenshareAllowed();
}
if (firstReport) {
this.sendHotkeyCodes();
@@ -564,19 +613,27 @@ export class CallControl extends EventEmitter implements CallControlState {
* track (`io.lotus.inject_audio`) rather than splicing the mic. `url` must be
* an https/blob URL the widget can fetch WITHOUT credentials — the host
* resolves an mxc clip to a `blob:` object URL first (authenticated media
* can't be fetched cross-realm by the widget). `volume` is 0–1.
* can't be fetched cross-realm by the widget) — and `audio` the same clip's
* bytes, which the fork prefers. `volume` is 0–1.
*
* The local user does not hear their own published track, so callers should
* also play the clip locally for feedback.
*/
public injectAudio(url: string, volume = 1): Promise<{ played: boolean; reason?: string }> {
public injectAudio(
url: string,
volume = 1,
audio?: ArrayBuffer,
): Promise<{ played: boolean; reason?: string }> {
// [EC#13] The fork now refuses while the local mic is muted and replies
// { played:false, reason:"muted" }; older forks reply {} (treated as played).
// [Gitea #43] `audio` carries the clip's bytes: a `blob:` URL only works on
// this origin, so a call page on its own origin can't fetch it. Forks that
// predate `audio` ignore it and use `url`.
return this.call.transport
.send<{ url: string; volume: number }, { played?: boolean; reason?: string }>(
'io.lotus.inject_audio',
{ url, volume },
)
.send<
{ url: string; volume: number; audio?: ArrayBuffer },
{ played?: boolean; reason?: string }
>('io.lotus.inject_audio', audio ? { url, volume, audio } : { url, volume })
.then((r) => ({ played: r?.played !== false, reason: r?.reason }))
.catch(() => ({ played: true }));
}
+12 -4
View File
@@ -29,6 +29,8 @@ import { CallControl } from './CallControl';
import { CallControlState } from './CallControlState';
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
import { canDelegateCapability } from './utils';
import { getCallPageUrl } from './callPageUrl';
import { restrictWidgetMessages } from '../widgetTransport';
// 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
@@ -253,10 +255,14 @@ export class CallEmbed {
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
}
const widgetUrl = new URL(
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
window.location.origin,
);
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
const externalPage = getCallPageUrl();
const widgetUrl = externalPage
? new URL(externalPage)
: new URL(
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/element-call/index.html`,
window.location.origin,
);
widgetUrl.search = params.toString();
const options: IWidget = {
@@ -305,6 +311,8 @@ export class CallEmbed {
const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId);
const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver);
// Only messages from the call's own frame and origin reach the widget API.
restrictWidgetMessages(call, iframe, widget.origin);
this.mx = mx;
this.call = call;
+40
View File
@@ -0,0 +1,40 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { resolveCallPageUrl } from './callPageUrl';
const URL_OK = 'https://call.chat.example.org/public/element-call/index.html';
test('uses an absolute https URL on the web', () => {
assert.equal(resolveCallPageUrl(URL_OK, false), URL_OK);
});
test('strips a query or hash (the app adds its own parameters)', () => {
assert.equal(resolveCallPageUrl(`${URL_OK}?x=1#y`, false), URL_OK);
});
test('allows http only on localhost (development)', () => {
assert.equal(
resolveCallPageUrl('http://127.0.0.1:5174/public/element-call/index.html', false),
'http://127.0.0.1:5174/public/element-call/index.html',
);
assert.equal(resolveCallPageUrl('http://call.example.org/index.html', false), undefined);
});
test('desktop keeps the bundled page', () => {
assert.equal(resolveCallPageUrl(URL_OK, true), undefined);
});
test('anything else falls back to the bundled page', () => {
[
undefined,
null,
42,
'',
' ',
'not a url',
'/public/element-call/index.html',
'http://call.chat.example.org/index.html',
['javascript', 'alert(1)'].join(':'),
'data:text/html,x',
].forEach((v) => assert.equal(resolveCallPageUrl(v, false), undefined, String(v)));
});
+37
View File
@@ -0,0 +1,37 @@
/**
* [Gitea #43] Where the Element Call page is loaded from.
*
* By default it's the copy bundled with this app (same origin). With
* `elementCallUrl` in config.json (e.g.
* "https://call.chat.lotusguild.org/public/element-call/index.html") the web
* app loads it from that origin instead, so the call frame can no longer
* reach this origin's storage (login token, crypto store) or service worker.
*
* Web only: the desktop app keeps its bundled copy (its CSP doesn't allow
* another frame origin, and a network copy could drift from the bundle).
* Anything that isn't an absolute https URL (http only on localhost, for
* development) is ignored, so a bad value falls
* back to the bundled page instead of breaking calls.
*/
export const resolveCallPageUrl = (value: unknown, desktop: boolean): string | undefined => {
if (desktop || typeof value !== 'string' || value.trim() === '') return undefined;
try {
const url = new URL(value);
// http only for local development (localhost is a secure context).
const local = url.hostname === 'localhost' || url.hostname === '127.0.0.1';
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && local)) return undefined;
url.search = '';
url.hash = '';
return url.href;
} catch {
return undefined;
}
};
let callPageUrl: string | undefined;
export const setCallPageUrl = (url: string | undefined): void => {
callPageUrl = url;
};
export const getCallPageUrl = (): string | undefined => callPageUrl;
+34 -7
View File
@@ -75,19 +75,46 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
};
/**
* [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.
* [Gitea #43] How the call bar's screenshare button behaves:
* - `hidden`: EC's own button is inside the frame (fork lotus.20/21), so ours hides;
* - `prompt`: starting asks the fork for its in-frame "Share your screen?"
* (no Capability Delegation in this engine);
* - `direct`: our own confirm, then a (delegated) set_screenshare.
*/
export const useFrameScreenshare = (control: CallControl | undefined): boolean => {
const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false);
export type ScreenshareMode = 'hidden' | 'prompt' | 'direct';
const screenshareMode = (control: CallControl): ScreenshareMode => {
if (control.frameScreenshare) return 'hidden';
return control.screenshareNeedsPrompt ? 'prompt' : 'direct';
};
export const useScreenshareMode = (control: CallControl | undefined): ScreenshareMode => {
const [mode, setMode] = useState<ScreenshareMode>(() =>
control ? screenshareMode(control) : 'direct',
);
useEffect(() => {
if (!control) {
setFrame(false);
setMode('direct');
return undefined;
}
const sync = () => setFrame(control.frameScreenshare);
const sync = () => setMode(screenshareMode(control));
sync();
return control.onFrameScreenshareChange(sync);
}, [control]);
return frame;
return mode;
};
/** [Gitea #43] The fork's in-frame "Share your screen?" prompt is showing. */
export const useScreensharePromptOpen = (control: CallControl | undefined): boolean => {
const [open, setOpen] = useState(() => control?.screensharePromptOpen ?? false);
useEffect(() => {
if (!control) {
setOpen(false);
return undefined;
}
const sync = () => setOpen(control.screensharePromptOpen);
sync();
return control.onFrameScreenshareChange(sync);
}, [control]);
return open;
};
+46
View File
@@ -0,0 +1,46 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ClientWidgetApi } from 'matrix-widget-api';
import { isFromWidgetFrame, restrictWidgetMessages } from './widgetTransport';
const frame = {} as Window;
const other = {} as Window;
const ORIGIN = 'https://call.example.org';
test('isFromWidgetFrame: only the widget frame at its origin', () => {
assert.equal(isFromWidgetFrame({ source: frame, origin: ORIGIN }, frame, ORIGIN), true);
assert.equal(isFromWidgetFrame({ source: other, origin: ORIGIN }, frame, ORIGIN), false);
assert.equal(isFromWidgetFrame({ source: frame, origin: 'null' }, frame, ORIGIN), false);
assert.equal(
isFromWidgetFrame({ source: frame, origin: 'https://evil.example' }, frame, ORIGIN),
false,
);
assert.equal(isFromWidgetFrame({ source: null, origin: ORIGIN }, null, ORIGIN), false);
});
test('restrictWidgetMessages swaps the listener and drops foreign messages', () => {
const seen: unknown[] = [];
const original = (ev: MessageEvent) => {
seen.push(ev.data);
};
const transport = { handleMessage: original };
const api = { transport } as unknown as ClientWidgetApi;
const iframe = { contentWindow: frame } as HTMLIFrameElement;
const listeners = new Set<EventListener>([original as EventListener]);
const inbound = {
addEventListener: (_t: string, l: EventListener) => listeners.add(l),
removeEventListener: (_t: string, l: EventListener) => listeners.delete(l),
} as unknown as Window;
restrictWidgetMessages(api, iframe, ORIGIN, inbound);
assert.equal(listeners.has(original as EventListener), false);
assert.equal(listeners.size, 1);
assert.equal(transport.handleMessage === original, false);
const dispatch = (ev: Partial<MessageEvent>) =>
listeners.forEach((l) => l(ev as unknown as Event));
dispatch({ source: other, origin: ORIGIN, data: 'spoof' });
dispatch({ source: frame, origin: 'null', data: 'wrong-origin' });
dispatch({ source: frame, origin: ORIGIN, data: 'real' });
assert.deepEqual(seen, ['real']);
});
+47
View File
@@ -0,0 +1,47 @@
import { ClientWidgetApi } from 'matrix-widget-api';
type MessageLike = Pick<MessageEvent, 'source' | 'origin'>;
/**
* True when a message came from the widget's own frame, at the origin the
* widget was loaded from.
*/
export const isFromWidgetFrame = (
ev: MessageLike,
frameWindow: Window | null | undefined,
widgetOrigin: string,
): boolean => !!frameWindow && ev.source === frameWindow && ev.origin === widgetOrigin;
type InboundTransport = {
handleMessage: (ev: MessageEvent) => void;
};
/**
* matrix-widget-api's host transport accepts a message from ANY window on the
* page as long as it carries the widget's id (its `strictOriginCheck` only
* compares against the host's own origin, and is off by default). The call
* widget's id is fixed ('call-embed'), so any other frame (a room widget, a
* URL-preview embed) could post fromWidget actions as the call: e.g. a fake
* push-to-talk keydown turned the user's mic on.
*
* Swap the transport's listener for one that only lets through messages from
* this widget's iframe and origin. `stop()` removes `handleMessage`, which is
* the guarded one after this. Call right after `new ClientWidgetApi(...)`,
* which has already started the transport.
*/
export const restrictWidgetMessages = (
api: ClientWidgetApi,
iframe: HTMLIFrameElement,
widgetOrigin: string,
inbound: Pick<Window, 'addEventListener' | 'removeEventListener'> = window,
): void => {
const transport = api.transport as unknown as InboundTransport;
const original = transport.handleMessage;
const guarded = (ev: MessageEvent) => {
if (!isFromWidgetFrame(ev, iframe.contentWindow, widgetOrigin)) return;
original(ev);
};
inbound.removeEventListener('message', original as EventListener);
transport.handleMessage = guarded;
inbound.addEventListener('message', guarded as EventListener);
};