From 8060d50a6fb52311f739fa556a95524f5f0a4c96 Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Mon, 20 Apr 2026 22:23:42 +1000 Subject: [PATCH] feat: implement CORS proxy for Matrix homeservers during development and refactor call handling logic --- src/app/features/call/CallService.ts | 1 + src/app/features/call/types.ts | 3 +- src/app/features/call/useCall.ts | 84 +++++++++++++++++------ src/app/features/call/useRtcConfig.ts | 79 ++++++++++++++++++--- src/app/features/room-nav/RoomNavItem.tsx | 7 +- src/app/features/room/RoomViewHeader.tsx | 14 +--- src/app/utils/matrix.ts | 2 +- vite.config.js | 79 +++++++++++++++++++++ 8 files changed, 221 insertions(+), 48 deletions(-) diff --git a/src/app/features/call/CallService.ts b/src/app/features/call/CallService.ts index adc31a1..452ea24 100644 --- a/src/app/features/call/CallService.ts +++ b/src/app/features/call/CallService.ts @@ -328,6 +328,7 @@ export class CallService { // Step 2: Try fetching LiveKit JWT from homeservers in priority order const result = await fetchLiveKitJWTFromServers( homeserverPriority, + this.config.homeserverBaseUrl, roomId, this.config.userId, this.config.deviceId, diff --git a/src/app/features/call/types.ts b/src/app/features/call/types.ts index 17097f4..60229ca 100644 --- a/src/app/features/call/types.ts +++ b/src/app/features/call/types.ts @@ -152,7 +152,8 @@ export interface CallEvent { * Call service configuration */ export interface CallServiceConfig { - livekitServiceUrl: string; + /** @deprecated No longer used - JWT fetched dynamically from multiple homeservers */ + livekitServiceUrl?: string; homeserverBaseUrl: string; accessToken: string; userId: string; diff --git a/src/app/features/call/useCall.ts b/src/app/features/call/useCall.ts index 271d883..6b0417d 100644 --- a/src/app/features/call/useCall.ts +++ b/src/app/features/call/useCall.ts @@ -82,26 +82,12 @@ export function useCallService(): CallContextValue { } try { - // Get LiveKit service URL from Matrix homeserver well-known - const wellKnown = await fetchWellKnownWithRTC(baseUrl); - // eslint-disable-next-line no-console - console.log('Call service init - well-known:', wellKnown); - const livekitFocus = getLiveKitFocus(wellKnown); - // eslint-disable-next-line no-console - console.log('Call service init - LiveKit focus:', livekitFocus); - const livekitServiceUrl = livekitFocus?.livekit_service_url; - - if (!livekitServiceUrl) { - // eslint-disable-next-line no-console - console.log('Call service init - No LiveKit config in well-known, calls not supported'); - setCallSupported(false); - setCallSupportLoading(false); - return; - } + // Initialize CallService without checking user's homeserver + // Call support will be determined per-room based on available homeservers + console.log('Call service init - Initializing (call support determined per-room)'); service = new CallService( { - livekitServiceUrl, homeserverBaseUrl: baseUrl, accessToken, userId, @@ -112,10 +98,10 @@ export function useCallService(): CallContextValue { setCallService(service); setGlobalCallService(service); // Make available globally for Paarrot API - setCallSupported(true); + setCallSupported(true); // Global flag - actual support checked per-room setCallSupportLoading(false); // eslint-disable-next-line no-console - console.log('Call service init - Success! Calls are supported'); + console.log('Call service init - Success!'); unsubscribe = service.on((event: CallEvent) => { // Update state for any event that might change the call state @@ -245,8 +231,62 @@ 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, callService } = useCall(); + const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall(); const mx = useMatrixClient(); + const [roomCallSupported, setRoomCallSupported] = useState(false); + const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true); + + // Check if calls are supported for this specific room by checking all priority homeservers + useEffect(() => { + const checkRoomCallSupport = async () => { + setRoomCallSupportLoading(true); + + const room = mx.getRoom(roomId); + if (!room) { + setRoomCallSupported(false); + setRoomCallSupportLoading(false); + return; + } + + // Get homeserver priority list for this room + const homeserverPriority = getLiveKitHomeserverPriority( + room, + mx.getHomeserverUrl(), + mx.getSafeUserId() + ); + + console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority); + + // Check each homeserver in priority order + for (const homeserver of homeserverPriority) { + try { + console.log(`Fetching well-known from ${homeserver}...`); + const wellKnown = await fetchWellKnownWithRTC(homeserver); + console.log(`Well-known response from ${homeserver}:`, wellKnown); + + const livekitFocus = getLiveKitFocus(wellKnown); + console.log(`LiveKit focus from ${homeserver}:`, livekitFocus); + + if (livekitFocus) { + console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`); + setRoomCallSupported(true); + setRoomCallSupportLoading(false); + return; + } + } catch (error) { + console.warn(`Failed to check call support on ${homeserver}:`, error); + // Continue to next homeserver + } + } + + // No homeserver supports calls + console.log(`No call support found for room ${roomId}`); + setRoomCallSupported(false); + setRoomCallSupportLoading(false); + }; + + checkRoomCallSupport(); + }, [roomId, mx]); const isInCall = activeCall?.roomId === roomId; const callState = isInCall ? activeCall.state : CallState.Idle; @@ -287,7 +327,7 @@ export function useRoomCall(roomId: string) { startVoiceCall, startVideoCall, endCall, - callSupported, - callSupportLoading, + callSupported: roomCallSupported, + callSupportLoading: roomCallSupportLoading, }; } diff --git a/src/app/features/call/useRtcConfig.ts b/src/app/features/call/useRtcConfig.ts index 437913d..84d74ce 100644 --- a/src/app/features/call/useRtcConfig.ts +++ b/src/app/features/call/useRtcConfig.ts @@ -10,7 +10,9 @@ import { getActiveCallMembers } from './useRoomCallMembers'; * Get room server from room ID (format: !roomid:server.com) */ function getRoomServer(roomId: string): string | undefined { - return getMxIdServer(roomId); + const server = getMxIdServer(roomId); + console.log(`[getRoomServer] Room ID: ${roomId} -> Server: ${server}`); + return server; } /** @@ -30,13 +32,20 @@ export function getLiveKitHomeserverPriority( const memberCount = room.getMembers().length; const isDM = memberCount <= 2; + console.log(`[getLiveKitHomeserverPriority] Room: ${room.roomId}`); + console.log(`[getLiveKitHomeserverPriority] Member count: ${memberCount}, isDM: ${isDM}`); + console.log(`[getLiveKitHomeserverPriority] User homeserver: ${userHomeserver}`); + // Check for active call members const activeCallMembers = getActiveCallMembers(room); const hasActiveCall = activeCallMembers.length > 0; + console.log(`[getLiveKitHomeserverPriority] Active call members: ${activeCallMembers.length}, hasActiveCall: ${hasActiveCall}`); + if (hasActiveCall) { // Joining existing call - prioritize room's homeserver (where call is hosted) const roomServer = getRoomServer(room.roomId); + console.log(`[getLiveKitHomeserverPriority] Room server (active call): ${roomServer}`); if (roomServer) { servers.push(`https://${roomServer}`); } @@ -60,32 +69,39 @@ export function getLiveKitHomeserverPriority( } else { // Starting new call - use original priority logic if (isDM) { + console.log(`[getLiveKitHomeserverPriority] DM path - user homeserver first`); // For DMs: user's homeserver first, then other member's homeserver servers.push(userHomeserver); const otherUserId = guessDmRoomUserId(room, myUserId); + console.log(`[getLiveKitHomeserverPriority] Other user ID: ${otherUserId}`); if (otherUserId && otherUserId !== myUserId) { const otherServer = getMxIdServer(otherUserId); + console.log(`[getLiveKitHomeserverPriority] Other user server: ${otherServer}`); const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); if (otherServer && otherServer !== userDomain) { servers.push(`https://${otherServer}`); } } } else { + console.log(`[getLiveKitHomeserverPriority] Channel path - room homeserver first`); // For channels: room's homeserver first, then user's homeserver const roomServer = getRoomServer(room.roomId); + console.log(`[getLiveKitHomeserverPriority] Room server from ID: ${roomServer}`); if (roomServer) { servers.push(`https://${roomServer}`); } // Fallback to user's homeserver if different const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); + console.log(`[getLiveKitHomeserverPriority] User domain: ${userDomain}, room server: ${roomServer}`); if (!roomServer || roomServer !== userDomain) { servers.push(userHomeserver); } } } + console.log(`[getLiveKitHomeserverPriority] Final server list:`, servers); return servers; } @@ -96,11 +112,16 @@ export function getLiveKitHomeserverPriority( */ export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined { const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci']; + console.log(`[getLiveKitFocus] rtcFoci from well-known:`, rtcFoci); + if (!rtcFoci || !Array.isArray(rtcFoci)) { + console.log(`[getLiveKitFocus] No rtc_foci found or not an array`); return undefined; } - return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit'); + const livekitFocus = rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit'); + console.log(`[getLiveKitFocus] Found LiveKit focus:`, livekitFocus); + return livekitFocus; } /** @@ -109,14 +130,28 @@ export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | und * @returns The well-known response with RTC configuration */ export async function fetchWellKnownWithRTC(baseUrl: string): Promise { - const wellKnownUrl = `${baseUrl}/.well-known/matrix/client`; + // In development mode (localhost), use CORS proxy to avoid CORS issues + const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + + let wellKnownUrl: string; + if (isDev) { + // Extract hostname from baseUrl for proxy + const hostname = baseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, ''); + wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`; + } else { + wellKnownUrl = `${baseUrl}/.well-known/matrix/client`; + } + + console.log(`[fetchWellKnownWithRTC] Fetching from: ${wellKnownUrl}`); const response = await fetch(wellKnownUrl); if (!response.ok) { throw new Error(`Failed to fetch well-known: ${response.status}`); } - return response.json(); + const data = await response.json(); + console.log(`[fetchWellKnownWithRTC] Response data:`, data); + return data; } /** @@ -132,7 +167,16 @@ export async function requestOpenIdToken( userId: string, accessToken: string ): Promise { - const url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`; + // In development mode (localhost), use CORS proxy to avoid CORS issues + const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + + let url: string; + if (isDev) { + const hostname = homeserverBaseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, ''); + url = `/_matrix-cors-proxy/${hostname}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`; + } else { + url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`; + } const response = await fetch(url, { method: 'POST', @@ -175,7 +219,21 @@ export async function fetchLiveKitJWT( // to create the LiveKit room name. const slotId = 'm.call'; - const response = await fetch(livekitServiceUrl, { + // In development mode (localhost), use CORS proxy to avoid CORS issues + const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; + + let url: string; + if (isDev) { + // Extract hostname and path from livekitServiceUrl + const urlObj = new URL(livekitServiceUrl); + const hostname = urlObj.hostname; + const pathname = urlObj.pathname; + url = `/_matrix-cors-proxy/${hostname}${pathname}`; + } else { + url = livekitServiceUrl; + } + + const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -202,7 +260,8 @@ export async function fetchLiveKitJWT( /** * Try fetching LiveKit JWT from multiple homeservers in priority order - * @param homeservers - Array of homeserver base URLs to try + * @param homeservers - Array of homeserver base URLs to check for LiveKit config + * @param userHomeserver - The user's own homeserver URL (for OpenID token) * @param roomId - The Matrix room ID * @param userId - The Matrix user ID * @param deviceId - The Matrix device ID @@ -211,6 +270,7 @@ export async function fetchLiveKitJWT( */ export async function fetchLiveKitJWTFromServers( homeservers: string[], + userHomeserver: string, roomId: string, userId: string, deviceId: string, @@ -229,8 +289,9 @@ export async function fetchLiveKitJWTFromServers( continue; } - // Get OpenID token from this homeserver - const openIdToken = await requestOpenIdToken(homeserver, userId, accessToken); + // Always get OpenID token from user's own homeserver + // (Can't use access token on remote homeserver) + const openIdToken = await requestOpenIdToken(userHomeserver, userId, accessToken); // Exchange for LiveKit JWT const jwt = await fetchLiveKitJWT( diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 3d97988..4a38854 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -56,7 +56,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { CallParticipantsIndicator } from './CallParticipantsIndicator'; -import { useCall } from '../call/useCall'; +import { useCall, useRoomCall } from '../call/useCall'; import { CallType } from '../call/types'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { StateEvent } from '../../../types/matrix/room'; @@ -75,7 +75,8 @@ const RoomNavItemMenu = forwardRef( const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); - const { startCall, endCall, activeCall, callSupported } = useCall(); + const { startCall: startCallGlobal, endCall, activeCall } = useCall(); + const { callSupported } = useRoomCall(room.roomId); const markAsRead = useMarkAsRead(mx); const permissions = useRoomPermissions(creators, powerLevels); @@ -119,7 +120,7 @@ const RoomNavItemMenu = forwardRef( if (activeCall) { await endCall(); } - await startCall(room.roomId, CallType.Voice); + await startCallGlobal(room.roomId, CallType.Voice); requestClose(); } catch (error) { console.error('Failed to join voice call:', error); diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index e608c74..697fa50 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -450,12 +450,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { {isConnecting ? ( ) : ( - - - {isUsingRemoteHomeserver && ( - - )} - + )} )} @@ -481,12 +476,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { {isConnecting ? ( ) : ( - - - {isUsingRemoteHomeserver && ( - - )} - + )} )} diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index dd5d4d4..7058371 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -23,7 +23,7 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName); -const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/); +const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])([^\s:]+):(\S+)$/); const validMxId = (id: string): boolean => !!matchMxId(id); diff --git a/vite.config.js b/vite.config.js index 83b0c94..95d9911 100644 --- a/vite.config.js +++ b/vite.config.js @@ -135,6 +135,83 @@ function servePublicAssets() { }; } +// CORS proxy for Matrix homeservers during development +function corsProxyMiddleware() { + return { + name: 'vite-plugin-cors-proxy', + configureServer(server) { + server.middlewares.use(async (req, res, next) => { + // Proxy /.well-known requests: /_matrix-cors-proxy/homeserver.com/.well-known/matrix/client + const corsProxyMatch = req.url?.match(/^\/_matrix-cors-proxy\/([^/]+)(\/.*)/); + if (corsProxyMatch) { + const [, hostname, pathname] = corsProxyMatch; + const targetUrl = `https://${hostname}${pathname}`; + + console.log(`[CORS Proxy] ${req.method} ${req.url}`); + console.log(`[CORS Proxy] Target: ${targetUrl}`); + + // Handle preflight OPTIONS request + if (req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.writeHead(204); + res.end(); + return; + } + + try { + // Collect request body for POST/PUT + let body = null; + if (req.method === 'POST' || req.method === 'PUT') { + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + body = Buffer.concat(chunks).toString(); + console.log(`[CORS Proxy] Request body:`, body); + } + + // Forward request with method, headers, and body + const fetchOptions = { + method: req.method, + headers: { + 'Content-Type': req.headers['content-type'] || 'application/json', + 'Authorization': req.headers['authorization'] || '', + }, + }; + + if (body) { + fetchOptions.body = body; + } + + const response = await fetch(targetUrl, fetchOptions); + const data = await response.text(); + + console.log(`[CORS Proxy] Response status: ${response.status}`); + console.log(`[CORS Proxy] Response data:`, data.substring(0, 500)); + + // Set CORS headers + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json'); + + res.writeHead(response.status); + res.end(data); + } catch (error) { + console.error(`[CORS Proxy] Error for ${targetUrl}:`, error); + res.writeHead(500); + res.end(JSON.stringify({ error: error.message })); + } + } else { + next(); + } + }); + }, + }; +} + export default defineConfig({ appType: 'spa', publicDir: false, @@ -143,12 +220,14 @@ export default defineConfig({ server: { port: 38347, host: '0.0.0.0', + cors: true, // Enable CORS for dev server fs: { // Allow serving files from one level up to the project root allow: ['..'], }, }, plugins: [ + corsProxyMiddleware(), // Add CORS proxy for development serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'), serveGifWorker(), servePublicAssets(),