diff --git a/src/app/features/call/CallSoundboard.tsx b/src/app/features/call/CallSoundboard.tsx index 6c81141a1..beadcc115 100644 --- a/src/app/features/call/CallSoundboard.tsx +++ b/src/app/features/call/CallSoundboard.tsx @@ -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. diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 36c22d370..2681618fc 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -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(null); diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index 7e27a9798..fb8912052 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -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() { )} > - {(clientConfig) => ( - - - - - - - - - - - - - - - - )} + {(clientConfig) => { + // [Gitea #43] Idempotent: where the call page is loaded from. + setCallPageUrl(resolveCallPageUrl(clientConfig.elementCallUrl, isTauri())); + return ( + + + + + + + + + + + + + + + + ); + }} diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 82a93d561..ea886f032 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -564,19 +564,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 })); } diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 6ef04d341..039b024b1 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -29,6 +29,7 @@ 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 @@ -254,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 = { diff --git a/src/app/plugins/call/callPageUrl.test.ts b/src/app/plugins/call/callPageUrl.test.ts new file mode 100644 index 000000000..1d932d193 --- /dev/null +++ b/src/app/plugins/call/callPageUrl.test.ts @@ -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))); +}); diff --git a/src/app/plugins/call/callPageUrl.ts b/src/app/plugins/call/callPageUrl.ts new file mode 100644 index 000000000..aab13d478 --- /dev/null +++ b/src/app/plugins/call/callPageUrl.ts @@ -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;