From 9161c4bde2272be55c00d97c97514d6bc97c0c8d Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Mon, 20 Apr 2026 21:09:22 +1000 Subject: [PATCH] feat: implement remote homeserver handling for LiveKit calls --- src/app/features/call/CallService.ts | 68 ++++++++++++--- src/app/features/call/types.ts | 2 + src/app/features/call/useCall.ts | 31 ++++++- src/app/features/call/useRtcConfig.ts | 106 +++++++++++++++++++++++ src/app/features/room/RoomViewHeader.tsx | 25 +++++- 5 files changed, 216 insertions(+), 16 deletions(-) diff --git a/src/app/features/call/CallService.ts b/src/app/features/call/CallService.ts index d66d50a..a323981 100644 --- a/src/app/features/call/CallService.ts +++ b/src/app/features/call/CallService.ts @@ -22,7 +22,14 @@ import { LiveKitJWTResponse, CallMemberEventContent, } from './types'; -import { fetchLiveKitJWT, fetchWellKnownWithRTC, getLiveKitFocus, requestOpenIdToken } from './useRtcConfig'; +import { + fetchLiveKitJWT, + fetchWellKnownWithRTC, + getLiveKitFocus, + requestOpenIdToken, + fetchLiveKitJWTFromServers, + getLiveKitHomeserverPriority, +} from './useRtcConfig'; import { getAudioSettings, SCREEN_SHARE_RESOLUTIONS, SCREEN_SHARE_BITRATES } from '../settings/audio/Audio'; import { getCallSounds, CallSoundType } from './CallSounds'; @@ -293,23 +300,48 @@ export class CallService { }); try { - // Step 1: Get an OpenID token from the Matrix homeserver - const openIdToken = await requestOpenIdToken( + // Step 1: Get room and determine which homeservers to try + const room = this.matrixClient.getRoom(roomId); + if (!room) { + throw new Error(`Room ${roomId} not found`); + } + + const homeserverPriority = getLiveKitHomeserverPriority( + room, this.config.homeserverBaseUrl, - this.config.userId, - this.config.accessToken + this.config.userId ); - // Step 2: Exchange OpenID token for LiveKit JWT - console.log('Requesting LiveKit JWT for Matrix room:', roomId); - this.livekitJwt = await fetchLiveKitJWT( - this.config.livekitServiceUrl, + console.log('Trying homeservers in order:', homeserverPriority); + + // Step 2: Try fetching LiveKit JWT from homeservers in priority order + const result = await fetchLiveKitJWTFromServers( + homeserverPriority, roomId, this.config.userId, this.config.deviceId, - openIdToken + this.config.accessToken ); + if (!result) { + throw new Error('Failed to get LiveKit JWT from any homeserver'); + } + + this.livekitJwt = result.jwt; + + // Track if using remote homeserver + const userHomeserverDomain = this.config.homeserverBaseUrl + .replace('https://', '') + .replace('http://', ''); + const usedHomeserverDomain = result.homeserver + .replace('https://', '') + .replace('http://', ''); + + if (usedHomeserverDomain !== userHomeserverDomain) { + this.activeCall!.livekitHomeserver = result.homeserver; + console.log(`Using remote homeserver for LiveKit: ${result.homeserver}`); + } + console.log('Got LiveKit JWT, connecting to:', this.livekitJwt.url); console.log('LiveKit JWT response:', JSON.stringify(this.livekitJwt)); @@ -918,6 +950,22 @@ export class CallService { return this.livekitRoom; } + /** + * Checks if the current call is using a remote homeserver for LiveKit + * @returns true if using a different homeserver than the user's + */ + isUsingRemoteHomeserver(): boolean { + return !!this.activeCall?.livekitHomeserver; + } + + /** + * Gets the homeserver being used for LiveKit (if remote) + * @returns The remote homeserver URL, or undefined if using user's homeserver + */ + getLiveKitHomeserver(): string | undefined { + return this.activeCall?.livekitHomeserver; + } + /** * Starts screen sharing with configurable resolution and bitrate * @returns true if screen share started successfully diff --git a/src/app/features/call/types.ts b/src/app/features/call/types.ts index f3b2e82..17097f4 100644 --- a/src/app/features/call/types.ts +++ b/src/app/features/call/types.ts @@ -119,6 +119,8 @@ export interface ActiveCall { isDeafened: boolean; isVideoEnabled: boolean; isScreenSharing: boolean; + /** The homeserver being used for LiveKit JWT (if different from user's homeserver) */ + livekitHomeserver?: string; } /** diff --git a/src/app/features/call/useCall.ts b/src/app/features/call/useCall.ts index c79a2ff..271d883 100644 --- a/src/app/features/call/useCall.ts +++ b/src/app/features/call/useCall.ts @@ -2,7 +2,7 @@ import { createContext, useContext, useState, useCallback, useEffect, useMemo } import { useMatrixClient } from '../../hooks/useMatrixClient'; import { CallService } from './CallService'; import { ActiveCall, CallEvent, CallEventType, CallState, CallType } from './types'; -import { fetchWellKnownWithRTC, getLiveKitFocus } from './useRtcConfig'; +import { fetchWellKnownWithRTC, getLiveKitFocus, getLiveKitHomeserverPriority } from './useRtcConfig'; interface CallContextValue { callService: CallService | null; @@ -245,10 +245,36 @@ export function useCallService(): CallContextValue { * Hook to check if a call is active in a specific room */ export function useRoomCall(roomId: string) { - const { activeCall, startCall, endCall, callSupported, callSupportLoading } = useCall(); + const { activeCall, startCall, endCall, callSupported, callSupportLoading, callService } = useCall(); + const mx = useMatrixClient(); const isInCall = activeCall?.roomId === roomId; const callState = isInCall ? activeCall.state : CallState.Idle; + const isUsingRemoteHomeserver = isInCall ? callService?.isUsingRemoteHomeserver() ?? false : false; + + // Check if a remote homeserver WOULD be used (pre-call check) + const wouldUseRemoteHomeserver = useMemo(() => { + if (isInCall) return isUsingRemoteHomeserver; + + const room = mx.getRoom(roomId); + if (!room) return false; + + const homeserverPriority = getLiveKitHomeserverPriority( + room, + mx.getHomeserverUrl(), + mx.getSafeUserId() + ); + + // Check if first priority is different from user's homeserver + const userHomeserverDomain = mx.getHomeserverUrl() + .replace('https://', '') + .replace('http://', ''); + const firstPriorityDomain = homeserverPriority[0] + ?.replace('https://', '') + .replace('http://', ''); + + return firstPriorityDomain !== userHomeserverDomain; + }, [isInCall, isUsingRemoteHomeserver, mx, roomId]); const startVoiceCall = useCallback(() => startCall(roomId, CallType.Voice), [roomId, startCall]); const startVideoCall = useCallback(() => startCall(roomId, CallType.Video), [roomId, startCall]); @@ -257,6 +283,7 @@ export function useRoomCall(roomId: string) { isInCall, callState, activeCall: isInCall ? activeCall : null, + isUsingRemoteHomeserver: wouldUseRemoteHomeserver, startVoiceCall, startVideoCall, endCall, diff --git a/src/app/features/call/useRtcConfig.ts b/src/app/features/call/useRtcConfig.ts index 88a0d58..ab5abfd 100644 --- a/src/app/features/call/useRtcConfig.ts +++ b/src/app/features/call/useRtcConfig.ts @@ -1,6 +1,59 @@ import { useCallback } from 'react'; +import { Room } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { LiveKitFocus, LiveKitJWTResponse, OpenIdToken, WellKnownWithRTC } from './types'; +import { getMxIdServer } from '../../utils/matrix'; +import { guessDmRoomUserId } from '../../utils/matrix'; + +/** + * Get room server from room ID (format: !roomid:server.com) + */ +function getRoomServer(roomId: string): string | undefined { + return getMxIdServer(roomId); +} + +/** + * Determine which homeservers to try for LiveKit JWT, in priority order + * @param room - Room to start call in + * @param userHomeserver - User's homeserver + * @param myUserId - Current user's Matrix ID + * @returns Array of homeserver base URLs to try in order + */ +export function getLiveKitHomeserverPriority( + room: Room, + userHomeserver: string, + myUserId: string +): string[] { + const servers: string[] = []; + const memberCount = room.getMembers().length; + const isDM = memberCount <= 2; + + if (isDM) { + // For DMs: user's homeserver first, then other member's homeserver + servers.push(userHomeserver); + + const otherUserId = guessDmRoomUserId(room, myUserId); + if (otherUserId && otherUserId !== myUserId) { + const otherServer = getMxIdServer(otherUserId); + if (otherServer && otherServer !== userHomeserver) { + servers.push(`https://${otherServer}`); + } + } + } else { + // For channels: room's homeserver first, then user's homeserver + const roomServer = getRoomServer(room.roomId); + if (roomServer) { + servers.push(`https://${roomServer}`); + } + + // Fallback to user's homeserver if different + if (!roomServer || roomServer !== userHomeserver.replace('https://', '').replace('http://', '')) { + servers.push(userHomeserver); + } + } + + return servers; +} /** * Fetches the LiveKit focus configuration from well-known data @@ -113,6 +166,59 @@ export async function fetchLiveKitJWT( return response.json(); } +/** + * Try fetching LiveKit JWT from multiple homeservers in priority order + * @param homeservers - Array of homeserver base URLs to try + * @param roomId - The Matrix room ID + * @param userId - The Matrix user ID + * @param deviceId - The Matrix device ID + * @param accessToken - The Matrix access token + * @returns LiveKit JWT response and the homeserver that provided it, or null + */ +export async function fetchLiveKitJWTFromServers( + homeservers: string[], + roomId: string, + userId: string, + deviceId: string, + accessToken: string +): Promise<{ jwt: LiveKitJWTResponse; homeserver: string } | null> { + for (const homeserver of homeservers) { + try { + console.log(`Trying LiveKit JWT from homeserver: ${homeserver}`); + + // Fetch well-known from this homeserver + const wellKnown = await fetchWellKnownWithRTC(homeserver); + const livekitFocus = getLiveKitFocus(wellKnown); + + if (!livekitFocus) { + console.log(`No LiveKit config on ${homeserver}, trying next...`); + continue; + } + + // Get OpenID token from this homeserver + const openIdToken = await requestOpenIdToken(homeserver, userId, accessToken); + + // Exchange for LiveKit JWT + const jwt = await fetchLiveKitJWT( + livekitFocus.livekit_service_url, + roomId, + userId, + deviceId, + openIdToken + ); + + console.log(`Successfully got LiveKit JWT from ${homeserver}`); + return { jwt, homeserver }; + } catch (error) { + console.warn(`Failed to get LiveKit JWT from ${homeserver}:`, error); + // Continue to next homeserver + } + } + + console.error('Failed to get LiveKit JWT from any homeserver'); + return null; +} + /** * Hook to get RTC configuration functions * Provides utilities for fetching well-known data and LiveKit JWTs diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index f8b3b97..e608c74 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -381,6 +381,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { isInCall, callState, activeCall, + isUsingRemoteHomeserver, startVoiceCall, startVideoCall, endCall, @@ -433,7 +434,10 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { offset={4} tooltip={ - {isInCall ? 'End Call' : 'Voice Call'} + + {isInCall ? 'End Call' : 'Voice Call'} + {isUsingRemoteHomeserver && !isInCall && ' (Remote Server)'} + } > @@ -446,7 +450,12 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { {isConnecting ? ( ) : ( - + + + {isUsingRemoteHomeserver && ( + + )} + )} )} @@ -456,7 +465,10 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { offset={4} tooltip={ - {isInCall ? 'End Call' : 'Video Call'} + + {isInCall ? 'End Call' : 'Video Call'} + {isUsingRemoteHomeserver && !isInCall && ' (Remote Server)'} + } > @@ -469,7 +481,12 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { {isConnecting ? ( ) : ( - + + + {isUsingRemoteHomeserver && ( + + )} + )} )}