diff --git a/src/app/core/services/server-directory.service.ts b/src/app/core/services/server-directory.service.ts index 3e74d65..c4da125 100644 --- a/src/app/core/services/server-directory.service.ts +++ b/src/app/core/services/server-directory.service.ts @@ -12,10 +12,21 @@ import { forkJoin } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; +import { STORAGE_KEY_CONNECTION_SETTINGS } from '../constants'; import { ServerInfo, User } from '../models/index'; import { v4 as uuidv4 } from 'uuid'; import { environment } from '../../../environments/environment'; +interface DefaultServerDefinition { + key: string; + name: string; + url: string; +} + +type DefaultEndpointTemplate = Omit & { + defaultKey: string; +}; + /** * A configured server endpoint that the user can connect to. */ @@ -30,6 +41,8 @@ export interface ServerEndpoint { isActive: boolean; /** Whether this is the built-in default endpoint. */ isDefault: boolean; + /** Stable identifier for a built-in default endpoint. */ + defaultKey?: string; /** Most recent health-check result. */ status: 'online' | 'offline' | 'checking' | 'unknown'; /** Last measured round-trip latency (ms). */ @@ -101,6 +114,8 @@ export interface UnbanServerMemberRequest { /** localStorage key that persists the user's configured endpoints. */ const ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; +/** localStorage key that tracks which built-in endpoints the user removed. */ +const REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys'; /** Timeout (ms) for server health-check and alternative-endpoint pings. */ const HEALTH_CHECK_TIMEOUT_MS = 5000; @@ -139,7 +154,7 @@ function normaliseDefaultServerUrl(rawUrl: string): string { * Derive the default server URL from the environment when provided, * otherwise match the current page protocol automatically. */ -function buildDefaultServerUrl(): string { +function buildFallbackDefaultServerUrl(): string { const configuredUrl = environment.defaultServerUrl?.trim(); if (configuredUrl) { @@ -149,14 +164,61 @@ function buildDefaultServerUrl(): string { return `${getDefaultHttpProtocol()}://localhost:3001`; } -/** Blueprint for the built-in default endpoint. */ -const DEFAULT_ENDPOINT: Omit = { - name: 'Default Server', - url: buildDefaultServerUrl(), - isActive: true, - isDefault: true, - status: 'unknown' -}; +function buildDefaultServerDefinitions(): DefaultServerDefinition[] { + const configuredDefaults = Array.isArray(environment.defaultServers) + ? environment.defaultServers + : []; + const seenKeys = new Set(); + const seenUrls = new Set(); + const definitions = configuredDefaults + .map((server, index) => { + const key = server.key?.trim() || `default-${index + 1}`; + const url = normaliseDefaultServerUrl(server.url ?? ''); + + if (!key || !url || seenKeys.has(key) || seenUrls.has(url)) { + return null; + } + + seenKeys.add(key); + seenUrls.add(url); + + return { + key, + name: server.name?.trim() || (index === 0 ? 'Default Server' : `Default Server ${index + 1}`), + url + } satisfies DefaultServerDefinition; + }) + .filter((definition): definition is DefaultServerDefinition => definition !== null); + + if (definitions.length > 0) { + return definitions; + } + + return [ + { + key: 'default', + name: 'Default Server', + url: buildFallbackDefaultServerUrl() + } + ]; +} + +const DEFAULT_SERVER_DEFINITIONS = buildDefaultServerDefinitions(); +/** Blueprints for built-in default endpoints. */ +const DEFAULT_ENDPOINTS: DefaultEndpointTemplate[] = DEFAULT_SERVER_DEFINITIONS.map( + (definition) => ({ + name: definition.name, + url: definition.url, + isActive: true, + isDefault: true, + defaultKey: definition.key, + status: 'unknown' + }) +); + +function getPrimaryDefaultServerUrl(): string { + return DEFAULT_ENDPOINTS[0]?.url ?? buildFallbackDefaultServerUrl(); +} /** * Manages the user's list of configured server endpoints and @@ -171,22 +233,31 @@ export class ServerDirectoryService { private readonly _servers = signal([]); /** Whether search queries should be fanned out to all non-offline endpoints. */ - private shouldSearchAllServers = false; + private shouldSearchAllServers = true; /** Reactive list of all configured endpoints. */ readonly servers = computed(() => this._servers()); - /** The currently active endpoint, falling back to the first in the list. */ + /** Endpoints currently enabled for discovery. */ + readonly activeServers = computed(() => this._servers().filter((endpoint) => endpoint.isActive)); + + /** Whether any built-in endpoints are currently missing from the list. */ + readonly hasMissingDefaultServers = computed(() => + DEFAULT_ENDPOINTS.some((endpoint) => !this.hasEndpointForDefault(this._servers(), endpoint)) + ); + + /** The primary active endpoint, falling back to the first configured endpoint. */ readonly activeServer = computed( - () => this._servers().find((endpoint) => endpoint.isActive) ?? this._servers()[0] + () => this.activeServers()[0] ?? this._servers()[0] ); constructor(private readonly http: HttpClient) { + this.loadConnectionSettings(); this.loadEndpoints(); } /** - * Add a new server endpoint (inactive by default). + * Add a new server endpoint (active by default). * * @param server - Name and URL of the endpoint to add. */ @@ -196,7 +267,7 @@ export class ServerDirectoryService { id: uuidv4(), name: server.name, url: sanitisedUrl, - isActive: false, + isActive: true, isDefault: false, status: 'unknown' }; @@ -241,24 +312,30 @@ export class ServerDirectoryService { /** * Remove an endpoint by ID. - * The built-in default endpoint cannot be removed. If the removed - * endpoint was active, the first remaining endpoint is activated. + * When the removed endpoint was active, the first remaining endpoint + * becomes active. */ removeServer(endpointId: string): void { const endpoints = this._servers(); const target = endpoints.find((endpoint) => endpoint.id === endpointId); - if (target?.isDefault) + if (!target || endpoints.length <= 1) return; - const wasActive = target?.isActive; + const wasActive = target.isActive; + + if (target.isDefault) { + this.markDefaultEndpointRemoved(target); + } this._servers.update((list) => list.filter((endpoint) => endpoint.id !== endpointId)); if (wasActive) { this._servers.update((list) => { - if (list.length > 0) - list[0].isActive = true; + if (list.length > 0 && !list.some((endpoint) => endpoint.isActive)) { + list[0] = { ...list[0], + isActive: true }; + } return [...list]; }); @@ -267,13 +344,75 @@ export class ServerDirectoryService { this.saveEndpoints(); } - /** Activate a specific endpoint and deactivate all others. */ + /** Restore any missing built-in endpoints without touching existing ones. */ + restoreDefaultServers(): ServerEndpoint[] { + const currentEndpoints = this._servers(); + const restoredEndpoints: ServerEndpoint[] = []; + + for (const defaultEndpoint of DEFAULT_ENDPOINTS) { + if (this.hasEndpointForDefault(currentEndpoints, defaultEndpoint)) { + continue; + } + + restoredEndpoints.push({ + ...defaultEndpoint, + id: uuidv4(), + isActive: true + }); + } + + if (restoredEndpoints.length === 0) { + this.clearRemovedDefaultEndpointKeys(); + return []; + } + + this._servers.update((endpoints) => { + const next = [...endpoints, ...restoredEndpoints]; + + if (!next.some((endpoint) => endpoint.isActive)) { + next[0] = { ...next[0], + isActive: true }; + } + + return next; + }); + + this.clearRemovedDefaultEndpointKeys(); + this.saveEndpoints(); + return restoredEndpoints; + } + + /** Mark an endpoint as active without changing other active endpoints. */ setActiveServer(endpointId: string): void { + this._servers.update((endpoints) => { + const target = endpoints.find((endpoint) => endpoint.id === endpointId); + + if (!target) { + return endpoints; + } + + return endpoints.map((endpoint) => + endpoint.id === endpointId ? { ...endpoint, + isActive: true } : endpoint + ); + }); + + this.saveEndpoints(); + } + + /** Deactivate an endpoint while keeping at least one endpoint active. */ + deactivateServer(endpointId: string): void { + const activeEndpointCount = this.activeServers().length; + + if (activeEndpointCount <= 1) { + return; + } + this._servers.update((endpoints) => - endpoints.map((endpoint) => ({ - ...endpoint, - isActive: endpoint.id === endpointId - })) + endpoints.map((endpoint) => + endpoint.id === endpointId ? { ...endpoint, + isActive: false } : endpoint + ) ); this.saveEndpoints(); @@ -635,7 +774,7 @@ export class ServerDirectoryService { return this.sanitiseUrl(selector.sourceUrl); } - return this.resolveEndpoint(selector)?.url ?? buildDefaultServerUrl(); + return this.resolveEndpoint(selector)?.url ?? getPrimaryDefaultServerUrl(); } /** @@ -672,7 +811,7 @@ export class ServerDirectoryService { /** Fan-out search across all non-offline endpoints, deduplicating results. */ private searchAllEndpoints(query: string): Observable { - const onlineEndpoints = this._servers().filter( + const onlineEndpoints = this.activeServers().filter( (endpoint) => endpoint.status !== 'offline' ); @@ -692,7 +831,7 @@ export class ServerDirectoryService { /** Retrieve all servers from all non-offline endpoints. */ private getAllServersFromAllEndpoints(): Observable { - const onlineEndpoints = this._servers().filter( + const onlineEndpoints = this.activeServers().filter( (endpoint) => endpoint.status !== 'offline' ); @@ -780,50 +919,201 @@ export class ServerDirectoryService { return typeof value === 'string' ? value : undefined; } + /** Apply persisted connection settings before any directory queries run. */ + private loadConnectionSettings(): void { + const stored = localStorage.getItem(STORAGE_KEY_CONNECTION_SETTINGS); + + if (!stored) { + this.shouldSearchAllServers = true; + return; + } + + try { + const parsed = JSON.parse(stored) as { searchAllServers?: boolean }; + + this.shouldSearchAllServers = parsed.searchAllServers ?? true; + } catch { + this.shouldSearchAllServers = true; + } + } + /** Load endpoints from localStorage, syncing the built-in default endpoint if needed. */ private loadEndpoints(): void { const stored = localStorage.getItem(ENDPOINTS_STORAGE_KEY); if (!stored) { - this.initialiseDefaultEndpoint(); + this.initialiseDefaultEndpoints(); return; } try { - let endpoints = JSON.parse(stored) as ServerEndpoint[]; - - // Ensure at least one endpoint is active - if (endpoints.length > 0 && !endpoints.some((ep) => ep.isActive)) { - endpoints[0].isActive = true; - } - - const defaultServerUrl = buildDefaultServerUrl(); - - endpoints = endpoints.map((endpoint) => { - if (endpoint.isDefault) { - return { ...endpoint, - url: defaultServerUrl }; - } - - return endpoint; - }); + const parsed = JSON.parse(stored) as ServerEndpoint[]; + const endpoints = this.reconcileStoredEndpoints(parsed); this._servers.set(endpoints); this.saveEndpoints(); } catch { - this.initialiseDefaultEndpoint(); + this.initialiseDefaultEndpoints(); } } - /** Create and persist the built-in default endpoint. */ - private initialiseDefaultEndpoint(): void { - const defaultEndpoint: ServerEndpoint = { ...DEFAULT_ENDPOINT, - id: uuidv4() }; + /** Create and persist the built-in default endpoints. */ + private initialiseDefaultEndpoints(): void { + const defaultEndpoints = DEFAULT_ENDPOINTS.map((endpoint) => ({ + ...endpoint, + id: uuidv4() + })); - this._servers.set([defaultEndpoint]); + this._servers.set(defaultEndpoints); this.saveEndpoints(); } + private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] { + const reconciled: ServerEndpoint[] = []; + const claimedDefaultKeys = new Set(); + const removedDefaultKeys = this.loadRemovedDefaultEndpointKeys(); + + for (const endpoint of Array.isArray(storedEndpoints) ? storedEndpoints : []) { + if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') { + continue; + } + + const sanitisedUrl = this.sanitiseUrl(endpoint.url); + const matchedDefault = this.matchDefaultEndpoint(endpoint, sanitisedUrl, claimedDefaultKeys); + + if (matchedDefault) { + claimedDefaultKeys.add(matchedDefault.defaultKey); + reconciled.push({ + ...endpoint, + name: matchedDefault.name, + url: matchedDefault.url, + isDefault: true, + defaultKey: matchedDefault.defaultKey, + status: endpoint.status ?? 'unknown' + }); + + continue; + } + + reconciled.push({ + ...endpoint, + url: sanitisedUrl, + status: endpoint.status ?? 'unknown' + }); + } + + for (const defaultEndpoint of DEFAULT_ENDPOINTS) { + if ( + !claimedDefaultKeys.has(defaultEndpoint.defaultKey) + && !removedDefaultKeys.has(defaultEndpoint.defaultKey) + && !this.hasEndpointForDefault(reconciled, defaultEndpoint) + ) { + reconciled.push({ + ...defaultEndpoint, + id: uuidv4(), + isActive: defaultEndpoint.isActive + }); + } + } + + if (reconciled.length > 0 && !reconciled.some((endpoint) => endpoint.isActive)) { + reconciled[0] = { ...reconciled[0], + isActive: true }; + } + + return reconciled; + } + + private matchDefaultEndpoint( + endpoint: ServerEndpoint, + sanitisedUrl: string, + claimedDefaultKeys: Set + ): DefaultEndpointTemplate | null { + if (endpoint.defaultKey) { + return DEFAULT_ENDPOINTS.find( + (candidate) => candidate.defaultKey === endpoint.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey) + ) ?? null; + } + + if (!endpoint.isDefault) { + return null; + } + + const matchingCurrentDefault = DEFAULT_ENDPOINTS.find( + (candidate) => candidate.url === sanitisedUrl && candidate.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey) + ); + + if (matchingCurrentDefault) { + return matchingCurrentDefault; + } + + return DEFAULT_ENDPOINTS.find( + (candidate) => candidate.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey) + ) ?? null; + } + + private hasEndpointForDefault( + endpoints: ServerEndpoint[], + defaultEndpoint: DefaultEndpointTemplate + ): boolean { + return endpoints.some((endpoint) => + endpoint.defaultKey === defaultEndpoint.defaultKey + || this.sanitiseUrl(endpoint.url) === defaultEndpoint.url + ); + } + + private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void { + const defaultKey = endpoint.defaultKey ?? this.findDefaultEndpointKeyByUrl(endpoint.url); + + if (!defaultKey) { + return; + } + + const removedDefaultKeys = this.loadRemovedDefaultEndpointKeys(); + + removedDefaultKeys.add(defaultKey); + this.saveRemovedDefaultEndpointKeys(removedDefaultKeys); + } + + private findDefaultEndpointKeyByUrl(url: string): string | null { + const sanitisedUrl = this.sanitiseUrl(url); + + return DEFAULT_ENDPOINTS.find((endpoint) => endpoint.url === sanitisedUrl)?.defaultKey ?? null; + } + + private loadRemovedDefaultEndpointKeys(): Set { + const stored = localStorage.getItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY); + + if (!stored) { + return new Set(); + } + + try { + const parsed = JSON.parse(stored) as unknown; + + if (!Array.isArray(parsed)) { + return new Set(); + } + + return new Set(parsed.filter((value): value is string => typeof value === 'string')); + } catch { + return new Set(); + } + } + + private saveRemovedDefaultEndpointKeys(keys: Set): void { + if (keys.size === 0) { + localStorage.removeItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY); + return; + } + + localStorage.setItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY, JSON.stringify([...keys])); + } + + private clearRemovedDefaultEndpointKeys(): void { + localStorage.removeItem(REMOVED_DEFAULT_ENDPOINT_KEYS_STORAGE_KEY); + } + /** Persist the current endpoint list to localStorage. */ private saveEndpoints(): void { localStorage.setItem(ENDPOINTS_STORAGE_KEY, JSON.stringify(this._servers())); diff --git a/src/app/core/services/webrtc.service.ts b/src/app/core/services/webrtc.service.ts index 3ce8964..8f95ada 100644 --- a/src/app/core/services/webrtc.service.ts +++ b/src/app/core/services/webrtc.service.ts @@ -19,7 +19,12 @@ import { inject, OnDestroy } from '@angular/core'; -import { Observable, Subject } from 'rxjs'; +import { + Observable, + of, + Subject, + Subscription +} from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; import { SignalingMessage, ChatEvent } from '../models/index'; import { TimeSyncService } from './time-sync.service'; @@ -88,8 +93,13 @@ export class WebRTCService implements OnDestroy { private readonly logger = new WebRTCLogger(() => this.debugging.enabled()); private lastIdentifyCredentials: IdentifyCredentials | null = null; - private lastJoinedServer: JoinedServerInfo | null = null; - private readonly memberServerIds = new Set(); + private readonly lastJoinedServerBySignalUrl = new Map(); + private readonly memberServerIdsBySignalUrl = new Map>(); + private readonly serverSignalingUrlMap = new Map(); + private readonly peerSignalingUrlMap = new Map(); + private readonly signalingManagers = new Map(); + private readonly signalingSubscriptions = new Map(); + private readonly signalingConnectionStates = new Map(); private activeServerId: string | null = null; /** The server ID where voice is currently active, or `null` when not in voice. */ private voiceServerId: string | null = null; @@ -168,20 +178,12 @@ export class WebRTCService implements OnDestroy { return this.mediaManager.voiceConnected$.asObservable(); } - private readonly signalingManager: SignalingManager; private readonly peerManager: PeerConnectionManager; private readonly mediaManager: MediaManager; private readonly screenShareManager: ScreenShareManager; constructor() { // Create managers with null callbacks first to break circular initialization - this.signalingManager = new SignalingManager( - this.logger, - () => this.lastIdentifyCredentials, - () => this.lastJoinedServer, - () => this.memberServerIds - ); - this.peerManager = new PeerConnectionManager(this.logger, null!); this.mediaManager = new MediaManager(this.logger, null!); @@ -190,7 +192,7 @@ export class WebRTCService implements OnDestroy { // Now wire up cross-references (all managers are instantiated) this.peerManager.setCallbacks({ - sendRawMessage: (msg: Record) => this.signalingManager.sendRawMessage(msg), + sendRawMessage: (msg: Record) => this.sendRawMessage(msg), getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), isSignalingConnected: (): boolean => this._isSignalingConnected(), getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(), @@ -231,23 +233,6 @@ export class WebRTCService implements OnDestroy { } private wireManagerEvents(): void { - // Signaling → connection status - this.signalingManager.connectionStatus$.subscribe(({ connected, errorMessage }) => { - this._isSignalingConnected.set(connected); - - if (connected) - this._hasEverConnected.set(true); - - this._hasConnectionError.set(!connected); - this._connectionErrorMessage.set(connected ? null : (errorMessage ?? null)); - }); - - // Signaling → message routing - this.signalingManager.messageReceived$.subscribe((msg) => this.handleSignalingMessage(msg)); - - // Signaling → heartbeat → broadcast states - this.signalingManager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates()); - // Internal control-plane messages for on-demand screen-share delivery. this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event)); @@ -277,6 +262,7 @@ export class WebRTCService implements OnDestroy { this.peerManager.peerDisconnected$.subscribe((peerId) => { this.activeRemoteScreenSharePeers.delete(peerId); this.peerServerMap.delete(peerId); + this.peerSignalingUrlMap.delete(peerId); this.screenShareManager.clearScreenShareRequest(peerId); }); @@ -293,37 +279,145 @@ export class WebRTCService implements OnDestroy { }); } - private handleSignalingMessage(message: IncomingSignalingMessage): void { + private ensureSignalingManager(signalUrl: string): SignalingManager { + const existingManager = this.signalingManagers.get(signalUrl); + + if (existingManager) { + return existingManager; + } + + const manager = new SignalingManager( + this.logger, + () => this.lastIdentifyCredentials, + () => this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null, + () => this.getMemberServerIdsForSignalUrl(signalUrl) + ); + const subscriptions: Subscription[] = [ + manager.connectionStatus$.subscribe(({ connected, errorMessage }) => + this.handleSignalingConnectionStatus(signalUrl, connected, errorMessage) + ), + manager.messageReceived$.subscribe((message) => this.handleSignalingMessage(message, signalUrl)), + manager.heartbeatTick$.subscribe(() => this.peerManager.broadcastCurrentStates()) + ]; + + this.signalingManagers.set(signalUrl, manager); + this.signalingSubscriptions.set(signalUrl, subscriptions); + return manager; + } + + private handleSignalingConnectionStatus( + signalUrl: string, + connected: boolean, + errorMessage?: string + ): void { + this.signalingConnectionStates.set(signalUrl, connected); + + if (connected) + this._hasEverConnected.set(true); + + const anyConnected = this.isAnySignalingConnected(); + + this._isSignalingConnected.set(anyConnected); + this._hasConnectionError.set(!anyConnected); + this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'Disconnected from signaling server')); + } + + private isAnySignalingConnected(): boolean { + for (const manager of this.signalingManagers.values()) { + if (manager.isSocketOpen()) { + return true; + } + } + + return false; + } + + private getConnectedSignalingManagers(): { signalUrl: string; manager: SignalingManager }[] { + const connectedManagers: { signalUrl: string; manager: SignalingManager }[] = []; + + for (const [signalUrl, manager] of this.signalingManagers.entries()) { + if (!manager.isSocketOpen()) { + continue; + } + + connectedManagers.push({ signalUrl, + manager }); + } + + return connectedManagers; + } + + private getOrCreateMemberServerSet(signalUrl: string): Set { + const existingSet = this.memberServerIdsBySignalUrl.get(signalUrl); + + if (existingSet) { + return existingSet; + } + + const createdSet = new Set(); + + this.memberServerIdsBySignalUrl.set(signalUrl, createdSet); + return createdSet; + } + + private getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet { + return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set(); + } + + private isJoinedServer(serverId: string): boolean { + for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { + if (memberServerIds.has(serverId)) { + return true; + } + } + + return false; + } + + private getJoinedServerCount(): number { + let joinedServerCount = 0; + + for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { + joinedServerCount += memberServerIds.size; + } + + return joinedServerCount; + } + + private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.signalingMessage$.next(message); - this.logger.info('Signaling message', { type: message.type }); + this.logger.info('Signaling message', { + signalUrl, + type: message.type + }); switch (message.type) { case SIGNALING_TYPE_CONNECTED: - this.handleConnectedSignalingMessage(message); + this.handleConnectedSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_SERVER_USERS: - this.handleServerUsersSignalingMessage(message); + this.handleServerUsersSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_USER_JOINED: - this.handleUserJoinedSignalingMessage(message); + this.handleUserJoinedSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_USER_LEFT: - this.handleUserLeftSignalingMessage(message); + this.handleUserLeftSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_OFFER: - this.handleOfferSignalingMessage(message); + this.handleOfferSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_ANSWER: - this.handleAnswerSignalingMessage(message); + this.handleAnswerSignalingMessage(message, signalUrl); return; case SIGNALING_TYPE_ICE_CANDIDATE: - this.handleIceCandidateSignalingMessage(message); + this.handleIceCandidateSignalingMessage(message, signalUrl); return; default: @@ -331,26 +425,40 @@ export class WebRTCService implements OnDestroy { } } - private handleConnectedSignalingMessage(message: IncomingSignalingMessage): void { - this.logger.info('Server connected', { oderId: message.oderId }); + private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + this.logger.info('Server connected', { + oderId: message.oderId, + signalUrl + }); + + if (message.serverId) { + this.serverSignalingUrlMap.set(message.serverId, signalUrl); + } if (typeof message.serverTime === 'number') { this.timeSync.setFromServerTime(message.serverTime); } } - private handleServerUsersSignalingMessage(message: IncomingSignalingMessage): void { + private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { const users = Array.isArray(message.users) ? message.users : []; this.logger.info('Server users', { count: users.length, + signalUrl, serverId: message.serverId }); + if (message.serverId) { + this.serverSignalingUrlMap.set(message.serverId, signalUrl); + } + for (const user of users) { if (!user.oderId) continue; + this.peerSignalingUrlMap.set(user.oderId, signalUrl); + if (message.serverId) { this.trackPeerInServer(user.oderId, message.serverId); } @@ -376,21 +484,31 @@ export class WebRTCService implements OnDestroy { } } - private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage): void { + private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.logger.info('User joined', { displayName: message.displayName, - oderId: message.oderId + oderId: message.oderId, + signalUrl }); + if (message.serverId) { + this.serverSignalingUrlMap.set(message.serverId, signalUrl); + } + + if (message.oderId) { + this.peerSignalingUrlMap.set(message.oderId, signalUrl); + } + if (message.oderId && message.serverId) { this.trackPeerInServer(message.oderId, message.serverId); } } - private handleUserLeftSignalingMessage(message: IncomingSignalingMessage): void { + private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { this.logger.info('User left', { displayName: message.displayName, oderId: message.oderId, + signalUrl, serverId: message.serverId }); @@ -404,17 +522,20 @@ export class WebRTCService implements OnDestroy { if (!hasRemainingSharedServers) { this.peerManager.removePeer(message.oderId); this.peerServerMap.delete(message.oderId); + this.peerSignalingUrlMap.delete(message.oderId); } } } - private handleOfferSignalingMessage(message: IncomingSignalingMessage): void { + private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { const fromUserId = message.fromUserId; const sdp = message.payload?.sdp; if (!fromUserId || !sdp) return; + this.peerSignalingUrlMap.set(fromUserId, signalUrl); + const offerEffectiveServer = this.voiceServerId || this.activeServerId; if (offerEffectiveServer && !this.peerServerMap.has(fromUserId)) { @@ -424,23 +545,27 @@ export class WebRTCService implements OnDestroy { this.peerManager.handleOffer(fromUserId, sdp); } - private handleAnswerSignalingMessage(message: IncomingSignalingMessage): void { + private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { const fromUserId = message.fromUserId; const sdp = message.payload?.sdp; if (!fromUserId || !sdp) return; + this.peerSignalingUrlMap.set(fromUserId, signalUrl); + this.peerManager.handleAnswer(fromUserId, sdp); } - private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage): void { + private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { const fromUserId = message.fromUserId; const candidate = message.payload?.candidate; if (!fromUserId || !candidate) return; + this.peerSignalingUrlMap.set(fromUserId, signalUrl); + this.peerManager.handleIceCandidate(fromUserId, candidate); } @@ -467,6 +592,7 @@ export class WebRTCService implements OnDestroy { this.peerManager.removePeer(peerId); this.peerServerMap.delete(peerId); + this.peerSignalingUrlMap.delete(peerId); } } @@ -490,7 +616,18 @@ export class WebRTCService implements OnDestroy { * @returns An observable that emits `true` once connected. */ connectToSignalingServer(serverUrl: string): Observable { - return this.signalingManager.connect(serverUrl); + const manager = this.ensureSignalingManager(serverUrl); + + if (manager.isSocketOpen()) { + return of(true); + } + + return manager.connect(serverUrl); + } + + /** Returns true when the signaling socket for a given URL is currently open. */ + isSignalingConnectedTo(serverUrl: string): boolean { + return this.signalingManagers.get(serverUrl)?.isSocketOpen() ?? false; } private trackPeerInServer(peerId: string, serverId: string): void { @@ -504,7 +641,7 @@ export class WebRTCService implements OnDestroy { } private replacePeerSharedServers(peerId: string, serverIds: string[]): boolean { - const sharedServerIds = serverIds.filter((serverId) => this.memberServerIds.has(serverId)); + const sharedServerIds = serverIds.filter((serverId) => this.isJoinedServer(serverId)); if (sharedServerIds.length === 0) { this.peerServerMap.delete(peerId); @@ -539,7 +676,17 @@ export class WebRTCService implements OnDestroy { * @returns `true` if connected within the timeout. */ async ensureSignalingConnected(timeoutMs?: number): Promise { - return this.signalingManager.ensureConnected(timeoutMs); + if (this.isAnySignalingConnected()) { + return true; + } + + for (const manager of this.signalingManagers.values()) { + if (await manager.ensureConnected(timeoutMs)) { + return true; + } + } + + return false; } /** @@ -548,7 +695,32 @@ export class WebRTCService implements OnDestroy { * @param message - The signaling message payload (excluding `from` / `timestamp`). */ sendSignalingMessage(message: Omit): void { - this.signalingManager.sendSignalingMessage(message, this._localPeerId()); + const targetPeerId = message.to; + + if (targetPeerId) { + const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId); + + if (targetSignalUrl) { + const targetManager = this.ensureSignalingManager(targetSignalUrl); + + targetManager.sendSignalingMessage(message, this._localPeerId()); + return; + } + } + + const connectedManagers = this.getConnectedSignalingManagers(); + + if (connectedManagers.length === 0) { + this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), { + type: message.type + }); + + return; + } + + for (const { manager } of connectedManagers) { + manager.sendSignalingMessage(message, this._localPeerId()); + } } /** @@ -557,7 +729,50 @@ export class WebRTCService implements OnDestroy { * @param message - Arbitrary JSON message. */ sendRawMessage(message: Record): void { - this.signalingManager.sendRawMessage(message); + const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null; + + if (targetPeerId) { + const targetSignalUrl = this.peerSignalingUrlMap.get(targetPeerId); + + if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) { + return; + } + } + + const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null; + + if (serverId) { + const serverSignalUrl = this.serverSignalingUrlMap.get(serverId); + + if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) { + return; + } + } + + const connectedManagers = this.getConnectedSignalingManagers(); + + if (connectedManagers.length === 0) { + this.logger.error('[signaling] No active signaling connection for outbound message', new Error('No signaling manager available'), { + type: typeof message['type'] === 'string' ? message['type'] : 'unknown' + }); + + return; + } + + for (const { manager } of connectedManagers) { + manager.sendRawMessage(message); + } + } + + private sendRawMessageToSignalUrl(signalUrl: string, message: Record): boolean { + const manager = this.signalingManagers.get(signalUrl); + + if (!manager) { + return false; + } + + manager.sendRawMessage(message); + return true; } /** @@ -576,7 +791,15 @@ export class WebRTCService implements OnDestroy { /** The last signaling URL used by the client, if any. */ getCurrentSignalingUrl(): string | null { - return this.signalingManager.getLastUrl(); + if (this.activeServerId) { + const activeServerSignalUrl = this.serverSignalingUrlMap.get(this.activeServerId); + + if (activeServerSignalUrl) { + return activeServerSignalUrl; + } + } + + return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null; } /** @@ -587,13 +810,22 @@ export class WebRTCService implements OnDestroy { * @param oderId - The user's unique order/peer ID. * @param displayName - The user's display name. */ - identify(oderId: string, displayName: string): void { + identify(oderId: string, displayName: string, signalUrl?: string): void { this.lastIdentifyCredentials = { oderId, displayName }; - this.sendRawMessage({ type: SIGNALING_TYPE_IDENTIFY, + const identifyMessage = { + type: SIGNALING_TYPE_IDENTIFY, oderId, - displayName }); + displayName + }; + + if (signalUrl) { + this.sendRawMessageToSignalUrl(signalUrl, identifyMessage); + return; + } + + this.sendRawMessage(identifyMessage); } /** @@ -602,13 +834,27 @@ export class WebRTCService implements OnDestroy { * @param roomId - The server / room ID to join. * @param userId - The local user ID. */ - joinRoom(roomId: string, userId: string): void { - this.lastJoinedServer = { serverId: roomId, - userId }; + joinRoom(roomId: string, userId: string, signalUrl?: string): void { + const resolvedSignalUrl = signalUrl + ?? this.serverSignalingUrlMap.get(roomId) + ?? this.getCurrentSignalingUrl(); - this.memberServerIds.add(roomId); - this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, - serverId: roomId }); + if (!resolvedSignalUrl) { + this.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId }); + return; + } + + this.serverSignalingUrlMap.set(roomId, resolvedSignalUrl); + this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, { + serverId: roomId, + userId + }); + + this.getOrCreateMemberServerSet(resolvedSignalUrl).add(roomId); + this.sendRawMessageToSignalUrl(resolvedSignalUrl, { + type: SIGNALING_TYPE_JOIN_SERVER, + serverId: roomId + }); } /** @@ -618,26 +864,46 @@ export class WebRTCService implements OnDestroy { * @param serverId - The target server ID. * @param userId - The local user ID. */ - switchServer(serverId: string, userId: string): void { - this.lastJoinedServer = { serverId, - userId }; + switchServer(serverId: string, userId: string, signalUrl?: string): void { + const resolvedSignalUrl = signalUrl + ?? this.serverSignalingUrlMap.get(serverId) + ?? this.getCurrentSignalingUrl(); - if (this.memberServerIds.has(serverId)) { - this.sendRawMessage({ type: SIGNALING_TYPE_VIEW_SERVER, - serverId }); + if (!resolvedSignalUrl) { + this.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId }); + return; + } + + this.serverSignalingUrlMap.set(serverId, resolvedSignalUrl); + this.lastJoinedServerBySignalUrl.set(resolvedSignalUrl, { + serverId, + userId + }); + + const memberServerIds = this.getOrCreateMemberServerSet(resolvedSignalUrl); + + if (memberServerIds.has(serverId)) { + this.sendRawMessageToSignalUrl(resolvedSignalUrl, { + type: SIGNALING_TYPE_VIEW_SERVER, + serverId + }); this.logger.info('Viewed server (already joined)', { serverId, + signalUrl: resolvedSignalUrl, userId, voiceConnected: this._isVoiceConnected() }); } else { - this.memberServerIds.add(serverId); - this.sendRawMessage({ type: SIGNALING_TYPE_JOIN_SERVER, - serverId }); + memberServerIds.add(serverId); + this.sendRawMessageToSignalUrl(resolvedSignalUrl, { + type: SIGNALING_TYPE_JOIN_SERVER, + serverId + }); this.logger.info('Joined new server via switch', { serverId, + signalUrl: resolvedSignalUrl, userId, voiceConnected: this._isVoiceConnected() }); @@ -654,25 +920,47 @@ export class WebRTCService implements OnDestroy { */ leaveRoom(serverId?: string): void { if (serverId) { - this.memberServerIds.delete(serverId); - this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, - serverId }); + const resolvedSignalUrl = this.serverSignalingUrlMap.get(serverId); + + if (resolvedSignalUrl) { + this.getOrCreateMemberServerSet(resolvedSignalUrl).delete(serverId); + this.sendRawMessageToSignalUrl(resolvedSignalUrl, { + type: SIGNALING_TYPE_LEAVE_SERVER, + serverId + }); + } else { + this.sendRawMessage({ + type: SIGNALING_TYPE_LEAVE_SERVER, + serverId + }); + + for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { + memberServerIds.delete(serverId); + } + } + + this.serverSignalingUrlMap.delete(serverId); this.logger.info('Left server', { serverId }); - if (this.memberServerIds.size === 0) { + if (this.getJoinedServerCount() === 0) { this.fullCleanup(); } return; } - this.memberServerIds.forEach((sid) => { - this.sendRawMessage({ type: SIGNALING_TYPE_LEAVE_SERVER, - serverId: sid }); - }); + for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) { + for (const sid of memberServerIds) { + this.sendRawMessageToSignalUrl(signalUrl, { + type: SIGNALING_TYPE_LEAVE_SERVER, + serverId: sid + }); + } + } - this.memberServerIds.clear(); + this.memberServerIdsBySignalUrl.clear(); + this.serverSignalingUrlMap.clear(); this.fullCleanup(); } @@ -682,12 +970,18 @@ export class WebRTCService implements OnDestroy { * @param serverId - The server to check. */ hasJoinedServer(serverId: string): boolean { - return this.memberServerIds.has(serverId); + return this.isJoinedServer(serverId); } /** Returns a read-only set of all currently-joined server IDs. */ getJoinedServerIds(): ReadonlySet { - return this.memberServerIds; + const joinedServerIds = new Set(); + + for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { + memberServerIds.forEach((serverId) => joinedServerIds.add(serverId)); + } + + return joinedServerIds; } /** @@ -942,11 +1236,15 @@ export class WebRTCService implements OnDestroy { /** Disconnect from the signaling server and clean up all state. */ disconnect(): void { + this.leaveRoom(); this.voiceServerId = null; this.peerServerMap.clear(); - this.leaveRoom(); + this.peerSignalingUrlMap.clear(); + this.lastJoinedServerBySignalUrl.clear(); + this.memberServerIdsBySignalUrl.clear(); + this.serverSignalingUrlMap.clear(); this.mediaManager.stopVoiceHeartbeat(); - this.signalingManager.close(); + this.destroyAllSignalingManagers(); this._isSignalingConnected.set(false); this._hasEverConnected.set(false); this._hasConnectionError.set(false); @@ -962,6 +1260,7 @@ export class WebRTCService implements OnDestroy { private fullCleanup(): void { this.voiceServerId = null; this.peerServerMap.clear(); + this.peerSignalingUrlMap.clear(); this.remoteScreenShareRequestsEnabled = false; this.desiredRemoteScreenSharePeers.clear(); this.activeRemoteScreenSharePeers.clear(); @@ -1040,10 +1339,25 @@ export class WebRTCService implements OnDestroy { } } + private destroyAllSignalingManagers(): void { + for (const subscriptions of this.signalingSubscriptions.values()) { + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + } + + for (const manager of this.signalingManagers.values()) { + manager.destroy(); + } + + this.signalingSubscriptions.clear(); + this.signalingManagers.clear(); + this.signalingConnectionStates.clear(); + } + ngOnDestroy(): void { this.disconnect(); this.serviceDestroyed$.complete(); - this.signalingManager.destroy(); this.peerManager.destroy(); this.mediaManager.destroy(); this.screenShareManager.destroy(); diff --git a/src/app/features/server-search/server-search.component.html b/src/app/features/server-search/server-search.component.html index 67bd6ce..75cf8f0 100644 --- a/src/app/features/server-search/server-search.component.html +++ b/src/app/features/server-search/server-search.component.html @@ -298,6 +298,24 @@ /> +
+ + +

This endpoint handles all signaling for this chat server.

+
+
- +
+ @if (hasMissingDefaultServers()) { + + } + +
-

Server directories to search for rooms. The active server is used for creating new rooms.

+

+ Active server endpoints stay enabled at the same time. You pick the endpoint when creating a new server. +

@@ -57,9 +71,10 @@
@if (!server.isActive) { } - @if (!server.isDefault) { + @if (server.isActive && hasMultipleActiveServers()) { + } + @if (hasMultipleServers()) { +
- +
+ @if (hasMissingDefaultServers()) { + + } + +

- Add multiple server directories to search for rooms across different networks. The active server will be used for creating and registering new - rooms. + Active server endpoints stay enabled at the same time. You pick the endpoint when creating and registering a new server.

@@ -84,9 +96,10 @@
@if (!server.isActive) { } - @if (!server.isDefault) { + @if (server.isActive && hasMultipleActiveServers()) { + } + @if (hasMultipleServers()) { +