feat(lotus): work when served from its own origin (cinny #43)
Two things tied EC to the host's origin: - Widget message check: matrix-widget-api's strictOriginCheck compares ev.origin with THIS frame's origin, so on call.chat.lotusguild.org every message from chat.lotusguild.org would be dropped and calls would not start. restrictToHost() instead requires ev.source === window.parent and ev.origin === parentUrl's origin. Same-origin deployments keep working (the host origin is our own origin there), and it is stricter than before: the sender must also be our parent window. - Soundboard: the host's blob: clip URL is origin-bound. io.lotus.inject_audio now accepts the clip's bytes (`audio`, ArrayBuffer, <= 8 MiB) and prefers them over `url`; hosts that only send `url` are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
43a5e93c37
commit
a93bd9d7d8
@@ -52,8 +52,10 @@ function acquireSharedAudio(): {
|
||||
* that was impossible against the prebuilt EC bundle (LiveKit's
|
||||
* LocalParticipant lived in EC's module scope).
|
||||
*
|
||||
* Action data: `{ url: string, volume?: number }`. `url` must be an https/blob
|
||||
* URL (the host resolves mxc → media URL).
|
||||
* Action data: `{ url?: string, audio?: ArrayBuffer, volume?: number }`.
|
||||
* `audio` is the clip's bytes (cinny #43: a host `blob:` URL can't be fetched
|
||||
* once EC is served from its own origin, so the host sends the bytes and they
|
||||
* win); otherwise `url` must be an https/blob URL (older hosts).
|
||||
*
|
||||
* No effect unless the host sends the action. Returns a teardown function that
|
||||
* also aborts any clip still playing.
|
||||
@@ -88,12 +90,12 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||
return;
|
||||
}
|
||||
const data = ev.detail.data as
|
||||
| { url?: unknown; volume?: unknown }
|
||||
| { url?: unknown; audio?: unknown; volume?: unknown }
|
||||
| undefined;
|
||||
const url = typeof data?.url === "string" ? safeMediaUrl(data.url) : null;
|
||||
if (!url) {
|
||||
const clip = parseInjectSource(data);
|
||||
if (!clip) {
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
logger.warn("[lotus] inject_audio: missing/invalid url");
|
||||
logger.warn("[lotus] inject_audio: missing/invalid url or audio");
|
||||
return;
|
||||
}
|
||||
const volume =
|
||||
@@ -115,7 +117,7 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||
}
|
||||
|
||||
w.api.transport.reply(ev.detail, {});
|
||||
void playInjectedClip(url, volume, rooms, activeClips).catch((e) =>
|
||||
void playInjectedClip(clip, volume, rooms, activeClips).catch((e) =>
|
||||
logger.warn("[lotus] inject_audio failed", e),
|
||||
);
|
||||
};
|
||||
@@ -144,6 +146,27 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/** Largest clip accepted as bytes (the host's soundboard clips are small). */
|
||||
export const MAX_INJECT_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The clip to play: the bytes the host sent (`audio`), or else a fetchable
|
||||
* `url`. Exported for tests.
|
||||
*/
|
||||
export function parseInjectSource(
|
||||
data: { url?: unknown; audio?: unknown } | undefined,
|
||||
): string | ArrayBuffer | null {
|
||||
const audio = data?.audio;
|
||||
if (
|
||||
audio instanceof ArrayBuffer &&
|
||||
audio.byteLength > 0 &&
|
||||
audio.byteLength <= MAX_INJECT_BYTES
|
||||
) {
|
||||
return audio;
|
||||
}
|
||||
return typeof data?.url === "string" ? safeMediaUrl(data.url) : null;
|
||||
}
|
||||
|
||||
/** Only allow fetchable media URLs; never same-origin credentialed GETs etc. */
|
||||
function safeMediaUrl(raw: string): string | null {
|
||||
try {
|
||||
@@ -155,7 +178,7 @@ function safeMediaUrl(raw: string): string | null {
|
||||
}
|
||||
|
||||
async function playInjectedClip(
|
||||
url: string,
|
||||
clip: string | ArrayBuffer,
|
||||
volume: number,
|
||||
rooms: LivekitRoom[],
|
||||
activeClips: Set<() => void>,
|
||||
@@ -188,25 +211,30 @@ async function playInjectedClip(
|
||||
};
|
||||
activeClips.add(placeholder);
|
||||
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(url, {
|
||||
credentials: "omit",
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
// Superseded by a newer clip mid-fetch — expected, not a failure.
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
if (typeof clip !== "string") {
|
||||
arrayBuffer = clip;
|
||||
} else {
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(clip, {
|
||||
credentials: "omit",
|
||||
mode: "cors",
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
// Superseded by a newer clip mid-fetch — expected, not a failure.
|
||||
if (aborted) return;
|
||||
throw e;
|
||||
}
|
||||
if (aborted) return;
|
||||
if (!resp.ok) {
|
||||
activeClips.delete(placeholder);
|
||||
throw new Error(`fetch ${clip} -> ${resp.status}`);
|
||||
}
|
||||
arrayBuffer = await resp.arrayBuffer();
|
||||
if (aborted) return;
|
||||
throw e;
|
||||
}
|
||||
if (aborted) return;
|
||||
if (!resp.ok) {
|
||||
activeClips.delete(placeholder);
|
||||
throw new Error(`fetch ${url} -> ${resp.status}`);
|
||||
}
|
||||
const arrayBuffer = await resp.arrayBuffer();
|
||||
if (aborted) return;
|
||||
|
||||
// [lotus] Reuse the shared module-level context/destination (#14) rather
|
||||
// than `new AudioContext()` per clip — see the declaration above.
|
||||
|
||||
Reference in New Issue
Block a user