From 024edbf5468c711fd4dd9c4a759cfdfcac1c7be9 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 13:27:42 -0400 Subject: [PATCH] feat(call): screenshare over the widget API with Capability Delegation (#43) 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- package-lock.json | 8 ++-- package.json | 2 +- src/app/plugins/call/CallControl.ts | 62 ++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37e56b74d..4a406513c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 6c0ed7d8c..f0fc024d2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index f829fd8d4..7b266f93d 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -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, 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', {