feat(call): screenshare over the widget API with Capability Delegation (#43)
CI / Build & Quality Checks (push) Successful in 2m50s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 13s
CI / Playwright smoke (e2e) (push) Successful in 16m14s

The call bar's Start/Stop Screenshare no longer clicks EC's hidden button
on Chromium (incl. WebView2): it sends io.lotus.set_screenshare with
postMessage `{ delegate: "display-capture" }` inside the user's click, so
the frame can call getDisplayMedia on engines that require the click.
matrix-widget-api has no postMessage options, so its sendInternal is
swapped for that one synchronous send; delegation needs the frame's real
origin, not `*`.

Firefox, Safari and WebKitGTK (Linux desktop) have no delegation and
still click EC's button (needs same-origin, which is still on). Gated on
the fork reporting `screenshareAction` in controls_state.

Pins @lotusguild/element-call-embedded 0.25.0-lotus.17.

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 13:27:42 -04:00
co-authored by Claude Opus 5.5
parent ba5b1ffe7d
commit 024edbf546
3 changed files with 66 additions and 6 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.16",
"@lotusguild/element-call-embedded": "0.25.0-lotus.17",
"@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.16",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.16/element-call-embedded-0.25.0-lotus.16.tgz",
"integrity": "sha512-X/MzZ2Ip7IpZD3B+SkBoBzgMfURyZuYD6OaGL9r7sw2nl7Y/0JzgpT8RWV12lu4DlOfWbbeO+ka1l+pTYVewdQ==",
"version": "0.25.0-lotus.17",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.17/element-call-embedded-0.25.0-lotus.17.tgz",
"integrity": "sha512-dxzo2Nw1NEEOe+twQXITAZtb+X7fFJdtqnElsFOu0ArccTB1QLbibc3F0XFp+BTfT5PZIyNaIp7fDArVSnj6qg==",
"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.16",
"@lotusguild/element-call-embedded": "0.25.0-lotus.17",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+61 -1
View File
@@ -19,6 +19,16 @@ export type LotusQualityPayload = {
screenshareMaxFramerate?: number | 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;
@@ -336,11 +346,20 @@ export class CallControl extends EventEmitter implements CallControlState {
// send it and keep the DOM path below.
private forkControls = 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's `io.lotus.controls_state` report. */
public onControlsState(data: unknown) {
if (typeof data !== 'object' || data === null) return;
const { screensharing, layout } = data as { screensharing?: unknown; layout?: unknown };
const { screensharing, layout, screenshareAction } = data as {
screensharing?: unknown;
layout?: unknown;
screenshareAction?: unknown;
};
this.forkControls = true;
this.forkScreenshare = screenshareAction === true;
this.applyControls(
typeof screensharing === 'boolean' ? screensharing : this.screenshare,
layout === 'spotlight' || layout === 'grid' ? layout === 'spotlight' : this.spotlight,
@@ -453,10 +472,51 @@ export class CallControl extends EventEmitter implements CallControlState {
this.applyScreenshareAudioMuted();
}
/**
* 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.
*/
public toggleScreenshare() {
if (this.forkScreenshare && canDelegateCapability()) {
this.sendDelegated('io.lotus.set_screenshare', { on: !this.screenshare }, 'display-capture');
return;
}
this.screenshareButton?.click();
}
/**
* Send a widget action with `postMessage(…, { delegate })` so the frame
* receives the user's activation for `capability`. matrix-widget-api has no
* postMessage options, so swap its `sendInternal` for this one synchronous
* send (the request is posted before `send()` returns). Delegation refuses a
* `*` target origin, so the frame's real origin is used.
*/
private sendDelegated(action: string, data: Record<string, unknown>, capability: string): void {
const target = this.iframe.contentWindow;
if (!target) return;
const targetOrigin = new URL(this.iframe.src, window.location.href).origin;
const transport = this.call.transport as unknown as {
sendInternal: (message: unknown) => void;
};
const hadOwn = Object.prototype.hasOwnProperty.call(transport, 'sendInternal');
const original = transport.sendInternal;
transport.sendInternal = (message: unknown) =>
target.postMessage(message, {
targetOrigin,
delegate: capability,
} as WindowPostMessageOptions);
try {
this.call.transport.send(action, data).catch(() => undefined);
} finally {
if (hadOwn) transport.sendInternal = original;
else delete (transport as { sendInternal?: unknown }).sendInternal;
}
}
public toggleSpotlight() {
if (this.forkControls) {
this.sendForkAction('io.lotus.set_layout', {