diff --git a/src/lotus/lotusActions.test.ts b/src/lotus/lotusActions.test.ts new file mode 100644 index 00000000..787acce8 --- /dev/null +++ b/src/lotus/lotusActions.test.ts @@ -0,0 +1,47 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, test } from "vitest"; + +import { LOTUS_TO_WIDGET_ACTIONS, LotusWidgetActions } from "./lotusActions"; + +describe("LotusWidgetActions", () => { + test("every action value is namespaced under io.lotus.*", () => { + for (const value of Object.values(LotusWidgetActions)) { + expect(value).toMatch(/^io\.lotus\./); + } + }); + + test("LOTUS_TO_WIDGET_ACTIONS contains exactly the toWidget actions", () => { + // CallState is the only fromWidget action (host <- widget); everything + // else is toWidget (host -> widget) and must be allow-listed so + // `initializeWidget` accepts it. + const expectedToWidget = [ + LotusWidgetActions.FocusParticipant, + LotusWidgetActions.InjectAudio, + LotusWidgetActions.SetQuality, + LotusWidgetActions.Decorations, + LotusWidgetActions.SetDeafen, + ]; + + expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget)); + expect(LOTUS_TO_WIDGET_ACTIONS).toHaveLength(expectedToWidget.length); + }); + + test("LOTUS_TO_WIDGET_ACTIONS excludes the fromWidget actions", () => { + // CallState, RequestState and DenoiseState are all fromWidget (widget -> + // host) per their doc comments in lotusActions.ts, so none of them should + // ever be allow-listed as a toWidget action. + expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.CallState); + expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain( + LotusWidgetActions.RequestState, + ); + expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain( + LotusWidgetActions.DenoiseState, + ); + }); +}); diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index f11029a2..f2fdf7f2 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -31,6 +31,21 @@ export enum LotusWidgetActions { Decorations = "io.lotus.decorations", /** toWidget: deafen remote audio (and optionally mute screenshare audio). */ SetDeafen = "io.lotus.set_deafen", + /** + * fromWidget: sent on (re)registration of a lotus toWidget handler (e.g. + * decorations) so the host can re-push state that would otherwise only be + * sent on change (decorations roster, the current focus pin) after an + * EC-side reconnect/remount. cinny's `resendForkState()` should respond to + * this the same way it responds to a fresh join. + */ + RequestState = "io.lotus.request_state", + /** + * fromWidget: real state of the in-source denoise engine — + * `{ active: boolean, model: string, error?: string }` — sent once the + * processor attaches (or after the rnnoise fallback also fails), so the host + * toggle can reflect reality rather than the requested state. + */ + DenoiseState = "io.lotus.denoise_state", } /** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */ diff --git a/src/widget.ts b/src/widget.ts index 768de69a..f9ac07c1 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -115,14 +115,30 @@ export const initializeWidget = ( ElementWidgetActions.JoinCall, ElementWidgetActions.HangupCall, ElementWidgetActions.DeviceMute, - // [lotus] custom toWidget actions handled by the fork (focus, audio-inject) - ...LOTUS_TO_WIDGET_ACTIONS, ].forEach((action) => { api.on(`action:${action}`, (ev: CustomEvent) => { ev.preventDefault(); lazyActions.emit(action, ev); }); }); + // [lotus] custom toWidget actions handled by the fork (focus, + // audio-inject, decorations, ...). Unlike the upstream actions above + // (whose handlers are process-lifetime), lotus handlers are + // registered/torn down with their owning React effect, so a request can + // arrive while none is mounted. `LazyEventEmitter.emit` would otherwise + // backlog it forever (never replied to, replayed stale on the next + // registration — see LazyEventEmitter), and the host's + // `transport.send` would hang until its own timeout. Reply immediately + // with `{}` when there's no handler instead, so a torn-down lotus + // action behaves like a no-op rather than a silent stall. + LOTUS_TO_WIDGET_ACTIONS.forEach((action) => { + api.on(`action:${action}`, (ev: CustomEvent) => { + ev.preventDefault(); + if (!lazyActions.emit(action, ev)) { + api.transport.reply(ev.detail, {}); + } + }); + }); // Now, initialize the matryoshka MatrixClient (so named because it routes // all requests through the host client via the widget API)