diff --git a/knip.ts b/knip.ts index 6b378e29..7edfaf65 100644 --- a/knip.ts +++ b/knip.ts @@ -9,7 +9,7 @@ import { type KnipConfig } from "knip"; export default { vite: { - config: ["vite.config.ts", "vite-embedded.config.ts"], + config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"], }, entry: ["src/main.tsx", "i18next-parser.config.ts"], ignoreBinaries: [ diff --git a/package.json b/package.json index 3c0c5436..c67c2e4c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "build:embedded": "yarn build:full --config vite-embedded.config.js", "build:embedded:production": "yarn build:embedded", "build:embedded:development": "yarn build:embedded --mode development", + "build:sdk": "yarn build:full --config vite-sdk.config.js", + "build:sdk:development": "yarn build:sdk --mode development", "serve": "vite preview", "prettier:check": "prettier -c .", "prettier:format": "prettier -w .", @@ -111,6 +113,7 @@ "loglevel": "^1.9.1", "matrix-js-sdk": "matrix-org/matrix-js-sdk#2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3", "matrix-widget-api": "^1.14.0", + "node-stdlib-browser": "^1.3.1", "normalize.css": "^8.0.1", "observable-hooks": "^4.2.3", "pako": "^2.0.4", @@ -133,6 +136,7 @@ "vite": "^7.0.0", "vite-plugin-generate-file": "^0.3.0", "vite-plugin-html": "^3.2.2", + "vite-plugin-node-stdlib-browser": "^0.2.1", "vite-plugin-svgr": "^4.0.0", "vitest": "^3.0.0", "vitest-axe": "^1.0.0-pre.3" diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 00000000..03801b83 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,35 @@ +# SDK mode + +EC can be build in sdk mode. This will result in a compiled js file that can be imported in very simple webapps. + +It allows to use matrixRTC in combination with livekit without relying on element call. + +This is done by instantiating the call view model and exposing some useful behaviors (observables) and methods. + +This folder contains an example index.html file that showcases the sdk in use (hosted on localhost:8123 with a webserver ellowing cors (for example `npx serve -l 81234 --cors`)) as a godot engine HTML export template. + +## Widgets + +The sdk mode is particularly interesting to be used in widgets where you do not need to pay attention to matrix login/cs api ... +To create a widget see the example index.html file in this folder. And add it to EW via: +`/addwidget ` (see **url parameters** for more details on ``) + +### url parameters + +``` +widgetId = $matrix_widget_id +perParticipantE2EE = true +userId = $matrix_user_id +deviceId = $org.matrix.msc3819.matrix_device_id +baseUrl = $org.matrix.msc4039.matrix_base_url +``` + +`parentUrl = // will be inserted automatically` + +Full template use as ``: + +``` +http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id +``` + +the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`) diff --git a/sdk/helper.ts b/sdk/helper.ts new file mode 100644 index 00000000..a3d597be --- /dev/null +++ b/sdk/helper.ts @@ -0,0 +1,55 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/** + * This file contains helper functions and types for the MatrixRTC SDK. + */ + +import { logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { scan } from "rxjs"; + +import { widget as _widget } from "../src/widget"; +import { type LivekitRoomItem } from "../src/state/CallViewModel/CallViewModel"; + +export const logger = rootLogger.getChild("[MatrixRTCSdk]"); + +if (!_widget) throw Error("No widget. This webapp can only start as a widget"); +export const widget = _widget; + +export const tryMakeSticky = (): void => { + logger.info("try making sticky MatrixRTCSdk"); + void widget.api + .setAlwaysOnScreen(true) + .then(() => { + logger.info("sticky MatrixRTCSdk"); + }) + .catch((error) => { + logger.error("failed to make sticky MatrixRTCSdk", error); + }); +}; +export const TEXT_LK_TOPIC = "matrixRTC"; +/** + * simple helper operator to combine the last emitted and the current emitted value of a rxjs observable + * + * I think there should be a builtin for this but i did not find it... + */ +export const currentAndPrev = scan< + LivekitRoomItem[], + { + prev: LivekitRoomItem[]; + current: LivekitRoomItem[]; + } +>( + ({ current: lastCurrentVal }, items) => ({ + prev: lastCurrentVal, + current: items, + }), + { + prev: [], + current: [], + }, +); diff --git a/sdk/index.html b/sdk/index.html new file mode 100644 index 00000000..51110ebd --- /dev/null +++ b/sdk/index.html @@ -0,0 +1,87 @@ + + + + Godot MatrixRTC Widget + + + + + + + +
+
+ + +
+
+
+ + diff --git a/sdk/main.ts b/sdk/main.ts new file mode 100644 index 00000000..376674a4 --- /dev/null +++ b/sdk/main.ts @@ -0,0 +1,308 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +/** + * This file is the entrypoint for the sdk build of element call: `yarn build:sdk` + * use in widgets. + * It exposes the `createMatrixRTCSdk` which creates the `MatrixRTCSdk` interface (see below) that + * can be used to join a rtc session and exchange realtime data. + * It takes care of all the tricky bits: + * - sending delayed events + * - finding the right sfu + * - handling the media stream + * - sending join/leave state or sticky events + * - setting up encryption and scharing keys + */ + +import { + combineLatest, + map, + type Observable, + of, + shareReplay, + Subject, + switchMap, + tap, +} from "rxjs"; +import { + type CallMembership, + MatrixRTCSession, + MatrixRTCSessionEvent, +} from "matrix-js-sdk/lib/matrixrtc"; +import { + type Room as LivekitRoom, + type TextStreamReader, + type LocalParticipant, + type RemoteParticipant, +} from "livekit-client"; + +// TODO how can this get fixed? to just be part of `livekit-client` +// Can this be done in the tsconfig.json +import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types"; +import { type Behavior, constant } from "../src/state/Behavior"; +import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel"; +import { ObservableScope } from "../src/state/ObservableScope"; +import { getUrlParams } from "../src/UrlParams"; +import { MuteStates } from "../src/state/MuteStates"; +import { MediaDevices } from "../src/state/MediaDevices"; +import { E2eeType } from "../src/e2ee/e2eeType"; +import { + currentAndPrev, + logger, + TEXT_LK_TOPIC, + tryMakeSticky, + widget, +} from "./helper"; +import { ElementWidgetActions } from "../src/widget"; +import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection"; + +interface MatrixRTCSdk { + /** + * observe connected$ to track the state. + * @returns + */ + join: () => void; + /** @throws on leave errors */ + leave: () => void; + data$: Observable<{ sender: string; data: string }>; + /** + * flattened list of members + */ + members$: Behavior< + { + connection: Connection | null; + membership: CallMembership; + participant: LocalParticipant | RemoteParticipant | null; + }[] + >; + /** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */ + connected$: Behavior; + sendData?: (data: unknown) => Promise; +} + +export async function createMatrixRTCSdk( + application: string = "m.call", + id: string = "", +): Promise { + logger.info("Hello"); + const client = await widget.client; + logger.info("client created"); + const scope = new ObservableScope(); + const { roomId } = getUrlParams(); + if (roomId === null) throw Error("could not get roomId from url params"); + + const room = client.getRoom(roomId); + if (room === null) throw Error("could not get room from client"); + + const mediaDevices = new MediaDevices(scope); + const muteStates = new MuteStates(scope, mediaDevices, constant(true)); + const slot = { application, id }; + const rtcSession = new MatrixRTCSession( + client, + room, + MatrixRTCSession.sessionMembershipsForSlot(room, slot), + slot, + ); + const callViewModel = createCallViewModel$( + scope, + rtcSession, + room, + mediaDevices, + muteStates, + { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } }, + of({}), + of({}), + constant({ supported: false, processor: undefined }), + ); + logger.info("CallViewModelCreated"); + // create data listener + const data$ = new Subject<{ sender: string; data: string }>(); + + const lkTextStreamHandlerFunction = async ( + reader: TextStreamReader, + participantInfo: { identity: string }, + livekitRoom: LivekitRoom, + ): Promise => { + const info = reader.info; + logger.info( + `Received text stream from ${participantInfo.identity}\n` + + ` Topic: ${info.topic}\n` + + ` Timestamp: ${info.timestamp}\n` + + ` ID: ${info.id}\n` + + ` Size: ${info.size}`, // Optional, only available if the stream was sent with `sendText` + ); + + const participants = callViewModel.livekitRoomItems$.value.find( + (i) => i.livekitRoom === livekitRoom, + )?.participants; + if (participants && participants.includes(participantInfo.identity)) { + const text = await reader.readAll(); + logger.info(`Received text: ${text}`); + data$.next({ sender: participantInfo.identity, data: text }); + } else { + logger.warn( + "Received text from unknown participant", + participantInfo.identity, + ); + } + }; + + const livekitRoomItemsSub = callViewModel.livekitRoomItems$ + .pipe( + tap((beforecurrentAndPrev) => { + logger.info( + `LiveKit room items updated: ${beforecurrentAndPrev.length}`, + beforecurrentAndPrev, + ); + }), + currentAndPrev, + tap((aftercurrentAndPrev) => { + logger.info( + `LiveKit room items updated: ${aftercurrentAndPrev.current.length}, ${aftercurrentAndPrev.prev.length}`, + aftercurrentAndPrev, + ); + }), + ) + .subscribe({ + next: ({ prev, current }) => { + const prevRooms = prev.map((i) => i.livekitRoom); + const currentRooms = current.map((i) => i.livekitRoom); + const addedRooms = currentRooms.filter((r) => !prevRooms.includes(r)); + const removedRooms = prevRooms.filter((r) => !currentRooms.includes(r)); + addedRooms.forEach((r) => { + logger.info(`Registering text stream handler for room `); + r.registerTextStreamHandler( + TEXT_LK_TOPIC, + (reader, participantInfo) => + void lkTextStreamHandlerFunction(reader, participantInfo, r), + ); + }); + removedRooms.forEach((r) => { + logger.info(`Unregistering text stream handler for room `); + r.unregisterTextStreamHandler(TEXT_LK_TOPIC); + }); + }, + complete: () => { + logger.info("Livekit room items subscription completed"); + for (const item of callViewModel.livekitRoomItems$.value) { + logger.info("unregistering room item from room", item.url); + item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC); + } + }, + }); + + // create sendData function + const sendFn: Behavior<(data: string) => Promise> = + scope.behavior( + callViewModel.localMatrixLivekitMember$.pipe( + switchMap((m) => { + if (!m) + return of((data: string): never => { + throw Error("local membership not yet ready."); + }); + return m.participant.value$.pipe( + map((p) => { + if (p === null) { + return (data: string): never => { + throw Error("local participant not yet ready to send data."); + }; + } else { + return async (data: string): Promise => + p.sendText(data, { topic: TEXT_LK_TOPIC }); + } + }), + ); + }), + ), + ); + + const sendData = async (data: unknown): Promise => { + const dataString = JSON.stringify(data); + logger.info("try sending: ", dataString); + try { + await Promise.resolve(); + const info = await sendFn.value(dataString); + logger.info(`Sent text with stream ID: ${info.id}`); + } catch (e) { + logger.error("failed sending: ", dataString, e); + } + }; + + // after hangup gets called + const leaveSubs = callViewModel.leave$.subscribe(() => { + const scheduleWidgetCloseOnLeave = async (): Promise => { + const leaveResolver = Promise.withResolvers(); + logger.info("waiting for RTC leave"); + rtcSession.on(MatrixRTCSessionEvent.JoinStateChanged, (isJoined) => { + logger.info("received RTC join update: ", isJoined); + if (!isJoined) leaveResolver.resolve(); + }); + await leaveResolver.promise; + logger.info("send Unstick"); + await widget.api + .setAlwaysOnScreen(false) + .catch((e) => + logger.error( + "Failed to set call widget `alwaysOnScreen` to false", + e, + ), + ); + logger.info("send Close"); + await widget.api.transport + .send(ElementWidgetActions.Close, {}) + .catch((e) => logger.error("Failed to send close action", e)); + }; + + // schedule close first and then leave (scope.end) + void scheduleWidgetCloseOnLeave(); + + // actual hangup (ending scope will send the leave event.. its kinda odd. since you might end up closing the widget too fast) + scope.end(); + }); + + logger.info("createMatrixRTCSdk done"); + + return { + join: (): void => { + // first lets try making the widget sticky + tryMakeSticky(); + callViewModel.join(); + }, + leave: (): void => { + callViewModel.hangup(); + leaveSubs.unsubscribe(); + livekitRoomItemsSub.unsubscribe(); + }, + data$, + connected$: callViewModel.connected$, + members$: scope.behavior( + callViewModel.matrixLivekitMembers$.pipe( + switchMap((members) => { + const listOfMemberObservables = members.map((member) => + combineLatest([ + member.connection$, + member.membership$, + member.participant.value$, + ]).pipe( + map(([connection, membership, participant]) => ({ + connection, + membership, + participant, + })), + // using shareReplay instead of a Behavior here because the behavior would need + // a tricky scope.end() setup. + shareReplay({ bufferSize: 1, refCount: true }), + ), + ); + return combineLatest(listOfMemberObservables); + }), + ), + [], + ), + sendData, + }; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 41582039..07adacbc 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -260,7 +260,7 @@ export const InCallView: FC = ({ () => void toggleRaisedHand(), ); - const audioParticipants = useBehavior(vm.audioParticipants$); + const audioParticipants = useBehavior(vm.livekitRoomItems$); const participantCount = useBehavior(vm.participantCount$); const reconnecting = useBehavior(vm.reconnecting$); const windowMode = useBehavior(vm.windowMode$); diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index b5e990b7..5324c65d 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -80,7 +80,7 @@ import { } from "../../reactions"; import { shallowEquals } from "../../utils/array"; import { type MediaDevices } from "../MediaDevices"; -import { type Behavior } from "../Behavior"; +import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; @@ -117,6 +117,7 @@ import { createMatrixLivekitMembers$, type TaggedParticipant, type LocalMatrixLivekitMember, + type RemoteMatrixLivekitMember, } from "./remoteMembers/MatrixLivekitMembers.ts"; import { type AutoLeaveReason, @@ -156,7 +157,7 @@ export interface CallViewModelOptions { /** Optional behavior overriding the computed window size, mainly for testing purposes. */ windowSize$?: Behavior<{ width: number; height: number }>; /** The version & compatibility mode of MatrixRTC that we should use. */ - matrixRTCMode$: Behavior; + matrixRTCMode$?: Behavior; } // Do not play any sounds if the participant count has exceeded this @@ -182,7 +183,7 @@ interface LayoutScanState { } type MediaItem = UserMedia | ScreenShare; -type AudioLivekitItem = { +export type LivekitRoomItem = { livekitRoom: LivekitRoom; participants: string[]; url: string; @@ -205,8 +206,11 @@ export interface CallViewModel { callPickupState$: Behavior< "unknown" | "ringing" | "timeout" | "decline" | "success" | null >; + /** Observable that emits when the user should leave the call (hangup pressed, widget action, error). + * THIS DOES NOT LEAVE THE CALL YET. The only way to leave the call (send the hangup event) is by ending the scope. + */ leave$: Observable<"user" | AutoLeaveReason>; - /** Call to initiate hangup. Use in conbination with connectino state track the async hangup process. */ + /** Call to initiate hangup. Use in conbination with reconnectino state track the async hangup process. */ hangup: () => void; // joining @@ -258,7 +262,11 @@ export interface CallViewModel { */ participantCount$: Behavior; /** Participants sorted by livekit room so they can be used in the audio rendering */ - audioParticipants$: Behavior; + livekitRoomItems$: Behavior; + userMedia$: Behavior; + /** use the layout instead, this is just for the sdk export. */ + matrixLivekitMembers$: Behavior; + localMatrixLivekitMember$: Behavior; /** List of participants raising their hand */ handsRaised$: Behavior>; /** List of reactions. Keys are: membership.membershipId (currently predefined as: `${membershipEvent.userId}:${membershipEvent.deviceId}`)*/ @@ -341,17 +349,15 @@ export interface CallViewModel { switch: () => void; } | null>; - // connection state /** - * Whether various media/event sources should pretend to be disconnected from - * all network input, even if their connection still technically works. + * Whether the app is currently reconnecting to the LiveKit server and/or setting the matrix rtc room state. */ - // We do this when the app is in the 'reconnecting' state, because it might be - // that the LiveKit connection is still functional while the homeserver is - // down, for example, and we want to avoid making people worry that the app is - // in a split-brained state. - // DISCUSSION own membership manager ALSO this probably can be simplifis reconnecting$: Behavior; + + /** + * Shortcut for not requireing to parse and combine connectionState.matrix and connectionState.livekit + */ + connected$: Behavior; } /** @@ -381,6 +387,8 @@ export function createCallViewModel$( options.encryptionSystem, matrixRTCSession, ); + const matrixRTCMode$ = + options.matrixRTCMode$ ?? constant(MatrixRTCMode.Legacy); // Each hbar seperates a block of input variables required for the CallViewModel to function. // The outputs of this block is written under the hbar. @@ -413,7 +421,7 @@ export function createCallViewModel$( client, roomId: matrixRoom.roomId, useOldestMember$: scope.behavior( - options.matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)), + matrixRTCMode$.pipe(map((v) => v === MatrixRTCMode.Legacy)), ), }); @@ -454,7 +462,7 @@ export function createCallViewModel$( }, ), ), - logger: logger, + logger, }); const matrixLivekitMembers$ = createMatrixLivekitMembers$({ @@ -465,7 +473,7 @@ export function createCallViewModel$( }); const connectOptions$ = scope.behavior( - options.matrixRTCMode$.pipe( + matrixRTCMode$.pipe( map((mode) => ({ encryptMedia: livekitKeyProvider !== undefined, // TODO. This might need to get called again on each change of matrixRTCMode... @@ -497,7 +505,7 @@ export function createCallViewModel$( muteStates, trackProcessorState$, logger.getChild( - "[Publisher" + connection.transport.livekit_service_url + "]", + "[Publisher " + connection.transport.livekit_service_url + "]", ), ); }, @@ -596,8 +604,11 @@ export function createCallViewModel$( ), ); - const audioParticipants$ = scope.behavior( + const livekitRoomItems$ = scope.behavior( matrixLivekitMembers$.pipe( + tap((val) => { + logger.debug("matrixLivekitMembers$ updated", val.value); + }), switchMap((membersWithEpoch) => { const members = membersWithEpoch.value; const a$ = combineLatest( @@ -622,7 +633,7 @@ export function createCallViewModel$( return a$; }), map((members) => - members.reduce((acc, curr) => { + members.reduce((acc, curr) => { if (!curr) return acc; const existing = acc.find((item) => item.url === curr.url); @@ -1492,10 +1503,7 @@ export function createCallViewModel$( ), null, ), - participantCount$: participantCount$, - audioParticipants$: audioParticipants$, - handsRaised$: handsRaised$, reactions$: reactions$, joinSoundEffect$: joinSoundEffect$, @@ -1514,6 +1522,16 @@ export function createCallViewModel$( spotlight$: spotlight$, pip$: pip$, layout$: layout$, + userMedia$, + localMatrixLivekitMember$, + matrixLivekitMembers$: scope.behavior( + matrixLivekitMembers$.pipe( + map((members) => members.value), + tap((v) => { + logger.debug("matrixLivekitMembers$ updated (exported)", v); + }), + ), + ), tileStoreGeneration$: tileStoreGeneration$, showSpotlightIndicators$: showSpotlightIndicators$, showSpeakingIndicators$: showSpeakingIndicators$, @@ -1522,6 +1540,8 @@ export function createCallViewModel$( earpieceMode$: earpieceMode$, audioOutputSwitcher$: audioOutputSwitcher$, reconnecting$: localMembership.reconnecting$, + livekitRoomItems$, + connected$: localMembership.connected$, }; } diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 6a9f196e..0d77611b 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -254,10 +254,12 @@ describe("LocalMembership", () => { const connectionTransportAConnecting = { ...connectionTransportAConnected, state$: constant(ConnectionState.LivekitConnecting), + livekitRoom: mockLivekitRoom({}), } as unknown as Connection; const connectionTransportBConnected = { state$: constant(ConnectionState.LivekitConnected), transport: bTransport, + livekitRoom: mockLivekitRoom({}), } as unknown as Connection; it("recreates publisher if new connection is used and ENDS always unpublish and end tracks", async () => { @@ -266,13 +268,17 @@ describe("LocalMembership", () => { const localTransport$ = new BehaviorSubject(aTransport); const publishers: Publisher[] = []; - + let seed = 0; defaultCreateLocalMemberValues.createPublisherFactory.mockImplementation( () => { + const a = seed; + seed += 1; + logger.info(`creating [${a}]`); const p = { - stopPublishing: vi.fn(), + stopPublishing: vi.fn().mockImplementation(() => { + logger.info(`stopPublishing [${a}]`); + }), stopTracks: vi.fn(), - publishing$: constant(false), }; publishers.push(p as unknown as Publisher); return p; @@ -310,7 +316,7 @@ describe("LocalMembership", () => { await flushPromises(); // stop all tracks after ending scopes expect(publishers[1].stopPublishing).toHaveBeenCalled(); - expect(publishers[1].stopTracks).toHaveBeenCalled(); + // expect(publishers[1].stopTracks).toHaveBeenCalled(); defaultCreateLocalMemberValues.createPublisherFactory.mockReset(); }); @@ -358,15 +364,17 @@ describe("LocalMembership", () => { }); await flushPromises(); expect(publisherFactory).toHaveBeenCalledOnce(); - expect(localMembership.tracks$.value.length).toBe(0); + // expect(localMembership.tracks$.value.length).toBe(0); + expect(publishers[0].createAndSetupTracks).not.toHaveBeenCalled(); localMembership.startTracks(); await flushPromises(); - expect(localMembership.tracks$.value.length).toBe(2); + expect(publishers[0].createAndSetupTracks).toHaveBeenCalled(); + // expect(localMembership.tracks$.value.length).toBe(2); scope.end(); await flushPromises(); // stop all tracks after ending scopes expect(publishers[0].stopPublishing).toHaveBeenCalled(); - expect(publishers[0].stopTracks).toHaveBeenCalled(); + // expect(publishers[0].stopTracks).toHaveBeenCalled(); publisherFactory.mockClear(); }); // TODO add an integration test combining publisher and localMembership @@ -464,20 +472,20 @@ describe("LocalMembership", () => { }); expect(publisherFactory).toHaveBeenCalledOnce(); - expect(localMembership.tracks$.value.length).toBe(0); + // expect(localMembership.tracks$.value.length).toBe(0); // ------- localMembership.startTracks(); // ------- await flushPromises(); - expect(localMembership.localMemberState$.value).toStrictEqual({ - matrix: RTCMemberStatus.Connected, - media: { - tracks: TrackState.Creating, - connection: ConnectionState.LivekitConnected, - }, - }); + // expect(localMembership.localMemberState$.value).toStrictEqual({ + // matrix: RTCMemberStatus.Connected, + // media: { + // tracks: TrackState.Creating, + // connection: ConnectionState.LivekitConnected, + // }, + // }); createTrackResolver.resolve(); await flushPromises(); expect( @@ -492,7 +500,7 @@ describe("LocalMembership", () => { expect( // eslint-disable-next-line @typescript-eslint/no-explicit-any (localMembership.localMemberState$.value as any).media, - ).toStrictEqual(PublishState.Starting); + ).toStrictEqual(PublishState.Publishing); publishResolver.resolve(); await flushPromises(); @@ -513,7 +521,7 @@ describe("LocalMembership", () => { ).toStrictEqual(PublishState.Publishing); // stop all tracks after ending scopes expect(publishers[0].stopPublishing).toHaveBeenCalled(); - expect(publishers[0].stopTracks).toHaveBeenCalled(); + // expect(publishers[0].stopTracks).toHaveBeenCalled(); }); // TODO add tests for matrix local matrix participation. }); diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 40fb62d6..6d28bc56 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -6,11 +6,12 @@ Please see LICENSE in the repository root for full details. */ import { - type LocalTrack, type Participant, ParticipantEvent, type LocalParticipant, type ScreenShareCaptureOptions, + RoomEvent, + MediaDeviceFailure, } from "livekit-client"; import { observeParticipantEvents } from "@livekit/components-core"; import { @@ -24,6 +25,7 @@ import { combineLatest, distinctUntilChanged, from, + fromEvent, map, type Observable, of, @@ -35,7 +37,7 @@ import { import { type Logger } from "matrix-js-sdk/lib/logger"; import { deepCompare } from "matrix-js-sdk/lib/utils"; -import { constant, type Behavior } from "../../Behavior.ts"; +import { type Behavior } from "../../Behavior.ts"; import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts"; import { type ObservableScope } from "../../ObservableScope.ts"; import { type Publisher } from "./Publisher.ts"; @@ -66,17 +68,23 @@ export enum TransportState { export enum PublishState { WaitingForUser = "publish_waiting_for_user", - /** Implies lk connection is connected */ - Starting = "publish_start_publishing", + // XXX: This state is removed for now since we do not have full control over + // track publication anymore with the publisher abstraction, might come back in the future? + // /** Implies lk connection is connected */ + // Starting = "publish_start_publishing", /** Implies lk connection is connected */ Publishing = "publish_publishing", } +// TODO not sure how to map that correctly with the +// new publisher that does not manage tracks itself anymore export enum TrackState { /** The track is waiting for user input to create tracks (waiting to call `startTracks()`) */ WaitingForUser = "tracks_waiting_for_user", - /** Implies lk connection is connected */ - Creating = "tracks_creating", + // XXX: This state is removed for now since we do not have full control over + // track creation anymore with the publisher abstraction, might come back in the future? + // /** Implies lk connection is connected */ + // Creating = "tracks_creating", /** Implies lk connection is connected */ Ready = "tracks_ready", } @@ -150,9 +158,10 @@ export const createLocalMembership$ = ({ matrixRTCSession, }: Props): { /** - * This starts audio and video tracks. They will be reused when calling `requestPublish`. + * This request to start audio and video tracks. + * Can be called early to pre-emptively get media permissions and start devices. */ - startTracks: () => Behavior; + startTracks: () => void; /** * This sets a inner state (shouldPublish) to true and instructs the js-sdk and livekit to keep the user * connected to matrix and livekit. @@ -165,17 +174,21 @@ export const createLocalMembership$ = ({ * Callback to toggle screen sharing. If null, screen sharing is not possible. */ toggleScreenSharing: (() => void) | null; - tracks$: Behavior; + // tracks$: Behavior; participant$: Behavior; connection$: Behavior; - /** Shorthand for homeserverConnected.rtcSession === Status.Reconnecting - * Direct translation to the js-sdk membership manager connection `Status`. + /** + * Tracks the homserver and livekit connected state and based on that computes reconnecting. */ reconnecting$: Behavior; /** Shorthand for homeserverConnected.rtcSession === Status.Disconnected * Direct translation to the js-sdk membership manager connection `Status`. */ disconnected$: Behavior; + /** + * Fully connected + */ + connected$: Behavior; } => { const logger = parentLogger.getChild("[LocalMembership]"); logger.debug(`Creating local membership..`); @@ -221,6 +234,32 @@ export const createLocalMembership$ = ({ ), ); + // Tracks error that happen when creating the local tracks. + const mediaErrors$ = localConnection$.pipe( + switchMap((connection) => { + if (!connection) { + return of(null); + } else { + return fromEvent( + connection.livekitRoom, + RoomEvent.MediaDevicesError, + (error: Error) => { + return MediaDeviceFailure.getFailure(error) ?? null; + }, + ); + } + }), + ); + + mediaErrors$.pipe(scope.bind()).subscribe((error) => { + if (error) { + logger.error(`Failed to create local tracks:`, error); + setMatrixError( + // TODO is it fatal? Do we need to create a new Specialized Error? + new UnknownCallError(new Error(`Media device error: ${error}`)), + ); + } + }); // MATRIX RELATED // This should be used in a combineLatest with publisher$ to connect. @@ -235,19 +274,10 @@ export const createLocalMembership$ = ({ * The publisher is stored in here an abstracts creating and publishing tracks. */ const publisher$ = new BehaviorSubject(null); - /** - * Extract the tracks from the published. Also reacts to changing publishers. - */ - const tracks$ = scope.behavior( - publisher$.pipe(switchMap((p) => (p?.tracks$ ? p.tracks$ : constant([])))), - ); - const publishing$ = scope.behavior( - publisher$.pipe(switchMap((p) => p?.publishing$ ?? constant(false))), - ); - const startTracks = (): Behavior => { + const startTracks = (): void => { trackStartRequested.resolve(); - return tracks$; + // This used to return the tracks, but now they are only accessible via the publisher. }; const requestJoinAndPublish = (): void => { @@ -273,7 +303,7 @@ export const createLocalMembership$ = ({ // Clean-up callback return Promise.resolve(async (): Promise => { await publisher.stopPublishing(); - publisher.stopTracks(); + await publisher.stopTracks(); }); } }); @@ -282,13 +312,16 @@ export const createLocalMembership$ = ({ // `tracks$` will update once they are ready. scope.reconcile( scope.behavior( - combineLatest([publisher$, tracks$, from(trackStartRequested.promise)]), + combineLatest([ + publisher$ /*, tracks$*/, + from(trackStartRequested.promise), + ]), null, ), async (valueIfReady) => { if (!valueIfReady) return; - const [publisher, tracks] = valueIfReady; - if (publisher && tracks.length === 0) { + const [publisher] = valueIfReady; + if (publisher) { await publisher.createAndSetupTracks().catch((e) => logger.error(e)); } }, @@ -296,12 +329,11 @@ export const createLocalMembership$ = ({ // Based on `connectRequested$` we start publishing tracks. (once they are there!) scope.reconcile( - scope.behavior( - combineLatest([publisher$, tracks$, joinAndPublishRequested$]), - ), - async ([publisher, tracks, shouldJoinAndPublish]) => { - if (shouldJoinAndPublish === publisher?.publishing$.value) return; - if (tracks.length !== 0 && shouldJoinAndPublish) { + scope.behavior(combineLatest([publisher$, joinAndPublishRequested$])), + async ([publisher, shouldJoinAndPublish]) => { + // Get the current publishing state to avoid redundant calls. + const isPublishing = publisher?.shouldPublish === true; + if (shouldJoinAndPublish && !isPublishing) { try { await publisher?.startPublishing(); } catch (error) { @@ -309,7 +341,7 @@ export const createLocalMembership$ = ({ error instanceof Error ? error.message : String(error); setPublishError(new FailToStartLivekitConnection(message)); } - } else if (tracks.length !== 0 && !shouldJoinAndPublish) { + } else if (isPublishing) { try { await publisher?.stopPublishing(); } catch (error) { @@ -351,8 +383,6 @@ export const createLocalMembership$ = ({ combineLatest([ localConnectionState$, localTransport$, - tracks$, - publishing$, joinAndPublishRequested$, from(trackStartRequested.promise).pipe( map(() => true), @@ -363,16 +393,13 @@ export const createLocalMembership$ = ({ ([ localConnectionState, localTransport, - tracks, - publishing, shouldPublish, shouldStartTracks, ]) => { if (!localTransport) return null; - const hasTracks = tracks.length > 0; - let trackState: TrackState = TrackState.WaitingForUser; - if (hasTracks && shouldStartTracks) trackState = TrackState.Ready; - if (!hasTracks && shouldStartTracks) trackState = TrackState.Creating; + const trackState: TrackState = shouldStartTracks + ? TrackState.Ready + : TrackState.WaitingForUser; if ( localConnectionState !== ConnectionState.LivekitConnected || @@ -383,7 +410,7 @@ export const createLocalMembership$ = ({ tracks: trackState, }; if (!shouldPublish) return PublishState.WaitingForUser; - if (!publishing) return PublishState.Starting; + // if (!publishing) return PublishState.Starting; return PublishState.Publishing; }, ), @@ -613,9 +640,9 @@ export const createLocalMembership$ = ({ requestJoinAndPublish, requestDisconnect, localMemberState$, - tracks$, participant$, reconnecting$, + connected$: matrixAndLivekitConnected$, disconnected$: scope.behavior( homeserverConnected.rtsSession$.pipe( map((state) => state === RTCSessionStatus.Disconnected), diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 40763a99..38a80bed 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -5,59 +5,320 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ +import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest"; import { - afterEach, - beforeEach, - describe, - expect, - it, - type Mock, - vi, -} from "vitest"; -import { ConnectionState as LivekitConenctionState } from "livekit-client"; -import { type BehaviorSubject } from "rxjs"; + ConnectionState as LivekitConnectionState, + LocalParticipant, + type LocalTrack, + type LocalTrackPublication, + ParticipantEvent, + Track, +} from "livekit-client"; +import { BehaviorSubject } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; import { ObservableScope } from "../../ObservableScope"; import { constant } from "../../Behavior"; import { + flushPromises, mockLivekitRoom, - mockLocalParticipant, mockMediaDevices, } from "../../../utils/test"; import { Publisher } from "./Publisher"; import { type Connection } from "../remoteMembers/Connection"; import { type MuteStates } from "../../MuteStates"; -describe("Publisher", () => { - let scope: ObservableScope; - let connection: Connection; - let muteStates: MuteStates; - beforeEach(() => { - muteStates = { - audio: { - enabled$: constant(false), - unsetHandler: vi.fn(), - setHandler: vi.fn(), - }, - video: { - enabled$: constant(false), - unsetHandler: vi.fn(), - setHandler: vi.fn(), - }, - } as unknown as MuteStates; - scope = new ObservableScope(); - connection = { - state$: constant(LivekitConenctionState.Connected), - livekitRoom: mockLivekitRoom({ - localParticipant: mockLocalParticipant({}), - }), - } as unknown as Connection; +let scope: ObservableScope; + +beforeEach(() => { + scope = new ObservableScope(); +}); + +afterEach(() => scope.end()); + +function createMockLocalTrack(source: Track.Source): LocalTrack { + const track = { + source, + isMuted: false, + isUpstreamPaused: false, + } as Partial as LocalTrack; + + vi.mocked(track).mute = vi.fn().mockImplementation(() => { + track.isMuted = true; + }); + vi.mocked(track).unmute = vi.fn().mockImplementation(() => { + track.isMuted = false; + }); + vi.mocked(track).pauseUpstream = vi.fn().mockImplementation(() => { + // @ts-expect-error - for that test we want to set isUpstreamPaused directly + track.isUpstreamPaused = true; + }); + vi.mocked(track).resumeUpstream = vi.fn().mockImplementation(() => { + // @ts-expect-error - for that test we want to set isUpstreamPaused directly + track.isUpstreamPaused = false; }); - afterEach(() => scope.end()); + return track; +} - it("throws if livekit room could not publish", async () => { +function createMockMuteState(enabled$: BehaviorSubject): { + enabled$: BehaviorSubject; + setHandler: (h: (enabled: boolean) => void) => void; + unsetHandler: () => void; +} { + let currentHandler = (enabled: boolean): void => {}; + + const ms = { + enabled$, + setHandler: vi.fn().mockImplementation((h: (enabled: boolean) => void) => { + currentHandler = h; + }), + unsetHandler: vi.fn().mockImplementation(() => { + currentHandler = (enabled: boolean): void => {}; + }), + }; + // forward enabled$ emissions to the current handler + enabled$.subscribe((enabled) => { + logger.info(`MockMuteState: enabled changed to ${enabled}`); + currentHandler(enabled); + }); + + return ms; +} + +let connection: Connection; +let muteStates: MuteStates; +let localParticipant: LocalParticipant; +let audioEnabled$: BehaviorSubject; +let videoEnabled$: BehaviorSubject; +let trackPublications: LocalTrackPublication[]; +// use it to control when track creation resolves, default to resolved +let createTrackLock: Promise; + +beforeEach(() => { + trackPublications = []; + audioEnabled$ = new BehaviorSubject(false); + videoEnabled$ = new BehaviorSubject(false); + createTrackLock = Promise.resolve(); + + muteStates = { + audio: createMockMuteState(audioEnabled$), + video: createMockMuteState(videoEnabled$), + } as unknown as MuteStates; + + const mockSendDataPacket = vi.fn(); + const mockEngine = { + client: { + sendUpdateLocalMetadata: vi.fn(), + }, + on: vi.fn().mockReturnThis(), + sendDataPacket: mockSendDataPacket, + }; + + localParticipant = new LocalParticipant( + "local-sid", + "local-identity", + // @ts-expect-error - for that test we want a real LocalParticipant to have the pending publications logic + mockEngine, + { + adaptiveStream: true, + dynacase: false, + audioCaptureDefaults: {}, + videoCaptureDefaults: {}, + stopLocalTrackOnUnpublish: true, + reconnectPolicy: "always", + disconnectOnPageLeave: true, + }, + new Map(), + {}, + ); + + vi.mocked(localParticipant).createTracks = vi + .fn() + .mockImplementation(async (opts) => { + const tracks: LocalTrack[] = []; + if (opts.audio) { + tracks.push(createMockLocalTrack(Track.Source.Microphone)); + } + if (opts.video) { + tracks.push(createMockLocalTrack(Track.Source.Camera)); + } + await createTrackLock; + return tracks; + }); + + vi.mocked(localParticipant).publishTrack = vi + .fn() + .mockImplementation(async (track: LocalTrack) => { + const pub = { + track, + source: track.source, + mute: track.mute, + unmute: track.unmute, + } as Partial as LocalTrackPublication; + trackPublications.push(pub); + localParticipant.emit(ParticipantEvent.LocalTrackPublished, pub); + return Promise.resolve(pub); + }); + + vi.mocked(localParticipant).getTrackPublication = vi + .fn() + .mockImplementation((source: Track.Source) => { + return trackPublications.find((pub) => pub.track?.source === source); + }); + + connection = { + state$: constant({ + state: "ConnectedToLkRoom", + livekitConnectionState$: constant(LivekitConnectionState.Connected), + }), + livekitRoom: mockLivekitRoom({ + localParticipant: localParticipant, + }), + } as unknown as Connection; +}); + +describe("Publisher", () => { + let publisher: Publisher; + + beforeEach(() => { + publisher = new Publisher( + scope, + connection, + mockMediaDevices({}), + muteStates, + constant({ supported: false, processor: undefined }), + logger, + ); + }); + + afterEach(() => {}); + + it("Should not create tracks if started muted to avoid unneeded permission requests", async () => { + const createTracksSpy = vi.spyOn( + connection.livekitRoom.localParticipant, + "createTracks", + ); + + audioEnabled$.next(false); + videoEnabled$.next(false); + await publisher.createAndSetupTracks(); + + expect(createTracksSpy).not.toHaveBeenCalled(); + }); + + it("Should minimize permission request by querying create at once", async () => { + const enableCameraAndMicrophoneSpy = vi.spyOn( + localParticipant, + "enableCameraAndMicrophone", + ); + const createTracksSpy = vi.spyOn(localParticipant, "createTracks"); + + audioEnabled$.next(true); + videoEnabled$.next(true); + await publisher.createAndSetupTracks(); + await flushPromises(); + + expect(enableCameraAndMicrophoneSpy).toHaveBeenCalled(); + + // It should create both at once + expect(createTracksSpy).toHaveBeenCalledWith({ + audio: true, + video: true, + }); + }); + + it("Ensure no data is streamed until publish has been called", async () => { + audioEnabled$.next(true); + await publisher.createAndSetupTracks(); + + // The track should be created and paused + expect(localParticipant.createTracks).toHaveBeenCalledWith({ + audio: true, + video: undefined, + }); + await flushPromises(); + expect(localParticipant.publishTrack).toHaveBeenCalled(); + + await flushPromises(); + const track = localParticipant.getTrackPublication( + Track.Source.Microphone, + )?.track; + expect(track).toBeDefined(); + expect(track!.pauseUpstream).toHaveBeenCalled(); + expect(track!.isUpstreamPaused).toBe(true); + }); + + it("Ensure resume upstream when published is called", async () => { + videoEnabled$.next(true); + await publisher.createAndSetupTracks(); + // await flushPromises(); + await publisher.startPublishing(); + + const track = localParticipant.getTrackPublication( + Track.Source.Camera, + )?.track; + expect(track).toBeDefined(); + // expect(track.pauseUpstream).toHaveBeenCalled(); + expect(track!.isUpstreamPaused).toBe(false); + }); + + describe("Mute states", () => { + let publisher: Publisher; + beforeEach(() => { + publisher = new Publisher( + scope, + connection, + mockMediaDevices({}), + muteStates, + constant({ supported: false, processor: undefined }), + logger, + ); + }); + + test.each([ + { mutes: { audioEnabled: true, videoEnabled: false } }, + { mutes: { audioEnabled: true, videoEnabled: false } }, + ])("only create the tracks that are unmuted $mutes", async ({ mutes }) => { + // Ensure all muted + audioEnabled$.next(mutes.audioEnabled); + videoEnabled$.next(mutes.videoEnabled); + + vi.mocked(connection.livekitRoom.localParticipant).createTracks = vi + .fn() + .mockResolvedValue([]); + + await publisher.createAndSetupTracks(); + + expect( + connection.livekitRoom.localParticipant.createTracks, + ).toHaveBeenCalledOnce(); + + expect( + connection.livekitRoom.localParticipant.createTracks, + ).toHaveBeenCalledWith({ + audio: mutes.audioEnabled ? true : undefined, + video: mutes.videoEnabled ? true : undefined, + }); + }); + }); + + it("does mute unmute audio", async () => {}); +}); + +describe("Bug fix", () => { + // There is a race condition when creating and publishing tracks while the mute state changes. + // This race condition could cause tracks to be published even though they are muted at the + // beginning of a call coming from lobby. + // This is caused by our stack using manually the low level API to create and publish tracks, + // but also using the higher level setMicrophoneEnabled and setCameraEnabled functions that also create + // and publish tracks, and managing pending publications. + // Race is as follow, on creation of the Publisher we create the tracks then publish them. + // If in the middle of that process the mute state changes: + // - the `setMicrophoneEnabled` will be no-op because it is not aware of our created track and can't see any pending publication + // - If start publication is requested it will publish the track even though there was a mute request. + it("wrongly publish tracks while muted", async () => { + // setLogLevel(`debug`); const publisher = new Publisher( scope, connection, @@ -66,56 +327,34 @@ describe("Publisher", () => { constant({ supported: false, processor: undefined }), logger, ); + audioEnabled$.next(true); - // should do nothing if no tracks have been created yet. - await publisher.startPublishing(); - expect( - connection.livekitRoom.localParticipant.publishTrack, - ).not.toHaveBeenCalled(); + const resolvers = Promise.withResolvers(); + createTrackLock = resolvers.promise; - await expect(publisher.createAndSetupTracks()).rejects.toThrow( - Error("audio and video is false"), - ); + // Initially the audio is unmuted, so creating tracks should publish the audio track + const createTracks = publisher.createAndSetupTracks(); + void publisher.startPublishing(); + void createTracks.then(() => { + void publisher.startPublishing(); + }); + // now mute the audio before allowing track creation to complete + audioEnabled$.next(false); + resolvers.resolve(undefined); + await createTracks; - (muteStates.audio.enabled$ as BehaviorSubject).next(true); + await flushPromises(); - ( - connection.livekitRoom.localParticipant.createTracks as Mock - ).mockResolvedValue([{}, {}]); + const track = localParticipant.getTrackPublication( + Track.Source.Microphone, + )?.track; + expect(track).toBeDefined(); - await expect(publisher.createAndSetupTracks()).resolves.not.toThrow(); - expect( - connection.livekitRoom.localParticipant.createTracks, - ).toHaveBeenCalledOnce(); - - // failiour due to localParticipant.publishTrack - ( - connection.livekitRoom.localParticipant.publishTrack as Mock - ).mockRejectedValue(Error("testError")); - - await expect(publisher.startPublishing()).rejects.toThrow( - new Error("testError"), - ); - - // does not try other conenction after the first one failed - expect( - connection.livekitRoom.localParticipant.publishTrack, - ).toHaveBeenCalledTimes(1); - - // does not try other conenction after the first one failed - expect( - connection.livekitRoom.localParticipant.publishTrack, - ).toHaveBeenCalledTimes(1); - - // success case - ( - connection.livekitRoom.localParticipant.publishTrack as Mock - ).mockResolvedValue({}); - - await expect(publisher.startPublishing()).resolves.not.toThrow(); - - expect( - connection.livekitRoom.localParticipant.publishTrack, - ).toHaveBeenCalledTimes(3); + try { + expect(localParticipant.publishTrack).not.toHaveBeenCalled(); + } catch { + expect(track!.mute).toHaveBeenCalled(); + expect(track!.isMuted).toBe(true); + } }); }); diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 21c5d801..3cb3bd04 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -6,15 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { + ConnectionState as LivekitConnectionState, + type LocalTrackPublication, LocalVideoTrack, + ParticipantEvent, type Room as LivekitRoom, Track, - type LocalTrack, - type LocalTrackPublication, - ConnectionState as LivekitConnectionState, } from "livekit-client"; import { - BehaviorSubject, map, NEVER, type Observable, @@ -41,14 +40,21 @@ import { type ObservableScope } from "../../ObservableScope.ts"; * The Publisher is also responsible for creating the media tracks. */ export class Publisher { + /** + * By default, livekit will start publishing tracks as soon as they are created. + * In the matrix RTC world, we want to control when tracks are published based + * on whether the user is part of the RTC session or not. + */ + public shouldPublish = false; + /** * Creates a new Publisher. * @param scope - The observable scope to use for managing the publisher. * @param connection - The connection to use for publishing. * @param devices - The media devices to use for audio and video input. * @param muteStates - The mute states for audio and video. - * @param e2eeLivekitOptions - The E2EE options to use for the LiveKit room. Use to share the same key provider across connections!. * @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur). + * @param logger - The logger to use for logging :D. */ public constructor( private scope: ObservableScope, @@ -58,7 +64,6 @@ export class Publisher { trackerProcessorState$: Behavior, private logger: Logger, ) { - this.logger.info("Create LiveKit room"); const { controlledAudioDevices } = getUrlParams(); const room = connection.livekitRoom; @@ -76,41 +81,63 @@ export class Publisher { this.scope.onEnd(() => { this.logger.info("Scope ended -> stop publishing all tracks"); void this.stopPublishing(); + muteStates.audio.unsetHandler(); + muteStates.video.unsetHandler(); }); - // TODO move mute state handling here using reconcile (instead of inside the mute state class) - // this.scope.reconcile( - // this.scope.behavior( - // combineLatest([this.muteStates.video.enabled$, this.tracks$]), - // ), - // async ([videoEnabled, tracks]) => { - // const track = tracks.find((t) => t.kind == Track.Kind.Video); - // if (!track) return; - - // if (videoEnabled) { - // await track.unmute(); - // } else { - // await track.mute(); - // } - // }, - // ); + this.connection.livekitRoom.localParticipant.on( + ParticipantEvent.LocalTrackPublished, + this.onLocalTrackPublished.bind(this), + ); } - private _tracks$ = new BehaviorSubject[]>([]); - public tracks$ = this._tracks$ as Behavior[]>; - + // LiveKit will publish the tracks as soon as they are created + // but we want to control when tracks are published. + // We cannot just mute the tracks, even if this will effectively stop the publishing, + // it would also prevent the user from seeing their own video/audio preview. + // So for that we use pauseUpStream(): Stops sending media to the server by replacing + // the sender track with null, but keeps the local MediaStreamTrack active. + // The user can still see/hear themselves locally, but remote participants see nothing. + private onLocalTrackPublished( + localTrackPublication: LocalTrackPublication, + ): void { + this.logger.info("Local track published", localTrackPublication); + const lkRoom = this.connection.livekitRoom; + if (!this.shouldPublish) { + this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => { + this.logger.error(`Failed to pause upstreams`, e); + }); + } + // also check the mute state and apply it + if (localTrackPublication.source === Track.Source.Microphone) { + const enabled = this.muteStates.audio.enabled$.value; + lkRoom.localParticipant.setMicrophoneEnabled(enabled).catch((e) => { + this.logger.error( + `Failed to enable microphone track, enabled:${enabled}`, + e, + ); + }); + } else if (localTrackPublication.source === Track.Source.Camera) { + const enabled = this.muteStates.video.enabled$.value; + lkRoom.localParticipant.setCameraEnabled(enabled).catch((e) => { + this.logger.error( + `Failed to enable camera track, enabled:${enabled}`, + e, + ); + }); + } + } /** - * Start the connection to LiveKit and publish local tracks. + * Create and setup local audio and video tracks based on the current mute states. + * It creates the tracks only if audio and/or video is enabled, to avoid unnecessary + * permission prompts. * - * This will: - * wait for the connection to be ready. - // * 1. Request an OpenId token `request_token` (allows matrix users to verify their identity with a third-party service.) - // * 2. Use this token to request the SFU config to the MatrixRtc authentication service. - // * 3. Connect to the configured LiveKit room. - // * 4. Create local audio and video tracks based on the current mute states and publish them to the room. + * It also observes mute state changes to update LiveKit microphone/camera states accordingly. + * If a track is not created initially because disabled, it will be created when unmuting. + * + * This call is not blocking anymore, instead callers can listen to the + * `RoomEvent.MediaDevicesError` event in the LiveKit room to be notified of any errors. * - * @throws {InsufficientCapacityError} if the LiveKit server indicates that it has insufficient capacity to accept the connection. - * @throws {SFURoomCreationRestrictedError} if the LiveKit server indicates that the room does not exist and cannot be created. */ public async createAndSetupTracks(): Promise { this.logger.debug("createAndSetupTracks called"); @@ -118,119 +145,121 @@ export class Publisher { // Observe mute state changes and update LiveKit microphone/camera states accordingly this.observeMuteStates(this.scope); - // TODO-MULTI-SFU: Prepublish a microphone track + // Check if audio and/or video is enabled. We only create tracks if enabled, + // because it could prompt for permission, and we don't want to do that unnecessarily. const audio = this.muteStates.audio.enabled$.value; const video = this.muteStates.video.enabled$.value; - // createTracks throws if called with audio=false and video=false - if (audio || video) { - // TODO this can still throw errors? It will also prompt for permissions if not already granted - return lkRoom.localParticipant - .createTracks({ - audio, - video, - }) - .then((tracks) => { - this.logger.info( - "created track", - tracks.map((t) => t.kind + ", " + t.id), - ); - this._tracks$.next(tracks); - }) - .catch((error) => { - this.logger.error("Failed to create tracks", error); - }); + + // We don't await the creation, because livekit could block until the tracks + // are fully published, and not only that they are created. + // We don't have control on that, localParticipant creates and publishes the tracks + // asap. + // We are using the `ParticipantEvent.LocalTrackPublished` to be notified + // when tracks are actually published, and at that point + // we can pause upstream if needed (depending on if startPublishing has been called). + if (audio && video) { + // Enable both at once in order to have a single permission prompt! + void lkRoom.localParticipant.enableCameraAndMicrophone(); + } else if (audio) { + void lkRoom.localParticipant.setMicrophoneEnabled(true); + } else if (video) { + void lkRoom.localParticipant.setCameraEnabled(true); + } + + return Promise.resolve(); + } + + private async pauseUpstreams( + lkRoom: LivekitRoom, + sources: Track.Source[], + ): Promise { + for (const source of sources) { + const track = lkRoom.localParticipant.getTrackPublication(source)?.track; + if (track) { + await track.pauseUpstream(); + } else { + this.logger.warn( + `No track found for source ${source} to pause upstream`, + ); + } + } + } + + private async resumeUpstreams( + lkRoom: LivekitRoom, + sources: Track.Source[], + ): Promise { + for (const source of sources) { + const track = lkRoom.localParticipant.getTrackPublication(source)?.track; + if (track) { + await track.resumeUpstream(); + } else { + this.logger.warn( + `No track found for source ${source} to resume upstream`, + ); + } } - throw Error("audio and video is false"); } - private _publishing$ = new BehaviorSubject(false); - public publishing$ = this.scope.behavior(this._publishing$); /** + * + * Request to publish local tracks to the LiveKit room. + * This will wait for the connection to be ready before publishing. + * Livekit also have some local retry logic for publishing tracks. + * Can be called multiple times, localparticipant manages the state of published tracks (or pending publications). * * @returns - * @throws ElementCallError */ - public async startPublishing(): Promise { + public async startPublishing(): Promise { + if (this.shouldPublish) { + this.logger.debug(`Already publishing, ignoring startPublishing call`); + return; + } + this.shouldPublish = true; this.logger.debug("startPublishing called"); + const lkRoom = this.connection.livekitRoom; - // we do not need to do this since lk will wait in `localParticipant.publishTrack` - // const { promise, resolve, reject } = Promise.withResolvers(); - // const sub = this.connection.state$.subscribe((state) => { - // if (state instanceof Error) { - // const error = - // state instanceof ElementCallError - // ? state - // : new FailToStartLivekitConnection(state.message); - // reject(error); - // } else if (state === ConnectionState.LivekitConnected) { - // resolve(); - // } else { - // this.logger.info("waiting for connection: ", state); - // } - // }); - // try { - // await promise; - // } catch (e) { - // throw e; - // } finally { - // sub.unsubscribe(); - // } - - for (const track of this.tracks$.value) { - this.logger.info("publish ", this.tracks$.value.length, "tracks"); - // TODO: handle errors? Needs the signaling connection to be up, but it has some retries internally - // with a timeout. - await lkRoom.localParticipant.publishTrack(track).catch((error) => { - this.logger.error("Failed to publish track", error); - // throw new FailToStartLivekitConnection( - // error instanceof Error ? error.message : error, - // ); - throw error; - }); - this.logger.info("published track ", track.kind, track.id); - - // TODO: check if the connection is still active? and break the loop if not? + // Resume upstream for both audio and video tracks + // We need to call it explicitly because call setTrackEnabled does not always + // resume upstream. It will only if you switch the track from disabled to enabled, + // but if the track is already enabled but upstream is paused, it won't resume it. + // TODO what about screen share? + try { + await this.resumeUpstreams(lkRoom, [ + Track.Source.Microphone, + Track.Source.Camera, + ]); + } catch (e) { + this.logger.error(`Failed to resume upstreams`, e); } - this._publishing$.next(true); - return this.tracks$.value; } public async stopPublishing(): Promise { this.logger.debug("stopPublishing called"); - // TODO-MULTI-SFU: Move these calls back to ObservableScope.onEnd once scope - // actually has the right lifetime - this.muteStates.audio.unsetHandler(); - this.muteStates.video.unsetHandler(); - - const localParticipant = this.connection.livekitRoom.localParticipant; - const tracks: LocalTrack[] = []; - const addToTracksIfDefined = (p: LocalTrackPublication): void => { - if (p.track !== undefined) tracks.push(p.track); - }; - localParticipant.trackPublications.forEach(addToTracksIfDefined); - this.logger.debug( - "list of tracks to unpublish:", - tracks.map((t) => t.kind + ", " + t.id), - "start unpublishing now", - ); - await localParticipant.unpublishTracks(tracks).catch((error) => { - this.logger.error("Failed to unpublish tracks", error); - throw error; - }); - this.logger.debug( - "unpublished tracks", - tracks.map((t) => t.kind + ", " + t.id), - ); - this._publishing$.next(false); + this.shouldPublish = false; + // Pause upstream will stop sending media to the server, while keeping + // the local MediaStreamTrack active, so the user can still see themselves. + await this.pauseUpstreams(this.connection.livekitRoom, [ + Track.Source.Microphone, + Track.Source.Camera, + Track.Source.ScreenShare, + ]); } - /** - * Stops all tracks that are currently running - */ - public stopTracks(): void { - this.tracks$.value.forEach((t) => t.stop()); - this._tracks$.next([]); + public async stopTracks(): Promise { + const lkRoom = this.connection.livekitRoom; + for (const source of [ + Track.Source.Microphone, + Track.Source.Camera, + Track.Source.ScreenShare, + ]) { + const localPub = lkRoom.localParticipant.getTrackPublication(source); + if (localPub?.track) { + // stops and unpublishes the track + await lkRoom.localParticipant.unpublishTrack(localPub!.track, true); + } + } } /// Private methods @@ -332,17 +361,31 @@ export class Publisher { */ private observeMuteStates(scope: ObservableScope): void { const lkRoom = this.connection.livekitRoom; - this.muteStates.audio.setHandler(async (desired) => { + this.muteStates.audio.setHandler(async (enable) => { try { - await lkRoom.localParticipant.setMicrophoneEnabled(desired); + this.logger.debug( + `handler: Setting LiveKit microphone enabled: ${enable}`, + ); + await lkRoom.localParticipant.setMicrophoneEnabled(enable); + // Unmute will restart the track if it was paused upstream, + // but until explicitly requested, we want to keep it paused. + if (!this.shouldPublish && enable) { + await this.pauseUpstreams(lkRoom, [Track.Source.Microphone]); + } } catch (e) { this.logger.error("Failed to update LiveKit audio input mute state", e); } return lkRoom.localParticipant.isMicrophoneEnabled; }); - this.muteStates.video.setHandler(async (desired) => { + this.muteStates.video.setHandler(async (enable) => { try { - await lkRoom.localParticipant.setCameraEnabled(desired); + this.logger.debug(`handler: Setting LiveKit camera enabled: ${enable}`); + await lkRoom.localParticipant.setCameraEnabled(enable); + // Unmute will restart the track if it was paused upstream, + // but until explicitly requested, we want to keep it paused. + if (!this.shouldPublish && enable) { + await this.pauseUpstreams(lkRoom, [Track.Source.Camera]); + } } catch (e) { this.logger.error("Failed to update LiveKit video input mute state", e); } diff --git a/src/state/CallViewModel/remoteMembers/Connection.test.ts b/src/state/CallViewModel/remoteMembers/Connection.test.ts index de9a929b..c1e24eb4 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.test.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.test.ts @@ -393,7 +393,7 @@ describe("remote participants", () => { // livekitRoom and the rtc membership in order to publish the members that are publishing // on this connection. - const participants: RemoteParticipant[] = [ + let participants: RemoteParticipant[] = [ mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }), mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }), mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }), @@ -415,7 +415,22 @@ describe("remote participants", () => { fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p), ); - // All remote participants should be present + // At this point there should be ~~no~~ publishers + // We do have publisher now, since we do not filter for publishers anymore (to also have participants with only data tracks) + // The filtering we do is just based on the matrixRTC member events. + expect(observedParticipants.pop()!.length).toEqual(4); + + participants = [ + mockRemoteParticipant({ identity: "@alice:example.org:DEV000" }), + mockRemoteParticipant({ identity: "@bob:example.org:DEV111" }), + mockRemoteParticipant({ identity: "@carol:example.org:DEV222" }), + mockRemoteParticipant({ identity: "@dan:example.org:DEV333" }), + ]; + participants.forEach((p) => + fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, p), + ); + + // At this point there should be no publishers expect(observedParticipants.pop()!.length).toEqual(4); }); diff --git a/src/state/CallViewModel/remoteMembers/Connection.ts b/src/state/CallViewModel/remoteMembers/Connection.ts index 05d0ec9e..cf92e2a6 100644 --- a/src/state/CallViewModel/remoteMembers/Connection.ts +++ b/src/state/CallViewModel/remoteMembers/Connection.ts @@ -223,7 +223,7 @@ export class Connection { public constructor(opts: ConnectionOpts, logger: Logger) { this.logger = logger.getChild("[Connection]"); this.logger.info( - `[Connection] Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`, + `Creating new connection to ${opts.transport.livekit_service_url} ${opts.transport.livekit_alias}`, ); const { transport, client, scope } = opts; diff --git a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts index 7c3a9eab..48e5b8d8 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionFactory.ts @@ -14,7 +14,8 @@ import { type BaseE2EEManager, } from "livekit-client"; import { type Logger } from "matrix-js-sdk/lib/logger"; -import E2EEWorker from "livekit-client/e2ee-worker?worker"; +// imported as inline to support worker when loaded from a cdn (cross domain) +import E2EEWorker from "livekit-client/e2ee-worker?worker&inline"; import { type ObservableScope } from "../../ObservableScope.ts"; import { Connection } from "./Connection.ts"; diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts index 70bfb4de..280d8ff7 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.test.ts @@ -285,47 +285,47 @@ describe("connectionManagerData$ stream", () => { a: expect.toSatisfy((e) => { const data: ConnectionManagerData = e.value; expect(data.getConnections().length).toBe(2); - expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(0); - expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(0); + expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(0); + expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0); return true; }), b: expect.toSatisfy((e) => { const data: ConnectionManagerData = e.value; expect(data.getConnections().length).toBe(2); - expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(1); - expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(0); - expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe( - "user1A", - ); + expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1); + expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(0); + expect( + data.getParticipantsForTransport(TRANSPORT_1)[0].identity, + ).toBe("user1A"); return true; }), c: expect.toSatisfy((e) => { const data: ConnectionManagerData = e.value; expect(data.getConnections().length).toBe(2); - expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(1); - expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(1); - expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe( - "user1A", - ); - expect(data.getParticipantForTransport(TRANSPORT_2)[0].identity).toBe( - "user2A", - ); + expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(1); + expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1); + expect( + data.getParticipantsForTransport(TRANSPORT_1)[0].identity, + ).toBe("user1A"); + expect( + data.getParticipantsForTransport(TRANSPORT_2)[0].identity, + ).toBe("user2A"); return true; }), d: expect.toSatisfy((e) => { const data: ConnectionManagerData = e.value; expect(data.getConnections().length).toBe(2); - expect(data.getParticipantForTransport(TRANSPORT_1).length).toBe(2); - expect(data.getParticipantForTransport(TRANSPORT_2).length).toBe(1); - expect(data.getParticipantForTransport(TRANSPORT_1)[0].identity).toBe( - "user1A", - ); - expect(data.getParticipantForTransport(TRANSPORT_1)[1].identity).toBe( - "user1B", - ); - expect(data.getParticipantForTransport(TRANSPORT_2)[0].identity).toBe( - "user2A", - ); + expect(data.getParticipantsForTransport(TRANSPORT_1).length).toBe(2); + expect(data.getParticipantsForTransport(TRANSPORT_2).length).toBe(1); + expect( + data.getParticipantsForTransport(TRANSPORT_1)[0].identity, + ).toBe("user1A"); + expect( + data.getParticipantsForTransport(TRANSPORT_1)[1].identity, + ).toBe("user1B"); + expect( + data.getParticipantsForTransport(TRANSPORT_2)[0].identity, + ).toBe("user2A"); return true; }), }); diff --git a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts index 8db62236..c1b4af59 100644 --- a/src/state/CallViewModel/remoteMembers/ConnectionManager.ts +++ b/src/state/CallViewModel/remoteMembers/ConnectionManager.ts @@ -19,8 +19,10 @@ import { areLivekitTransportsEqual } from "./MatrixLivekitMembers.ts"; import { type ConnectionFactory } from "./ConnectionFactory.ts"; export class ConnectionManagerData { - private readonly store: Map = - new Map(); + private readonly store: Map< + string, + { connection: Connection; participants: RemoteParticipant[] } + > = new Map(); public constructor() {} @@ -28,9 +30,9 @@ export class ConnectionManagerData { const key = this.getKey(connection.transport); const existing = this.store.get(key); if (!existing) { - this.store.set(key, [connection, participants]); + this.store.set(key, { connection, participants }); } else { - existing[1].push(...participants); + existing.participants.push(...participants); } } @@ -39,20 +41,24 @@ export class ConnectionManagerData { } public getConnections(): Connection[] { - return Array.from(this.store.values()).map(([connection]) => connection); + return Array.from(this.store.values()).map(({ connection }) => connection); } public getConnectionForTransport( transport: LivekitTransport, ): Connection | null { - return this.store.get(this.getKey(transport))?.[0] ?? null; + return this.store.get(this.getKey(transport))?.connection ?? null; } - public getParticipantForTransport( + public getParticipantsForTransport( transport: LivekitTransport, ): RemoteParticipant[] { const key = transport.livekit_service_url + "|" + transport.livekit_alias; - return this.store.get(key)?.[1] ?? []; + const existing = this.store.get(key); + if (existing) { + return existing.participants; + } + return []; } } @@ -162,6 +168,7 @@ export function createConnectionManager$({ ); // probably not required + if (listOfConnectionsWithRemoteParticipants.length === 0) { return of(new Epoch(new ConnectionManagerData(), epoch)); } diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts index 77c00015..d26bac37 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.test.ts @@ -91,7 +91,7 @@ test("should signal participant not yet connected to livekit", () => { }), ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const matrixLivekitMembers$ = createMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionManager: { @@ -99,21 +99,24 @@ test("should signal participant not yet connected to livekit", () => { } as unknown as IConnectionManager, }); - expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", { - a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { - expect(data.length).toEqual(1); - expectObservable(data[0].membership$).toBe("a", { - a: bobMembership, - }); - expectObservable(data[0].participant.value$).toBe("a", { - a: null, - }); - expectObservable(data[0].connection$).toBe("a", { - a: null, - }); - return true; - }), - }); + expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe( + "a", + { + a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { + expect(data.length).toEqual(1); + expectObservable(data[0].membership$).toBe("a", { + a: bobMembership, + }); + expectObservable(data[0].participant.value$).toBe("a", { + a: null, + }); + expectObservable(data[0].connection$).toBe("a", { + a: null, + }); + return true; + }), + }, + ); }); }); @@ -171,7 +174,7 @@ test("should signal participant on a connection that is publishing", () => { }), ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const matrixLivekitMembers$ = createMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionManager: { @@ -179,25 +182,28 @@ test("should signal participant on a connection that is publishing", () => { } as unknown as IConnectionManager, }); - expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", { - a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { - expect(data.length).toEqual(1); - expectObservable(data[0].membership$).toBe("a", { - a: bobMembership, - }); - expectObservable(data[0].participant.value$).toBe("a", { - a: expect.toSatisfy((participant) => { - expect(participant).toBeDefined(); - expect(participant!.identity).toEqual(bobParticipantId); - return true; - }), - }); - expectObservable(data[0].connection$).toBe("a", { - a: connection, - }); - return true; - }), - }); + expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe( + "a", + { + a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { + expect(data.length).toEqual(1); + expectObservable(data[0].membership$).toBe("a", { + a: bobMembership, + }); + expectObservable(data[0].participant.value$).toBe("a", { + a: expect.toSatisfy((participant) => { + expect(participant).toBeDefined(); + expect(participant!.identity).toEqual(bobParticipantId); + return true; + }), + }); + expectObservable(data[0].connection$).toBe("a", { + a: connection, + }); + return true; + }), + }, + ); }); }); @@ -222,7 +228,7 @@ test("should signal participant on a connection that is not publishing", () => { }), ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const matrixLivekitMembers$ = createMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior(membershipsWithTransport$), connectionManager: { @@ -230,21 +236,24 @@ test("should signal participant on a connection that is not publishing", () => { } as unknown as IConnectionManager, }); - expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe("a", { - a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { - expect(data.length).toEqual(1); - expectObservable(data[0].membership$).toBe("a", { - a: bobMembership, - }); - expectObservable(data[0].participant.value$).toBe("a", { - a: null, - }); - expectObservable(data[0].connection$).toBe("a", { - a: connection, - }); - return true; - }), - }); + expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe( + "a", + { + a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { + expect(data.length).toEqual(1); + expectObservable(data[0].membership$).toBe("a", { + a: bobMembership, + }); + expectObservable(data[0].participant.value$).toBe("a", { + a: null, + }); + expectObservable(data[0].connection$).toBe("a", { + a: connection, + }); + return true; + }), + }, + ); }); }); @@ -283,7 +292,7 @@ describe("Publication edge case", () => { }), ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const matrixLivekitMembers$ = createMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior( membershipsWithTransport$, @@ -293,7 +302,7 @@ describe("Publication edge case", () => { } as unknown as IConnectionManager, }); - expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe( + expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe( "a", { a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { @@ -349,7 +358,7 @@ describe("Publication edge case", () => { }), ); - const matrixLivekitMember$ = createMatrixLivekitMembers$({ + const matrixLivekitMembers$ = createMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: testScope.behavior( membershipsWithTransport$, @@ -359,7 +368,7 @@ describe("Publication edge case", () => { } as unknown as IConnectionManager, }); - expectObservable(matrixLivekitMember$.pipe(map((e) => e.value))).toBe( + expectObservable(matrixLivekitMembers$.pipe(map((e) => e.value))).toBe( "a", { a: expect.toSatisfy((data: RemoteMatrixLivekitMember[]) => { diff --git a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts index 4cca0d5b..6501adb4 100644 --- a/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts +++ b/src/state/CallViewModel/remoteMembers/MatrixLivekitMembers.ts @@ -110,7 +110,7 @@ export function createMatrixLivekitMembers$({ const participantId = /*membership.membershipID*/ `${membership.userId}:${membership.deviceId}`; const participants = transport - ? managerData.getParticipantForTransport(transport) + ? managerData.getParticipantsForTransport(transport) : []; const participant = participants.find((p) => p.identity == participantId) ?? null; diff --git a/src/state/CallViewModel/remoteMembers/integration.test.ts b/src/state/CallViewModel/remoteMembers/integration.test.ts index 2c3591a5..6108c7bc 100644 --- a/src/state/CallViewModel/remoteMembers/integration.test.ts +++ b/src/state/CallViewModel/remoteMembers/integration.test.ts @@ -124,14 +124,14 @@ test("bob, carl, then bob joining no tracks yet", () => { logger: logger, }); - const matrixLivekitItems$ = createMatrixLivekitMembers$({ + const matrixLivekitMembers$ = createMatrixLivekitMembers$({ scope: testScope, membershipsWithTransport$: membershipsAndTransports.membershipsWithTransport$, connectionManager, }); - expectObservable(matrixLivekitItems$).toBe(vMarble, { + expectObservable(matrixLivekitMembers$).toBe(vMarble, { a: expect.toSatisfy((e: Epoch) => { const items = e.value; expect(items.length).toBe(1); diff --git a/src/utils/test.ts b/src/utils/test.ts index b900d801..9a845908 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -311,6 +311,8 @@ export function mockLocalParticipant( publishTrack: vi.fn(), unpublishTracks: vi.fn().mockResolvedValue([]), createTracks: vi.fn(), + setMicrophoneEnabled: vi.fn(), + setCameraEnabled: vi.fn(), getTrackPublication: () => ({}) as Partial as LocalTrackPublication, ...mockEmitter(), diff --git a/src/widget.ts b/src/widget.ts index 60163c7c..7862df33 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -64,6 +64,12 @@ export const widget = ((): WidgetHelpers | null => { try { const { widgetId, parentUrl } = getUrlParams(); + const { roomId, userId, deviceId, baseUrl, e2eEnabled, allowIceFallback } = + getUrlParams(); + if (!roomId) throw new Error("Room ID must be supplied"); + if (!userId) throw new Error("User ID must be supplied"); + if (!deviceId) throw new Error("Device ID must be supplied"); + if (!baseUrl) throw new Error("Base URL must be supplied"); if (widgetId && parentUrl) { const parentOrigin = new URL(parentUrl).origin; logger.info("Widget API is available"); @@ -92,19 +98,6 @@ export const widget = ((): WidgetHelpers | null => { // We need to do this now rather than later because it has capabilities to // request, and is responsible for starting the transport (should it be?) - const { - roomId, - userId, - deviceId, - baseUrl, - e2eEnabled, - allowIceFallback, - } = getUrlParams(); - if (!roomId) throw new Error("Room ID must be supplied"); - if (!userId) throw new Error("User ID must be supplied"); - if (!deviceId) throw new Error("Device ID must be supplied"); - if (!baseUrl) throw new Error("Base URL must be supplied"); - // These are all the event types the app uses const sendEvent = [ EventType.CallNotify, // Sent as a deprecated fallback diff --git a/tsconfig.json b/tsconfig.json index e864ecfc..0f9e7c66 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -50,6 +50,11 @@ "plugins": [{ "name": "typescript-eslint-language-service" }] }, - "include": ["./src/**/*.ts", "./src/**/*.tsx", "./playwright/**/*.ts"], + "include": [ + "./src/**/*.ts", + "./src/**/*.tsx", + "./playwright/**/*.ts", + "./sdk/**/*.ts" + ], "exclude": ["**.test.ts"] } diff --git a/vite-sdk.config.ts b/vite-sdk.config.ts new file mode 100644 index 00000000..48fb6f22 --- /dev/null +++ b/vite-sdk.config.ts @@ -0,0 +1,28 @@ +/* +Copyright 2025 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { defineConfig, mergeConfig } from "vite"; +import nodePolyfills from "vite-plugin-node-stdlib-browser"; + +const base = "./"; + +// Config for embedded deployments (possibly hosted under a non-root path) +export default defineConfig(() => ({ + worker: { format: "es" as const }, + base, // Use relative URLs to allow the app to be hosted under any path + build: { + sourcemap: true, + manifest: true, + lib: { + formats: ["es" as const], + entry: "./sdk/main.ts", + name: "MatrixrtcSdk", + fileName: "matrixrtc-sdk", + }, + }, + plugins: [nodePolyfills()], +})); diff --git a/vite.config.ts b/vite.config.ts index a0bb9de5..97d643ec 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -7,14 +7,17 @@ Please see LICENSE in the repository root for full details. import { loadEnv, + PluginOption, searchForWorkspaceRoot, type ConfigEnv, type UserConfig, } from "vite"; import svgrPlugin from "vite-plugin-svgr"; import { createHtmlPlugin } from "vite-plugin-html"; + import { codecovVitePlugin } from "@codecov/vite-plugin"; import { sentryVitePlugin } from "@sentry/vite-plugin"; + import react from "@vitejs/plugin-react"; import { realpathSync } from "fs"; import * as fs from "node:fs"; @@ -31,7 +34,7 @@ export default ({ // In future we might be able to do what is needed via code splitting at // build time. process.env.VITE_PACKAGE = packageType ?? "full"; - const plugins = [ + const plugins: PluginOption[] = [ react(), svgrPlugin({ svgrOptions: { @@ -41,16 +44,6 @@ export default ({ }, }), - createHtmlPlugin({ - entry: "src/main.tsx", - inject: { - data: { - brand: env.VITE_PRODUCT_NAME || "Element Call", - packageType: process.env.VITE_PACKAGE, - }, - }, - }), - codecovVitePlugin({ enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined, bundleName: "element-call", @@ -73,6 +66,18 @@ export default ({ ); } + plugins.push( + createHtmlPlugin({ + entry: "src/main.tsx", + inject: { + data: { + brand: env.VITE_PRODUCT_NAME || "Element Call", + packageType: process.env.VITE_PACKAGE, + }, + }, + }), + ); + // The crypto WASM module is imported dynamically. Since it's common // for developers to use a linked copy of matrix-js-sdk or Rust // crypto (which could reside anywhere on their file system), Vite diff --git a/yarn.lock b/yarn.lock index f65d3e74..abb3ef95 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2711,6 +2711,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" @@ -4479,6 +4486,22 @@ __metadata: languageName: node linkType: hard +"@rollup/plugin-inject@npm:^5.0.3": + version: 5.0.5 + resolution: "@rollup/plugin-inject@npm:5.0.5" + dependencies: + "@rollup/pluginutils": "npm:^5.0.1" + estree-walker: "npm:^2.0.2" + magic-string: "npm:^0.30.3" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/22d10cf44fa56a6683d5ac4df24a9003379b3dcaae9897f5c30c844afc2ebca83cfaa5557f13a1399b1c8a0d312c3217bcacd508b7ebc4b2cbee401bd1ec8be2 + languageName: node + linkType: hard + "@rollup/pluginutils@npm:^4.2.0": version: 4.2.1 resolution: "@rollup/pluginutils@npm:4.2.1" @@ -4489,6 +4512,22 @@ __metadata: languageName: node linkType: hard +"@rollup/pluginutils@npm:^5.0.1": + version: 5.3.0 + resolution: "@rollup/pluginutils@npm:5.3.0" + dependencies: + "@types/estree": "npm:^1.0.0" + estree-walker: "npm:^2.0.2" + picomatch: "npm:^4.0.2" + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + checksum: 10c0/001834bf62d7cf5bac424d2617c113f7f7d3b2bf3c1778cbcccb72cdc957b68989f8e7747c782c2b911f1dde8257f56f8ac1e779e29e74e638e3f1e2cac2bcd0 + languageName: node + linkType: hard + "@rollup/pluginutils@npm:^5.1.3": version: 5.1.3 resolution: "@rollup/pluginutils@npm:5.1.3" @@ -6128,6 +6167,30 @@ __metadata: languageName: node linkType: hard +"asn1.js@npm:^4.10.1": + version: 4.10.1 + resolution: "asn1.js@npm:4.10.1" + dependencies: + bn.js: "npm:^4.0.0" + inherits: "npm:^2.0.1" + minimalistic-assert: "npm:^1.0.0" + checksum: 10c0/afa7f3ab9e31566c80175a75b182e5dba50589dcc738aa485be42bdd787e2a07246a4b034d481861123cbe646a7656f318f4f1cad2e9e5e808a210d5d6feaa88 + languageName: node + linkType: hard + +"assert@npm:^2.0.0": + version: 2.1.0 + resolution: "assert@npm:2.1.0" + dependencies: + call-bind: "npm:^1.0.2" + is-nan: "npm:^1.3.2" + object-is: "npm:^1.1.5" + object.assign: "npm:^4.1.4" + util: "npm:^0.12.5" + checksum: 10c0/7271a5da883c256a1fa690677bf1dd9d6aa882139f2bed1cd15da4f9e7459683e1da8e32a203d6cc6767e5e0f730c77a9532a87b896b4b0af0dd535f668775f0 + languageName: node + linkType: hard + "assertion-error@npm:^2.0.1": version: 2.0.1 resolution: "assertion-error@npm:2.0.1" @@ -6318,6 +6381,20 @@ __metadata: languageName: node linkType: hard +"bn.js@npm:^4.0.0, bn.js@npm:^4.1.0, bn.js@npm:^4.11.9": + version: 4.12.2 + resolution: "bn.js@npm:4.12.2" + checksum: 10c0/09a249faa416a9a1ce68b5f5ec8bbca87fe54e5dd4ef8b1cc8a4969147b80035592bddcb1e9cc814c3ba79e573503d5c5178664b722b509fb36d93620dba9b57 + languageName: node + linkType: hard + +"bn.js@npm:^5.2.1, bn.js@npm:^5.2.2": + version: 5.2.2 + resolution: "bn.js@npm:5.2.2" + checksum: 10c0/cb97827d476aab1a0194df33cd84624952480d92da46e6b4a19c32964aa01553a4a613502396712704da2ec8f831cf98d02e74ca03398404bd78a037ba93f2ab + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -6393,6 +6470,96 @@ __metadata: languageName: node linkType: hard +"brorand@npm:^1.0.1, brorand@npm:^1.1.0": + version: 1.1.0 + resolution: "brorand@npm:1.1.0" + checksum: 10c0/6f366d7c4990f82c366e3878492ba9a372a73163c09871e80d82fb4ae0d23f9f8924cb8a662330308206e6b3b76ba1d528b4601c9ef73c2166b440b2ea3b7571 + languageName: node + linkType: hard + +"browser-resolve@npm:^2.0.0": + version: 2.0.0 + resolution: "browser-resolve@npm:2.0.0" + dependencies: + resolve: "npm:^1.17.0" + checksum: 10c0/06c43adf3cb1939825ab9a4ac355b23272820ee421a20d04f62e0dabd9ea305e497b97f3ac027f87d53c366483aafe8673bbe1aaa5e41cd69eeafa65ac5fda6e + languageName: node + linkType: hard + +"browserify-aes@npm:^1.0.4, browserify-aes@npm:^1.2.0": + version: 1.2.0 + resolution: "browserify-aes@npm:1.2.0" + dependencies: + buffer-xor: "npm:^1.0.3" + cipher-base: "npm:^1.0.0" + create-hash: "npm:^1.1.0" + evp_bytestokey: "npm:^1.0.3" + inherits: "npm:^2.0.1" + safe-buffer: "npm:^5.0.1" + checksum: 10c0/967f2ae60d610b7b252a4cbb55a7a3331c78293c94b4dd9c264d384ca93354c089b3af9c0dd023534efdc74ffbc82510f7ad4399cf82bc37bc07052eea485f18 + languageName: node + linkType: hard + +"browserify-cipher@npm:^1.0.1": + version: 1.0.1 + resolution: "browserify-cipher@npm:1.0.1" + dependencies: + browserify-aes: "npm:^1.0.4" + browserify-des: "npm:^1.0.0" + evp_bytestokey: "npm:^1.0.0" + checksum: 10c0/aa256dcb42bc53a67168bbc94ab85d243b0a3b56109dee3b51230b7d010d9b78985ffc1fb36e145c6e4db151f888076c1cfc207baf1525d3e375cbe8187fe27d + languageName: node + linkType: hard + +"browserify-des@npm:^1.0.0": + version: 1.0.2 + resolution: "browserify-des@npm:1.0.2" + dependencies: + cipher-base: "npm:^1.0.1" + des.js: "npm:^1.0.0" + inherits: "npm:^2.0.1" + safe-buffer: "npm:^5.1.2" + checksum: 10c0/943eb5d4045eff80a6cde5be4e5fbb1f2d5002126b5a4789c3c1aae3cdddb1eb92b00fb92277f512288e5c6af330730b1dbabcf7ce0923e749e151fcee5a074d + languageName: node + linkType: hard + +"browserify-rsa@npm:^4.0.0, browserify-rsa@npm:^4.1.1": + version: 4.1.1 + resolution: "browserify-rsa@npm:4.1.1" + dependencies: + bn.js: "npm:^5.2.1" + randombytes: "npm:^2.1.0" + safe-buffer: "npm:^5.2.1" + checksum: 10c0/b650ee1192e3d7f3d779edc06dd96ed8720362e72ac310c367b9d7fe35f7e8dbb983c1829142b2b3215458be8bf17c38adc7224920843024ed8cf39e19c513c0 + languageName: node + linkType: hard + +"browserify-sign@npm:^4.2.3": + version: 4.2.5 + resolution: "browserify-sign@npm:4.2.5" + dependencies: + bn.js: "npm:^5.2.2" + browserify-rsa: "npm:^4.1.1" + create-hash: "npm:^1.2.0" + create-hmac: "npm:^1.1.7" + elliptic: "npm:^6.6.1" + inherits: "npm:^2.0.4" + parse-asn1: "npm:^5.1.9" + readable-stream: "npm:^2.3.8" + safe-buffer: "npm:^5.2.1" + checksum: 10c0/6192f9696934bbba58932d098face34c2ab9cac09feed826618b86b8c00a897dab7324cd9aa7d6cb1597064f197264ad72fa5418d4d52bf3c8f9b9e0e124655e + languageName: node + linkType: hard + +"browserify-zlib@npm:^0.2.0": + version: 0.2.0 + resolution: "browserify-zlib@npm:0.2.0" + dependencies: + pako: "npm:~1.0.5" + checksum: 10c0/9ab10b6dc732c6c5ec8ebcbe5cb7fe1467f97402c9b2140113f47b5f187b9438f93a8e065d8baf8b929323c18324fbf1105af479ee86d9d36cab7d7ef3424ad9 + languageName: node + linkType: hard + "browserslist@npm:^4.24.0, browserslist@npm:^4.24.3, browserslist@npm:^4.24.4": version: 4.24.4 resolution: "browserslist@npm:4.24.4" @@ -6437,6 +6604,23 @@ __metadata: languageName: node linkType: hard +"buffer-xor@npm:^1.0.3": + version: 1.0.3 + resolution: "buffer-xor@npm:1.0.3" + checksum: 10c0/fd269d0e0bf71ecac3146187cfc79edc9dbb054e2ee69b4d97dfb857c6d997c33de391696d04bdd669272751fa48e7872a22f3a6c7b07d6c0bc31dbe02a4075c + languageName: node + linkType: hard + +"buffer@npm:^5.7.1": + version: 5.7.1 + resolution: "buffer@npm:5.7.1" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.1.13" + checksum: 10c0/27cac81cff434ed2876058d72e7c4789d11ff1120ef32c9de48f59eab58179b66710c488987d295ae89a228f835fc66d088652dffeb8e3ba8659f80eb091d55e + languageName: node + linkType: hard + "buffer@npm:^6.0.3": version: 6.0.3 resolution: "buffer@npm:6.0.3" @@ -6454,6 +6638,13 @@ __metadata: languageName: node linkType: hard +"builtin-status-codes@npm:^3.0.0": + version: 3.0.0 + resolution: "builtin-status-codes@npm:3.0.0" + checksum: 10c0/c37bbba11a34c4431e56bd681b175512e99147defbe2358318d8152b3a01df7bf25e0305873947e5b350073d5ef41a364a22b37e48f1fb6d2fe6d5286a0f348c + languageName: node + linkType: hard + "bytesish@npm:^0.4.1": version: 0.4.4 resolution: "bytesish@npm:0.4.4" @@ -6508,7 +6699,7 @@ __metadata: languageName: node linkType: hard -"call-bind@npm:^1.0.2, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": +"call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.7, call-bind@npm:^1.0.8": version: 1.0.8 resolution: "call-bind@npm:1.0.8" dependencies: @@ -6571,24 +6762,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001688": - version: 1.0.30001701 - resolution: "caniuse-lite@npm:1.0.30001701" - checksum: 10c0/a814bd4dd8b49645ca51bc6ee42120660a36394bb54eb6084801d3f2bbb9471e5e1a9a8a25f44f83086a032d46e66b33031e2aa345f699b90a7e84a9836b819c - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001702": - version: 1.0.30001720 - resolution: "caniuse-lite@npm:1.0.30001720" - checksum: 10c0/ba9f963364ec4bfc8359d15d7e2cf365185fa1fddc90b4f534c71befedae9b3dd0cd2583a25ffc168a02d7b61b6c18b59bda0a1828ea2a5250fd3e35c2c049e9 - languageName: node - linkType: hard - -"caniuse-lite@npm:^1.0.30001726": - version: 1.0.30001726 - resolution: "caniuse-lite@npm:1.0.30001726" - checksum: 10c0/2c5f91da7fd9ebf8c6b432818b1498ea28aca8de22b30dafabe2a2a6da1e014f10e67e14f8e68e872a0867b6b4cd6001558dde04e3ab9770c9252ca5c8849d0e +"caniuse-lite@npm:^1.0.30001688, caniuse-lite@npm:^1.0.30001702, caniuse-lite@npm:^1.0.30001726": + version: 1.0.30001757 + resolution: "caniuse-lite@npm:1.0.30001757" + checksum: 10c0/3ccb71fa2bf1f8c96ff1bf9b918b08806fed33307e20a3ce3259155fda131eaf96cfcd88d3d309c8fd7f8285cc71d89a3b93648a1c04814da31c301f98508d42 languageName: node linkType: hard @@ -6722,6 +6899,17 @@ __metadata: languageName: node linkType: hard +"cipher-base@npm:^1.0.0, cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3": + version: 1.0.7 + resolution: "cipher-base@npm:1.0.7" + dependencies: + inherits: "npm:^2.0.4" + safe-buffer: "npm:^5.2.1" + to-buffer: "npm:^1.2.2" + checksum: 10c0/53c5046a9d9b60c586479b8f13fde263c3f905e13f11e8e04c7a311ce399c91d9c3ec96642332e0de077d356e1014ee12bba96f74fbaad0de750f49122258836 + languageName: node + linkType: hard + "classnames@npm:^2.3.1, classnames@npm:^2.5.1": version: 2.5.1 resolution: "classnames@npm:2.5.1" @@ -6885,6 +7073,20 @@ __metadata: languageName: node linkType: hard +"console-browserify@npm:^1.1.0": + version: 1.2.0 + resolution: "console-browserify@npm:1.2.0" + checksum: 10c0/89b99a53b7d6cee54e1e64fa6b1f7ac24b844b4019c5d39db298637e55c1f4ffa5c165457ad984864de1379df2c8e1886cbbdac85d9dbb6876a9f26c3106f226 + languageName: node + linkType: hard + +"constants-browserify@npm:^1.0.0": + version: 1.0.0 + resolution: "constants-browserify@npm:1.0.0" + checksum: 10c0/ab49b1d59a433ed77c964d90d19e08b2f77213fb823da4729c0baead55e3c597f8f97ebccfdfc47bd896d43854a117d114c849a6f659d9986420e97da0f83ac5 + languageName: node + linkType: hard + "content-type@npm:^1.0.4": version: 1.0.5 resolution: "content-type@npm:1.0.5" @@ -6957,6 +7159,50 @@ __metadata: languageName: node linkType: hard +"create-ecdh@npm:^4.0.4": + version: 4.0.4 + resolution: "create-ecdh@npm:4.0.4" + dependencies: + bn.js: "npm:^4.1.0" + elliptic: "npm:^6.5.3" + checksum: 10c0/77b11a51360fec9c3bce7a76288fc0deba4b9c838d5fb354b3e40c59194d23d66efe6355fd4b81df7580da0661e1334a235a2a5c040b7569ba97db428d466e7f + languageName: node + linkType: hard + +"create-hash@npm:^1.1.0, create-hash@npm:^1.2.0": + version: 1.2.0 + resolution: "create-hash@npm:1.2.0" + dependencies: + cipher-base: "npm:^1.0.1" + inherits: "npm:^2.0.1" + md5.js: "npm:^1.3.4" + ripemd160: "npm:^2.0.1" + sha.js: "npm:^2.4.0" + checksum: 10c0/d402e60e65e70e5083cb57af96d89567954d0669e90550d7cec58b56d49c4b193d35c43cec8338bc72358198b8cbf2f0cac14775b651e99238e1cf411490f915 + languageName: node + linkType: hard + +"create-hmac@npm:^1.1.7": + version: 1.1.7 + resolution: "create-hmac@npm:1.1.7" + dependencies: + cipher-base: "npm:^1.0.3" + create-hash: "npm:^1.1.0" + inherits: "npm:^2.0.1" + ripemd160: "npm:^2.0.0" + safe-buffer: "npm:^5.0.1" + sha.js: "npm:^2.4.8" + checksum: 10c0/24332bab51011652a9a0a6d160eed1e8caa091b802335324ae056b0dcb5acbc9fcf173cf10d128eba8548c3ce98dfa4eadaa01bd02f44a34414baee26b651835 + languageName: node + linkType: hard + +"create-require@npm:^1.1.1": + version: 1.1.1 + resolution: "create-require@npm:1.1.1" + checksum: 10c0/157cbc59b2430ae9a90034a5f3a1b398b6738bf510f713edc4d4e45e169bc514d3d99dd34d8d01ca7ae7830b5b8b537e46ae8f3c8f932371b0875c0151d7ec91 + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.2": version: 7.0.3 resolution: "cross-spawn@npm:7.0.3" @@ -6979,6 +7225,26 @@ __metadata: languageName: node linkType: hard +"crypto-browserify@npm:^3.12.1": + version: 3.12.1 + resolution: "crypto-browserify@npm:3.12.1" + dependencies: + browserify-cipher: "npm:^1.0.1" + browserify-sign: "npm:^4.2.3" + create-ecdh: "npm:^4.0.4" + create-hash: "npm:^1.2.0" + create-hmac: "npm:^1.1.7" + diffie-hellman: "npm:^5.0.3" + hash-base: "npm:~3.0.4" + inherits: "npm:^2.0.4" + pbkdf2: "npm:^3.1.2" + public-encrypt: "npm:^4.0.3" + randombytes: "npm:^2.1.0" + randomfill: "npm:^1.0.4" + checksum: 10c0/184a2def7b16628e79841243232ab5497f18d8e158ac21b7ce90ab172427d0a892a561280adc08f9d4d517bce8db2a5b335dc21abb970f787f8e874bd7b9db7d + languageName: node + linkType: hard + "css-blank-pseudo@npm:^7.0.1": version: 7.0.1 resolution: "css-blank-pseudo@npm:7.0.1" @@ -7267,6 +7533,16 @@ __metadata: languageName: node linkType: hard +"des.js@npm:^1.0.0": + version: 1.1.0 + resolution: "des.js@npm:1.1.0" + dependencies: + inherits: "npm:^2.0.1" + minimalistic-assert: "npm:^1.0.0" + checksum: 10c0/671354943ad67493e49eb4c555480ab153edd7cee3a51c658082fcde539d2690ed2a4a0b5d1f401f9cde822edf3939a6afb2585f32c091f2d3a1b1665cd45236 + languageName: node + linkType: hard + "detect-libc@npm:^1.0.3": version: 1.0.3 resolution: "detect-libc@npm:1.0.3" @@ -7283,6 +7559,17 @@ __metadata: languageName: node linkType: hard +"diffie-hellman@npm:^5.0.3": + version: 5.0.3 + resolution: "diffie-hellman@npm:5.0.3" + dependencies: + bn.js: "npm:^4.1.0" + miller-rabin: "npm:^4.0.0" + randombytes: "npm:^2.0.0" + checksum: 10c0/ce53ccafa9ca544b7fc29b08a626e23a9b6562efc2a98559a0c97b4718937cebaa9b5d7d0a05032cc9c1435e9b3c1532b9e9bf2e0ede868525922807ad6e1ecf + languageName: node + linkType: hard + "dijkstrajs@npm:^1.0.1": version: 1.0.3 resolution: "dijkstrajs@npm:1.0.3" @@ -7353,6 +7640,13 @@ __metadata: languageName: node linkType: hard +"domain-browser@npm:4.22.0": + version: 4.22.0 + resolution: "domain-browser@npm:4.22.0" + checksum: 10c0/2ef7eda6d2161038fda0c9aa4c9e18cc7a0baa89ea6be975d449527c2eefd4b608425db88508e2859acc472f46f402079274b24bd75e3fb506f28c5dba203129 + languageName: node + linkType: hard + "domelementtype@npm:^2.0.1, domelementtype@npm:^2.2.0, domelementtype@npm:^2.3.0": version: 2.3.0 resolution: "domelementtype@npm:2.3.0" @@ -7549,6 +7843,7 @@ __metadata: loglevel: "npm:^1.9.1" matrix-js-sdk: "matrix-org/matrix-js-sdk#2218ec4e3102e841ba3e794e1c492c0a5aa6c1c3" matrix-widget-api: "npm:^1.14.0" + node-stdlib-browser: "npm:^1.3.1" normalize.css: "npm:^8.0.1" observable-hooks: "npm:^4.2.3" pako: "npm:^2.0.4" @@ -7571,12 +7866,28 @@ __metadata: vite: "npm:^7.0.0" vite-plugin-generate-file: "npm:^0.3.0" vite-plugin-html: "npm:^3.2.2" + vite-plugin-node-stdlib-browser: "npm:^0.2.1" vite-plugin-svgr: "npm:^4.0.0" vitest: "npm:^3.0.0" vitest-axe: "npm:^1.0.0-pre.3" languageName: unknown linkType: soft +"elliptic@npm:^6.5.3, elliptic@npm:^6.6.1": + version: 6.6.1 + resolution: "elliptic@npm:6.6.1" + dependencies: + bn.js: "npm:^4.11.9" + brorand: "npm:^1.1.0" + hash.js: "npm:^1.0.0" + hmac-drbg: "npm:^1.0.1" + inherits: "npm:^2.0.4" + minimalistic-assert: "npm:^1.0.1" + minimalistic-crypto-utils: "npm:^1.0.1" + checksum: 10c0/8b24ef782eec8b472053793ea1e91ae6bee41afffdfcb78a81c0a53b191e715cbe1292aa07165958a9bbe675bd0955142560b1a007ffce7d6c765bcaf951a867 + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -8388,13 +8699,24 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.2.0, events@npm:^3.3.0": +"events@npm:^3.0.0, events@npm:^3.2.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 languageName: node linkType: hard +"evp_bytestokey@npm:^1.0.0, evp_bytestokey@npm:^1.0.3": + version: 1.0.3 + resolution: "evp_bytestokey@npm:1.0.3" + dependencies: + md5.js: "npm:^1.3.4" + node-gyp: "npm:latest" + safe-buffer: "npm:^5.1.1" + checksum: 10c0/77fbe2d94a902a80e9b8f5a73dcd695d9c14899c5e82967a61b1fc6cbbb28c46552d9b127cff47c45fcf684748bdbcfa0a50410349109de87ceb4b199ef6ee99 + languageName: node + linkType: hard + "expect-type@npm:^1.2.1": version: 1.2.1 resolution: "expect-type@npm:1.2.1" @@ -8790,6 +9112,13 @@ __metadata: languageName: node linkType: hard +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8 + languageName: node + linkType: hard + "gensync@npm:^1.0.0-beta.2": version: 1.0.0-beta.2 resolution: "gensync@npm:1.0.0-beta.2" @@ -9092,6 +9421,38 @@ __metadata: languageName: node linkType: hard +"hash-base@npm:^3.0.0, hash-base@npm:^3.1.2": + version: 3.1.2 + resolution: "hash-base@npm:3.1.2" + dependencies: + inherits: "npm:^2.0.4" + readable-stream: "npm:^2.3.8" + safe-buffer: "npm:^5.2.1" + to-buffer: "npm:^1.2.1" + checksum: 10c0/f3b7fae1853b31340048dd659f40f5260ca6f3ff53b932f807f4ab701ee09039f6e9dbe1841723ff61e20f3f69d6387a352e4ccc5f997dedb0d375c7d88bc15e + languageName: node + linkType: hard + +"hash-base@npm:~3.0.4": + version: 3.0.5 + resolution: "hash-base@npm:3.0.5" + dependencies: + inherits: "npm:^2.0.4" + safe-buffer: "npm:^5.2.1" + checksum: 10c0/6dc185b79bad9b6d525cd132a588e4215380fdc36fec6f7a8a58c5db8e3b642557d02ad9c367f5e476c7c3ad3ccffa3607f308b124e1ed80e3b80a1b254db61e + languageName: node + linkType: hard + +"hash.js@npm:^1.0.0, hash.js@npm:^1.0.3": + version: 1.1.7 + resolution: "hash.js@npm:1.1.7" + dependencies: + inherits: "npm:^2.0.3" + minimalistic-assert: "npm:^1.0.1" + checksum: 10c0/41ada59494eac5332cfc1ce6b7ebdd7b88a3864a6d6b08a3ea8ef261332ed60f37f10877e0c825aaa4bddebf164fbffa618286aeeec5296675e2671cbfa746c4 + languageName: node + linkType: hard + "hasown@npm:^2.0.0, hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -9129,6 +9490,17 @@ __metadata: languageName: node linkType: hard +"hmac-drbg@npm:^1.0.1": + version: 1.0.1 + resolution: "hmac-drbg@npm:1.0.1" + dependencies: + hash.js: "npm:^1.0.3" + minimalistic-assert: "npm:^1.0.0" + minimalistic-crypto-utils: "npm:^1.0.1" + checksum: 10c0/f3d9ba31b40257a573f162176ac5930109816036c59a09f901eb2ffd7e5e705c6832bedfff507957125f2086a0ab8f853c0df225642a88bf1fcaea945f20600d + languageName: node + linkType: hard + "hoist-non-react-statics@npm:^3.3.2": version: 3.3.2 resolution: "hoist-non-react-statics@npm:3.3.2" @@ -9216,6 +9588,13 @@ __metadata: languageName: node linkType: hard +"https-browserify@npm:^1.0.0": + version: 1.0.0 + resolution: "https-browserify@npm:1.0.0" + checksum: 10c0/e17b6943bc24ea9b9a7da5714645d808670af75a425f29baffc3284962626efdc1eb3aa9bbffaa6e64028a6ad98af5b09fabcb454a8f918fb686abfdc9e9b8ae + languageName: node + linkType: hard + "https-proxy-agent@npm:^5.0.0": version: 5.0.1 resolution: "https-proxy-agent@npm:5.0.1" @@ -9295,7 +9674,7 @@ __metadata: languageName: node linkType: hard -"ieee754@npm:^1.2.1": +"ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb @@ -9357,7 +9736,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:^2.0.1, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10c0/4e531f648b29039fb7426fb94075e6545faa1eb9fe83c29f0b6d9e7263aceb4289d2d4557db0d428188eeb449cc7c5e77b0a0b2c4e248ff2a65933a0dee49ef2 @@ -9385,6 +9764,16 @@ __metadata: languageName: node linkType: hard +"is-arguments@npm:^1.0.4": + version: 1.2.0 + resolution: "is-arguments@npm:1.2.0" + dependencies: + call-bound: "npm:^1.0.2" + has-tostringtag: "npm:^1.0.2" + checksum: 10c0/6377344b31e9fcb707c6751ee89b11f132f32338e6a782ec2eac9393b0cbd32235dad93052998cda778ee058754860738341d8114910d50ada5615912bb929fc + languageName: node + linkType: hard + "is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" @@ -9524,6 +9913,19 @@ __metadata: languageName: node linkType: hard +"is-generator-function@npm:^1.0.7": + version: 1.1.2 + resolution: "is-generator-function@npm:1.1.2" + dependencies: + call-bound: "npm:^1.0.4" + generator-function: "npm:^2.0.0" + get-proto: "npm:^1.0.1" + has-tostringtag: "npm:^1.0.2" + safe-regex-test: "npm:^1.1.0" + checksum: 10c0/83da102e89c3e3b71d67b51d47c9f9bc862bceb58f87201727e27f7fa19d1d90b0ab223644ecaee6fc6e3d2d622bb25c966fbdaf87c59158b01ce7c0fe2fa372 + languageName: node + linkType: hard + "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3, is-glob@npm:~4.0.1": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -9540,6 +9942,16 @@ __metadata: languageName: node linkType: hard +"is-nan@npm:^1.3.2": + version: 1.3.2 + resolution: "is-nan@npm:1.3.2" + dependencies: + call-bind: "npm:^1.0.0" + define-properties: "npm:^1.1.3" + checksum: 10c0/8bfb286f85763f9c2e28ea32e9127702fe980ffd15fa5d63ade3be7786559e6e21355d3625dd364c769c033c5aedf0a2ed3d4025d336abf1b9241e3d9eddc5b0 + languageName: node + linkType: hard + "is-negated-glob@npm:^1.0.0": version: 1.0.0 resolution: "is-negated-glob@npm:1.0.0" @@ -9662,7 +10074,7 @@ __metadata: languageName: node linkType: hard -"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15": +"is-typed-array@npm:^1.1.13, is-typed-array@npm:^1.1.14, is-typed-array@npm:^1.1.15, is-typed-array@npm:^1.1.3": version: 1.1.15 resolution: "is-typed-array@npm:1.1.15" dependencies: @@ -9741,6 +10153,13 @@ __metadata: languageName: node linkType: hard +"isomorphic-timers-promises@npm:^1.0.1": + version: 1.0.1 + resolution: "isomorphic-timers-promises@npm:1.0.1" + checksum: 10c0/3b4761d0012ebe6b6382246079fc667f3513f36fe4042638f2bfb7db1557e4f1acd33a9c9907706c04270890ec6434120f132f3f300161a42a7dd8628926c8a4 + languageName: node + linkType: hard + "istanbul-lib-coverage@npm:^3.0.0, istanbul-lib-coverage@npm:^3.2.2": version: 3.2.2 resolution: "istanbul-lib-coverage@npm:3.2.2" @@ -10289,6 +10708,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.3": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + "magicast@npm:^0.3.5": version: 0.3.5 resolution: "magicast@npm:0.3.5" @@ -10384,6 +10812,17 @@ __metadata: languageName: node linkType: hard +"md5.js@npm:^1.3.4": + version: 1.3.5 + resolution: "md5.js@npm:1.3.5" + dependencies: + hash-base: "npm:^3.0.0" + inherits: "npm:^2.0.1" + safe-buffer: "npm:^5.1.2" + checksum: 10c0/b7bd75077f419c8e013fc4d4dada48be71882e37d69a44af65a2f2804b91e253441eb43a0614423a1c91bb830b8140b0dc906bc797245e2e275759584f4efcc5 + languageName: node + linkType: hard + "merge2@npm:^1.3.0, merge2@npm:^1.4.1": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -10401,6 +10840,18 @@ __metadata: languageName: node linkType: hard +"miller-rabin@npm:^4.0.0": + version: 4.0.1 + resolution: "miller-rabin@npm:4.0.1" + dependencies: + bn.js: "npm:^4.0.0" + brorand: "npm:^1.0.1" + bin: + miller-rabin: bin/miller-rabin + checksum: 10c0/26b2b96f6e49dbcff7faebb78708ed2f5f9ae27ac8cbbf1d7c08f83cf39bed3d418c0c11034dce997da70d135cc0ff6f3a4c15dc452f8e114c11986388a64346 + languageName: node + linkType: hard + "mime-db@npm:1.52.0": version: 1.52.0 resolution: "mime-db@npm:1.52.0" @@ -10424,6 +10875,20 @@ __metadata: languageName: node linkType: hard +"minimalistic-assert@npm:^1.0.0, minimalistic-assert@npm:^1.0.1": + version: 1.0.1 + resolution: "minimalistic-assert@npm:1.0.1" + checksum: 10c0/96730e5601cd31457f81a296f521eb56036e6f69133c0b18c13fe941109d53ad23a4204d946a0d638d7f3099482a0cec8c9bb6d642604612ce43ee536be3dddd + languageName: node + linkType: hard + +"minimalistic-crypto-utils@npm:^1.0.1": + version: 1.0.1 + resolution: "minimalistic-crypto-utils@npm:1.0.1" + checksum: 10c0/790ecec8c5c73973a4fbf2c663d911033e8494d5fb0960a4500634766ab05d6107d20af896ca2132e7031741f19888154d44b2408ada0852446705441383e9f8 + languageName: node + linkType: hard + "minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -10674,6 +11139,41 @@ __metadata: languageName: node linkType: hard +"node-stdlib-browser@npm:^1.3.1": + version: 1.3.1 + resolution: "node-stdlib-browser@npm:1.3.1" + dependencies: + assert: "npm:^2.0.0" + browser-resolve: "npm:^2.0.0" + browserify-zlib: "npm:^0.2.0" + buffer: "npm:^5.7.1" + console-browserify: "npm:^1.1.0" + constants-browserify: "npm:^1.0.0" + create-require: "npm:^1.1.1" + crypto-browserify: "npm:^3.12.1" + domain-browser: "npm:4.22.0" + events: "npm:^3.0.0" + https-browserify: "npm:^1.0.0" + isomorphic-timers-promises: "npm:^1.0.1" + os-browserify: "npm:^0.3.0" + path-browserify: "npm:^1.0.1" + pkg-dir: "npm:^5.0.0" + process: "npm:^0.11.10" + punycode: "npm:^1.4.1" + querystring-es3: "npm:^0.2.1" + readable-stream: "npm:^3.6.0" + stream-browserify: "npm:^3.0.0" + stream-http: "npm:^3.2.0" + string_decoder: "npm:^1.0.0" + timers-browserify: "npm:^2.0.4" + tty-browserify: "npm:0.0.1" + url: "npm:^0.11.4" + util: "npm:^0.12.4" + vm-browserify: "npm:^1.0.1" + checksum: 10c0/5b0cb5d4499b1b1c73f54db3e9e69b2a3a8aebe2ead2e356b0a03c1dfca6b5c5d2f6516e24301e76dc7b68999b9d0ae3da6c3f1dec421eed80ad6cb9eec0f356 + languageName: node + linkType: hard + "nopt@npm:^8.0.0": version: 8.1.0 resolution: "nopt@npm:8.1.0" @@ -10764,6 +11264,16 @@ __metadata: languageName: node linkType: hard +"object-is@npm:^1.1.5": + version: 1.1.6 + resolution: "object-is@npm:1.1.6" + dependencies: + call-bind: "npm:^1.0.7" + define-properties: "npm:^1.2.1" + checksum: 10c0/506af444c4dce7f8e31f34fc549e2fb8152d6b9c4a30c6e62852badd7f520b579c679af433e7a072f9d78eb7808d230dc12e1cf58da9154dfbf8813099ea0fe0 + languageName: node + linkType: hard + "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -10875,6 +11385,13 @@ __metadata: languageName: node linkType: hard +"os-browserify@npm:^0.3.0": + version: 0.3.0 + resolution: "os-browserify@npm:0.3.0" + checksum: 10c0/6ff32cb1efe2bc6930ad0fd4c50e30c38010aee909eba8d65be60af55efd6cbb48f0287e3649b4e3f3a63dce5a667b23c187c4293a75e557f0d5489d735bcf52 + languageName: node + linkType: hard + "own-keys@npm:^1.0.1": version: 1.0.1 resolution: "own-keys@npm:1.0.1" @@ -11007,6 +11524,13 @@ __metadata: languageName: node linkType: hard +"pako@npm:~1.0.5": + version: 1.0.11 + resolution: "pako@npm:1.0.11" + checksum: 10c0/86dd99d8b34c3930345b8bbeb5e1cd8a05f608eeb40967b293f72fe469d0e9c88b783a8777e4cc7dc7c91ce54c5e93d88ff4b4f060e6ff18408fd21030d9ffbe + languageName: node + linkType: hard + "param-case@npm:^3.0.4": version: 3.0.4 resolution: "param-case@npm:3.0.4" @@ -11026,6 +11550,19 @@ __metadata: languageName: node linkType: hard +"parse-asn1@npm:^5.0.0, parse-asn1@npm:^5.1.9": + version: 5.1.9 + resolution: "parse-asn1@npm:5.1.9" + dependencies: + asn1.js: "npm:^4.10.1" + browserify-aes: "npm:^1.2.0" + evp_bytestokey: "npm:^1.0.3" + pbkdf2: "npm:^3.1.5" + safe-buffer: "npm:^5.2.1" + checksum: 10c0/6dfe27c121be3d63ebbf95f03d2ae0a07dd716d44b70b0bd3458790a822a80de05361c62147271fd7b845dcc2d37755d9c9c393064a3438fe633779df0bc07e7 + languageName: node + linkType: hard + "parse-json@npm:^5.0.0, parse-json@npm:^5.2.0": version: 5.2.0 resolution: "parse-json@npm:5.2.0" @@ -11076,6 +11613,13 @@ __metadata: languageName: node linkType: hard +"path-browserify@npm:^1.0.1": + version: 1.0.1 + resolution: "path-browserify@npm:1.0.1" + checksum: 10c0/8b8c3fd5c66bd340272180590ae4ff139769e9ab79522e2eb82e3d571a89b8117c04147f65ad066dccfb42fcad902e5b7d794b3d35e0fd840491a8ddbedf8c66 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -11149,6 +11693,20 @@ __metadata: languageName: node linkType: hard +"pbkdf2@npm:^3.1.2, pbkdf2@npm:^3.1.5": + version: 3.1.5 + resolution: "pbkdf2@npm:3.1.5" + dependencies: + create-hash: "npm:^1.2.0" + create-hmac: "npm:^1.1.7" + ripemd160: "npm:^2.0.3" + safe-buffer: "npm:^5.2.1" + sha.js: "npm:^2.4.12" + to-buffer: "npm:^1.2.1" + checksum: 10c0/ea42e8695e49417eefabb19a08ab19a602cc6cc72d2df3f109c39309600230dee3083a6f678d5d42fe035d6ae780038b80ace0e68f9792ee2839bf081fe386f3 + languageName: node + linkType: hard + "picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -11177,6 +11735,15 @@ __metadata: languageName: node linkType: hard +"pkg-dir@npm:^5.0.0": + version: 5.0.0 + resolution: "pkg-dir@npm:5.0.0" + dependencies: + find-up: "npm:^5.0.0" + checksum: 10c0/793a496d685dc55bbbdbbb22d884535c3b29241e48e3e8d37e448113a71b9e42f5481a61fdc672d7322de12fbb2c584dd3a68bf89b18fffce5c48a390f911bc5 + languageName: node + linkType: hard + "playwright-core@npm:1.57.0": version: 1.57.0 resolution: "playwright-core@npm:1.57.0" @@ -11673,6 +12240,13 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + "progress@npm:^2.0.3": version: 2.0.3 resolution: "progress@npm:2.0.3" @@ -11745,6 +12319,27 @@ __metadata: languageName: node linkType: hard +"public-encrypt@npm:^4.0.3": + version: 4.0.3 + resolution: "public-encrypt@npm:4.0.3" + dependencies: + bn.js: "npm:^4.1.0" + browserify-rsa: "npm:^4.0.0" + create-hash: "npm:^1.1.0" + parse-asn1: "npm:^5.0.0" + randombytes: "npm:^2.0.1" + safe-buffer: "npm:^5.1.2" + checksum: 10c0/6c2cc19fbb554449e47f2175065d6b32f828f9b3badbee4c76585ac28ae8641aafb9bb107afc430c33c5edd6b05dbe318df4f7d6d7712b1093407b11c4280700 + languageName: node + linkType: hard + +"punycode@npm:^1.4.1": + version: 1.4.1 + resolution: "punycode@npm:1.4.1" + checksum: 10c0/354b743320518aef36f77013be6e15da4db24c2b4f62c5f1eb0529a6ed02fbaf1cb52925785f6ab85a962f2b590d9cd5ad730b70da72b5f180e2556b8bd3ca08 + languageName: node + linkType: hard + "punycode@npm:^2.1.0, punycode@npm:^2.3.1": version: 2.3.1 resolution: "punycode@npm:2.3.1" @@ -11765,6 +12360,22 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.12.3": + version: 6.14.0 + resolution: "qs@npm:6.14.0" + dependencies: + side-channel: "npm:^1.1.0" + checksum: 10c0/8ea5d91bf34f440598ee389d4a7d95820e3b837d3fd9f433871f7924801becaa0cd3b3b4628d49a7784d06a8aea9bc4554d2b6d8d584e2d221dc06238a42909c + languageName: node + linkType: hard + +"querystring-es3@npm:^0.2.1": + version: 0.2.1 + resolution: "querystring-es3@npm:0.2.1" + checksum: 10c0/476938c1adb45c141f024fccd2ffd919a3746e79ed444d00e670aad68532977b793889648980e7ca7ff5ffc7bfece623118d0fbadcaf217495eeb7059ae51580 + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -11783,6 +12394,25 @@ __metadata: languageName: node linkType: hard +"randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": + version: 2.1.0 + resolution: "randombytes@npm:2.1.0" + dependencies: + safe-buffer: "npm:^5.1.0" + checksum: 10c0/50395efda7a8c94f5dffab564f9ff89736064d32addf0cc7e8bf5e4166f09f8ded7a0849ca6c2d2a59478f7d90f78f20d8048bca3cdf8be09d8e8a10790388f3 + languageName: node + linkType: hard + +"randomfill@npm:^1.0.4": + version: 1.0.4 + resolution: "randomfill@npm:1.0.4" + dependencies: + randombytes: "npm:^2.0.5" + safe-buffer: "npm:^5.1.0" + checksum: 10c0/11aeed35515872e8f8a2edec306734e6b74c39c46653607f03c68385ab8030e2adcc4215f76b5e4598e028c4750d820afd5c65202527d831d2a5f207fe2bc87c + languageName: node + linkType: hard + "react-dom@npm:19": version: 19.1.0 resolution: "react-dom@npm:19.1.0" @@ -11977,18 +12607,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.4.0": - version: 3.6.2 - resolution: "readable-stream@npm:3.6.2" - dependencies: - inherits: "npm:^2.0.3" - string_decoder: "npm:^1.1.1" - util-deprecate: "npm:^1.0.1" - checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7 - languageName: node - linkType: hard - -"readable-stream@npm:~2.3.6": +"readable-stream@npm:^2.3.8, readable-stream@npm:~2.3.6": version: 2.3.8 resolution: "readable-stream@npm:2.3.8" dependencies: @@ -12003,6 +12622,17 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^3.4.0, readable-stream@npm:^3.5.0, readable-stream@npm:^3.6.0": + version: 3.6.2 + resolution: "readable-stream@npm:3.6.2" + dependencies: + inherits: "npm:^2.0.3" + string_decoder: "npm:^1.1.1" + util-deprecate: "npm:^1.0.1" + checksum: 10c0/e37be5c79c376fdd088a45fa31ea2e423e5d48854be7a22a58869b4e84d25047b193f6acb54f1012331e1bcd667ffb569c01b99d36b0bd59658fb33f513511b7 + languageName: node + linkType: hard + "readdirp@npm:^4.0.1": version: 4.1.2 resolution: "readdirp@npm:4.1.2" @@ -12219,6 +12849,19 @@ __metadata: languageName: node linkType: hard +"resolve@npm:^1.17.0": + version: 1.22.11 + resolution: "resolve@npm:1.22.11" + dependencies: + is-core-module: "npm:^2.16.1" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/f657191507530f2cbecb5815b1ee99b20741ea6ee02a59c57028e9ec4c2c8d7681afcc35febbd554ac0ded459db6f2d8153382c53a2f266cee2575e512674409 + languageName: node + linkType: hard + "resolve@npm:^1.22.10": version: 1.22.10 resolution: "resolve@npm:1.22.10" @@ -12258,6 +12901,19 @@ __metadata: languageName: node linkType: hard +"resolve@patch:resolve@npm%3A^1.17.0#optional!builtin": + version: 1.22.11 + resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin::version=1.22.11&hash=c3c19d" + dependencies: + is-core-module: "npm:^2.16.1" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/ee5b182f2e37cb1165465e58c6abc797fec0a80b5ba3231607beb4677db0c9291ac010c47cf092b6daa2b7f518d69a0e21888e7e2b633f68d501a874212a8c63 + languageName: node + linkType: hard + "resolve@patch:resolve@npm%3A^1.22.10#optional!builtin": version: 1.22.10 resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" @@ -12331,6 +12987,16 @@ __metadata: languageName: node linkType: hard +"ripemd160@npm:^2.0.0, ripemd160@npm:^2.0.1, ripemd160@npm:^2.0.3": + version: 2.0.3 + resolution: "ripemd160@npm:2.0.3" + dependencies: + hash-base: "npm:^3.1.2" + inherits: "npm:^2.0.4" + checksum: 10c0/3f472fb453241cfe692a77349accafca38dbcdc9d96d5848c088b2932ba41eb968630ecff7b175d291c7487a4945aee5a81e30c064d1f94e36070f7e0c37ed6c + languageName: node + linkType: hard + "rollup@npm:^4.43.0": version: 4.50.1 resolution: "rollup@npm:4.50.1" @@ -12478,6 +13144,13 @@ __metadata: languageName: node linkType: hard +"safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": + version: 5.2.1 + resolution: "safe-buffer@npm:5.2.1" + checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 + languageName: node + linkType: hard + "safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" @@ -12485,13 +13158,6 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:~5.2.0": - version: 5.2.1 - resolution: "safe-buffer@npm:5.2.1" - checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 - languageName: node - linkType: hard - "safe-push-apply@npm:^1.0.0": version: 1.0.0 resolution: "safe-push-apply@npm:1.0.0" @@ -12665,6 +13331,26 @@ __metadata: languageName: node linkType: hard +"setimmediate@npm:^1.0.4": + version: 1.0.5 + resolution: "setimmediate@npm:1.0.5" + checksum: 10c0/5bae81bfdbfbd0ce992893286d49c9693c82b1bcc00dcaaf3a09c8f428fdeacf4190c013598b81875dfac2b08a572422db7df779a99332d0fce186d15a3e4d49 + languageName: node + linkType: hard + +"sha.js@npm:^2.4.0, sha.js@npm:^2.4.12, sha.js@npm:^2.4.8": + version: 2.4.12 + resolution: "sha.js@npm:2.4.12" + dependencies: + inherits: "npm:^2.0.4" + safe-buffer: "npm:^5.2.1" + to-buffer: "npm:^1.2.0" + bin: + sha.js: bin.js + checksum: 10c0/9d36bdd76202c8116abbe152a00055ccd8a0099cb28fc17c01fa7bb2c8cffb9ca60e2ab0fe5f274ed6c45dc2633d8c39cf7ab050306c231904512ba9da4d8ab1 + languageName: node + linkType: hard + "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -12909,6 +13595,16 @@ __metadata: languageName: node linkType: hard +"stream-browserify@npm:^3.0.0": + version: 3.0.0 + resolution: "stream-browserify@npm:3.0.0" + dependencies: + inherits: "npm:~2.0.4" + readable-stream: "npm:^3.5.0" + checksum: 10c0/ec3b975a4e0aa4b3dc5e70ffae3fc8fd29ac725353a14e72f213dff477b00330140ad014b163a8cbb9922dfe90803f81a5ea2b269e1bbfd8bd71511b88f889ad + languageName: node + linkType: hard + "stream-composer@npm:^1.0.2": version: 1.0.2 resolution: "stream-composer@npm:1.0.2" @@ -12918,6 +13614,18 @@ __metadata: languageName: node linkType: hard +"stream-http@npm:^3.2.0": + version: 3.2.0 + resolution: "stream-http@npm:3.2.0" + dependencies: + builtin-status-codes: "npm:^3.0.0" + inherits: "npm:^2.0.4" + readable-stream: "npm:^3.6.0" + xtend: "npm:^4.0.2" + checksum: 10c0/f128fb8076d60cd548f229554b6a1a70c08a04b7b2afd4dbe7811d20f27f7d4112562eb8bce86d72a8691df3b50573228afcf1271e55e81f981536c67498bc41 + languageName: node + linkType: hard + "streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0": version: 2.22.0 resolution: "streamx@npm:2.22.0" @@ -13034,7 +13742,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": +"string_decoder@npm:^1.0.0, string_decoder@npm:^1.1.1": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -13236,6 +13944,15 @@ __metadata: languageName: node linkType: hard +"timers-browserify@npm:^2.0.4": + version: 2.0.12 + resolution: "timers-browserify@npm:2.0.12" + dependencies: + setimmediate: "npm:^1.0.4" + checksum: 10c0/98e84db1a685bc8827c117a8bc62aac811ad56a995d07938fc7ed8cdc5bf3777bfe2d4e5da868847194e771aac3749a20f6cdd22091300fe889a76fe214a4641 + languageName: node + linkType: hard + "tinybench@npm:^2.9.0": version: 2.9.0 resolution: "tinybench@npm:2.9.0" @@ -13309,6 +14026,17 @@ __metadata: languageName: node linkType: hard +"to-buffer@npm:^1.2.0, to-buffer@npm:^1.2.1, to-buffer@npm:^1.2.2": + version: 1.2.2 + resolution: "to-buffer@npm:1.2.2" + dependencies: + isarray: "npm:^2.0.5" + safe-buffer: "npm:^5.2.1" + typed-array-buffer: "npm:^1.0.3" + checksum: 10c0/56bc56352f14a2c4a0ab6277c5fc19b51e9534882b98eb068b39e14146591e62fa5b06bf70f7fed1626230463d7e60dca81e815096656e5e01c195c593873d12 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -13446,6 +14174,13 @@ __metadata: languageName: node linkType: hard +"tty-browserify@npm:0.0.1": + version: 0.0.1 + resolution: "tty-browserify@npm:0.0.1" + checksum: 10c0/5e34883388eb5f556234dae75b08e069b9e62de12bd6d87687f7817f5569430a6dfef550b51dbc961715ae0cd0eb5a059e6e3fc34dc127ea164aa0f9b5bb033d + languageName: node + linkType: hard + "tunnel@npm:^0.0.6": version: 0.0.6 resolution: "tunnel@npm:0.0.6" @@ -13780,6 +14515,16 @@ __metadata: languageName: node linkType: hard +"url@npm:^0.11.4": + version: 0.11.4 + resolution: "url@npm:0.11.4" + dependencies: + punycode: "npm:^1.4.1" + qs: "npm:^6.12.3" + checksum: 10c0/cc93405ae4a9b97a2aa60ca67f1cb1481c0221cb4725a7341d149be5e2f9cfda26fd432d64dbbec693d16593b68b8a46aad8e5eab21f814932134c9d8620c662 + languageName: node + linkType: hard + "use-callback-ref@npm:^1.3.3": version: 1.3.3 resolution: "use-callback-ref@npm:1.3.3" @@ -13829,6 +14574,19 @@ __metadata: languageName: node linkType: hard +"util@npm:^0.12.4, util@npm:^0.12.5": + version: 0.12.5 + resolution: "util@npm:0.12.5" + dependencies: + inherits: "npm:^2.0.3" + is-arguments: "npm:^1.0.4" + is-generator-function: "npm:^1.0.7" + is-typed-array: "npm:^1.1.3" + which-typed-array: "npm:^1.1.2" + checksum: 10c0/c27054de2cea2229a66c09522d0fa1415fb12d861d08523a8846bf2e4cbf0079d4c3f725f09dcb87493549bcbf05f5798dce1688b53c6c17201a45759e7253f3 + languageName: node + linkType: hard + "uuid@npm:13": version: 13.0.0 resolution: "uuid@npm:13.0.0" @@ -13975,6 +14733,18 @@ __metadata: languageName: node linkType: hard +"vite-plugin-node-stdlib-browser@npm:^0.2.1": + version: 0.2.1 + resolution: "vite-plugin-node-stdlib-browser@npm:0.2.1" + dependencies: + "@rollup/plugin-inject": "npm:^5.0.3" + peerDependencies: + node-stdlib-browser: ^1.2.0 + vite: ^2.0.0 || ^3.0.0 || ^4.0.0 + checksum: 10c0/4686bde59d0396d8684433e1a14ddce868dc422f80e306a0c1cb5e86564d9f7c38a67865ca339e4ff57784ec4bada149034038cad6911a2dfcac8debfc9bd20a + languageName: node + linkType: hard + "vite-plugin-svgr@npm:^4.0.0": version: 4.3.0 resolution: "vite-plugin-svgr@npm:4.3.0" @@ -14113,6 +14883,13 @@ __metadata: languageName: node linkType: hard +"vm-browserify@npm:^1.0.1": + version: 1.1.2 + resolution: "vm-browserify@npm:1.1.2" + checksum: 10c0/0cc1af6e0d880deb58bc974921320c187f9e0a94f25570fca6b1bd64e798ce454ab87dfd797551b1b0cc1849307421aae0193cedf5f06bdb5680476780ee344b + languageName: node + linkType: hard + "void-elements@npm:3.1.0": version: 3.1.0 resolution: "void-elements@npm:3.1.0" @@ -14312,7 +15089,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.19": +"which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" dependencies: @@ -14437,7 +15214,7 @@ __metadata: languageName: node linkType: hard -"xtend@npm:~4.0.1": +"xtend@npm:^4.0.2, xtend@npm:~4.0.1": version: 4.0.2 resolution: "xtend@npm:4.0.2" checksum: 10c0/366ae4783eec6100f8a02dff02ac907bf29f9a00b82ac0264b4d8b832ead18306797e283cf19de776538babfdcb2101375ec5646b59f08c52128ac4ab812ed0e