diff --git a/README.md b/README.md index 703e378..db2e6a3 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,12 @@ Desktop chat app with three parts: - `electron/` desktop shell, IPC, and local database - `server/` directory server, join request API, and websocket events +## Architecture + +- Renderer architecture and refactor conventions live in `docs/architecture.md` +- Electron renderer integrations should go through `src/app/core/platform/electron/` +- Pure shared logic belongs in `src/app/core/helpers/` and reusable contracts belong in `src/app/core/models/` + ## Install 1. Run `npm install` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0dbd7ca --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,139 @@ +# Frontend Architecture + +This document defines the target structure for the Angular renderer and the boundaries between web-safe code, Electron adapters, reusable logic, and feature UI. + +## Goals + +- Keep feature code easy to navigate by grouping it around user-facing domains. +- Push platform-specific behavior behind explicit adapters. +- Move pure logic into helpers and dedicated model files instead of embedding it in components and large services. +- Split large services into smaller units with one clear responsibility each. + +## Target Structure + +```text +src/app/ + app.ts + domains/ + / + application/ # facades and use-case orchestration + domain/ # models and pure domain logic + infrastructure/ # adapters, api clients, persistence + feature/ # smart domain UI containers + ui/ # dumb/presentational domain UI + infrastructure/ + persistence/ # shared storage adapters and persistence facades + realtime/ # shared signaling, peer transport, media runtime state + core/ + constants.ts # cross-domain technical constants only + helpers/ # transitional pure helpers still being moved out + models/ # reusable cross-domain contracts only + platform/ + platform.service.ts # runtime/environment detection adapters + external-link.service.ts # browser/electron navigation adapters + electron/ # renderer-side adapters for preload / Electron APIs + realtime/ # compatibility/public import boundary for realtime + services/ # technical cross-domain services only + features/ # transitional feature area while slices move into domains/*/feature + shared/ + ui/ # shared presentational primitives + utils/ # shared pure utilities + store/ # ngrx reducers, effects, selectors, actions +``` + +## Layering Rules + +Dependency direction must stay one-way: + +1. `domains/*/feature`, `domains/*/ui`, and transitional `features/` may depend on `domains/`, `infrastructure/`, `core/`, `shared/`, and `store/`. +2. `domains/*/application` may depend on its own `domain/`, `infrastructure/`, top-level `infrastructure/`, `core/`, and `store/`. +3. `domains/*/domain` should stay framework-light and hold domain models plus pure logic. +4. `domains/*/infrastructure` may depend on `core/platform/`, browser APIs, HTTP, persistence, and external adapters. +5. Top-level `infrastructure/` should hold shared technical runtime implementations; `core/` should hold cross-domain utilities, compatibility entry points, and platform adapters rather than full runtime subsystems. +6. `core/models/` should only hold shared cross-domain contracts. Domain-specific models belong inside their domain folder. + +## Responsibility Split + +Use these roles consistently when a domain starts to absorb too many concerns: + +- `application`: Angular-facing facades and use-case orchestration. +- `domain`: contracts, policies, calculations, mapping rules, and other pure domain logic. +- `infrastructure`: platform adapters, storage, transport, HTTP, IPC, and persistence boundaries. +- `feature`: smart components that wire store, routing, and facades. +- `ui`: presentational components with minimal business knowledge. + +## Platform Boundary + +Renderer code should not access `window.electronAPI` directly except inside the Electron adapter layer. + +Current convention: + +- Use `core/platform/electron/electron-api.models.ts` for Angular-side Electron typings. +- Use `core/platform/electron/electron-bridge.service.ts` to reach preload APIs from Angular code. +- Keep runtime detection and browser/Electron wrappers such as `core/platform/platform.service.ts` and `core/platform/external-link.service.ts` inside `core/platform/` rather than `core/services/`. +- Keep Electron-only persistence and file-system logic inside dedicated adapter services such as `domains/attachment/infrastructure/attachment-storage.service.ts`. + +This keeps feature and domain code platform-agnostic and makes the browser runtime easier to reason about. + +## Models And Pure Logic + +- Attachment runtime types now live in `domains/attachment/domain/attachment.models.ts`. +- Attachment download-policy rules now live in `domains/attachment/domain/attachment.logic.ts`. +- Attachment file-path sanitizing and storage-bucket selection live in `domains/attachment/infrastructure/attachment-storage.helpers.ts`. +- New domain types should be placed in a dedicated model file inside their domain folder when they are not shared across domains. +- Only cross-domain contracts should stay in `core/models/`. + +## Incremental Refactor Path + +The repo is large enough that refactoring must stay incremental. Preferred order: + +1. Extract platform adapters from direct renderer global access. +2. Split persistence and cache behavior out of large orchestration services. +3. Move reusable types and helper logic into dedicated files. +4. Break remaining multi-responsibility facades into managers or coordinators. + +## Current Baseline + +The current refactor establishes these patterns: + +- Shared Electron bridge for Angular services and root app wiring. +- Attachment now lives under `domains/attachment/` with `application/attachment.facade.ts` as the public Angular-facing boundary. +- Attachment application orchestration is now split across `domains/attachment/application/attachment-manager.service.ts`, `attachment-transfer.service.ts`, `attachment-persistence.service.ts`, and `attachment-runtime.store.ts`. +- Attachment runtime types and pure transfer-policy logic live in `domains/attachment/domain/`. +- Attachment disk persistence and storage helpers live in `domains/attachment/infrastructure/`. +- Shared browser/Electron persistence adapters now live in `infrastructure/persistence/`, with `DatabaseService` as the renderer-facing facade over IndexedDB and Electron CQRS-backed SQLite. +- Auth HTTP/login orchestration now lives under `domains/auth/application/auth.service.ts`. +- Chat GIF search and KLIPY integration now live under `domains/chat/application/klipy.service.ts`. +- Voice-session now lives under `domains/voice-session/` with `application/voice-session.facade.ts` as the public Angular-facing boundary. +- Voice-session models and pure route/room mapping logic live in `domains/voice-session/domain/`. +- Voice workspace UI state now lives in `domains/voice-session/application/voice-workspace.service.ts`, and persisted voice/screen-share preferences now live in `domains/voice-session/infrastructure/voice-settings.storage.ts`. +- Voice activity tracking now lives in `domains/voice-connection/application/voice-activity.service.ts`. +- Server-directory now lives under `domains/server-directory/` with `application/server-directory.facade.ts` as the public Angular-facing boundary. +- Server-directory endpoint state now lives in `domains/server-directory/application/server-endpoint-state.service.ts`. +- Server-directory contracts and user-facing compatibility messaging live in `domains/server-directory/domain/`. +- Endpoint default URL normalization and built-in endpoint templates live in `domains/server-directory/domain/server-endpoint-defaults.ts`. +- Endpoint localStorage persistence, HTTP fan-out, compatibility checks, and health probes live in `domains/server-directory/infrastructure/`. +- `infrastructure/realtime/realtime-session.service.ts` now holds the shared realtime runtime service. +- `core/realtime/index.ts` is the compatibility/public import surface and re-exports that runtime service as `RealtimeSessionFacade` for technical cross-domain consumers. +- `domains/voice-connection/` now exposes `application/voice-connection.facade.ts` as the voice-specific Angular-facing boundary. +- `domains/screen-share/` now exposes `application/screen-share.facade.ts` plus `application/screen-share-source-picker.service.ts`, and `domain/screen-share.config.ts` is the real source for screen-share presets/options plus `ELECTRON_ENTIRE_SCREEN_SOURCE_NAME`. +- Shared transport contracts and the low-level signaling and peer-connection stack now live under `infrastructure/realtime/`. +- Realtime debug network metric collection now lives in `infrastructure/realtime/logging/debug-network-metrics.ts`; the debug console reads that infrastructure state rather than keeping the metric store inside `core/services/`. +- Shared media handling, voice orchestration helpers, noise reduction, and screen-share capture adapters now live in `infrastructure/realtime/media/`. +- The old `domains/webrtc/*` and `core/services/webrtc.service.ts` compatibility shims have been removed after all in-repo callers moved to `core/realtime`, `domains/voice-connection/`, and `domains/screen-share/`. +- Multi-server signaling topology and signaling-manager lifecycle extracted from `WebRTCService` into `ServerSignalingCoordinator`. +- Angular-facing signal and connection-state bookkeeping extracted from `WebRTCService` into `WebRtcStateController`. +- Peer/media event streams, peer messaging, remote stream access, and screen-share entry points extracted from `WebRTCService` into `PeerMediaFacade`. +- Lower-level signaling connect/send/identify helpers extracted from `WebRTCService` into `SignalingTransportHandler`. +- Incoming signaling message semantics extracted from `WebRTCService` into `IncomingSignalingMessageHandler`. +- Outbound server-membership signaling commands extracted from `WebRTCService` into `ServerMembershipSignalingHandler`. +- Remote screen-share request state and control-plane handling extracted from `WebRTCService` into `RemoteScreenShareRequestController`. +- Voice session and heartbeat orchestration extracted from `WebRTCService` into `VoiceSessionController`. +- Desktop client-version lookup and endpoint compatibility checks for server-directory live in `domains/server-directory/infrastructure/server-endpoint-compatibility.service.ts`. +- Endpoint health probing and fallback transport for server-directory live in `domains/server-directory/infrastructure/server-endpoint-health.service.ts`. +- Server-directory HTTP fan-out, endpoint resolution, and API response normalization live in `domains/server-directory/infrastructure/server-directory-api.service.ts`. + +## Next Candidates + +- Migrate remaining voice and room UI from transitional `features/` into domain-local `feature/` and `ui/` folders. +- Migrate remaining WebRTC-facing UI from transitional `features/` and `shared/` locations into domain-local `feature/` and `ui/` folders where it improves ownership. \ No newline at end of file diff --git a/server/data/metoyou.sqlite b/server/data/metoyou.sqlite index 162c88c..f19b4c7 100644 Binary files a/server/data/metoyou.sqlite and b/server/data/metoyou.sqlite differ diff --git a/src/app/app.ts b/src/app/app.ts index e510bc6..145c271 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -14,13 +14,14 @@ import { import { CommonModule } from '@angular/common'; import { Store } from '@ngrx/store'; -import { DatabaseService } from './core/services/database.service'; +import { DatabaseService } from './infrastructure/persistence'; import { DesktopAppUpdateService } from './core/services/desktop-app-update.service'; -import { ServerDirectoryService } from './core/services/server-directory.service'; +import { ServerDirectoryFacade } from './domains/server-directory'; import { TimeSyncService } from './core/services/time-sync.service'; -import { VoiceSessionService } from './core/services/voice-session.service'; -import { ExternalLinkService } from './core/services/external-link.service'; +import { VoiceSessionFacade } from './domains/voice-session'; +import { ExternalLinkService } from './core/platform'; import { SettingsModalService } from './core/services/settings-modal.service'; +import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ServersRailComponent } from './features/servers/servers-rail.component'; import { TitleBarComponent } from './features/shell/title-bar.component'; import { FloatingVoiceControlsComponent } from './features/voice/floating-voice-controls/floating-voice-controls.component'; @@ -36,15 +37,6 @@ import { STORAGE_KEY_LAST_VISITED_ROUTE } from './core/constants'; -interface DeepLinkElectronApi { - consumePendingDeepLink?: () => Promise; - onDeepLinkReceived?: (listener: (url: string) => void) => () => void; -} - -type DeepLinkWindow = Window & { - electronAPI?: DeepLinkElectronApi; -}; - @Component({ selector: 'app-root', imports: [ @@ -68,11 +60,12 @@ export class App implements OnInit, OnDestroy { private databaseService = inject(DatabaseService); private router = inject(Router); - private servers = inject(ServerDirectoryService); + private servers = inject(ServerDirectoryFacade); private settingsModal = inject(SettingsModalService); private timeSync = inject(TimeSyncService); - private voiceSession = inject(VoiceSessionService); + private voiceSession = inject(VoiceSessionFacade); private externalLinks = inject(ExternalLinkService); + private electronBridge = inject(ElectronBridgeService); private deepLinkCleanup: (() => void) | null = null; @HostListener('document:click', ['$event']) @@ -155,7 +148,7 @@ export class App implements OnInit, OnDestroy { } private async setupDesktopDeepLinks(): Promise { - const electronApi = this.getDeepLinkElectronApi(); + const electronApi = this.electronBridge.getApi(); if (!electronApi) { return; @@ -186,12 +179,6 @@ export class App implements OnInit, OnDestroy { }); } - private getDeepLinkElectronApi(): DeepLinkElectronApi | null { - return typeof window !== 'undefined' - ? (window as DeepLinkWindow).electronAPI ?? null - : null; - } - private isPublicRoute(url: string): boolean { return url === '/login' || url === '/register' || diff --git a/src/app/core/platform/electron/electron-api.models.ts b/src/app/core/platform/electron/electron-api.models.ts new file mode 100644 index 0000000..a328c03 --- /dev/null +++ b/src/app/core/platform/electron/electron-api.models.ts @@ -0,0 +1,150 @@ +export interface LinuxScreenShareAudioRoutingInfo { + available: boolean; + active: boolean; + monitorCaptureSupported: boolean; + screenShareSinkName: string; + screenShareMonitorSourceName: string; + voiceSinkName: string; + reason?: string; +} + +export interface LinuxScreenShareMonitorCaptureInfo { + bitsPerSample: number; + captureId: string; + channelCount: number; + sampleRate: number; + sourceName: string; +} + +export interface LinuxScreenShareMonitorAudioChunkPayload { + captureId: string; + chunk: Uint8Array; +} + +export interface LinuxScreenShareMonitorAudioEndedPayload { + captureId: string; + reason?: string; +} + +export interface ClipboardFilePayload { + data: string; + lastModified: number; + mime: string; + name: string; + path?: string; +} + +export type AutoUpdateMode = 'auto' | 'off' | 'version'; + +export type DesktopUpdateStatus = + | 'idle' + | 'disabled' + | 'checking' + | 'downloading' + | 'up-to-date' + | 'restart-required' + | 'unsupported' + | 'no-manifest' + | 'target-unavailable' + | 'target-older-than-installed' + | 'error'; + +export type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable'; + +export interface DesktopUpdateServerContext { + manifestUrls: string[]; + serverVersion: string | null; + serverVersionStatus: DesktopUpdateServerVersionStatus; +} + +export interface DesktopUpdateState { + autoUpdateMode: AutoUpdateMode; + availableVersions: string[]; + configuredManifestUrls: string[]; + currentVersion: string; + defaultManifestUrls: string[]; + isSupported: boolean; + lastCheckedAt: number | null; + latestVersion: string | null; + manifestUrl: string | null; + manifestUrls: string[]; + minimumServerVersion: string | null; + preferredVersion: string | null; + restartRequired: boolean; + serverBlocked: boolean; + serverBlockMessage: string | null; + serverVersion: string | null; + serverVersionStatus: DesktopUpdateServerVersionStatus; + status: DesktopUpdateStatus; + statusMessage: string | null; + targetVersion: string | null; +} + +export interface DesktopSettingsSnapshot { + autoUpdateMode: AutoUpdateMode; + autoStart: boolean; + hardwareAcceleration: boolean; + manifestUrls: string[]; + preferredVersion: string | null; + runtimeHardwareAcceleration: boolean; + restartRequired: boolean; +} + +export interface DesktopSettingsPatch { + autoUpdateMode?: AutoUpdateMode; + autoStart?: boolean; + hardwareAcceleration?: boolean; + manifestUrls?: string[]; + preferredVersion?: string | null; + vaapiVideoEncode?: boolean; +} + +export interface ElectronCommand { + type: string; + payload: unknown; +} + +export interface ElectronQuery { + type: string; + payload: unknown; +} + +export interface ElectronApi { + linuxDisplayServer: string; + minimizeWindow: () => void; + maximizeWindow: () => void; + closeWindow: () => void; + openExternal: (url: string) => Promise; + getSources: () => Promise<{ id: string; name: string; thumbnail: string }[]>; + prepareLinuxScreenShareAudioRouting: () => Promise; + activateLinuxScreenShareAudioRouting: () => Promise; + deactivateLinuxScreenShareAudioRouting: () => Promise; + startLinuxScreenShareMonitorCapture: () => Promise; + stopLinuxScreenShareMonitorCapture: (captureId?: string) => Promise; + onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; + onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; + getAppDataPath: () => Promise; + consumePendingDeepLink: () => Promise; + getDesktopSettings: () => Promise; + getAutoUpdateState: () => Promise; + configureAutoUpdateContext: (context: Partial) => Promise; + checkForAppUpdates: () => Promise; + restartToApplyUpdate: () => Promise; + onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void; + setDesktopSettings: (patch: DesktopSettingsPatch) => Promise; + relaunchApp: () => Promise; + onDeepLinkReceived: (listener: (url: string) => void) => () => void; + readClipboardFiles: () => Promise; + readFile: (filePath: string) => Promise; + writeFile: (filePath: string, data: string) => Promise; + saveFileAs: (defaultFileName: string, data: string) => Promise<{ saved: boolean; cancelled: boolean }>; + fileExists: (filePath: string) => Promise; + deleteFile: (filePath: string) => Promise; + ensureDir: (dirPath: string) => Promise; + command: (command: ElectronCommand) => Promise; + query: (query: ElectronQuery) => Promise; +} + +export type ElectronWindow = Window & { + electronAPI?: ElectronApi; +}; diff --git a/src/app/core/platform/electron/electron-bridge.service.ts b/src/app/core/platform/electron/electron-bridge.service.ts new file mode 100644 index 0000000..0cf0274 --- /dev/null +++ b/src/app/core/platform/electron/electron-bridge.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import type { ElectronApi } from './electron-api.models'; +import { getElectronApi } from './get-electron-api'; + +@Injectable({ providedIn: 'root' }) +export class ElectronBridgeService { + get isAvailable(): boolean { + return this.getApi() !== null; + } + + getApi(): ElectronApi | null { + return getElectronApi(); + } + + requireApi(): ElectronApi { + const api = this.getApi(); + + if (!api) { + throw new Error('Electron API is not available in this runtime.'); + } + + return api; + } +} diff --git a/src/app/core/platform/electron/get-electron-api.ts b/src/app/core/platform/electron/get-electron-api.ts new file mode 100644 index 0000000..9d9839c --- /dev/null +++ b/src/app/core/platform/electron/get-electron-api.ts @@ -0,0 +1,7 @@ +import type { ElectronApi, ElectronWindow } from './electron-api.models'; + +export function getElectronApi(): ElectronApi | null { + return typeof window !== 'undefined' + ? (window as ElectronWindow).electronAPI ?? null + : null; +} diff --git a/src/app/core/services/external-link.service.ts b/src/app/core/platform/external-link.service.ts similarity index 68% rename from src/app/core/services/external-link.service.ts rename to src/app/core/platform/external-link.service.ts index 1c73289..6498270 100644 --- a/src/app/core/services/external-link.service.ts +++ b/src/app/core/platform/external-link.service.ts @@ -1,13 +1,5 @@ import { Injectable, inject } from '@angular/core'; -import { PlatformService } from './platform.service'; - -interface ExternalLinkElectronApi { - openExternal?: (url: string) => Promise; -} - -type ExternalLinkWindow = Window & { - electronAPI?: ExternalLinkElectronApi; -}; +import { ElectronBridgeService } from './electron/electron-bridge.service'; /** * Opens URLs in the system default browser (Electron) or a new tab (browser). @@ -17,18 +9,21 @@ type ExternalLinkWindow = Window & { */ @Injectable({ providedIn: 'root' }) export class ExternalLinkService { - private platform = inject(PlatformService); + private readonly electronBridge = inject(ElectronBridgeService); /** Open a URL externally. Only http/https URLs are allowed. */ open(url: string): void { if (!url || !(url.startsWith('http://') || url.startsWith('https://'))) return; - if (this.platform.isElectron) { - (window as ExternalLinkWindow).electronAPI?.openExternal?.(url); - } else { - window.open(url, '_blank', 'noopener,noreferrer'); + const electronApi = this.electronBridge.getApi(); + + if (electronApi) { + void electronApi.openExternal(url); + return; } + + window.open(url, '_blank', 'noopener,noreferrer'); } /** @@ -41,22 +36,19 @@ export class ExternalLinkService { if (!target) return false; - const href = target.href; // resolved full URL + const href = target.href; if (!href) return false; - // Skip non-navigable URLs if (href.startsWith('javascript:') || href.startsWith('blob:') || href.startsWith('data:')) return false; - // Skip same-page anchors const rawAttr = target.getAttribute('href'); if (rawAttr?.startsWith('#')) return false; - // Skip Angular router links if (target.hasAttribute('routerlink') || target.hasAttribute('ng-reflect-router-link')) return false; diff --git a/src/app/core/platform/index.ts b/src/app/core/platform/index.ts new file mode 100644 index 0000000..0165723 --- /dev/null +++ b/src/app/core/platform/index.ts @@ -0,0 +1,2 @@ +export * from './platform.service'; +export * from './external-link.service'; diff --git a/src/app/core/platform/platform.service.ts b/src/app/core/platform/platform.service.ts new file mode 100644 index 0000000..549634f --- /dev/null +++ b/src/app/core/platform/platform.service.ts @@ -0,0 +1,15 @@ +import { Injectable, inject } from '@angular/core'; +import { ElectronBridgeService } from './electron/electron-bridge.service'; + +@Injectable({ providedIn: 'root' }) +export class PlatformService { + readonly isElectron: boolean; + readonly isBrowser: boolean; + private readonly electronBridge = inject(ElectronBridgeService); + + constructor() { + this.isElectron = this.electronBridge.isAvailable; + + this.isBrowser = !this.isElectron; + } +} diff --git a/src/app/core/realtime/index.ts b/src/app/core/realtime/index.ts new file mode 100644 index 0000000..d066280 --- /dev/null +++ b/src/app/core/realtime/index.ts @@ -0,0 +1,3 @@ +export { WebRTCService as RealtimeSessionFacade } from '../../infrastructure/realtime/realtime-session.service'; +export * from '../../infrastructure/realtime/realtime.constants'; +export * from '../../infrastructure/realtime/realtime.types'; diff --git a/src/app/core/services/attachment.service.ts b/src/app/core/services/attachment.service.ts deleted file mode 100644 index 74c8b33..0000000 --- a/src/app/core/services/attachment.service.ts +++ /dev/null @@ -1,1378 +0,0 @@ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, complexity, max-statements-per-line */ -import { - Injectable, - inject, - signal, - effect -} from '@angular/core'; -import { NavigationEnd, Router } from '@angular/router'; -import { take } from 'rxjs'; -import { v4 as uuidv4 } from 'uuid'; -import { WebRTCService } from './webrtc.service'; -import { Store } from '@ngrx/store'; -import { selectCurrentRoomName } from '../../store/rooms/rooms.selectors'; -import { DatabaseService } from './database.service'; -import { recordDebugNetworkFileChunk } from './debug-network-metrics.service'; -import { ROOM_URL_PATTERN } from '../constants'; -import type { - ChatAttachmentAnnouncement, - ChatAttachmentMeta, - ChatEvent -} from '../models/index'; - -/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ -const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB - -/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */ -export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB -/** - * EWMA smoothing weight for the *previous* speed estimate. - * The complementary weight (1 − this value) is applied to the - * instantaneous measurement. - */ -const EWMA_PREVIOUS_WEIGHT = 0.7; -const EWMA_CURRENT_WEIGHT = 1 - EWMA_PREVIOUS_WEIGHT; -/** Fallback MIME type when none is provided by the sender. */ -const DEFAULT_MIME_TYPE = 'application/octet-stream'; -/** localStorage key used by the legacy attachment store (migration target). */ -const LEGACY_STORAGE_KEY = 'metoyou_attachments'; -/** User-facing error when no peers are available for a request. */ -const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.'; -/** User-facing error when connected peers cannot provide a requested file. */ -const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.'; - -/** - * Metadata describing a file attachment linked to a chat message. - */ -export type AttachmentMeta = ChatAttachmentMeta; - -/** - * Runtime representation of an attachment including download - * progress and blob URL state. - */ -export interface Attachment extends AttachmentMeta { - /** Whether the file content is available locally (blob URL set). */ - available: boolean; - /** Object URL for in-browser rendering / download. */ - objectUrl?: string; - /** Number of bytes received so far (during chunked download). */ - receivedBytes?: number; - /** Estimated download speed (bytes / second), EWMA-smoothed. */ - speedBps?: number; - /** Epoch ms when the download started. */ - startedAtMs?: number; - /** Epoch ms of the most recent chunk received. */ - lastUpdateMs?: number; - /** User-facing request failure shown in the attachment card. */ - requestError?: string; -} - -type FileAnnounceEvent = ChatEvent & { - type: 'file-announce'; - messageId: string; - file: ChatAttachmentAnnouncement; -}; - -type FileChunkEvent = ChatEvent & { - type: 'file-chunk'; - messageId: string; - fileId: string; - index: number; - total: number; - data: string; - fromPeerId?: string; -}; - -type FileRequestEvent = ChatEvent & { - type: 'file-request'; - messageId: string; - fileId: string; - fromPeerId?: string; -}; - -type FileCancelEvent = ChatEvent & { - type: 'file-cancel'; - messageId: string; - fileId: string; - fromPeerId?: string; -}; - -type FileNotFoundEvent = ChatEvent & { - type: 'file-not-found'; - messageId: string; - fileId: string; -}; - -type FileAnnouncePayload = Pick; -interface FileChunkPayload { - messageId?: string; - fileId?: string; - fromPeerId?: string; - index?: number; - total?: number; - data?: ChatEvent['data']; -} -type FileRequestPayload = Pick; -type FileCancelPayload = Pick; -type FileNotFoundPayload = Pick; - -interface AttachmentElectronApi { - getAppDataPath?: () => Promise; - fileExists?: (filePath: string) => Promise; - readFile?: (filePath: string) => Promise; - deleteFile?: (filePath: string) => Promise; - ensureDir?: (dirPath: string) => Promise; - writeFile?: (filePath: string, data: string) => Promise; -} - -type ElectronWindow = Window & { - electronAPI?: AttachmentElectronApi; -}; - -type LocalFileWithPath = File & { - path?: string; -}; - -/** - * Manages peer-to-peer file transfer, local persistence, and - * in-memory caching of file attachments linked to chat messages. - * - * Files are announced to peers via a `file-announce` event and - * transferred using a chunked base-64 protocol over WebRTC data - * channels. On Electron, files under {@link MAX_AUTO_SAVE_SIZE_BYTES} - * are automatically persisted to the app-data directory. - */ -@Injectable({ providedIn: 'root' }) -export class AttachmentService { - private readonly webrtc = inject(WebRTCService); - private readonly ngrxStore = inject(Store); - private readonly database = inject(DatabaseService); - private readonly router = inject(Router); - - /** Primary index: `messageId → Attachment[]`. */ - private attachmentsByMessage = new Map(); - /** Runtime cache of `messageId → roomId` for attachment gating. */ - private messageRoomIds = new Map(); - /** Room currently being watched in the router, or `null` outside room routes. */ - private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); - - /** Incremented on every mutation so signal consumers re-render. */ - updated = signal(0); - - /** - * In-memory map of original `File` objects retained by the uploader - * so that file-request handlers can stream them on demand. - * Key format: `"messageId:fileId"`. - */ - private originalFiles = new Map(); - - /** Set of `"messageId:fileId:peerId"` keys representing cancelled transfers. */ - private cancelledTransfers = new Set(); - - /** - * Map of `"messageId:fileId" → Set` tracking which peers - * have already been asked for a particular file. - */ - private pendingRequests = new Map>(); - - /** - * In-flight chunk assembly buffers. - * `"messageId:fileId" → ArrayBuffer[]` (indexed by chunk ordinal). - */ - private chunkBuffers = new Map(); - - /** - * Number of chunks received for each in-flight transfer. - * `"messageId:fileId" → number`. - */ - private chunkCounts = new Map(); - - /** Whether the initial DB load has been performed. */ - private isDatabaseInitialised = false; - - constructor() { - effect(() => { - if (this.database.isReady() && !this.isDatabaseInitialised) { - this.isDatabaseInitialised = true; - this.initFromDatabase(); - } - }); - - this.router.events.subscribe((event) => { - if (!(event instanceof NavigationEnd)) { - return; - } - - this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url); - - if (this.watchedRoomId) { - void this.requestAutoDownloadsForRoom(this.watchedRoomId); - } - }); - - this.webrtc.onPeerConnected.subscribe(() => { - if (this.watchedRoomId) { - void this.requestAutoDownloadsForRoom(this.watchedRoomId); - } - }); - } - - private getElectronApi(): AttachmentElectronApi | undefined { - return (window as ElectronWindow).electronAPI; - } - - /** Return the attachment list for a given message. */ - getForMessage(messageId: string): Attachment[] { - return this.attachmentsByMessage.get(messageId) ?? []; - } - - /** Cache the room that owns a message so background downloads can be gated by the watched server. */ - rememberMessageRoom(messageId: string, roomId: string): void { - if (!messageId || !roomId) - return; - - this.messageRoomIds.set(messageId, roomId); - } - - /** Queue best-effort auto-download checks for a message's eligible attachments. */ - queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void { - void this.requestAutoDownloadsForMessage(messageId, attachmentId); - } - - /** Auto-request eligible missing attachments for the currently watched room. */ - async requestAutoDownloadsForRoom(roomId: string): Promise { - if (!roomId || !this.isRoomWatched(roomId)) - return; - - if (this.database.isReady()) { - const messages = await this.database.getMessages(roomId, 500, 0); - - for (const message of messages) { - this.rememberMessageRoom(message.id, message.roomId); - await this.requestAutoDownloadsForMessage(message.id); - } - - return; - } - - for (const [messageId] of this.attachmentsByMessage) { - const attachmentRoomId = await this.resolveMessageRoomId(messageId); - - if (attachmentRoomId === roomId) { - await this.requestAutoDownloadsForMessage(messageId); - } - } - } - - /** Remove every attachment associated with a message. */ - async deleteForMessage(messageId: string): Promise { - const attachments = this.attachmentsByMessage.get(messageId) ?? []; - const hadCachedAttachments = attachments.length > 0 || this.attachmentsByMessage.has(messageId); - const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId); - const savedPathsToDelete = new Set(); - - for (const attachment of attachments) { - if (attachment.objectUrl) { - try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ } - } - - if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) { - savedPathsToDelete.add(attachment.savedPath); - } - } - - this.attachmentsByMessage.delete(messageId); - this.messageRoomIds.delete(messageId); - this.clearMessageScopedState(messageId); - - if (hadCachedAttachments) { - this.touch(); - } - - if (this.database.isReady()) { - await this.database.deleteAttachmentsForMessage(messageId); - } - - for (const diskPath of savedPathsToDelete) { - await this.deleteSavedFile(diskPath); - } - } - - /** - * Build a map of minimal attachment metadata for a set of message IDs. - * Used during inventory-based message synchronisation so that peers - * learn about attachments without transferring file content. - * - * @param messageIds - Messages to collect metadata for. - * @returns Record keyed by messageId whose values are arrays of - * {@link AttachmentMeta} (local paths are scrubbed). - */ - getAttachmentMetasForMessages( - messageIds: string[] - ): Record { - const result: Record = {}; - - for (const messageId of messageIds) { - const attachments = this.attachmentsByMessage.get(messageId); - - if (attachments && attachments.length > 0) { - result[messageId] = attachments.map((attachment) => ({ - id: attachment.id, - messageId: attachment.messageId, - filename: attachment.filename, - size: attachment.size, - mime: attachment.mime, - isImage: attachment.isImage, - uploaderPeerId: attachment.uploaderPeerId, - filePath: undefined, // never share local paths - savedPath: undefined // never share local paths - })); - } - } - - return result; - } - - /** - * Register attachment metadata received via message sync - * (content is not yet available - only metadata). - * - * @param attachmentMap - Map of `messageId → AttachmentMeta[]` from peer. - */ - registerSyncedAttachments( - attachmentMap: Record, - messageRoomIds?: Record - ): void { - if (messageRoomIds) { - for (const [messageId, roomId] of Object.entries(messageRoomIds)) { - this.rememberMessageRoom(messageId, roomId); - } - } - - const newAttachments: Attachment[] = []; - - for (const [messageId, metas] of Object.entries(attachmentMap)) { - const existing = this.attachmentsByMessage.get(messageId) ?? []; - - for (const meta of metas) { - const alreadyKnown = existing.find((entry) => entry.id === meta.id); - - if (!alreadyKnown) { - const attachment: Attachment = { ...meta, - available: false, - receivedBytes: 0 }; - - existing.push(attachment); - newAttachments.push(attachment); - } - } - - if (existing.length > 0) { - this.attachmentsByMessage.set(messageId, existing); - } - } - - if (newAttachments.length > 0) { - this.touch(); - - for (const attachment of newAttachments) { - void this.persistAttachmentMeta(attachment); - this.queueAutoDownloadsForMessage(attachment.messageId, attachment.id); - } - } - } - - /** - * Request a file from any connected peer that might have it. - * Automatically cycles through all connected peers if the first - * one does not have the file. - * - * @param messageId - Parent message. - * @param attachment - Attachment to request. - */ - requestFromAnyPeer(messageId: string, attachment: Attachment): void { - const clearedRequestError = this.clearAttachmentRequestError(attachment); - const connectedPeers = this.webrtc.getConnectedPeers(); - - if (connectedPeers.length === 0) { - attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR; - this.touch(); - console.warn('[Attachments] No connected peers to request file from'); - return; - } - - if (clearedRequestError) - this.touch(); - - const requestKey = this.buildRequestKey(messageId, attachment.id); - - this.pendingRequests.set(requestKey, new Set()); - this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId); - } - - /** - * Handle a `file-not-found` response - try the next available peer. - */ - handleFileNotFound(payload: FileNotFoundPayload): void { - const { messageId, fileId } = payload; - - if (!messageId || !fileId) - return; - - const attachments = this.attachmentsByMessage.get(messageId) ?? []; - const attachment = attachments.find((entry) => entry.id === fileId); - const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId); - - if (!didSendRequest && attachment) { - attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR; - this.touch(); - } - } - - /** - * Alias for {@link requestFromAnyPeer}. - * Convenience wrapper for image-specific call-sites. - */ - requestImageFromAnyPeer(messageId: string, attachment: Attachment): void { - this.requestFromAnyPeer(messageId, attachment); - } - - /** Alias for {@link requestFromAnyPeer}. */ - requestFile(messageId: string, attachment: Attachment): void { - this.requestFromAnyPeer(messageId, attachment); - } - - /** - * Announce and optionally stream files attached to a newly sent - * message to all connected peers. - * - * 1. Each file is assigned a UUID. - * 2. A `file-announce` event is broadcast to peers. - * 3. Peers watching the message's server can request any - * auto-download-eligible media on demand. - * - * @param messageId - ID of the parent message. - * @param files - Array of user-selected `File` objects. - * @param uploaderPeerId - Peer ID of the uploader (used by receivers - * to prefer the original source when requesting content). - */ - async publishAttachments( - messageId: string, - files: File[], - uploaderPeerId?: string - ): Promise { - const attachments: Attachment[] = []; - - for (const file of files) { - const fileId = uuidv4(); - const attachment: Attachment = { - id: fileId, - messageId, - filename: file.name, - size: file.size, - mime: file.type || DEFAULT_MIME_TYPE, - isImage: file.type.startsWith('image/'), - uploaderPeerId, - filePath: (file as LocalFileWithPath).path, - available: false - }; - - attachments.push(attachment); - - // Retain the original File so we can serve file-request later - this.originalFiles.set(`${messageId}:${fileId}`, file); - - // Make the file immediately visible to the uploader - try { - attachment.objectUrl = URL.createObjectURL(file); - attachment.available = true; - } catch { /* non-critical */ } - - // Auto-save small files to Electron disk cache - if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { - void this.saveFileToDisk(attachment, file); - } - - // Broadcast metadata to peers - const fileAnnounceEvent: FileAnnounceEvent = { - type: 'file-announce', - messageId, - file: { - id: fileId, - filename: attachment.filename, - size: attachment.size, - mime: attachment.mime, - isImage: attachment.isImage, - uploaderPeerId - } - }; - - this.webrtc.broadcastMessage(fileAnnounceEvent); - - } - - const existingList = this.attachmentsByMessage.get(messageId) ?? []; - - this.attachmentsByMessage.set(messageId, [...existingList, ...attachments]); - this.touch(); - - for (const attachment of attachments) { - void this.persistAttachmentMeta(attachment); - } - } - - /** Handle a `file-announce` event from a peer. */ - handleFileAnnounce(payload: FileAnnouncePayload): void { - const { messageId, file } = payload; - - if (!messageId || !file) - return; - - const list = this.attachmentsByMessage.get(messageId) ?? []; - const alreadyKnown = list.find((entry) => entry.id === file.id); - - if (alreadyKnown) - return; - - const attachment: Attachment = { - id: file.id, - messageId, - filename: file.filename, - size: file.size, - mime: file.mime, - isImage: !!file.isImage, - uploaderPeerId: file.uploaderPeerId, - available: false, - receivedBytes: 0 - }; - - list.push(attachment); - this.attachmentsByMessage.set(messageId, list); - this.touch(); - void this.persistAttachmentMeta(attachment); - this.queueAutoDownloadsForMessage(messageId, attachment.id); - } - - /** - * Handle an incoming `file-chunk` event. - * - * Chunks are collected in {@link chunkBuffers} until the total - * expected count is reached, at which point the buffers are - * assembled into a Blob and an object URL is created. - */ - handleFileChunk(payload: FileChunkPayload): void { - const { messageId, fileId, fromPeerId, index, total, data } = payload; - - if ( - !messageId || !fileId || - typeof index !== 'number' || - typeof total !== 'number' || - typeof data !== 'string' - ) - return; - - const list = this.attachmentsByMessage.get(messageId) ?? []; - const attachment = list.find((entry) => entry.id === fileId); - - if (!attachment) - return; - - const decodedBytes = this.base64ToUint8Array(data); - const assemblyKey = `${messageId}:${fileId}`; - const requestKey = this.buildRequestKey(messageId, fileId); - - this.pendingRequests.delete(requestKey); - this.clearAttachmentRequestError(attachment); - - // Initialise assembly buffer on first chunk - let chunkBuffer = this.chunkBuffers.get(assemblyKey); - - if (!chunkBuffer) { - chunkBuffer = new Array(total); - this.chunkBuffers.set(assemblyKey, chunkBuffer); - this.chunkCounts.set(assemblyKey, 0); - } - - // Store the chunk (idempotent: ignore duplicate indices) - if (!chunkBuffer[index]) { - chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer; - this.chunkCounts.set(assemblyKey, (this.chunkCounts.get(assemblyKey) ?? 0) + 1); - } - - // Update progress stats - const now = Date.now(); - const previousReceived = attachment.receivedBytes ?? 0; - - attachment.receivedBytes = previousReceived + decodedBytes.byteLength; - - if (fromPeerId) - recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now); - - if (!attachment.startedAtMs) - attachment.startedAtMs = now; - - if (!attachment.lastUpdateMs) - attachment.lastUpdateMs = now; - - const elapsedMs = Math.max(1, now - attachment.lastUpdateMs); - const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000; - const previousSpeed = attachment.speedBps ?? instantaneousBps; - - attachment.speedBps = - EWMA_PREVIOUS_WEIGHT * previousSpeed + - EWMA_CURRENT_WEIGHT * instantaneousBps; - - attachment.lastUpdateMs = now; - - this.touch(); // trigger UI update for progress bars - - // Check if assembly is complete - const receivedChunkCount = this.chunkCounts.get(assemblyKey) ?? 0; - - if (receivedChunkCount === total || (attachment.receivedBytes ?? 0) >= attachment.size) { - const completeBuffer = this.chunkBuffers.get(assemblyKey); - - if (completeBuffer && completeBuffer.every((part) => part instanceof ArrayBuffer)) { - const blob = new Blob(completeBuffer, { type: attachment.mime }); - - attachment.available = true; - attachment.objectUrl = URL.createObjectURL(blob); - - if (this.shouldPersistDownloadedAttachment(attachment)) { - void this.saveFileToDisk(attachment, blob); - } - - // Clean up assembly state - this.chunkBuffers.delete(assemblyKey); - this.chunkCounts.delete(assemblyKey); - this.touch(); - void this.persistAttachmentMeta(attachment); - } - } - } - - /** - * Handle an incoming `file-request` from a peer by streaming the - * file content if available locally. - * - * Lookup order: - * 1. In-memory original (`originalFiles` map). - * 2. Electron `filePath` (uploader's original on disk). - * 3. Electron `savedPath` (disk-cache copy). - * 4. Electron disk-cache by room name (backward compat). - * 5. In-memory object-URL blob (browser fallback). - * - * If none of these sources has the file, a `file-not-found` - * message is sent so the requester can try another peer. - */ - async handleFileRequest(payload: FileRequestPayload): Promise { - const { messageId, fileId, fromPeerId } = payload; - - if (!messageId || !fileId || !fromPeerId) - return; - - // 1. In-memory original - const exactKey = `${messageId}:${fileId}`; - - let originalFile = this.originalFiles.get(exactKey); - - // 1b. Fallback: search by fileId suffix (handles rare messageId drift) - if (!originalFile) { - for (const [key, file] of this.originalFiles) { - if (key.endsWith(`:${fileId}`)) { - originalFile = file; - break; - } - } - } - - if (originalFile) { - await this.streamFileToPeer(fromPeerId, messageId, fileId, originalFile); - return; - } - - const list = this.attachmentsByMessage.get(messageId) ?? []; - const attachment = list.find((entry) => entry.id === fileId); - const electronApi = this.getElectronApi(); - - // 2. Electron filePath - if (attachment?.filePath && electronApi?.fileExists && electronApi?.readFile) { - try { - if (await electronApi.fileExists(attachment.filePath)) { - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, attachment.filePath); - return; - } - } catch { /* fall through */ } - } - - // 3. Electron savedPath - if (attachment?.savedPath && electronApi?.fileExists && electronApi?.readFile) { - try { - if (await electronApi.fileExists(attachment.savedPath)) { - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, attachment.savedPath); - return; - } - } catch { /* fall through */ } - } - - // 3b. Disk cache by room name (backward compatibility) - if (attachment?.isImage && electronApi?.getAppDataPath && electronApi?.fileExists && electronApi?.readFile) { - try { - const appDataPath = await electronApi.getAppDataPath(); - - if (appDataPath) { - const roomName = await this.resolveCurrentRoomName(); - const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; - const diskPath = `${appDataPath}/server/${sanitisedRoom}/image/${attachment.filename}`; - - if (await electronApi.fileExists(diskPath)) { - await this.streamFileFromDiskToPeer(fromPeerId, messageId, fileId, diskPath); - return; - } - } - } catch { /* fall through */ } - } - - // 4. In-memory blob - if (attachment?.available && attachment.objectUrl) { - try { - const response = await fetch(attachment.objectUrl); - const blob = await response.blob(); - const file = new File([blob], attachment.filename, { type: attachment.mime }); - - await this.streamFileToPeer(fromPeerId, messageId, fileId, file); - return; - } catch { /* fall through */ } - } - - // 5. File not available locally - const fileNotFoundEvent: FileNotFoundEvent = { - type: 'file-not-found', - messageId, - fileId - }; - - this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent); - } - - /** - * Cancel an in-progress download from the requester side. - * Resets local assembly state and notifies the uploader to stop. - */ - cancelRequest(messageId: string, attachment: Attachment): void { - const targetPeerId = attachment.uploaderPeerId; - - if (!targetPeerId) - return; - - try { - // Reset assembly state - const assemblyKey = `${messageId}:${attachment.id}`; - - this.chunkBuffers.delete(assemblyKey); - this.chunkCounts.delete(assemblyKey); - - attachment.receivedBytes = 0; - attachment.speedBps = 0; - attachment.startedAtMs = undefined; - attachment.lastUpdateMs = undefined; - - if (attachment.objectUrl) { - try { URL.revokeObjectURL(attachment.objectUrl); } catch { /* ignore */ } - - attachment.objectUrl = undefined; - } - - attachment.available = false; - this.touch(); - - // Notify uploader to stop streaming - const fileCancelEvent: FileCancelEvent = { - type: 'file-cancel', - messageId, - fileId: attachment.id - }; - - this.webrtc.sendToPeer(targetPeerId, fileCancelEvent); - } catch { /* best-effort */ } - } - - /** - * Handle a `file-cancel` from the requester - record the - * cancellation so the streaming loop breaks early. - */ - handleFileCancel(payload: FileCancelPayload): void { - const { messageId, fileId, fromPeerId } = payload; - - if (!messageId || !fileId || !fromPeerId) - return; - - this.cancelledTransfers.add( - this.buildTransferKey(messageId, fileId, fromPeerId) - ); - } - - /** - * Provide a `File` for a pending request (uploader side) and - * stream it to the requesting peer. - */ - async fulfillRequestWithFile( - messageId: string, - fileId: string, - targetPeerId: string, - file: File - ): Promise { - this.originalFiles.set(`${messageId}:${fileId}`, file); - await this.streamFileToPeer(targetPeerId, messageId, fileId, file); - } - - /** Bump the reactive update counter so signal-based consumers re-render. */ - private touch(): void { - this.updated.set(this.updated() + 1); - } - - /** Composite key for transfer-cancellation tracking. */ - private buildTransferKey(messageId: string, fileId: string, peerId: string): string { - return `${messageId}:${fileId}:${peerId}`; - } - - /** Composite key for pending-request tracking. */ - private buildRequestKey(messageId: string, fileId: string): string { - return `${messageId}:${fileId}`; - } - - private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise { - if (!messageId) - return; - - const roomId = await this.resolveMessageRoomId(messageId); - - if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) { - return; - } - - const attachments = this.attachmentsByMessage.get(messageId) ?? []; - - for (const attachment of attachments) { - if (attachmentId && attachment.id !== attachmentId) - continue; - - if (!this.shouldAutoRequestWhenWatched(attachment)) - continue; - - if (attachment.available) - continue; - - if ((attachment.receivedBytes ?? 0) > 0) - continue; - - if (this.pendingRequests.has(this.buildRequestKey(messageId, attachment.id))) - continue; - - this.requestFromAnyPeer(messageId, attachment); - } - } - - private clearMessageScopedState(messageId: string): void { - const scopedPrefix = `${messageId}:`; - - for (const key of Array.from(this.originalFiles.keys())) { - if (key.startsWith(scopedPrefix)) { - this.originalFiles.delete(key); - } - } - - for (const key of Array.from(this.pendingRequests.keys())) { - if (key.startsWith(scopedPrefix)) { - this.pendingRequests.delete(key); - } - } - - for (const key of Array.from(this.chunkBuffers.keys())) { - if (key.startsWith(scopedPrefix)) { - this.chunkBuffers.delete(key); - } - } - - for (const key of Array.from(this.chunkCounts.keys())) { - if (key.startsWith(scopedPrefix)) { - this.chunkCounts.delete(key); - } - } - - for (const key of Array.from(this.cancelledTransfers)) { - if (key.startsWith(scopedPrefix)) { - this.cancelledTransfers.delete(key); - } - } - } - - private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise> { - const retainedSavedPaths = new Set(); - - for (const [existingMessageId, attachments] of this.attachmentsByMessage) { - if (existingMessageId === messageId) - continue; - - for (const attachment of attachments) { - if (attachment.savedPath) { - retainedSavedPaths.add(attachment.savedPath); - } - } - } - - if (!this.database.isReady()) { - return retainedSavedPaths; - } - - const persistedAttachments = await this.database.getAllAttachments(); - - for (const attachment of persistedAttachments) { - if (attachment.messageId !== messageId && attachment.savedPath) { - retainedSavedPaths.add(attachment.savedPath); - } - } - - return retainedSavedPaths; - } - - private async deleteSavedFile(filePath: string): Promise { - const electronApi = this.getElectronApi(); - - if (!electronApi?.deleteFile) - return; - - await electronApi.deleteFile(filePath); - } - - /** Clear any user-facing request error stored on an attachment. */ - private clearAttachmentRequestError(attachment: Attachment): boolean { - if (!attachment.requestError) - return false; - - attachment.requestError = undefined; - return true; - } - - /** Check whether a specific transfer has been cancelled. */ - private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean { - return this.cancelledTransfers.has( - this.buildTransferKey(messageId, fileId, targetPeerId) - ); - } - - /** Check whether a file is inline-previewable media. */ - private isMedia(attachment: { mime: string }): boolean { - return attachment.mime.startsWith('image/') || - attachment.mime.startsWith('video/') || - attachment.mime.startsWith('audio/'); - } - - /** Auto-download only the assets that already supported eager loading when watched. */ - private shouldAutoRequestWhenWatched(attachment: Attachment): boolean { - return attachment.isImage || - (this.isMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES); - } - - /** Check whether a completed download should be cached on disk. */ - private shouldPersistDownloadedAttachment(attachment: Attachment): boolean { - return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES || - attachment.mime.startsWith('video/') || - attachment.mime.startsWith('audio/'); - } - - /** - * Send a `file-request` to the best untried peer. - * @returns `true` if a request was dispatched. - */ - private sendFileRequestToNextPeer( - messageId: string, - fileId: string, - preferredPeerId?: string - ): boolean { - const connectedPeers = this.webrtc.getConnectedPeers(); - const requestKey = this.buildRequestKey(messageId, fileId); - const triedPeers = this.pendingRequests.get(requestKey) ?? new Set(); - - // Pick the best untried peer: preferred first, then any - let targetPeerId: string | undefined; - - if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) { - targetPeerId = preferredPeerId; - } else { - targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId)); - } - - if (!targetPeerId) { - this.pendingRequests.delete(requestKey); - return false; - } - - triedPeers.add(targetPeerId); - this.pendingRequests.set(requestKey, triedPeers); - - const fileRequestEvent: FileRequestEvent = { - type: 'file-request', - messageId, - fileId - }; - - this.webrtc.sendToPeer(targetPeerId, fileRequestEvent); - - return true; - } - - /** Broadcast a file in base-64 chunks to all connected peers. */ - private async streamFileToPeers( - messageId: string, - fileId: string, - file: File - ): Promise { - const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); - - let offset = 0; - let chunkIndex = 0; - - while (offset < file.size) { - const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); - const arrayBuffer = await slice.arrayBuffer(); - const base64 = this.arrayBufferToBase64(arrayBuffer); - const fileChunkEvent: FileChunkEvent = { - type: 'file-chunk', - messageId, - fileId, - index: chunkIndex, - total: totalChunks, - data: base64 - }; - - this.webrtc.broadcastMessage(fileChunkEvent); - - offset += FILE_CHUNK_SIZE_BYTES; - chunkIndex++; - } - } - - /** Stream a file in base-64 chunks to a single peer. */ - private async streamFileToPeer( - targetPeerId: string, - messageId: string, - fileId: string, - file: File - ): Promise { - const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); - - let offset = 0; - let chunkIndex = 0; - - while (offset < file.size) { - if (this.isTransferCancelled(targetPeerId, messageId, fileId)) - break; - - const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); - const arrayBuffer = await slice.arrayBuffer(); - const base64 = this.arrayBufferToBase64(arrayBuffer); - const fileChunkEvent: FileChunkEvent = { - type: 'file-chunk', - messageId, - fileId, - index: chunkIndex, - total: totalChunks, - data: base64 - }; - - await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); - - offset += FILE_CHUNK_SIZE_BYTES; - chunkIndex++; - } - } - - /** - * Read a file from Electron disk and stream it to a peer as - * base-64 chunks. - */ - private async streamFileFromDiskToPeer( - targetPeerId: string, - messageId: string, - fileId: string, - diskPath: string - ): Promise { - const electronApi = this.getElectronApi(); - - if (!electronApi?.readFile) - return; - - const base64Full = await electronApi.readFile(diskPath); - const fileBytes = this.base64ToUint8Array(base64Full); - const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); - - for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { - if (this.isTransferCancelled(targetPeerId, messageId, fileId)) - break; - - const start = chunkIndex * FILE_CHUNK_SIZE_BYTES; - const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES); - const slice = fileBytes.subarray(start, end); - const sliceBuffer = (slice.buffer as ArrayBuffer).slice( - slice.byteOffset, - slice.byteOffset + slice.byteLength - ); - const base64Chunk = this.arrayBufferToBase64(sliceBuffer); - const fileChunkEvent: FileChunkEvent = { - type: 'file-chunk', - messageId, - fileId, - index: chunkIndex, - total: totalChunks, - data: base64Chunk - }; - - this.webrtc.sendToPeer(targetPeerId, fileChunkEvent); - } - } - - /** - * Save a file to the Electron app-data directory, organised by - * room name and media type. - */ - private async saveFileToDisk(attachment: Attachment, blob: Blob): Promise { - try { - const electronApi = this.getElectronApi(); - const appDataPath: string | undefined = await electronApi?.getAppDataPath?.(); - - if (!appDataPath || !electronApi?.ensureDir || !electronApi.writeFile) - return; - - const roomName = await this.resolveCurrentRoomName(); - const sanitisedRoom = roomName.replace(/[^\w.-]+/g, '_') || 'room'; - const subDirectory = attachment.mime.startsWith('video/') - ? 'video' - : attachment.mime.startsWith('audio/') - ? 'audio' - : attachment.mime.startsWith('image/') - ? 'image' - : 'files'; - const directoryPath = `${appDataPath}/server/${sanitisedRoom}/${subDirectory}`; - - await electronApi.ensureDir(directoryPath); - - const arrayBuffer = await blob.arrayBuffer(); - const base64 = this.arrayBufferToBase64(arrayBuffer); - const diskPath = `${directoryPath}/${attachment.filename}`; - - await electronApi.writeFile(diskPath, base64); - - attachment.savedPath = diskPath; - void this.persistAttachmentMeta(attachment); - } catch { /* disk save is best-effort */ } - } - - /** On startup, try loading previously saved files from disk (Electron). */ - private async tryLoadSavedFiles(): Promise { - const electronApi = this.getElectronApi(); - - if (!electronApi?.fileExists || !electronApi?.readFile) - return; - - try { - let hasChanges = false; - - for (const [, attachments] of this.attachmentsByMessage) { - for (const attachment of attachments) { - if (attachment.available) - continue; - - // 1. Try savedPath (disk cache) - if (attachment.savedPath) { - try { - if (await electronApi.fileExists(attachment.savedPath)) { - this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.savedPath)); - hasChanges = true; - continue; - } - } catch { /* fall through */ } - } - - // 2. Try filePath (uploader's original) - if (attachment.filePath) { - try { - if (await electronApi.fileExists(attachment.filePath)) { - this.restoreAttachmentFromDisk(attachment, await electronApi.readFile(attachment.filePath)); - hasChanges = true; - - if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { - const response = await fetch(attachment.objectUrl!); - - void this.saveFileToDisk(attachment, await response.blob()); - } - - continue; - } - } catch { /* fall through */ } - } - } - } - - if (hasChanges) - this.touch(); - } catch { /* startup load is best-effort */ } - } - - /** - * Helper: decode a base-64 string from disk, create blob + object URL, - * and populate the `originalFiles` map for serving file requests. - */ - private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void { - const bytes = this.base64ToUint8Array(base64); - const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime }); - - attachment.objectUrl = URL.createObjectURL(blob); - attachment.available = true; - const file = new File([blob], attachment.filename, { type: attachment.mime }); - - this.originalFiles.set(`${attachment.messageId}:${attachment.id}`, file); - } - - /** Save attachment metadata to the database (without file content). */ - private async persistAttachmentMeta(attachment: Attachment): Promise { - if (!this.database.isReady()) - return; - - try { - await this.database.saveAttachment({ - id: attachment.id, - messageId: attachment.messageId, - filename: attachment.filename, - size: attachment.size, - mime: attachment.mime, - isImage: attachment.isImage, - uploaderPeerId: attachment.uploaderPeerId, - filePath: attachment.filePath, - savedPath: attachment.savedPath - }); - } catch { /* persistence is best-effort */ } - } - - /** Load all attachment metadata from the database. */ - private async loadFromDatabase(): Promise { - try { - const allRecords: AttachmentMeta[] = await this.database.getAllAttachments(); - const grouped = new Map(); - - for (const record of allRecords) { - const attachment: Attachment = { ...record, - available: false }; - const bucket = grouped.get(record.messageId) ?? []; - - bucket.push(attachment); - grouped.set(record.messageId, bucket); - } - - this.attachmentsByMessage = grouped; - this.touch(); - } catch { /* load is best-effort */ } - } - - private extractWatchedRoomId(url: string): string | null { - const roomMatch = url.match(ROOM_URL_PATTERN); - - return roomMatch ? roomMatch[1] : null; - } - - private isRoomWatched(roomId: string | null | undefined): boolean { - return !!roomId && roomId === this.watchedRoomId; - } - - private async resolveMessageRoomId(messageId: string): Promise { - const cachedRoomId = this.messageRoomIds.get(messageId); - - if (cachedRoomId) - return cachedRoomId; - - if (!this.database.isReady()) - return null; - - try { - const message = await this.database.getMessageById(messageId); - - if (!message?.roomId) - return null; - - this.rememberMessageRoom(messageId, message.roomId); - return message.roomId; - } catch { - return null; - } - } - - /** One-time migration from localStorage to the database. */ - private async migrateFromLocalStorage(): Promise { - try { - const raw = localStorage.getItem(LEGACY_STORAGE_KEY); - - if (!raw) - return; - - const legacyRecords: AttachmentMeta[] = JSON.parse(raw); - - for (const meta of legacyRecords) { - const existing = this.attachmentsByMessage.get(meta.messageId) ?? []; - - if (!existing.find((entry) => entry.id === meta.id)) { - const attachment: Attachment = { ...meta, - available: false }; - - existing.push(attachment); - this.attachmentsByMessage.set(meta.messageId, existing); - void this.persistAttachmentMeta(attachment); - } - } - - localStorage.removeItem(LEGACY_STORAGE_KEY); - this.touch(); - } catch { /* migration is best-effort */ } - } - - /** Full initialisation sequence: load DB → migrate → restore files. */ - private async initFromDatabase(): Promise { - await this.loadFromDatabase(); - await this.migrateFromLocalStorage(); - await this.tryLoadSavedFiles(); - } - - /** Resolve the display name of the current room via the NgRx store. */ - private resolveCurrentRoomName(): Promise { - return new Promise((resolve) => { - this.ngrxStore - .select(selectCurrentRoomName) - .pipe(take(1)) - .subscribe((name) => resolve(name || '')); - }); - } - - /** Convert an ArrayBuffer to a base-64 string. */ - private arrayBufferToBase64(buffer: ArrayBuffer): string { - let binary = ''; - - const bytes = new Uint8Array(buffer); - - for (let index = 0; index < bytes.byteLength; index++) { - binary += String.fromCharCode(bytes[index]); - } - - return btoa(binary); - } - - /** Convert a base-64 string to a Uint8Array. */ - private base64ToUint8Array(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - - for (let index = 0; index < binary.length; index++) { - bytes[index] = binary.charCodeAt(index); - } - - return bytes; - } -} diff --git a/src/app/core/services/debugging/debugging-network-snapshot.builder.ts b/src/app/core/services/debugging/debugging-network-snapshot.builder.ts index e03027b..bf1b6c3 100644 --- a/src/app/core/services/debugging/debugging-network-snapshot.builder.ts +++ b/src/app/core/services/debugging/debugging-network-snapshot.builder.ts @@ -1,5 +1,5 @@ /* eslint-disable complexity, padding-line-between-statements */ -import { getDebugNetworkMetricSnapshot } from '../debug-network-metrics.service'; +import { getDebugNetworkMetricSnapshot } from '../../../infrastructure/realtime/logging/debug-network-metrics'; import type { Room, User } from '../../models/index'; import { LOCAL_NETWORK_NODE_ID, diff --git a/src/app/core/services/desktop-app-update.service.ts b/src/app/core/services/desktop-app-update.service.ts index 5648a2b..806e922 100644 --- a/src/app/core/services/desktop-app-update.service.ts +++ b/src/app/core/services/desktop-app-update.service.ts @@ -5,65 +5,16 @@ import { inject, signal } from '@angular/core'; -import { PlatformService } from './platform.service'; -import { ServerDirectoryService, type ServerEndpoint } from './server-directory.service'; - -type AutoUpdateMode = 'auto' | 'off' | 'version'; -type DesktopUpdateStatus = - | 'idle' - | 'disabled' - | 'checking' - | 'downloading' - | 'up-to-date' - | 'restart-required' - | 'unsupported' - | 'no-manifest' - | 'target-unavailable' - | 'target-older-than-installed' - | 'error'; -type DesktopUpdateServerVersionStatus = 'unknown' | 'reported' | 'missing' | 'unavailable'; - -interface DesktopUpdateState { - autoUpdateMode: AutoUpdateMode; - availableVersions: string[]; - configuredManifestUrls: string[]; - currentVersion: string; - defaultManifestUrls: string[]; - isSupported: boolean; - lastCheckedAt: number | null; - latestVersion: string | null; - manifestUrl: string | null; - manifestUrls: string[]; - minimumServerVersion: string | null; - preferredVersion: string | null; - restartRequired: boolean; - serverBlocked: boolean; - serverBlockMessage: string | null; - serverVersion: string | null; - serverVersionStatus: DesktopUpdateServerVersionStatus; - status: DesktopUpdateStatus; - statusMessage: string | null; - targetVersion: string | null; -} - -interface DesktopUpdateServerContext { - manifestUrls: string[]; - serverVersion: string | null; - serverVersionStatus: DesktopUpdateServerVersionStatus; -} - -interface DesktopUpdateElectronApi { - checkForAppUpdates?: () => Promise; - configureAutoUpdateContext?: (context: Partial) => Promise; - getAutoUpdateState?: () => Promise; - onAutoUpdateStateChanged?: (listener: (state: DesktopUpdateState) => void) => () => void; - restartToApplyUpdate?: () => Promise; - setDesktopSettings?: (patch: { - autoUpdateMode?: AutoUpdateMode; - manifestUrls?: string[]; - preferredVersion?: string | null; - }) => Promise; -} +import { PlatformService } from '../platform'; +import { type ServerEndpoint, ServerDirectoryFacade } from '../../domains/server-directory'; +import { + type AutoUpdateMode, + type DesktopUpdateServerContext, + type DesktopUpdateServerVersionStatus, + type DesktopUpdateState, + type ElectronApi +} from '../platform/electron/electron-api.models'; +import { ElectronBridgeService } from '../platform/electron/electron-bridge.service'; interface ServerHealthResponse { releaseManifestUrl?: string; @@ -77,10 +28,6 @@ interface ServerHealthSnapshot { serverVersionStatus: DesktopUpdateServerVersionStatus; } -type DesktopUpdateWindow = Window & { - electronAPI?: DesktopUpdateElectronApi; -}; - const SERVER_CONTEXT_REFRESH_INTERVAL_MS = 5 * 60_000; const SERVER_CONTEXT_TIMEOUT_MS = 5_000; @@ -153,7 +100,8 @@ export class DesktopAppUpdateService { readonly state = signal(createInitialState()); private injector = inject(Injector); - private servers = inject(ServerDirectoryService); + private servers = inject(ServerDirectoryFacade); + private electronBridge = inject(ElectronBridgeService); private initialized = false; private refreshTimerId: number | null = null; private removeStateListener: (() => void) | null = null; @@ -393,9 +341,7 @@ export class DesktopAppUpdateService { } catch {} } - private getElectronApi(): DesktopUpdateElectronApi | null { - return typeof window !== 'undefined' - ? (window as DesktopUpdateWindow).electronAPI ?? null - : null; + private getElectronApi(): ElectronApi | null { + return this.electronBridge.getApi(); } } diff --git a/src/app/core/services/index.ts b/src/app/core/services/index.ts index 4a2260c..fe3b2af 100644 --- a/src/app/core/services/index.ts +++ b/src/app/core/services/index.ts @@ -1,14 +1,4 @@ export * from './notification-audio.service'; -export * from './platform.service'; -export * from './browser-database.service'; -export * from './electron-database.service'; -export * from './database.service'; export * from '../models/debugging.models'; export * from './debugging/debugging.service'; -export * from './webrtc.service'; -export * from './server-directory.service'; -export * from './klipy.service'; -export * from './voice-session.service'; -export * from './voice-activity.service'; -export * from './external-link.service'; export * from './settings-modal.service'; diff --git a/src/app/core/services/platform.service.ts b/src/app/core/services/platform.service.ts deleted file mode 100644 index e73b78f..0000000 --- a/src/app/core/services/platform.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@angular/core'; - -type ElectronPlatformWindow = Window & { - electronAPI?: unknown; -}; - -@Injectable({ providedIn: 'root' }) -export class PlatformService { - readonly isElectron: boolean; - readonly isBrowser: boolean; - - constructor() { - this.isElectron = - typeof window !== 'undefined' && !!(window as ElectronPlatformWindow).electronAPI; - - this.isBrowser = !this.isElectron; - } -} diff --git a/src/app/core/services/server-directory.service.ts b/src/app/core/services/server-directory.service.ts deleted file mode 100644 index 2765876..0000000 --- a/src/app/core/services/server-directory.service.ts +++ /dev/null @@ -1,1284 +0,0 @@ -/* eslint-disable @typescript-eslint/member-ordering, @angular-eslint/prefer-inject, @typescript-eslint/no-invalid-void-type */ -import { - Injectable, - signal, - computed -} from '@angular/core'; -import { HttpClient, HttpParams } from '@angular/common/http'; -import { - Observable, - of, - throwError, - 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; -} - -interface HealthCheckPayload { - serverVersion?: unknown; -} - -interface DesktopUpdateStateSnapshot { - currentVersion?: unknown; -} - -interface DesktopUpdateBridge { - getAutoUpdateState?: () => Promise; -} - -type VersionAwareWindow = Window & { - electronAPI?: DesktopUpdateBridge; -}; - -type DefaultEndpointTemplate = Omit & { - defaultKey: string; -}; - -/** - * A configured server endpoint that the user can connect to. - */ -export interface ServerEndpoint { - /** Unique endpoint identifier. */ - id: string; - /** Human-readable label shown in the UI. */ - name: string; - /** Base URL (e.g. `http://localhost:3001`). */ - url: string; - /** Whether this is the currently selected endpoint. */ - 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' | 'incompatible'; - /** Last measured round-trip latency (ms). */ - latency?: number; - /** Last reported signaling-server version from /api/health. */ - serverVersion?: string; - /** Local desktop client version used for compatibility checks. */ - clientVersion?: string; -} - -export interface ServerSourceSelector { - sourceId?: string; - sourceUrl?: string; -} - -export interface ServerJoinAccessRequest { - roomId: string; - userId: string; - userPublicKey: string; - displayName: string; - password?: string; - inviteId?: string; -} - -export interface ServerJoinAccessResponse { - success: boolean; - signalingUrl: string; - joinedBefore: boolean; - via: 'membership' | 'password' | 'invite' | 'public'; - server: ServerInfo; -} - -export interface CreateServerInviteRequest { - requesterUserId: string; - requesterDisplayName?: string; - requesterRole?: string; -} - -export interface ServerInviteInfo { - id: string; - serverId: string; - createdAt: number; - expiresAt: number; - inviteUrl: string; - browserUrl: string; - appUrl: string; - sourceUrl: string; - createdBy?: string; - createdByDisplayName?: string; - isExpired: boolean; - server: ServerInfo; -} - -export interface KickServerMemberRequest { - actorUserId: string; - actorRole?: string; - targetUserId: string; -} - -export interface BanServerMemberRequest extends KickServerMemberRequest { - banId?: string; - displayName?: string; - reason?: string; - expiresAt?: number; -} - -export interface UnbanServerMemberRequest { - actorUserId: string; - actorRole?: string; - banId?: string; - targetUserId?: string; -} - -/** 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; - -export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users'; - -function getDefaultHttpProtocol(): 'http' | 'https' { - return typeof window !== 'undefined' && window.location?.protocol === 'https:' - ? 'https' - : 'http'; -} - -function normaliseDefaultServerUrl(rawUrl: string): string { - let cleaned = rawUrl.trim(); - - if (!cleaned) - return ''; - - if (cleaned.toLowerCase().startsWith('ws://')) { - cleaned = `http://${cleaned.slice(5)}`; - } else if (cleaned.toLowerCase().startsWith('wss://')) { - cleaned = `https://${cleaned.slice(6)}`; - } else if (cleaned.startsWith('//')) { - cleaned = `${getDefaultHttpProtocol()}:${cleaned}`; - } else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) { - cleaned = `${getDefaultHttpProtocol()}://${cleaned}`; - } - - cleaned = cleaned.replace(/\/+$/, ''); - - if (cleaned.toLowerCase().endsWith('/api')) { - cleaned = cleaned.slice(0, -4); - } - - return cleaned; -} - -function normalizeSemanticVersion(rawVersion: unknown): string | null { - if (typeof rawVersion !== 'string') { - return null; - } - - const trimmed = rawVersion.trim(); - - if (!trimmed) { - return null; - } - - const match = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i); - - if (!match) { - return null; - } - - const major = Number.parseInt(match[1], 10); - const minor = Number.parseInt(match[2], 10); - const patch = Number.parseInt(match[3], 10); - - if ( - Number.isNaN(major) - || Number.isNaN(minor) - || Number.isNaN(patch) - ) { - return null; - } - - return `${major}.${minor}.${patch}`; -} - -/** - * Derive the default server URL from the environment when provided, - * otherwise match the current page protocol automatically. - */ -function buildFallbackDefaultServerUrl(): string { - const configuredUrl = environment.defaultServerUrl?.trim(); - - if (configuredUrl) { - return normaliseDefaultServerUrl(configuredUrl); - } - - return `${getDefaultHttpProtocol()}://localhost:3001`; -} - -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 - * provides an HTTP client for server-directory API calls - * (search, register, join/leave, heartbeat, etc.). - * - * Endpoints are persisted in `localStorage` and exposed as - * Angular signals for reactive consumption. - */ -@Injectable({ providedIn: 'root' }) -export class ServerDirectoryService { - private readonly _servers = signal([]); - private clientVersionPromise: Promise | null = null; - - /** Whether search queries should be fanned out to all non-offline endpoints. */ - private shouldSearchAllServers = true; - - /** Reactive list of all configured endpoints. */ - readonly servers = computed(() => this._servers()); - - /** Endpoints currently enabled for discovery. */ - readonly activeServers = computed(() => - this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible') - ); - - /** 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.activeServers()[0] ?? null); - - constructor(private readonly http: HttpClient) { - this.loadConnectionSettings(); - this.loadEndpoints(); - void this.testAllServers(); - } - - /** - * Add a new server endpoint (active by default). - * - * @param server - Name and URL of the endpoint to add. - */ - addServer(server: { name: string; url: string }): ServerEndpoint { - const sanitisedUrl = this.sanitiseUrl(server.url); - const newEndpoint: ServerEndpoint = { - id: uuidv4(), - name: server.name, - url: sanitisedUrl, - isActive: true, - isDefault: false, - status: 'unknown' - }; - - this._servers.update((endpoints) => [...endpoints, newEndpoint]); - this.saveEndpoints(); - return newEndpoint; - } - - /** Ensure an endpoint exists for a given URL, optionally activating it. */ - ensureServerEndpoint( - server: { name: string; url: string }, - options?: { setActive?: boolean } - ): ServerEndpoint { - const sanitisedUrl = this.sanitiseUrl(server.url); - const existing = this.findServerByUrl(sanitisedUrl); - - if (existing) { - if (options?.setActive) { - this.setActiveServer(existing.id); - } - - return existing; - } - - const created = this.addServer({ name: server.name, - url: sanitisedUrl }); - - if (options?.setActive) { - this.setActiveServer(created.id); - } - - return created; - } - - /** Find a configured endpoint by URL. */ - findServerByUrl(url: string): ServerEndpoint | undefined { - const sanitisedUrl = this.sanitiseUrl(url); - - return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl); - } - - /** - * Remove an endpoint by ID. - * 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 || endpoints.length <= 1) - return; - - 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.some((endpoint) => endpoint.isActive)) { - list[0] = { ...list[0], - isActive: true }; - } - - return [...list]; - }); - } - - this.saveEndpoints(); - } - - /** 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 || target.status === 'incompatible') { - 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.id === endpointId ? { ...endpoint, - isActive: false } : endpoint - ) - ); - - this.saveEndpoints(); - } - - /** Update the health status and optional latency of an endpoint. */ - updateServerStatus( - endpointId: string, - status: ServerEndpoint['status'], - latency?: number, - versions?: { - serverVersion?: string | null; - clientVersion?: string | null; - } - ): void { - this._servers.update((endpoints) => { - const updatedEndpoints = endpoints.map((endpoint) => { - if (endpoint.id !== endpointId) { - return endpoint; - } - - return { - ...endpoint, - status, - latency, - isActive: status === 'incompatible' ? false : endpoint.isActive, - serverVersion: versions?.serverVersion ?? endpoint.serverVersion, - clientVersion: versions?.clientVersion ?? endpoint.clientVersion - }; - }); - - if (updatedEndpoints.some((endpoint) => endpoint.isActive)) { - return updatedEndpoints; - } - - const fallbackIndex = updatedEndpoints.findIndex((endpoint) => endpoint.status !== 'incompatible'); - - if (fallbackIndex < 0) { - return updatedEndpoints; - } - - const nextEndpoints = [...updatedEndpoints]; - - nextEndpoints[fallbackIndex] = { - ...nextEndpoints[fallbackIndex], - isActive: true - }; - - return nextEndpoints; - }); - - this.saveEndpoints(); - } - - /** Verify whether a selector resolves to an endpoint compatible with this client version. */ - async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise { - const endpoint = this.resolveEndpoint(selector); - - if (!endpoint) { - return false; - } - - if (endpoint.status === 'incompatible') { - return false; - } - - const clientVersion = await this.getClientVersion(); - - if (!clientVersion) { - return true; - } - - await this.testServer(endpoint.id); - - const refreshedEndpoint = this._servers().find((candidate) => candidate.id === endpoint.id); - - return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible'; - } - - /** Enable or disable fan-out search across all endpoints. */ - setSearchAllServers(enabled: boolean): void { - this.shouldSearchAllServers = enabled; - } - - /** - * Probe a single endpoint's health and update its status. - * - * @param endpointId - ID of the endpoint to test. - * @returns `true` if the server responded successfully. - */ - async testServer(endpointId: string): Promise { - const endpoint = this._servers().find((entry) => entry.id === endpointId); - - if (!endpoint) - return false; - - this.updateServerStatus(endpointId, 'checking'); - const startTime = Date.now(); - const clientVersion = await this.getClientVersion(); - - try { - const response = await fetch(`${endpoint.url}/api/health`, { - method: 'GET', - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS) - }); - const latency = Date.now() - startTime; - - if (response.ok) { - const payload = await response.json() as HealthCheckPayload; - const serverVersion = normalizeSemanticVersion(payload.serverVersion); - const isVersionCompatible = !clientVersion - || (serverVersion !== null && serverVersion === clientVersion); - - if (!isVersionCompatible) { - this.updateServerStatus(endpointId, 'incompatible', latency, { - serverVersion, - clientVersion - }); - - return false; - } - - this.updateServerStatus(endpointId, 'online', latency, { - serverVersion, - clientVersion - }); - - return true; - } - - this.updateServerStatus(endpointId, 'offline'); - return false; - } catch { - // Fall back to the /servers endpoint - try { - const response = await fetch(`${endpoint.url}/api/servers`, { - method: 'GET', - signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS) - }); - const latency = Date.now() - startTime; - - if (response.ok) { - this.updateServerStatus(endpointId, 'online', latency); - return true; - } - } catch { /* both checks failed */ } - - this.updateServerStatus(endpointId, 'offline'); - return false; - } - } - - /** Probe all configured endpoints in parallel. */ - async testAllServers(): Promise { - const endpoints = this._servers(); - - await Promise.all(endpoints.map((endpoint) => this.testServer(endpoint.id))); - } - - /** Expose the API base URL for external consumers. */ - getApiBaseUrl(selector?: ServerSourceSelector): string { - return this.buildApiBaseUrl(selector); - } - - /** Get the WebSocket URL derived from the active endpoint. */ - getWebSocketUrl(selector?: ServerSourceSelector): string { - return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws'); - } - - /** - * Search for public servers matching a query string. - * When {@link shouldSearchAllServers} is `true`, the search is - * fanned out to every non-offline endpoint. - */ - searchServers(query: string): Observable { - if (this.shouldSearchAllServers) { - return this.searchAllEndpoints(query); - } - - return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); - } - - /** Retrieve the full list of public servers. */ - getServers(): Observable { - if (this.shouldSearchAllServers) { - return this.getAllServersFromAllEndpoints(); - } - - return this.http - .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) - .pipe( - map((response) => this.normalizeServerList(response, this.activeServer())), - catchError((error) => { - console.error('Failed to get servers:', error); - return of([]); - }) - ); - } - - /** Fetch details for a single server. */ - getServer(serverId: string, selector?: ServerSourceSelector): Observable { - return this.http - .get(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`) - .pipe( - map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), - catchError((error) => { - console.error('Failed to get server:', error); - return of(null); - }) - ); - } - - /** Register a new server listing in the directory. */ - registerServer( - server: Omit & { id?: string; password?: string | null }, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers`, server) - .pipe( - catchError((error) => { - console.error('Failed to register server:', error); - return throwError(() => error); - }) - ); - } - - /** Update an existing server listing. */ - updateServer( - serverId: string, - updates: Partial & { - currentOwnerId: string; - actingRole?: string; - password?: string | null; - }, - selector?: ServerSourceSelector - ): Observable { - return this.http - .put(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`, updates) - .pipe( - catchError((error) => { - console.error('Failed to update server:', error); - return throwError(() => error); - }) - ); - } - - /** Remove a server listing from the directory. */ - unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable { - return this.http - .delete(`${this.buildApiBaseUrl(selector)}/servers/${serverId}`) - .pipe( - catchError((error) => { - console.error('Failed to unregister server:', error); - return throwError(() => error); - }) - ); - } - - /** Retrieve users currently connected to a server. */ - getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable { - return this.http - .get(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/users`) - .pipe( - catchError((error) => { - console.error('Failed to get server users:', error); - return of([]); - }) - ); - } - - /** Send a join request for a server and receive the signaling URL. */ - requestJoin( - request: ServerJoinAccessRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post( - `${this.buildApiBaseUrl(selector)}/servers/${request.roomId}/join`, - request - ) - .pipe( - catchError((error) => { - console.error('Failed to send join request:', error); - return throwError(() => error); - }) - ); - } - - /** Create an expiring invite link for a server. */ - createInvite( - serverId: string, - request: CreateServerInviteRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/invites`, request) - .pipe( - catchError((error) => { - console.error('Failed to create invite:', error); - return throwError(() => error); - }) - ); - } - - /** Retrieve public invite metadata. */ - getInvite(inviteId: string, selector?: ServerSourceSelector): Observable { - return this.http - .get(`${this.buildApiBaseUrl(selector)}/invites/${inviteId}`) - .pipe( - catchError((error) => { - console.error('Failed to get invite:', error); - return throwError(() => error); - }) - ); - } - - /** Remove a member's stored join access for a server. */ - kickServerMember( - serverId: string, - request: KickServerMemberRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request) - .pipe( - catchError((error) => { - console.error('Failed to kick server member:', error); - return throwError(() => error); - }) - ); - } - - /** Ban a member from a server invite/password access list. */ - banServerMember( - serverId: string, - request: BanServerMemberRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request) - .pipe( - catchError((error) => { - console.error('Failed to ban server member:', error); - return throwError(() => error); - }) - ); - } - - /** Remove a stored server ban. */ - unbanServerMember( - serverId: string, - request: UnbanServerMemberRequest, - selector?: ServerSourceSelector - ): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request) - .pipe( - catchError((error) => { - console.error('Failed to unban server member:', error); - return throwError(() => error); - }) - ); - } - - /** Remove a user's remembered membership after leaving a server. */ - notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable { - return this.http - .post(`${this.buildApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }) - .pipe( - catchError((error) => { - console.error('Failed to notify leave:', error); - return of(undefined); - }) - ); - } - - /** Update the live user count for a server listing. */ - updateUserCount(serverId: string, count: number): Observable { - return this.http - .patch(`${this.buildApiBaseUrl()}/servers/${serverId}/user-count`, { count }) - .pipe( - catchError((error) => { - console.error('Failed to update user count:', error); - return of(undefined); - }) - ); - } - - /** Send a heartbeat to keep the server listing active. */ - sendHeartbeat(serverId: string): Observable { - return this.http - .post(`${this.buildApiBaseUrl()}/servers/${serverId}/heartbeat`, {}) - .pipe( - catchError((error) => { - console.error('Failed to send heartbeat:', error); - return of(undefined); - }) - ); - } - - /** - * Build the active endpoint's API base URL, stripping trailing - * slashes and accidental `/api` suffixes. - */ - private buildApiBaseUrl(selector?: ServerSourceSelector): string { - return `${this.resolveBaseServerUrl(selector)}/api`; - } - - /** Strip trailing slashes and `/api` suffix from a URL. */ - private sanitiseUrl(rawUrl: string): string { - let cleaned = rawUrl.trim().replace(/\/+$/, ''); - - if (cleaned.toLowerCase().endsWith('/api')) { - cleaned = cleaned.slice(0, -4); - } - - return cleaned; - } - - private resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null { - if (selector?.sourceId) { - return this._servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null; - } - - if (selector?.sourceUrl) { - return this.findServerByUrl(selector.sourceUrl) ?? null; - } - - return this.activeServer() - ?? this._servers().find((endpoint) => endpoint.status !== 'incompatible') - ?? this._servers()[0] - ?? null; - } - - private resolveBaseServerUrl(selector?: ServerSourceSelector): string { - if (selector?.sourceUrl) { - return this.sanitiseUrl(selector.sourceUrl); - } - - return this.resolveEndpoint(selector)?.url ?? getPrimaryDefaultServerUrl(); - } - - /** - * Handle both `{ servers: [...] }` and direct `ServerInfo[]` - * response shapes from the directory API. - */ - private unwrapServersResponse( - response: { servers: ServerInfo[]; total: number } | ServerInfo[] - ): ServerInfo[] { - if (Array.isArray(response)) - return response; - - return response.servers ?? []; - } - - /** Search a single endpoint for servers matching a query. */ - private searchSingleEndpoint( - query: string, - apiBaseUrl: string, - source?: ServerEndpoint | null - ): Observable { - const params = new HttpParams().set('q', query); - - return this.http - .get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }) - .pipe( - map((response) => this.normalizeServerList(response, source)), - catchError((error) => { - console.error('Failed to search servers:', error); - return of([]); - }) - ); - } - - /** Fan-out search across all non-offline endpoints, deduplicating results. */ - private searchAllEndpoints(query: string): Observable { - const onlineEndpoints = this.activeServers().filter( - (endpoint) => endpoint.status !== 'offline' - ); - - if (onlineEndpoints.length === 0) { - return this.searchSingleEndpoint(query, this.buildApiBaseUrl(), this.activeServer()); - } - - const requests = onlineEndpoints.map((endpoint) => - this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint) - ); - - return forkJoin(requests).pipe( - map((resultArrays) => resultArrays.flat()), - map((servers) => this.deduplicateById(servers)) - ); - } - - /** Retrieve all servers from all non-offline endpoints. */ - private getAllServersFromAllEndpoints(): Observable { - const onlineEndpoints = this.activeServers().filter( - (endpoint) => endpoint.status !== 'offline' - ); - - if (onlineEndpoints.length === 0) { - return this.http - .get<{ servers: ServerInfo[]; total: number }>(`${this.buildApiBaseUrl()}/servers`) - .pipe( - map((response) => this.normalizeServerList(response, this.activeServer())), - catchError(() => of([])) - ); - } - - const requests = onlineEndpoints.map((endpoint) => - this.http - .get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`) - .pipe( - map((response) => this.normalizeServerList(response, endpoint)), - catchError(() => of([] as ServerInfo[])) - ) - ); - - return forkJoin(requests).pipe(map((resultArrays) => resultArrays.flat())); - } - - /** Remove duplicate servers (by `id`), keeping the first occurrence. */ - private deduplicateById(items: T[]): T[] { - const seen = new Set(); - - return items.filter((item) => { - if (seen.has(item.id)) - return false; - - seen.add(item.id); - return true; - }); - } - - private normalizeServerList( - response: { servers: ServerInfo[]; total: number } | ServerInfo[], - source?: ServerEndpoint | null - ): ServerInfo[] { - return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source)); - } - - private normalizeServerInfo( - server: ServerInfo | Record, - source?: ServerEndpoint | null - ): ServerInfo { - const candidate = server as Record; - const sourceName = this.getStringValue(candidate['sourceName']); - const sourceUrl = this.getStringValue(candidate['sourceUrl']); - - return { - id: this.getStringValue(candidate['id']) ?? '', - name: this.getStringValue(candidate['name']) ?? 'Unnamed server', - description: this.getStringValue(candidate['description']), - topic: this.getStringValue(candidate['topic']), - hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API', - ownerId: this.getStringValue(candidate['ownerId']), - ownerName: this.getStringValue(candidate['ownerName']), - ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']), - userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])), - maxUsers: this.getNumberValue(candidate['maxUsers']), - hasPassword: this.getBooleanValue(candidate['hasPassword']), - isPrivate: this.getBooleanValue(candidate['isPrivate']), - tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [], - createdAt: this.getNumberValue(candidate['createdAt'], Date.now()), - sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id, - sourceName: sourceName ?? source?.name, - sourceUrl: sourceUrl - ? this.sanitiseUrl(sourceUrl) - : (source ? this.sanitiseUrl(source.url) : undefined) - }; - } - - private getBooleanValue(value: unknown): boolean { - return typeof value === 'boolean' ? value : value === 1; - } - - private getNumberValue(value: unknown, fallback = 0): number { - return typeof value === 'number' ? value : fallback; - } - - private getStringValue(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; - } - - private async getClientVersion(): Promise { - if (!this.clientVersionPromise) { - this.clientVersionPromise = this.resolveClientVersion(); - } - - return this.clientVersionPromise; - } - - private async resolveClientVersion(): Promise { - if (typeof window === 'undefined') { - return null; - } - - const electronApi = (window as VersionAwareWindow).electronAPI; - - if (!electronApi?.getAutoUpdateState) { - return null; - } - - try { - const state = await electronApi.getAutoUpdateState(); - - return normalizeSemanticVersion(state?.currentVersion); - } catch { - return null; - } - } - - /** 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.initialiseDefaultEndpoints(); - return; - } - - try { - const parsed = JSON.parse(stored) as ServerEndpoint[]; - const endpoints = this.reconcileStoredEndpoints(parsed); - - this._servers.set(endpoints); - this.saveEndpoints(); - } catch { - this.initialiseDefaultEndpoints(); - } - } - - /** Create and persist the built-in default endpoints. */ - private initialiseDefaultEndpoints(): void { - const defaultEndpoints = DEFAULT_ENDPOINTS.map((endpoint) => ({ - ...endpoint, - id: uuidv4() - })); - - 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 deleted file mode 100644 index 00cf396..0000000 --- a/src/app/core/services/webrtc.service.ts +++ /dev/null @@ -1,1388 +0,0 @@ -/** - * WebRTCService - thin Angular service that composes specialised managers. - * - * Each concern lives in its own file under `./webrtc/`: - * • SignalingManager - WebSocket lifecycle & reconnection - * • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels - * • MediaManager - mic voice, mute, deafen, bitrate - * • ScreenShareManager - screen capture & mixed audio - * • WebRTCLogger - debug / diagnostic logging - * - * This file wires them together and exposes a public API that is - * identical to the old monolithic service so consumers don't change. - */ -/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unused-vars */ -import { - Injectable, - signal, - computed, - inject, - OnDestroy -} from '@angular/core'; -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'; -import { DebuggingService } from './debugging.service'; -import { ScreenShareSourcePickerService } from './screen-share-source-picker.service'; - -import { - SignalingManager, - PeerConnectionManager, - MediaManager, - ScreenShareManager, - WebRTCLogger, - IdentifyCredentials, - JoinedServerInfo, - VoiceStateSnapshot, - LatencyProfile, - ScreenShareStartOptions, - SIGNALING_TYPE_IDENTIFY, - SIGNALING_TYPE_JOIN_SERVER, - SIGNALING_TYPE_VIEW_SERVER, - SIGNALING_TYPE_LEAVE_SERVER, - SIGNALING_TYPE_OFFER, - SIGNALING_TYPE_ANSWER, - SIGNALING_TYPE_ICE_CANDIDATE, - SIGNALING_TYPE_CONNECTED, - SIGNALING_TYPE_SERVER_USERS, - SIGNALING_TYPE_USER_JOINED, - SIGNALING_TYPE_USER_LEFT, - DEFAULT_DISPLAY_NAME, - P2P_TYPE_SCREEN_SHARE_REQUEST, - P2P_TYPE_SCREEN_SHARE_STOP, - P2P_TYPE_VOICE_STATE, - P2P_TYPE_SCREEN_STATE -} from './webrtc'; - -interface SignalingUserSummary { - oderId: string; - displayName: string; -} - -interface IncomingSignalingPayload { - sdp?: RTCSessionDescriptionInit; - candidate?: RTCIceCandidateInit; -} - -type IncomingSignalingMessage = Omit, 'type' | 'payload'> & { - type: string; - payload?: IncomingSignalingPayload; - oderId?: string; - serverTime?: number; - serverId?: string; - serverIds?: string[]; - users?: SignalingUserSummary[]; - displayName?: string; - fromUserId?: string; -}; - -@Injectable({ - providedIn: 'root' -}) -export class WebRTCService implements OnDestroy { - private readonly timeSync = inject(TimeSyncService); - private readonly debugging = inject(DebuggingService); - private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService); - - private readonly logger = new WebRTCLogger(() => this.debugging.enabled()); - - private lastIdentifyCredentials: IdentifyCredentials | null = null; - 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; - /** Maps each remote peer ID to the shared servers they currently belong to. */ - private readonly peerServerMap = new Map>(); - private readonly serviceDestroyed$ = new Subject(); - private remoteScreenShareRequestsEnabled = false; - private readonly desiredRemoteScreenSharePeers = new Set(); - private readonly activeRemoteScreenSharePeers = new Set(); - - private readonly _localPeerId = signal(uuidv4()); - private readonly _isSignalingConnected = signal(false); - private readonly _isVoiceConnected = signal(false); - private readonly _connectedPeers = signal([]); - private readonly _isMuted = signal(false); - private readonly _isDeafened = signal(false); - private readonly _isScreenSharing = signal(false); - private readonly _isNoiseReductionEnabled = signal(false); - private readonly _screenStreamSignal = signal(null); - private readonly _isScreenShareRemotePlaybackSuppressed = signal(false); - private readonly _forceDefaultRemotePlaybackOutput = signal(false); - private readonly _hasConnectionError = signal(false); - private readonly _connectionErrorMessage = signal(null); - private readonly _hasEverConnected = signal(false); - /** - * Reactive snapshot of per-peer latencies (ms). - * Updated whenever a ping/pong round-trip completes. - * Keyed by remote peer (oderId). - */ - private readonly _peerLatencies = signal>(new Map()); - - // Public computed signals (unchanged external API) - readonly peerId = computed(() => this._localPeerId()); - readonly isConnected = computed(() => this._isSignalingConnected()); - readonly hasEverConnected = computed(() => this._hasEverConnected()); - readonly isVoiceConnected = computed(() => this._isVoiceConnected()); - readonly connectedPeers = computed(() => this._connectedPeers()); - readonly isMuted = computed(() => this._isMuted()); - readonly isDeafened = computed(() => this._isDeafened()); - readonly isScreenSharing = computed(() => this._isScreenSharing()); - readonly isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled()); - readonly screenStream = computed(() => this._screenStreamSignal()); - readonly isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed()); - readonly forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput()); - readonly hasConnectionError = computed(() => this._hasConnectionError()); - readonly connectionErrorMessage = computed(() => this._connectionErrorMessage()); - readonly shouldShowConnectionError = computed(() => { - if (!this._hasConnectionError()) - return false; - - if (this._isVoiceConnected() && this._connectedPeers().length > 0) - return false; - - return true; - }); - /** Per-peer latency map (ms). Read via `peerLatencies()`. */ - readonly peerLatencies = computed(() => this._peerLatencies()); - - private readonly signalingMessage$ = new Subject(); - readonly onSignalingMessage = this.signalingMessage$.asObservable(); - - // Delegates to managers - get onMessageReceived(): Observable { - return this.peerManager.messageReceived$.asObservable(); - } - get onPeerConnected(): Observable { - return this.peerManager.peerConnected$.asObservable(); - } - get onPeerDisconnected(): Observable { - return this.peerManager.peerDisconnected$.asObservable(); - } - get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { - return this.peerManager.remoteStream$.asObservable(); - } - get onVoiceConnected(): Observable { - return this.mediaManager.voiceConnected$.asObservable(); - } - - private readonly peerManager: PeerConnectionManager; - private readonly mediaManager: MediaManager; - private readonly screenShareManager: ScreenShareManager; - - constructor() { - // Create managers with null callbacks first to break circular initialization - this.peerManager = new PeerConnectionManager(this.logger, null!); - - this.mediaManager = new MediaManager(this.logger, null!); - - this.screenShareManager = new ScreenShareManager(this.logger, null!); - - // Now wire up cross-references (all managers are instantiated) - this.peerManager.setCallbacks({ - sendRawMessage: (msg: Record) => this.sendRawMessage(msg), - getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), - isSignalingConnected: (): boolean => this._isSignalingConnected(), - getVoiceStateSnapshot: (): VoiceStateSnapshot => this.getCurrentVoiceState(), - getIdentifyCredentials: (): IdentifyCredentials | null => this.lastIdentifyCredentials, - getLocalPeerId: (): string => this._localPeerId(), - isScreenSharingActive: (): boolean => this._isScreenSharing() - }); - - this.mediaManager.setCallbacks({ - getActivePeers: (): Map => - this.peerManager.activePeerConnections, - renegotiate: (peerId: string): Promise => this.peerManager.renegotiate(peerId), - broadcastMessage: (event: ChatEvent): void => this.peerManager.broadcastMessage(event), - getIdentifyOderId: (): string => this.lastIdentifyCredentials?.oderId || this._localPeerId(), - getIdentifyDisplayName: (): string => - this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME - }); - - this.screenShareManager.setCallbacks({ - getActivePeers: (): Map => - this.peerManager.activePeerConnections, - getLocalMediaStream: (): MediaStream | null => this.mediaManager.getLocalStream(), - renegotiate: (peerId: string): Promise => this.peerManager.renegotiate(peerId), - broadcastCurrentStates: (): void => this.peerManager.broadcastCurrentStates(), - selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open( - sources, - options.includeSystemAudio - ), - updateLocalScreenShareState: (state): void => { - this._isScreenSharing.set(state.active); - this._screenStreamSignal.set(state.stream); - this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback); - this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput); - } - }); - - this.wireManagerEvents(); - } - - private wireManagerEvents(): void { - // Internal control-plane messages for on-demand screen-share delivery. - this.peerManager.messageReceived$.subscribe((event) => this.handlePeerControlMessage(event)); - - // Peer manager → connected peers signal - this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => - this._connectedPeers.set(peers) - ); - - // If we are already sharing when a new peer connection finishes, push the - // current screen-share tracks to that peer and renegotiate. - this.peerManager.peerConnected$.subscribe((peerId) => { - if (!this.screenShareManager.getIsScreenActive()) { - if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) { - this.requestRemoteScreenShares([peerId]); - } - - return; - } - - this.screenShareManager.syncScreenShareToPeer(peerId); - - if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) { - this.requestRemoteScreenShares([peerId]); - } - }); - - this.peerManager.peerDisconnected$.subscribe((peerId) => { - this.activeRemoteScreenSharePeers.delete(peerId); - this.peerServerMap.delete(peerId); - this.peerSignalingUrlMap.delete(peerId); - this.screenShareManager.clearScreenShareRequest(peerId); - }); - - // Media manager → voice connected signal - this.mediaManager.voiceConnected$.subscribe(() => { - this._isVoiceConnected.set(true); - }); - - // Peer manager → latency updates - this.peerManager.peerLatencyChanged$.subscribe(({ peerId, latencyMs }) => { - const next = new Map(this.peerManager.peerLatencies); - - this._peerLatencies.set(next); - }); - } - - 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', { - signalUrl, - type: message.type - }); - - switch (message.type) { - case SIGNALING_TYPE_CONNECTED: - this.handleConnectedSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_SERVER_USERS: - this.handleServerUsersSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_USER_JOINED: - this.handleUserJoinedSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_USER_LEFT: - this.handleUserLeftSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_OFFER: - this.handleOfferSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_ANSWER: - this.handleAnswerSignalingMessage(message, signalUrl); - return; - - case SIGNALING_TYPE_ICE_CANDIDATE: - this.handleIceCandidateSignalingMessage(message, signalUrl); - return; - - default: - return; - } - } - - 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, 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); - } - - const existing = this.peerManager.activePeerConnections.get(user.oderId); - - if (this.canReusePeerConnection(existing)) { - this.logger.info('Reusing active peer connection', { - connectionState: existing?.connection.connectionState ?? 'unknown', - dataChannelState: existing?.dataChannel?.readyState ?? 'missing', - oderId: user.oderId, - serverId: message.serverId, - signalUrl - }); - continue; - } - - if (existing) { - this.logger.info('Removing failed peer before recreate', { - connectionState: existing.connection.connectionState, - dataChannelState: existing.dataChannel?.readyState ?? 'missing', - oderId: user.oderId, - serverId: message.serverId, - signalUrl - }); - this.peerManager.removePeer(user.oderId); - } - - this.logger.info('Create peer connection to existing user', { - oderId: user.oderId, - serverId: message.serverId, - signalUrl - }); - - this.peerManager.createPeerConnection(user.oderId, true); - void this.peerManager.createAndSendOffer(user.oderId); - } - } - - private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { - this.logger.info('User joined', { - displayName: message.displayName, - 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, signalUrl: string): void { - this.logger.info('User left', { - displayName: message.displayName, - oderId: message.oderId, - signalUrl, - serverId: message.serverId - }); - - if (message.oderId) { - const hasRemainingSharedServers = Array.isArray(message.serverIds) - ? this.replacePeerSharedServers(message.oderId, message.serverIds) - : (message.serverId - ? this.untrackPeerFromServer(message.oderId, message.serverId) - : false); - - if (!hasRemainingSharedServers) { - this.peerManager.removePeer(message.oderId); - this.peerServerMap.delete(message.oderId); - this.peerSignalingUrlMap.delete(message.oderId); - } - } - } - - 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)) { - this.trackPeerInServer(fromUserId, offerEffectiveServer); - } - - this.peerManager.handleOffer(fromUserId, sdp); - } - - 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, 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); - } - - /** - * Close all peer connections that were discovered from a server - * other than `serverId`. Also removes their entries from - * {@link peerServerMap} so the bookkeeping stays clean. - * - * This ensures audio (and data channels) are scoped to only - * the voice-active (or currently viewed) server. - */ - private closePeersNotInServer(serverId: string): void { - const peersToClose: string[] = []; - - this.peerServerMap.forEach((peerServerIds, peerId) => { - if (!peerServerIds.has(serverId)) { - peersToClose.push(peerId); - } - }); - - for (const peerId of peersToClose) { - this.logger.info('Closing peer from different server', { peerId, - currentServer: serverId }); - - this.peerManager.removePeer(peerId); - this.peerServerMap.delete(peerId); - this.peerSignalingUrlMap.delete(peerId); - } - } - - private getCurrentVoiceState(): VoiceStateSnapshot { - return { - isConnected: this._isVoiceConnected(), - isMuted: this._isMuted(), - isDeafened: this._isDeafened(), - isScreenSharing: this._isScreenSharing(), - roomId: this.mediaManager.getCurrentVoiceRoomId(), - serverId: this.mediaManager.getCurrentVoiceServerId() - }; - } - - // PUBLIC API - matches the old monolithic service's interface - - /** - * Connect to a signaling server via WebSocket. - * - * @param serverUrl - The WebSocket URL of the signaling server. - * @returns An observable that emits `true` once connected. - */ - connectToSignalingServer(serverUrl: string): Observable { - 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 { - if (!peerId || !serverId) - return; - - const trackedServers = this.peerServerMap.get(peerId) ?? new Set(); - - trackedServers.add(serverId); - this.peerServerMap.set(peerId, trackedServers); - } - - private replacePeerSharedServers(peerId: string, serverIds: string[]): boolean { - const sharedServerIds = serverIds.filter((serverId) => this.isJoinedServer(serverId)); - - if (sharedServerIds.length === 0) { - this.peerServerMap.delete(peerId); - return false; - } - - this.peerServerMap.set(peerId, new Set(sharedServerIds)); - return true; - } - - private untrackPeerFromServer(peerId: string, serverId: string): boolean { - const trackedServers = this.peerServerMap.get(peerId); - - if (!trackedServers) - return false; - - trackedServers.delete(serverId); - - if (trackedServers.size === 0) { - this.peerServerMap.delete(peerId); - return false; - } - - this.peerServerMap.set(peerId, trackedServers); - return true; - } - - /** - * Ensure the signaling WebSocket is connected, reconnecting if needed. - * - * @param timeoutMs - Maximum time (ms) to wait for the connection. - * @returns `true` if connected within the timeout. - */ - async ensureSignalingConnected(timeoutMs?: number): Promise { - if (this.isAnySignalingConnected()) { - return true; - } - - for (const manager of this.signalingManagers.values()) { - if (await manager.ensureConnected(timeoutMs)) { - return true; - } - } - - return false; - } - - /** - * Send a signaling-level message (with `from` and `timestamp` auto-populated). - * - * @param message - The signaling message payload (excluding `from` / `timestamp`). - */ - sendSignalingMessage(message: Omit): void { - 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()); - } - } - - /** - * Send a raw JSON payload through the signaling WebSocket. - * - * @param message - Arbitrary JSON message. - */ - sendRawMessage(message: Record): void { - 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; - } - - /** - * Track the currently-active server ID (for server-scoped operations). - * - * @param serverId - The server to mark as active. - */ - setCurrentServer(serverId: string): void { - this.activeServerId = serverId; - } - - /** The server ID currently being viewed / active, or `null`. */ - get currentServerId(): string | null { - return this.activeServerId; - } - - /** The last signaling URL used by the client, if any. */ - getCurrentSignalingUrl(): string | null { - if (this.activeServerId) { - const activeServerSignalUrl = this.serverSignalingUrlMap.get(this.activeServerId); - - if (activeServerSignalUrl) { - return activeServerSignalUrl; - } - } - - return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null; - } - - /** - * Send an identify message to the signaling server. - * - * The credentials are cached so they can be replayed after a reconnect. - * - * @param oderId - The user's unique order/peer ID. - * @param displayName - The user's display name. - */ - identify(oderId: string, displayName: string, signalUrl?: string): void { - const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME; - - this.lastIdentifyCredentials = { oderId, - displayName: normalizedDisplayName }; - - const identifyMessage = { - type: SIGNALING_TYPE_IDENTIFY, - oderId, - displayName: normalizedDisplayName - }; - - if (signalUrl) { - this.sendRawMessageToSignalUrl(signalUrl, identifyMessage); - return; - } - - const connectedManagers = this.getConnectedSignalingManagers(); - - if (connectedManagers.length === 0) { - return; - } - - for (const { manager } of connectedManagers) { - manager.sendRawMessage(identifyMessage); - } - } - - /** - * Join a server (room) on the signaling server. - * - * @param roomId - The server / room ID to join. - * @param userId - The local user ID. - */ - joinRoom(roomId: string, userId: string, signalUrl?: string): void { - const resolvedSignalUrl = signalUrl - ?? this.serverSignalingUrlMap.get(roomId) - ?? this.getCurrentSignalingUrl(); - - 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 - }); - } - - /** - * Switch to a different server. If already a member, sends a view event; - * otherwise joins the server. - * - * @param serverId - The target server ID. - * @param userId - The local user ID. - */ - switchServer(serverId: string, userId: string, signalUrl?: string): void { - const resolvedSignalUrl = signalUrl - ?? this.serverSignalingUrlMap.get(serverId) - ?? this.getCurrentSignalingUrl(); - - 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 { - 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() - }); - } - } - - /** - * Leave one or all servers. - * - * If `serverId` is provided, leaves only that server. - * Otherwise leaves every joined server and performs a full cleanup. - * - * @param serverId - Optional server to leave; omit to leave all. - */ - leaveRoom(serverId?: string): void { - if (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.getJoinedServerCount() === 0) { - this.fullCleanup(); - } - - return; - } - - for (const [signalUrl, memberServerIds] of this.memberServerIdsBySignalUrl.entries()) { - for (const sid of memberServerIds) { - this.sendRawMessageToSignalUrl(signalUrl, { - type: SIGNALING_TYPE_LEAVE_SERVER, - serverId: sid - }); - } - } - - this.memberServerIdsBySignalUrl.clear(); - this.serverSignalingUrlMap.clear(); - this.fullCleanup(); - } - - /** - * Check whether the local client has joined a given server. - * - * @param serverId - The server to check. - */ - hasJoinedServer(serverId: string): boolean { - return this.isJoinedServer(serverId); - } - - /** Returns a read-only set of all currently-joined server IDs. */ - getJoinedServerIds(): ReadonlySet { - const joinedServerIds = new Set(); - - for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { - memberServerIds.forEach((serverId) => joinedServerIds.add(serverId)); - } - - return joinedServerIds; - } - - /** - * Broadcast a {@link ChatEvent} to every connected peer. - * - * @param event - The chat event to send. - */ - broadcastMessage(event: ChatEvent): void { - this.peerManager.broadcastMessage(event); - } - - /** - * Send a {@link ChatEvent} to a specific peer. - * - * @param peerId - The target peer ID. - * @param event - The chat event to send. - */ - sendToPeer(peerId: string, event: ChatEvent): void { - this.peerManager.sendToPeer(peerId, event); - } - - syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void { - const nextDesiredPeers = new Set( - peerIds.filter((peerId): peerId is string => !!peerId) - ); - - if (!enabled) { - this.remoteScreenShareRequestsEnabled = false; - this.desiredRemoteScreenSharePeers.clear(); - this.stopRemoteScreenShares([...this.activeRemoteScreenSharePeers]); - return; - } - - this.remoteScreenShareRequestsEnabled = true; - - for (const activePeerId of [...this.activeRemoteScreenSharePeers]) { - if (!nextDesiredPeers.has(activePeerId)) { - this.stopRemoteScreenShares([activePeerId]); - } - } - - this.desiredRemoteScreenSharePeers.clear(); - nextDesiredPeers.forEach((peerId) => this.desiredRemoteScreenSharePeers.add(peerId)); - this.requestRemoteScreenShares([...nextDesiredPeers]); - } - - /** - * Send a {@link ChatEvent} to a peer with back-pressure awareness. - * - * @param peerId - The target peer ID. - * @param event - The chat event to send. - */ - async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise { - return this.peerManager.sendToPeerBuffered(peerId, event); - } - - /** Returns an array of currently-connected peer IDs. */ - getConnectedPeers(): string[] { - return this.peerManager.getConnectedPeerIds(); - } - - /** - * Get the composite remote {@link MediaStream} for a connected peer. - * - * @param peerId - The remote peer whose stream to retrieve. - * @returns The stream, or `null` if the peer has no active stream. - */ - getRemoteStream(peerId: string): MediaStream | null { - return this.peerManager.remotePeerStreams.get(peerId) ?? null; - } - - /** - * Get the remote voice-only stream for a connected peer. - * - * @param peerId - The remote peer whose voice stream to retrieve. - * @returns The stream, or `null` if the peer has no active voice audio. - */ - getRemoteVoiceStream(peerId: string): MediaStream | null { - return this.peerManager.remotePeerVoiceStreams.get(peerId) ?? null; - } - - /** - * Get the remote screen-share stream for a connected peer. - * - * This contains the screen video track and any audio track that belongs to - * the screen share itself, not the peer's normal voice-chat audio. - * - * @param peerId - The remote peer whose screen-share stream to retrieve. - * @returns The stream, or `null` if the peer has no active screen share. - */ - getRemoteScreenShareStream(peerId: string): MediaStream | null { - return this.peerManager.remotePeerScreenShareStreams.get(peerId) ?? null; - } - - /** - * Get the current local media stream (microphone audio). - * - * @returns The local {@link MediaStream}, or `null` if voice is not active. - */ - getLocalStream(): MediaStream | null { - return this.mediaManager.getLocalStream(); - } - - /** - * Get the raw local microphone stream before gain / RNNoise processing. - * - * @returns The raw microphone {@link MediaStream}, or `null` if voice is not active. - */ - getRawMicStream(): MediaStream | null { - return this.mediaManager.getRawMicStream(); - } - - /** - * Request microphone access and start sending audio to all peers. - * - * @returns The captured local {@link MediaStream}. - */ - async enableVoice(): Promise { - const stream = await this.mediaManager.enableVoice(); - - this.syncMediaSignals(); - return stream; - } - - /** Stop local voice capture and remove audio senders from peers. */ - disableVoice(): void { - this.voiceServerId = null; - this.mediaManager.disableVoice(); - this._isVoiceConnected.set(false); - } - - /** - * Inject an externally-obtained media stream as the local voice source. - * - * @param stream - The media stream to use. - */ - async setLocalStream(stream: MediaStream): Promise { - await this.mediaManager.setLocalStream(stream); - this.syncMediaSignals(); - } - - /** - * Toggle the local microphone mute state. - * - * @param muted - Explicit state; if omitted, the current state is toggled. - */ - toggleMute(muted?: boolean): void { - this.mediaManager.toggleMute(muted); - this._isMuted.set(this.mediaManager.getIsMicMuted()); - } - - /** - * Toggle self-deafen (suppress incoming audio playback). - * - * @param deafened - Explicit state; if omitted, the current state is toggled. - */ - toggleDeafen(deafened?: boolean): void { - this.mediaManager.toggleDeafen(deafened); - this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); - } - - /** - * Toggle RNNoise noise reduction on the local microphone. - * - * When enabled, the raw mic audio is routed through an AudioWorklet - * that applies neural-network noise suppression before being sent - * to peers. - * - * @param enabled - Explicit state; if omitted, the current state is toggled. - */ - async toggleNoiseReduction(enabled?: boolean): Promise { - await this.mediaManager.toggleNoiseReduction(enabled); - this._isNoiseReductionEnabled.set(this.mediaManager.getIsNoiseReductionEnabled()); - } - - /** - * Set the output volume for remote audio playback. - * - * @param volume - Normalised volume (0-1). - */ - setOutputVolume(volume: number): void { - this.mediaManager.setOutputVolume(volume); - } - - /** - * Set the input (microphone) volume. - * - * Adjusts a Web Audio GainNode on the local mic stream so the level - * sent to peers changes in real time without renegotiation. - * - * @param volume - Normalised volume (0-1). - */ - setInputVolume(volume: number): void { - this.mediaManager.setInputVolume(volume); - } - - /** - * Set the maximum audio bitrate for all peer connections. - * - * @param kbps - Target bitrate in kilobits per second. - */ - async setAudioBitrate(kbps: number): Promise { - return this.mediaManager.setAudioBitrate(kbps); - } - - /** - * Apply a predefined latency profile that maps to a specific bitrate. - * - * @param profile - One of `'low'`, `'balanced'`, or `'high'`. - */ - async setLatencyProfile(profile: LatencyProfile): Promise { - return this.mediaManager.setLatencyProfile(profile); - } - - /** - * Start broadcasting voice-presence heartbeats to all peers. - * - * Also marks the given server as the active voice server and closes - * any peer connections that belong to other servers so that audio - * is isolated to the correct voice channel. - * - * @param roomId - The voice channel room ID. - * @param serverId - The voice channel server ID. - */ - startVoiceHeartbeat(roomId?: string, serverId?: string): void { - if (serverId) { - this.voiceServerId = serverId; - } - - this.mediaManager.startVoiceHeartbeat(roomId, serverId); - } - - /** Stop the voice-presence heartbeat. */ - stopVoiceHeartbeat(): void { - this.mediaManager.stopVoiceHeartbeat(); - } - - /** - * Start sharing the screen (or a window) with all connected peers. - * - * @param options - Screen-share capture options. - * @returns The screen-capture {@link MediaStream}. - */ - async startScreenShare(options: ScreenShareStartOptions): Promise { - return await this.screenShareManager.startScreenShare(options); - } - - /** Stop screen sharing and restore microphone audio on all peers. */ - stopScreenShare(): void { - this.screenShareManager.stopScreenShare(); - } - - /** Disconnect from the signaling server and clean up all state. */ - disconnect(): void { - this.leaveRoom(); - this.voiceServerId = null; - this.peerServerMap.clear(); - this.peerSignalingUrlMap.clear(); - this.lastJoinedServerBySignalUrl.clear(); - this.memberServerIdsBySignalUrl.clear(); - this.serverSignalingUrlMap.clear(); - this.mediaManager.stopVoiceHeartbeat(); - this.destroyAllSignalingManagers(); - this._isSignalingConnected.set(false); - this._hasEverConnected.set(false); - this._hasConnectionError.set(false); - this._connectionErrorMessage.set(null); - this.serviceDestroyed$.next(); - } - - /** Alias for {@link disconnect}. */ - disconnectAll(): void { - this.disconnect(); - } - - private fullCleanup(): void { - this.voiceServerId = null; - this.peerServerMap.clear(); - this.peerSignalingUrlMap.clear(); - this.remoteScreenShareRequestsEnabled = false; - this.desiredRemoteScreenSharePeers.clear(); - this.activeRemoteScreenSharePeers.clear(); - this.peerManager.closeAllPeers(); - this._connectedPeers.set([]); - this.mediaManager.disableVoice(); - this._isVoiceConnected.set(false); - this.screenShareManager.stopScreenShare(); - this._isScreenSharing.set(false); - this._screenStreamSignal.set(null); - this._isScreenShareRemotePlaybackSuppressed.set(false); - this._forceDefaultRemotePlaybackOutput.set(false); - } - - /** Synchronise Angular signals from the MediaManager's internal state. */ - private syncMediaSignals(): void { - this._isVoiceConnected.set(this.mediaManager.getIsVoiceActive()); - this._isMuted.set(this.mediaManager.getIsMicMuted()); - this._isDeafened.set(this.mediaManager.getIsSelfDeafened()); - } - - /** Returns true if a peer connection is still alive enough to finish negotiating. */ - private canReusePeerConnection(peer: import('./webrtc').PeerData | undefined): boolean { - if (!peer) - return false; - - const connState = peer.connection?.connectionState; - - return connState !== 'closed' && connState !== 'failed'; - } - - private handlePeerControlMessage(event: ChatEvent): void { - if (!event.fromPeerId) { - return; - } - - if (event.type === P2P_TYPE_SCREEN_STATE && event.isScreenSharing === false) { - this.peerManager.clearRemoteScreenShareStream(event.fromPeerId); - return; - } - - if (event.type === P2P_TYPE_SCREEN_SHARE_REQUEST) { - this.screenShareManager.requestScreenShareForPeer(event.fromPeerId); - return; - } - - if (event.type === P2P_TYPE_SCREEN_SHARE_STOP) { - this.screenShareManager.stopScreenShareForPeer(event.fromPeerId); - } - } - - private requestRemoteScreenShares(peerIds: string[]): void { - const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds()); - - for (const peerId of peerIds) { - if (!connectedPeerIds.has(peerId) || this.activeRemoteScreenSharePeers.has(peerId)) { - continue; - } - - this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_REQUEST }); - this.activeRemoteScreenSharePeers.add(peerId); - } - } - - private stopRemoteScreenShares(peerIds: string[]): void { - const connectedPeerIds = new Set(this.peerManager.getConnectedPeerIds()); - - for (const peerId of peerIds) { - if (this.activeRemoteScreenSharePeers.has(peerId) && connectedPeerIds.has(peerId)) { - this.peerManager.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_STOP }); - } - - this.activeRemoteScreenSharePeers.delete(peerId); - this.peerManager.clearRemoteScreenShareStream(peerId); - } - } - - 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.peerManager.destroy(); - this.mediaManager.destroy(); - this.screenShareManager.destroy(); - } -} diff --git a/src/app/core/services/webrtc/index.ts b/src/app/core/services/webrtc/index.ts deleted file mode 100644 index 5a1efa6..0000000 --- a/src/app/core/services/webrtc/index.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Barrel export for the WebRTC sub-module. - * - * Other modules should import from here: - * import { ... } from './webrtc'; - */ -export * from './webrtc.constants'; -export * from './webrtc.types'; -export * from './webrtc-logger'; -export * from './signaling.manager'; -export * from './peer-connection.manager'; -export * from './media.manager'; -export * from './screen-share.manager'; -export * from './screen-share.config'; -export * from './noise-reduction.manager'; diff --git a/src/app/core/services/webrtc/screen-share-platforms/shared.ts b/src/app/core/services/webrtc/screen-share-platforms/shared.ts deleted file mode 100644 index d50a6e6..0000000 --- a/src/app/core/services/webrtc/screen-share-platforms/shared.ts +++ /dev/null @@ -1,80 +0,0 @@ -export interface DesktopSource { - id: string; - name: string; - thumbnail: string; -} - -export interface ElectronDesktopSourceSelection { - includeSystemAudio: boolean; - source: DesktopSource; -} - -export interface ElectronDesktopCaptureResult { - includeSystemAudio: boolean; - stream: MediaStream; -} - -export interface LinuxScreenShareAudioRoutingInfo { - available: boolean; - active: boolean; - monitorCaptureSupported: boolean; - screenShareSinkName: string; - screenShareMonitorSourceName: string; - voiceSinkName: string; - reason?: string; -} - -export interface LinuxScreenShareMonitorCaptureInfo { - bitsPerSample: number; - captureId: string; - channelCount: number; - sampleRate: number; - sourceName: string; -} - -export interface LinuxScreenShareMonitorAudioChunkPayload { - captureId: string; - chunk: Uint8Array; -} - -export interface LinuxScreenShareMonitorAudioEndedPayload { - captureId: string; - reason?: string; -} - -export interface ScreenShareElectronApi { - getSources?: () => Promise; - prepareLinuxScreenShareAudioRouting?: () => Promise; - activateLinuxScreenShareAudioRouting?: () => Promise; - deactivateLinuxScreenShareAudioRouting?: () => Promise; - startLinuxScreenShareMonitorCapture?: () => Promise; - stopLinuxScreenShareMonitorCapture?: (captureId?: string) => Promise; - onLinuxScreenShareMonitorAudioChunk?: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void; - onLinuxScreenShareMonitorAudioEnded?: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void; -} - -export type ElectronDesktopVideoConstraint = MediaTrackConstraints & { - mandatory: { - chromeMediaSource: 'desktop'; - chromeMediaSourceId: string; - maxWidth: number; - maxHeight: number; - maxFrameRate: number; - }; -}; - -export type ElectronDesktopAudioConstraint = MediaTrackConstraints & { - mandatory: { - chromeMediaSource: 'desktop'; - chromeMediaSourceId: string; - }; -}; - -export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints { - video: ElectronDesktopVideoConstraint; - audio?: false | ElectronDesktopAudioConstraint; -} - -export type ScreenShareWindow = Window & { - electronAPI?: ScreenShareElectronApi; -}; diff --git a/src/app/domains/attachment/application/attachment-manager.service.ts b/src/app/domains/attachment/application/attachment-manager.service.ts new file mode 100644 index 0000000..fc4e730 --- /dev/null +++ b/src/app/domains/attachment/application/attachment-manager.service.ts @@ -0,0 +1,224 @@ +import { + Injectable, + effect, + inject +} from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { RealtimeSessionFacade } from '../../../core/realtime'; +import { DatabaseService } from '../../../infrastructure/persistence'; +import { ROOM_URL_PATTERN } from '../../../core/constants'; +import { shouldAutoRequestWhenWatched } from '../domain/attachment.logic'; +import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; +import type { + FileAnnouncePayload, + FileCancelPayload, + FileChunkPayload, + FileNotFoundPayload, + FileRequestPayload +} from '../domain/attachment-transfer.models'; +import { AttachmentPersistenceService } from './attachment-persistence.service'; +import { AttachmentRuntimeStore } from './attachment-runtime.store'; +import { AttachmentTransferService } from './attachment-transfer.service'; + +@Injectable({ providedIn: 'root' }) +export class AttachmentManagerService { + get updated() { + return this.runtimeStore.updated; + } + + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly router = inject(Router); + private readonly database = inject(DatabaseService); + private readonly runtimeStore = inject(AttachmentRuntimeStore); + private readonly persistence = inject(AttachmentPersistenceService); + private readonly transfer = inject(AttachmentTransferService); + + private watchedRoomId: string | null = this.extractWatchedRoomId(this.router.url); + private isDatabaseInitialised = false; + + constructor() { + effect(() => { + if (this.database.isReady() && !this.isDatabaseInitialised) { + this.isDatabaseInitialised = true; + void this.persistence.initFromDatabase(); + } + }); + + this.router.events.subscribe((event) => { + if (!(event instanceof NavigationEnd)) { + return; + } + + this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url); + + if (this.watchedRoomId) { + void this.requestAutoDownloadsForRoom(this.watchedRoomId); + } + }); + + this.webrtc.onPeerConnected.subscribe(() => { + if (this.watchedRoomId) { + void this.requestAutoDownloadsForRoom(this.watchedRoomId); + } + }); + } + + getForMessage(messageId: string): Attachment[] { + return this.runtimeStore.getAttachmentsForMessage(messageId); + } + + rememberMessageRoom(messageId: string, roomId: string): void { + if (!messageId || !roomId) + return; + + this.runtimeStore.rememberMessageRoom(messageId, roomId); + } + + queueAutoDownloadsForMessage(messageId: string, attachmentId?: string): void { + void this.requestAutoDownloadsForMessage(messageId, attachmentId); + } + + async requestAutoDownloadsForRoom(roomId: string): Promise { + if (!roomId || !this.isRoomWatched(roomId)) + return; + + if (this.database.isReady()) { + const messages = await this.database.getMessages(roomId, 500, 0); + + for (const message of messages) { + this.runtimeStore.rememberMessageRoom(message.id, message.roomId); + await this.requestAutoDownloadsForMessage(message.id); + } + + return; + } + + for (const [messageId] of this.runtimeStore.getAttachmentEntries()) { + const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId); + + if (attachmentRoomId === roomId) { + await this.requestAutoDownloadsForMessage(messageId); + } + } + } + + async deleteForMessage(messageId: string): Promise { + await this.persistence.deleteForMessage(messageId); + } + + getAttachmentMetasForMessages(messageIds: string[]): Record { + return this.transfer.getAttachmentMetasForMessages(messageIds); + } + + registerSyncedAttachments( + attachmentMap: Record, + messageRoomIds?: Record + ): void { + this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds); + + for (const [messageId, attachments] of Object.entries(attachmentMap)) { + for (const attachment of attachments) { + this.queueAutoDownloadsForMessage(messageId, attachment.id); + } + } + } + + requestFromAnyPeer(messageId: string, attachment: Attachment): void { + this.transfer.requestFromAnyPeer(messageId, attachment); + } + + handleFileNotFound(payload: FileNotFoundPayload): void { + this.transfer.handleFileNotFound(payload); + } + + requestImageFromAnyPeer(messageId: string, attachment: Attachment): void { + this.transfer.requestImageFromAnyPeer(messageId, attachment); + } + + requestFile(messageId: string, attachment: Attachment): void { + this.transfer.requestFile(messageId, attachment); + } + + async publishAttachments( + messageId: string, + files: File[], + uploaderPeerId?: string + ): Promise { + await this.transfer.publishAttachments(messageId, files, uploaderPeerId); + } + + handleFileAnnounce(payload: FileAnnouncePayload): void { + this.transfer.handleFileAnnounce(payload); + + if (payload.messageId && payload.file?.id) { + this.queueAutoDownloadsForMessage(payload.messageId, payload.file.id); + } + } + + handleFileChunk(payload: FileChunkPayload): void { + this.transfer.handleFileChunk(payload); + } + + async handleFileRequest(payload: FileRequestPayload): Promise { + await this.transfer.handleFileRequest(payload); + } + + cancelRequest(messageId: string, attachment: Attachment): void { + this.transfer.cancelRequest(messageId, attachment); + } + + handleFileCancel(payload: FileCancelPayload): void { + this.transfer.handleFileCancel(payload); + } + + async fulfillRequestWithFile( + messageId: string, + fileId: string, + targetPeerId: string, + file: File + ): Promise { + await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file); + } + + private async requestAutoDownloadsForMessage(messageId: string, attachmentId?: string): Promise { + if (!messageId) + return; + + const roomId = await this.persistence.resolveMessageRoomId(messageId); + + if (!roomId || !this.isRoomWatched(roomId) || this.webrtc.getConnectedPeers().length === 0) { + return; + } + + const attachments = this.runtimeStore.getAttachmentsForMessage(messageId); + + for (const attachment of attachments) { + if (attachmentId && attachment.id !== attachmentId) + continue; + + if (!shouldAutoRequestWhenWatched(attachment)) + continue; + + if (attachment.available) + continue; + + if ((attachment.receivedBytes ?? 0) > 0) + continue; + + if (this.transfer.hasPendingRequest(messageId, attachment.id)) + continue; + + this.transfer.requestFromAnyPeer(messageId, attachment); + } + } + + private extractWatchedRoomId(url: string): string | null { + const roomMatch = url.match(ROOM_URL_PATTERN); + + return roomMatch ? roomMatch[1] : null; + } + + private isRoomWatched(roomId: string | null | undefined): boolean { + return !!roomId && roomId === this.watchedRoomId; + } +} diff --git a/src/app/domains/attachment/application/attachment-persistence.service.ts b/src/app/domains/attachment/application/attachment-persistence.service.ts new file mode 100644 index 0000000..329e38e --- /dev/null +++ b/src/app/domains/attachment/application/attachment-persistence.service.ts @@ -0,0 +1,264 @@ +import { Injectable, inject } from '@angular/core'; +import { take } from 'rxjs'; +import { Store } from '@ngrx/store'; +import { selectCurrentRoomName } from '../../../store/rooms/rooms.selectors'; +import { DatabaseService } from '../../../infrastructure/persistence'; +import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; +import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; +import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants'; +import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../domain/attachment-transfer.constants'; +import { AttachmentRuntimeStore } from './attachment-runtime.store'; + +@Injectable({ providedIn: 'root' }) +export class AttachmentPersistenceService { + private readonly runtimeStore = inject(AttachmentRuntimeStore); + private readonly ngrxStore = inject(Store); + private readonly attachmentStorage = inject(AttachmentStorageService); + private readonly database = inject(DatabaseService); + + async deleteForMessage(messageId: string): Promise { + const attachments = this.runtimeStore.getAttachmentsForMessage(messageId); + const hadCachedAttachments = attachments.length > 0 || this.runtimeStore.hasAttachmentsForMessage(messageId); + const retainedSavedPaths = await this.getRetainedSavedPathsForOtherMessages(messageId); + const savedPathsToDelete = new Set(); + + for (const attachment of attachments) { + if (attachment.objectUrl) { + try { + URL.revokeObjectURL(attachment.objectUrl); + } catch { /* ignore */ } + } + + if (attachment.savedPath && !retainedSavedPaths.has(attachment.savedPath)) { + savedPathsToDelete.add(attachment.savedPath); + } + } + + this.runtimeStore.deleteAttachmentsForMessage(messageId); + this.runtimeStore.deleteMessageRoom(messageId); + this.runtimeStore.clearMessageScopedState(messageId); + + if (hadCachedAttachments) { + this.runtimeStore.touch(); + } + + if (this.database.isReady()) { + await this.database.deleteAttachmentsForMessage(messageId); + } + + for (const diskPath of savedPathsToDelete) { + await this.attachmentStorage.deleteFile(diskPath); + } + } + + async persistAttachmentMeta(attachment: Attachment): Promise { + if (!this.database.isReady()) + return; + + try { + await this.database.saveAttachment({ + id: attachment.id, + messageId: attachment.messageId, + filename: attachment.filename, + size: attachment.size, + mime: attachment.mime, + isImage: attachment.isImage, + uploaderPeerId: attachment.uploaderPeerId, + filePath: attachment.filePath, + savedPath: attachment.savedPath + }); + } catch { /* persistence is best-effort */ } + } + + async saveFileToDisk(attachment: Attachment, blob: Blob): Promise { + try { + const roomName = await this.resolveCurrentRoomName(); + const diskPath = await this.attachmentStorage.saveBlob(attachment, blob, roomName); + + if (!diskPath) + return; + + attachment.savedPath = diskPath; + void this.persistAttachmentMeta(attachment); + } catch { /* disk save is best-effort */ } + } + + async initFromDatabase(): Promise { + await this.loadFromDatabase(); + await this.migrateFromLocalStorage(); + await this.tryLoadSavedFiles(); + } + + async resolveMessageRoomId(messageId: string): Promise { + const cachedRoomId = this.runtimeStore.getMessageRoomId(messageId); + + if (cachedRoomId) + return cachedRoomId; + + if (!this.database.isReady()) + return null; + + try { + const message = await this.database.getMessageById(messageId); + + if (!message?.roomId) + return null; + + this.runtimeStore.rememberMessageRoom(messageId, message.roomId); + return message.roomId; + } catch { + return null; + } + } + + async resolveCurrentRoomName(): Promise { + return new Promise((resolve) => { + this.ngrxStore + .select(selectCurrentRoomName) + .pipe(take(1)) + .subscribe((name) => resolve(name || '')); + }); + } + + private async loadFromDatabase(): Promise { + try { + const allRecords: AttachmentMeta[] = await this.database.getAllAttachments(); + const grouped = new Map(); + + for (const record of allRecords) { + const attachment: Attachment = { ...record, + available: false }; + const bucket = grouped.get(record.messageId) ?? []; + + bucket.push(attachment); + grouped.set(record.messageId, bucket); + } + + this.runtimeStore.replaceAttachments(grouped); + this.runtimeStore.touch(); + } catch { /* load is best-effort */ } + } + + private async migrateFromLocalStorage(): Promise { + try { + const raw = localStorage.getItem(LEGACY_ATTACHMENTS_STORAGE_KEY); + + if (!raw) + return; + + const legacyRecords: AttachmentMeta[] = JSON.parse(raw); + + for (const meta of legacyRecords) { + const existing = [...this.runtimeStore.getAttachmentsForMessage(meta.messageId)]; + + if (!existing.find((entry) => entry.id === meta.id)) { + const attachment: Attachment = { ...meta, + available: false }; + + existing.push(attachment); + this.runtimeStore.setAttachmentsForMessage(meta.messageId, existing); + void this.persistAttachmentMeta(attachment); + } + } + + localStorage.removeItem(LEGACY_ATTACHMENTS_STORAGE_KEY); + this.runtimeStore.touch(); + } catch { /* migration is best-effort */ } + } + + private async tryLoadSavedFiles(): Promise { + try { + let hasChanges = false; + + for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) { + for (const attachment of attachments) { + if (attachment.available) + continue; + + if (attachment.savedPath) { + const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath); + + if (savedBase64) { + this.restoreAttachmentFromDisk(attachment, savedBase64); + hasChanges = true; + continue; + } + } + + if (attachment.filePath) { + const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath); + + if (originalBase64) { + this.restoreAttachmentFromDisk(attachment, originalBase64); + hasChanges = true; + + if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) { + const response = await fetch(attachment.objectUrl); + + void this.saveFileToDisk(attachment, await response.blob()); + } + + continue; + } + } + } + } + + if (hasChanges) + this.runtimeStore.touch(); + } catch { /* startup load is best-effort */ } + } + + private restoreAttachmentFromDisk(attachment: Attachment, base64: string): void { + const bytes = this.base64ToUint8Array(base64); + const blob = new Blob([bytes.buffer as ArrayBuffer], { type: attachment.mime }); + + attachment.objectUrl = URL.createObjectURL(blob); + attachment.available = true; + + this.runtimeStore.setOriginalFile( + `${attachment.messageId}:${attachment.id}`, + new File([blob], attachment.filename, { type: attachment.mime }) + ); + } + + private async getRetainedSavedPathsForOtherMessages(messageId: string): Promise> { + const retainedSavedPaths = new Set(); + + for (const [existingMessageId, attachments] of this.runtimeStore.getAttachmentEntries()) { + if (existingMessageId === messageId) + continue; + + for (const attachment of attachments) { + if (attachment.savedPath) { + retainedSavedPaths.add(attachment.savedPath); + } + } + } + + if (!this.database.isReady()) { + return retainedSavedPaths; + } + + const persistedAttachments = await this.database.getAllAttachments(); + + for (const attachment of persistedAttachments) { + if (attachment.messageId !== messageId && attachment.savedPath) { + retainedSavedPaths.add(attachment.savedPath); + } + } + + return retainedSavedPaths; + } + + private base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; + } +} diff --git a/src/app/domains/attachment/application/attachment-runtime.store.ts b/src/app/domains/attachment/application/attachment-runtime.store.ts new file mode 100644 index 0000000..482b981 --- /dev/null +++ b/src/app/domains/attachment/application/attachment-runtime.store.ts @@ -0,0 +1,160 @@ +import { Injectable, signal } from '@angular/core'; +import type { Attachment } from '../domain/attachment.models'; + +@Injectable({ providedIn: 'root' }) +export class AttachmentRuntimeStore { + readonly updated = signal(0); + + private attachmentsByMessage = new Map(); + private messageRoomIds = new Map(); + private originalFiles = new Map(); + private cancelledTransfers = new Set(); + private pendingRequests = new Map>(); + private chunkBuffers = new Map(); + private chunkCounts = new Map(); + + touch(): void { + this.updated.set(this.updated() + 1); + } + + getAttachmentsForMessage(messageId: string): Attachment[] { + return this.attachmentsByMessage.get(messageId) ?? []; + } + + setAttachmentsForMessage(messageId: string, attachments: Attachment[]): void { + if (attachments.length === 0) { + this.attachmentsByMessage.delete(messageId); + return; + } + + this.attachmentsByMessage.set(messageId, attachments); + } + + hasAttachmentsForMessage(messageId: string): boolean { + return this.attachmentsByMessage.has(messageId); + } + + deleteAttachmentsForMessage(messageId: string): void { + this.attachmentsByMessage.delete(messageId); + } + + replaceAttachments(nextAttachments: Map): void { + this.attachmentsByMessage = nextAttachments; + } + + getAttachmentEntries(): IterableIterator<[string, Attachment[]]> { + return this.attachmentsByMessage.entries(); + } + + rememberMessageRoom(messageId: string, roomId: string): void { + this.messageRoomIds.set(messageId, roomId); + } + + getMessageRoomId(messageId: string): string | undefined { + return this.messageRoomIds.get(messageId); + } + + deleteMessageRoom(messageId: string): void { + this.messageRoomIds.delete(messageId); + } + + setOriginalFile(key: string, file: File): void { + this.originalFiles.set(key, file); + } + + getOriginalFile(key: string): File | undefined { + return this.originalFiles.get(key); + } + + findOriginalFileByFileId(fileId: string): File | null { + for (const [key, file] of this.originalFiles) { + if (key.endsWith(`:${fileId}`)) { + return file; + } + } + + return null; + } + + addCancelledTransfer(key: string): void { + this.cancelledTransfers.add(key); + } + + hasCancelledTransfer(key: string): boolean { + return this.cancelledTransfers.has(key); + } + + setPendingRequestPeers(key: string, peers: Set): void { + this.pendingRequests.set(key, peers); + } + + getPendingRequestPeers(key: string): Set | undefined { + return this.pendingRequests.get(key); + } + + hasPendingRequest(key: string): boolean { + return this.pendingRequests.has(key); + } + + deletePendingRequest(key: string): void { + this.pendingRequests.delete(key); + } + + setChunkBuffer(key: string, buffer: ArrayBuffer[]): void { + this.chunkBuffers.set(key, buffer); + } + + getChunkBuffer(key: string): ArrayBuffer[] | undefined { + return this.chunkBuffers.get(key); + } + + deleteChunkBuffer(key: string): void { + this.chunkBuffers.delete(key); + } + + setChunkCount(key: string, count: number): void { + this.chunkCounts.set(key, count); + } + + getChunkCount(key: string): number | undefined { + return this.chunkCounts.get(key); + } + + deleteChunkCount(key: string): void { + this.chunkCounts.delete(key); + } + + clearMessageScopedState(messageId: string): void { + const scopedPrefix = `${messageId}:`; + + for (const key of Array.from(this.originalFiles.keys())) { + if (key.startsWith(scopedPrefix)) { + this.originalFiles.delete(key); + } + } + + for (const key of Array.from(this.pendingRequests.keys())) { + if (key.startsWith(scopedPrefix)) { + this.pendingRequests.delete(key); + } + } + + for (const key of Array.from(this.chunkBuffers.keys())) { + if (key.startsWith(scopedPrefix)) { + this.chunkBuffers.delete(key); + } + } + + for (const key of Array.from(this.chunkCounts.keys())) { + if (key.startsWith(scopedPrefix)) { + this.chunkCounts.delete(key); + } + } + + for (const key of Array.from(this.cancelledTransfers)) { + if (key.startsWith(scopedPrefix)) { + this.cancelledTransfers.delete(key); + } + } + } +} diff --git a/src/app/domains/attachment/application/attachment-transfer-transport.service.ts b/src/app/domains/attachment/application/attachment-transfer-transport.service.ts new file mode 100644 index 0000000..d7b0494 --- /dev/null +++ b/src/app/domains/attachment/application/attachment-transfer-transport.service.ts @@ -0,0 +1,109 @@ +import { Injectable, inject } from '@angular/core'; +import { RealtimeSessionFacade } from '../../../core/realtime'; +import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; +import { FILE_CHUNK_SIZE_BYTES } from '../domain/attachment-transfer.constants'; +import { FileChunkEvent } from '../domain/attachment-transfer.models'; + +@Injectable({ providedIn: 'root' }) +export class AttachmentTransferTransportService { + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly attachmentStorage = inject(AttachmentStorageService); + + decodeBase64(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + + return bytes; + } + + async streamFileToPeer( + targetPeerId: string, + messageId: string, + fileId: string, + file: File, + isCancelled: () => boolean + ): Promise { + const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); + + let offset = 0; + let chunkIndex = 0; + + while (offset < file.size) { + if (isCancelled()) + break; + + const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES); + const arrayBuffer = await slice.arrayBuffer(); + const base64 = this.arrayBufferToBase64(arrayBuffer); + const fileChunkEvent: FileChunkEvent = { + type: 'file-chunk', + messageId, + fileId, + index: chunkIndex, + total: totalChunks, + data: base64 + }; + + await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); + + offset += FILE_CHUNK_SIZE_BYTES; + chunkIndex++; + } + } + + async streamFileFromDiskToPeer( + targetPeerId: string, + messageId: string, + fileId: string, + diskPath: string, + isCancelled: () => boolean + ): Promise { + const base64Full = await this.attachmentStorage.readFile(diskPath); + + if (!base64Full) + return; + + const fileBytes = this.decodeBase64(base64Full); + const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + if (isCancelled()) + break; + + const start = chunkIndex * FILE_CHUNK_SIZE_BYTES; + const end = Math.min(fileBytes.byteLength, start + FILE_CHUNK_SIZE_BYTES); + const slice = fileBytes.subarray(start, end); + const sliceBuffer = (slice.buffer as ArrayBuffer).slice( + slice.byteOffset, + slice.byteOffset + slice.byteLength + ); + const base64Chunk = this.arrayBufferToBase64(sliceBuffer); + const fileChunkEvent: FileChunkEvent = { + type: 'file-chunk', + messageId, + fileId, + index: chunkIndex, + total: totalChunks, + data: base64Chunk + }; + + this.webrtc.sendToPeer(targetPeerId, fileChunkEvent); + } + } + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + let binary = ''; + + const bytes = new Uint8Array(buffer); + + for (let index = 0; index < bytes.byteLength; index++) { + binary += String.fromCharCode(bytes[index]); + } + + return btoa(binary); + } +} diff --git a/src/app/domains/attachment/application/attachment-transfer.service.ts b/src/app/domains/attachment/application/attachment-transfer.service.ts new file mode 100644 index 0000000..724d5a5 --- /dev/null +++ b/src/app/domains/attachment/application/attachment-transfer.service.ts @@ -0,0 +1,566 @@ +import { Injectable, inject } from '@angular/core'; +import { recordDebugNetworkFileChunk } from '../../../infrastructure/realtime/logging/debug-network-metrics'; +import { RealtimeSessionFacade } from '../../../core/realtime'; +import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; +import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants'; +import { shouldPersistDownloadedAttachment } from '../domain/attachment.logic'; +import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; +import { + ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT, + ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT, + DEFAULT_ATTACHMENT_MIME_TYPE, + FILE_NOT_FOUND_REQUEST_ERROR, + NO_CONNECTED_PEERS_REQUEST_ERROR +} from '../domain/attachment-transfer.constants'; +import { + type FileAnnounceEvent, + type FileAnnouncePayload, + type FileCancelEvent, + type FileCancelPayload, + type FileChunkPayload, + type FileNotFoundEvent, + type FileNotFoundPayload, + type FileRequestEvent, + type FileRequestPayload, + type LocalFileWithPath +} from '../domain/attachment-transfer.models'; +import { AttachmentPersistenceService } from './attachment-persistence.service'; +import { AttachmentRuntimeStore } from './attachment-runtime.store'; +import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; + +@Injectable({ providedIn: 'root' }) +export class AttachmentTransferService { + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly runtimeStore = inject(AttachmentRuntimeStore); + private readonly attachmentStorage = inject(AttachmentStorageService); + private readonly persistence = inject(AttachmentPersistenceService); + private readonly transport = inject(AttachmentTransferTransportService); + + getAttachmentMetasForMessages(messageIds: string[]): Record { + const result: Record = {}; + + for (const messageId of messageIds) { + const attachments = this.runtimeStore.getAttachmentsForMessage(messageId); + + if (attachments.length > 0) { + result[messageId] = attachments.map((attachment) => ({ + id: attachment.id, + messageId: attachment.messageId, + filename: attachment.filename, + size: attachment.size, + mime: attachment.mime, + isImage: attachment.isImage, + uploaderPeerId: attachment.uploaderPeerId, + filePath: undefined, + savedPath: undefined + })); + } + } + + return result; + } + + registerSyncedAttachments( + attachmentMap: Record, + messageRoomIds?: Record + ): void { + if (messageRoomIds) { + for (const [messageId, roomId] of Object.entries(messageRoomIds)) { + this.runtimeStore.rememberMessageRoom(messageId, roomId); + } + } + + const newAttachments: Attachment[] = []; + + for (const [messageId, metas] of Object.entries(attachmentMap)) { + const existing = [...this.runtimeStore.getAttachmentsForMessage(messageId)]; + + for (const meta of metas) { + const alreadyKnown = existing.find((entry) => entry.id === meta.id); + + if (!alreadyKnown) { + const attachment: Attachment = { ...meta, + available: false, + receivedBytes: 0 }; + + existing.push(attachment); + newAttachments.push(attachment); + } + } + + this.runtimeStore.setAttachmentsForMessage(messageId, existing); + } + + if (newAttachments.length > 0) { + this.runtimeStore.touch(); + + for (const attachment of newAttachments) { + void this.persistence.persistAttachmentMeta(attachment); + } + } + } + + requestFromAnyPeer(messageId: string, attachment: Attachment): void { + const clearedRequestError = this.clearAttachmentRequestError(attachment); + const connectedPeers = this.webrtc.getConnectedPeers(); + + if (connectedPeers.length === 0) { + attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR; + this.runtimeStore.touch(); + console.warn('[Attachments] No connected peers to request file from'); + return; + } + + if (clearedRequestError) + this.runtimeStore.touch(); + + this.runtimeStore.setPendingRequestPeers( + this.buildRequestKey(messageId, attachment.id), + new Set() + ); + + this.sendFileRequestToNextPeer(messageId, attachment.id, attachment.uploaderPeerId); + } + + handleFileNotFound(payload: FileNotFoundPayload): void { + const { messageId, fileId } = payload; + + if (!messageId || !fileId) + return; + + const attachments = this.runtimeStore.getAttachmentsForMessage(messageId); + const attachment = attachments.find((entry) => entry.id === fileId); + const didSendRequest = this.sendFileRequestToNextPeer(messageId, fileId, attachment?.uploaderPeerId); + + if (!didSendRequest && attachment) { + attachment.requestError = FILE_NOT_FOUND_REQUEST_ERROR; + this.runtimeStore.touch(); + } + } + + requestImageFromAnyPeer(messageId: string, attachment: Attachment): void { + this.requestFromAnyPeer(messageId, attachment); + } + + requestFile(messageId: string, attachment: Attachment): void { + this.requestFromAnyPeer(messageId, attachment); + } + + hasPendingRequest(messageId: string, fileId: string): boolean { + return this.runtimeStore.hasPendingRequest(this.buildRequestKey(messageId, fileId)); + } + + async publishAttachments( + messageId: string, + files: File[], + uploaderPeerId?: string + ): Promise { + const attachments: Attachment[] = []; + + for (const file of files) { + const fileId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; + const attachment: Attachment = { + id: fileId, + messageId, + filename: file.name, + size: file.size, + mime: file.type || DEFAULT_ATTACHMENT_MIME_TYPE, + isImage: file.type.startsWith('image/'), + uploaderPeerId, + filePath: (file as LocalFileWithPath).path, + available: false + }; + + attachments.push(attachment); + this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file); + + try { + attachment.objectUrl = URL.createObjectURL(file); + attachment.available = true; + } catch { /* non-critical */ } + + if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) { + void this.persistence.saveFileToDisk(attachment, file); + } + + const fileAnnounceEvent: FileAnnounceEvent = { + type: 'file-announce', + messageId, + file: { + id: fileId, + filename: attachment.filename, + size: attachment.size, + mime: attachment.mime, + isImage: attachment.isImage, + uploaderPeerId + } + }; + + this.webrtc.broadcastMessage(fileAnnounceEvent); + } + + const existingList = this.runtimeStore.getAttachmentsForMessage(messageId); + + this.runtimeStore.setAttachmentsForMessage(messageId, [...existingList, ...attachments]); + this.runtimeStore.touch(); + + for (const attachment of attachments) { + void this.persistence.persistAttachmentMeta(attachment); + } + } + + handleFileAnnounce(payload: FileAnnouncePayload): void { + const { messageId, file } = payload; + + if (!messageId || !file) + return; + + const list = [...this.runtimeStore.getAttachmentsForMessage(messageId)]; + const alreadyKnown = list.find((entry) => entry.id === file.id); + + if (alreadyKnown) + return; + + const attachment: Attachment = { + id: file.id, + messageId, + filename: file.filename, + size: file.size, + mime: file.mime, + isImage: !!file.isImage, + uploaderPeerId: file.uploaderPeerId, + available: false, + receivedBytes: 0 + }; + + list.push(attachment); + this.runtimeStore.setAttachmentsForMessage(messageId, list); + this.runtimeStore.touch(); + void this.persistence.persistAttachmentMeta(attachment); + } + + handleFileChunk(payload: FileChunkPayload): void { + const { messageId, fileId, fromPeerId, index, total, data } = payload; + + if ( + !messageId || !fileId || + typeof index !== 'number' || + typeof total !== 'number' || + typeof data !== 'string' + ) { + return; + } + + const list = this.runtimeStore.getAttachmentsForMessage(messageId); + const attachment = list.find((entry) => entry.id === fileId); + + if (!attachment) + return; + + const decodedBytes = this.transport.decodeBase64(data); + const assemblyKey = `${messageId}:${fileId}`; + const requestKey = this.buildRequestKey(messageId, fileId); + + this.runtimeStore.deletePendingRequest(requestKey); + this.clearAttachmentRequestError(attachment); + + const chunkBuffer = this.getOrCreateChunkBuffer(assemblyKey, total); + + if (!chunkBuffer[index]) { + chunkBuffer[index] = decodedBytes.buffer as ArrayBuffer; + this.runtimeStore.setChunkCount(assemblyKey, (this.runtimeStore.getChunkCount(assemblyKey) ?? 0) + 1); + } + + this.updateTransferProgress(attachment, decodedBytes, fromPeerId); + + this.runtimeStore.touch(); + this.finalizeTransferIfComplete(attachment, assemblyKey, total); + } + + async handleFileRequest(payload: FileRequestPayload): Promise { + const { messageId, fileId, fromPeerId } = payload; + + if (!messageId || !fileId || !fromPeerId) + return; + + const exactKey = `${messageId}:${fileId}`; + const originalFile = this.runtimeStore.getOriginalFile(exactKey) + ?? this.runtimeStore.findOriginalFileByFileId(fileId); + + if (originalFile) { + await this.transport.streamFileToPeer( + fromPeerId, + messageId, + fileId, + originalFile, + () => this.isTransferCancelled(fromPeerId, messageId, fileId) + ); + + return; + } + + const list = this.runtimeStore.getAttachmentsForMessage(messageId); + const attachment = list.find((entry) => entry.id === fileId); + const diskPath = attachment + ? await this.attachmentStorage.resolveExistingPath(attachment) + : null; + + if (diskPath) { + await this.transport.streamFileFromDiskToPeer( + fromPeerId, + messageId, + fileId, + diskPath, + () => this.isTransferCancelled(fromPeerId, messageId, fileId) + ); + + return; + } + + if (attachment?.isImage) { + const roomName = await this.persistence.resolveCurrentRoomName(); + const legacyDiskPath = await this.attachmentStorage.resolveLegacyImagePath( + attachment.filename, + roomName + ); + + if (legacyDiskPath) { + await this.transport.streamFileFromDiskToPeer( + fromPeerId, + messageId, + fileId, + legacyDiskPath, + () => this.isTransferCancelled(fromPeerId, messageId, fileId) + ); + + return; + } + } + + if (attachment?.available && attachment.objectUrl) { + try { + const response = await fetch(attachment.objectUrl); + const blob = await response.blob(); + const file = new File([blob], attachment.filename, { type: attachment.mime }); + + await this.transport.streamFileToPeer( + fromPeerId, + messageId, + fileId, + file, + () => this.isTransferCancelled(fromPeerId, messageId, fileId) + ); + + return; + } catch { /* fall through */ } + } + + const fileNotFoundEvent: FileNotFoundEvent = { + type: 'file-not-found', + messageId, + fileId + }; + + this.webrtc.sendToPeer(fromPeerId, fileNotFoundEvent); + } + + cancelRequest(messageId: string, attachment: Attachment): void { + const targetPeerId = attachment.uploaderPeerId; + + if (!targetPeerId) + return; + + try { + const assemblyKey = `${messageId}:${attachment.id}`; + + this.runtimeStore.deleteChunkBuffer(assemblyKey); + this.runtimeStore.deleteChunkCount(assemblyKey); + + attachment.receivedBytes = 0; + attachment.speedBps = 0; + attachment.startedAtMs = undefined; + attachment.lastUpdateMs = undefined; + + if (attachment.objectUrl) { + try { + URL.revokeObjectURL(attachment.objectUrl); + } catch { /* ignore */ } + + attachment.objectUrl = undefined; + } + + attachment.available = false; + this.runtimeStore.touch(); + + const fileCancelEvent: FileCancelEvent = { + type: 'file-cancel', + messageId, + fileId: attachment.id + }; + + this.webrtc.sendToPeer(targetPeerId, fileCancelEvent); + } catch { /* best-effort */ } + } + + handleFileCancel(payload: FileCancelPayload): void { + const { messageId, fileId, fromPeerId } = payload; + + if (!messageId || !fileId || !fromPeerId) + return; + + this.runtimeStore.addCancelledTransfer( + this.buildTransferKey(messageId, fileId, fromPeerId) + ); + } + + async fulfillRequestWithFile( + messageId: string, + fileId: string, + targetPeerId: string, + file: File + ): Promise { + this.runtimeStore.setOriginalFile(`${messageId}:${fileId}`, file); + await this.transport.streamFileToPeer( + targetPeerId, + messageId, + fileId, + file, + () => this.isTransferCancelled(targetPeerId, messageId, fileId) + ); + } + + private buildTransferKey(messageId: string, fileId: string, peerId: string): string { + return `${messageId}:${fileId}:${peerId}`; + } + + private buildRequestKey(messageId: string, fileId: string): string { + return `${messageId}:${fileId}`; + } + + private clearAttachmentRequestError(attachment: Attachment): boolean { + if (!attachment.requestError) + return false; + + attachment.requestError = undefined; + return true; + } + + private isTransferCancelled(targetPeerId: string, messageId: string, fileId: string): boolean { + return this.runtimeStore.hasCancelledTransfer( + this.buildTransferKey(messageId, fileId, targetPeerId) + ); + } + + private sendFileRequestToNextPeer( + messageId: string, + fileId: string, + preferredPeerId?: string + ): boolean { + const connectedPeers = this.webrtc.getConnectedPeers(); + const requestKey = this.buildRequestKey(messageId, fileId); + const triedPeers = this.runtimeStore.getPendingRequestPeers(requestKey) ?? new Set(); + + let targetPeerId: string | undefined; + + if (preferredPeerId && connectedPeers.includes(preferredPeerId) && !triedPeers.has(preferredPeerId)) { + targetPeerId = preferredPeerId; + } else { + targetPeerId = connectedPeers.find((peerId) => !triedPeers.has(peerId)); + } + + if (!targetPeerId) { + this.runtimeStore.deletePendingRequest(requestKey); + return false; + } + + triedPeers.add(targetPeerId); + this.runtimeStore.setPendingRequestPeers(requestKey, triedPeers); + + const fileRequestEvent: FileRequestEvent = { + type: 'file-request', + messageId, + fileId + }; + + this.webrtc.sendToPeer(targetPeerId, fileRequestEvent); + + return true; + } + + private getOrCreateChunkBuffer(assemblyKey: string, total: number): ArrayBuffer[] { + const existingChunkBuffer = this.runtimeStore.getChunkBuffer(assemblyKey); + + if (existingChunkBuffer) { + return existingChunkBuffer; + } + + const createdChunkBuffer = new Array(total); + + this.runtimeStore.setChunkBuffer(assemblyKey, createdChunkBuffer); + this.runtimeStore.setChunkCount(assemblyKey, 0); + + return createdChunkBuffer; + } + + private updateTransferProgress( + attachment: Attachment, + decodedBytes: Uint8Array, + fromPeerId?: string + ): void { + const now = Date.now(); + const previousReceived = attachment.receivedBytes ?? 0; + + attachment.receivedBytes = previousReceived + decodedBytes.byteLength; + + if (fromPeerId) { + recordDebugNetworkFileChunk(fromPeerId, decodedBytes.byteLength, now); + } + + if (!attachment.startedAtMs) + attachment.startedAtMs = now; + + if (!attachment.lastUpdateMs) + attachment.lastUpdateMs = now; + + const elapsedMs = Math.max(1, now - attachment.lastUpdateMs); + const instantaneousBps = (decodedBytes.byteLength / elapsedMs) * 1000; + const previousSpeed = attachment.speedBps ?? instantaneousBps; + + attachment.speedBps = + ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT * previousSpeed + + ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT * instantaneousBps; + + attachment.lastUpdateMs = now; + } + + private finalizeTransferIfComplete( + attachment: Attachment, + assemblyKey: string, + total: number + ): void { + const receivedChunkCount = this.runtimeStore.getChunkCount(assemblyKey) ?? 0; + const completeBuffer = this.runtimeStore.getChunkBuffer(assemblyKey); + + if ( + !completeBuffer + || (receivedChunkCount !== total && (attachment.receivedBytes ?? 0) < attachment.size) + || !completeBuffer.every((part) => part instanceof ArrayBuffer) + ) { + return; + } + + const blob = new Blob(completeBuffer, { type: attachment.mime }); + + attachment.available = true; + attachment.objectUrl = URL.createObjectURL(blob); + + if (shouldPersistDownloadedAttachment(attachment)) { + void this.persistence.saveFileToDisk(attachment, blob); + } + + this.runtimeStore.deleteChunkBuffer(assemblyKey); + this.runtimeStore.deleteChunkCount(assemblyKey); + this.runtimeStore.touch(); + void this.persistence.persistAttachmentMeta(attachment); + } +} diff --git a/src/app/domains/attachment/application/attachment.facade.ts b/src/app/domains/attachment/application/attachment.facade.ts new file mode 100644 index 0000000..137527f --- /dev/null +++ b/src/app/domains/attachment/application/attachment.facade.ts @@ -0,0 +1,119 @@ +import { Injectable, inject } from '@angular/core'; +import { AttachmentManagerService } from './attachment-manager.service'; + +@Injectable({ providedIn: 'root' }) +export class AttachmentFacade { + get updated() { + return this.manager.updated; + } + + private readonly manager = inject(AttachmentManagerService); + + getForMessage( + ...args: Parameters + ): ReturnType { + return this.manager.getForMessage(...args); + } + + rememberMessageRoom( + ...args: Parameters + ): ReturnType { + return this.manager.rememberMessageRoom(...args); + } + + queueAutoDownloadsForMessage( + ...args: Parameters + ): ReturnType { + return this.manager.queueAutoDownloadsForMessage(...args); + } + + requestAutoDownloadsForRoom( + ...args: Parameters + ): ReturnType { + return this.manager.requestAutoDownloadsForRoom(...args); + } + + deleteForMessage( + ...args: Parameters + ): ReturnType { + return this.manager.deleteForMessage(...args); + } + + getAttachmentMetasForMessages( + ...args: Parameters + ): ReturnType { + return this.manager.getAttachmentMetasForMessages(...args); + } + + registerSyncedAttachments( + ...args: Parameters + ): ReturnType { + return this.manager.registerSyncedAttachments(...args); + } + + requestFromAnyPeer( + ...args: Parameters + ): ReturnType { + return this.manager.requestFromAnyPeer(...args); + } + + handleFileNotFound( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileNotFound(...args); + } + + requestImageFromAnyPeer( + ...args: Parameters + ): ReturnType { + return this.manager.requestImageFromAnyPeer(...args); + } + + requestFile( + ...args: Parameters + ): ReturnType { + return this.manager.requestFile(...args); + } + + publishAttachments( + ...args: Parameters + ): ReturnType { + return this.manager.publishAttachments(...args); + } + + handleFileAnnounce( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileAnnounce(...args); + } + + handleFileChunk( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileChunk(...args); + } + + handleFileRequest( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileRequest(...args); + } + + cancelRequest( + ...args: Parameters + ): ReturnType { + return this.manager.cancelRequest(...args); + } + + handleFileCancel( + ...args: Parameters + ): ReturnType { + return this.manager.handleFileCancel(...args); + } + + fulfillRequestWithFile( + ...args: Parameters + ): ReturnType { + return this.manager.fulfillRequestWithFile(...args); + } +} diff --git a/src/app/domains/attachment/domain/attachment-transfer.constants.ts b/src/app/domains/attachment/domain/attachment-transfer.constants.ts new file mode 100644 index 0000000..fe8d0b9 --- /dev/null +++ b/src/app/domains/attachment/domain/attachment-transfer.constants.ts @@ -0,0 +1,21 @@ +/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ +export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB + +/** + * EWMA smoothing weight for the previous speed estimate. + * The complementary weight is applied to the latest sample. + */ +export const ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT = 0.7; +export const ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT = 1 - ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT; + +/** Fallback MIME type when none is provided by the sender. */ +export const DEFAULT_ATTACHMENT_MIME_TYPE = 'application/octet-stream'; + +/** localStorage key used by the legacy attachment store during migration. */ +export const LEGACY_ATTACHMENTS_STORAGE_KEY = 'metoyou_attachments'; + +/** User-facing error when no peers are available for a request. */ +export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are available to provide this file right now.'; + +/** User-facing error when connected peers cannot provide a requested file. */ +export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.'; diff --git a/src/app/domains/attachment/domain/attachment-transfer.models.ts b/src/app/domains/attachment/domain/attachment-transfer.models.ts new file mode 100644 index 0000000..45596c3 --- /dev/null +++ b/src/app/domains/attachment/domain/attachment-transfer.models.ts @@ -0,0 +1,56 @@ +import type { ChatAttachmentAnnouncement, ChatEvent } from '../../../core/models/index'; + +export type FileAnnounceEvent = ChatEvent & { + type: 'file-announce'; + messageId: string; + file: ChatAttachmentAnnouncement; +}; + +export type FileChunkEvent = ChatEvent & { + type: 'file-chunk'; + messageId: string; + fileId: string; + index: number; + total: number; + data: string; + fromPeerId?: string; +}; + +export type FileRequestEvent = ChatEvent & { + type: 'file-request'; + messageId: string; + fileId: string; + fromPeerId?: string; +}; + +export type FileCancelEvent = ChatEvent & { + type: 'file-cancel'; + messageId: string; + fileId: string; + fromPeerId?: string; +}; + +export type FileNotFoundEvent = ChatEvent & { + type: 'file-not-found'; + messageId: string; + fileId: string; +}; + +export type FileAnnouncePayload = Pick; + +export interface FileChunkPayload { + messageId?: string; + fileId?: string; + fromPeerId?: string; + index?: number; + total?: number; + data?: ChatEvent['data']; +} + +export type FileRequestPayload = Pick; +export type FileCancelPayload = Pick; +export type FileNotFoundPayload = Pick; + +export type LocalFileWithPath = File & { + path?: string; +}; diff --git a/src/app/domains/attachment/domain/attachment.constants.ts b/src/app/domains/attachment/domain/attachment.constants.ts new file mode 100644 index 0000000..8edcc15 --- /dev/null +++ b/src/app/domains/attachment/domain/attachment.constants.ts @@ -0,0 +1,2 @@ +/** Maximum file size (bytes) that is automatically saved or pushed for inline previews. */ +export const MAX_AUTO_SAVE_SIZE_BYTES = 10 * 1024 * 1024; // 10 MB diff --git a/src/app/domains/attachment/domain/attachment.logic.ts b/src/app/domains/attachment/domain/attachment.logic.ts new file mode 100644 index 0000000..1cad4d1 --- /dev/null +++ b/src/app/domains/attachment/domain/attachment.logic.ts @@ -0,0 +1,19 @@ +import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants'; +import type { Attachment } from './attachment.models'; + +export function isAttachmentMedia(attachment: Pick): boolean { + return attachment.mime.startsWith('image/') || + attachment.mime.startsWith('video/') || + attachment.mime.startsWith('audio/'); +} + +export function shouldAutoRequestWhenWatched(attachment: Attachment): boolean { + return attachment.isImage || + (isAttachmentMedia(attachment) && attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES); +} + +export function shouldPersistDownloadedAttachment(attachment: Pick): boolean { + return attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES || + attachment.mime.startsWith('video/') || + attachment.mime.startsWith('audio/'); +} diff --git a/src/app/domains/attachment/domain/attachment.models.ts b/src/app/domains/attachment/domain/attachment.models.ts new file mode 100644 index 0000000..166ee6a --- /dev/null +++ b/src/app/domains/attachment/domain/attachment.models.ts @@ -0,0 +1,13 @@ +import type { ChatAttachmentMeta } from '../../../core/models/index'; + +export type AttachmentMeta = ChatAttachmentMeta; + +export interface Attachment extends AttachmentMeta { + available: boolean; + objectUrl?: string; + receivedBytes?: number; + speedBps?: number; + startedAtMs?: number; + lastUpdateMs?: number; + requestError?: string; +} diff --git a/src/app/domains/attachment/index.ts b/src/app/domains/attachment/index.ts new file mode 100644 index 0000000..c96e739 --- /dev/null +++ b/src/app/domains/attachment/index.ts @@ -0,0 +1,3 @@ +export * from './application/attachment.facade'; +export * from './domain/attachment.constants'; +export * from './domain/attachment.models'; diff --git a/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts b/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts new file mode 100644 index 0000000..e018eb8 --- /dev/null +++ b/src/app/domains/attachment/infrastructure/attachment-storage.helpers.ts @@ -0,0 +1,23 @@ +const ROOM_NAME_SANITIZER = /[^\w.-]+/g; + +export function sanitizeAttachmentRoomName(roomName: string): string { + const sanitizedRoomName = roomName.trim().replace(ROOM_NAME_SANITIZER, '_'); + + return sanitizedRoomName || 'room'; +} + +export function resolveAttachmentStorageBucket(mime: string): 'video' | 'audio' | 'image' | 'files' { + if (mime.startsWith('video/')) { + return 'video'; + } + + if (mime.startsWith('audio/')) { + return 'audio'; + } + + if (mime.startsWith('image/')) { + return 'image'; + } + + return 'files'; +} diff --git a/src/app/domains/attachment/infrastructure/attachment-storage.service.ts b/src/app/domains/attachment/infrastructure/attachment-storage.service.ts new file mode 100644 index 0000000..27eb5fa --- /dev/null +++ b/src/app/domains/attachment/infrastructure/attachment-storage.service.ts @@ -0,0 +1,127 @@ +import { Injectable, inject } from '@angular/core'; +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import type { Attachment } from '../domain/attachment.models'; +import { resolveAttachmentStorageBucket, sanitizeAttachmentRoomName } from './attachment-storage.helpers'; + +@Injectable({ providedIn: 'root' }) +export class AttachmentStorageService { + private readonly electronBridge = inject(ElectronBridgeService); + + async resolveExistingPath( + attachment: Pick + ): Promise { + return this.findExistingPath([attachment.filePath, attachment.savedPath]); + } + + async resolveLegacyImagePath(filename: string, roomName: string): Promise { + const appDataPath = await this.resolveAppDataPath(); + + if (!appDataPath) { + return null; + } + + return this.findExistingPath([`${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/image/${filename}`]); + } + + async readFile(filePath: string): Promise { + const electronApi = this.electronBridge.getApi(); + + if (!electronApi || !filePath) { + return null; + } + + try { + return await electronApi.readFile(filePath); + } catch { + return null; + } + } + + async saveBlob( + attachment: Pick, + blob: Blob, + roomName: string + ): Promise { + const electronApi = this.electronBridge.getApi(); + const appDataPath = await this.resolveAppDataPath(); + + if (!electronApi || !appDataPath) { + return null; + } + + try { + const directoryPath = `${appDataPath}/server/${sanitizeAttachmentRoomName(roomName)}/${resolveAttachmentStorageBucket(attachment.mime)}`; + + await electronApi.ensureDir(directoryPath); + + const arrayBuffer = await blob.arrayBuffer(); + const diskPath = `${directoryPath}/${attachment.filename}`; + + await electronApi.writeFile(diskPath, this.arrayBufferToBase64(arrayBuffer)); + + return diskPath; + } catch { + return null; + } + } + + async deleteFile(filePath: string): Promise { + const electronApi = this.electronBridge.getApi(); + + if (!electronApi || !filePath) { + return; + } + + try { + await electronApi.deleteFile(filePath); + } catch { /* best-effort cleanup */ } + } + + private async resolveAppDataPath(): Promise { + const electronApi = this.electronBridge.getApi(); + + if (!electronApi) { + return null; + } + + try { + return await electronApi.getAppDataPath(); + } catch { + return null; + } + } + + private async findExistingPath(candidates: (string | null | undefined)[]): Promise { + const electronApi = this.electronBridge.getApi(); + + if (!electronApi) { + return null; + } + + for (const candidatePath of candidates) { + if (!candidatePath) { + continue; + } + + try { + if (await electronApi.fileExists(candidatePath)) { + return candidatePath; + } + } catch { /* keep trying remaining candidates */ } + } + + return null; + } + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + let binary = ''; + + const bytes = new Uint8Array(buffer); + + for (let index = 0; index < bytes.byteLength; index++) { + binary += String.fromCharCode(bytes[index]); + } + + return btoa(binary); + } +} diff --git a/src/app/core/services/auth.service.ts b/src/app/domains/auth/application/auth.service.ts similarity index 92% rename from src/app/core/services/auth.service.ts rename to src/app/domains/auth/application/auth.service.ts index 0fdface..3cbcc1d 100644 --- a/src/app/core/services/auth.service.ts +++ b/src/app/domains/auth/application/auth.service.ts @@ -2,7 +2,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { ServerDirectoryService, ServerEndpoint } from './server-directory.service'; +import { type ServerEndpoint, ServerDirectoryFacade } from '../../server-directory'; /** * Response returned by the authentication endpoints (login / register). @@ -20,14 +20,14 @@ export interface LoginResponse { * Handles user authentication (login and registration) against a * configurable back-end server. * - * The target server is resolved via {@link ServerDirectoryService}: the + * The target server is resolved via {@link ServerDirectoryFacade}: the * caller may pass an explicit `serverId`, otherwise the currently active * server endpoint is used. */ @Injectable({ providedIn: 'root' }) export class AuthService { private readonly http = inject(HttpClient); - private readonly serverDirectory = inject(ServerDirectoryService); + private readonly serverDirectory = inject(ServerDirectoryFacade); /** * Resolve the API base URL for the given server. diff --git a/src/app/domains/auth/index.ts b/src/app/domains/auth/index.ts new file mode 100644 index 0000000..b56289d --- /dev/null +++ b/src/app/domains/auth/index.ts @@ -0,0 +1 @@ +export * from './application/auth.service'; diff --git a/src/app/core/services/klipy.service.ts b/src/app/domains/chat/application/klipy.service.ts similarity index 97% rename from src/app/core/services/klipy.service.ts rename to src/app/domains/chat/application/klipy.service.ts index 012ae98..4dfcfc5 100644 --- a/src/app/core/services/klipy.service.ts +++ b/src/app/domains/chat/application/klipy.service.ts @@ -13,7 +13,7 @@ import { throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { ServerDirectoryService } from './server-directory.service'; +import { ServerDirectoryFacade } from '../../server-directory'; export interface KlipyGif { id: string; @@ -41,7 +41,7 @@ const KLIPY_CUSTOMER_ID_STORAGE_KEY = 'metoyou_klipy_customer_id'; @Injectable({ providedIn: 'root' }) export class KlipyService { private readonly http = inject(HttpClient); - private readonly serverDirectory = inject(ServerDirectoryService); + private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly availabilityState = signal({ enabled: false, loading: true diff --git a/src/app/domains/chat/index.ts b/src/app/domains/chat/index.ts new file mode 100644 index 0000000..6e77d33 --- /dev/null +++ b/src/app/domains/chat/index.ts @@ -0,0 +1 @@ +export * from './application/klipy.service'; diff --git a/src/app/core/services/screen-share-source-picker.service.ts b/src/app/domains/screen-share/application/screen-share-source-picker.service.ts similarity index 96% rename from src/app/core/services/screen-share-source-picker.service.ts rename to src/app/domains/screen-share/application/screen-share-source-picker.service.ts index e688c2b..395567c 100644 --- a/src/app/core/services/screen-share-source-picker.service.ts +++ b/src/app/domains/screen-share/application/screen-share-source-picker.service.ts @@ -3,8 +3,8 @@ import { computed, signal } from '@angular/core'; -import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from './voice-settings.storage'; -import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from './webrtc/webrtc.constants'; +import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../voice-session'; +import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../domain/screen-share.config'; export type ScreenShareSourceKind = 'screen' | 'window'; diff --git a/src/app/domains/screen-share/application/screen-share.facade.ts b/src/app/domains/screen-share/application/screen-share.facade.ts new file mode 100644 index 0000000..2ffb2c0 --- /dev/null +++ b/src/app/domains/screen-share/application/screen-share.facade.ts @@ -0,0 +1,31 @@ +import { Injectable, inject } from '@angular/core'; +import { RealtimeSessionFacade } from '../../../core/realtime'; +import { ScreenShareStartOptions } from '../domain/screen-share.config'; + +@Injectable({ providedIn: 'root' }) +export class ScreenShareFacade { + readonly isScreenSharing = inject(RealtimeSessionFacade).isScreenSharing; + readonly screenStream = inject(RealtimeSessionFacade).screenStream; + readonly isScreenShareRemotePlaybackSuppressed = inject(RealtimeSessionFacade).isScreenShareRemotePlaybackSuppressed; + readonly forceDefaultRemotePlaybackOutput = inject(RealtimeSessionFacade).forceDefaultRemotePlaybackOutput; + readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream; + readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected; + + private readonly realtime = inject(RealtimeSessionFacade); + + getRemoteScreenShareStream(peerId: string): MediaStream | null { + return this.realtime.getRemoteScreenShareStream(peerId); + } + + async startScreenShare(options: ScreenShareStartOptions): Promise { + return await this.realtime.startScreenShare(options); + } + + stopScreenShare(): void { + this.realtime.stopScreenShare(); + } + + syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void { + this.realtime.syncRemoteScreenShareRequests(peerIds, enabled); + } +} diff --git a/src/app/core/services/webrtc/screen-share.config.ts b/src/app/domains/screen-share/domain/screen-share.config.ts similarity index 97% rename from src/app/core/services/webrtc/screen-share.config.ts rename to src/app/domains/screen-share/domain/screen-share.config.ts index dd8824d..b7977f5 100644 --- a/src/app/core/services/webrtc/screen-share.config.ts +++ b/src/app/domains/screen-share/domain/screen-share.config.ts @@ -17,6 +17,8 @@ export interface ScreenShareQualityPreset { scaleResolutionDownBy?: number; } +export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen'; + export const DEFAULT_SCREEN_SHARE_QUALITY: ScreenShareQuality = 'balanced'; export const DEFAULT_SCREEN_SHARE_START_OPTIONS: ScreenShareStartOptions = { diff --git a/src/app/domains/screen-share/index.ts b/src/app/domains/screen-share/index.ts new file mode 100644 index 0000000..0f348e4 --- /dev/null +++ b/src/app/domains/screen-share/index.ts @@ -0,0 +1,3 @@ +export * from './application/screen-share.facade'; +export * from './application/screen-share-source-picker.service'; +export * from './domain/screen-share.config'; diff --git a/src/app/domains/server-directory/application/server-directory.facade.ts b/src/app/domains/server-directory/application/server-directory.facade.ts new file mode 100644 index 0000000..2edd23c --- /dev/null +++ b/src/app/domains/server-directory/application/server-directory.facade.ts @@ -0,0 +1,259 @@ +import { + Injectable, + inject, + type Signal +} from '@angular/core'; +import { Observable } from 'rxjs'; +import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../core/constants'; +import { ServerInfo, User } from '../../../core/models'; +import { CLIENT_UPDATE_REQUIRED_MESSAGE } from '../domain/server-directory.constants'; +import { ServerDirectoryApiService } from '../infrastructure/server-directory-api.service'; +import type { + BanServerMemberRequest, + CreateServerInviteRequest, + KickServerMemberRequest, + ServerEndpoint, + ServerEndpointVersions, + ServerInviteInfo, + ServerJoinAccessRequest, + ServerJoinAccessResponse, + ServerSourceSelector, + UnbanServerMemberRequest +} from '../domain/server-directory.models'; +import { ServerEndpointCompatibilityService } from '../infrastructure/server-endpoint-compatibility.service'; +import { ServerEndpointHealthService } from '../infrastructure/server-endpoint-health.service'; +import { ServerEndpointStateService } from './server-endpoint-state.service'; + +export { CLIENT_UPDATE_REQUIRED_MESSAGE }; + +@Injectable({ providedIn: 'root' }) +export class ServerDirectoryFacade { + readonly servers: Signal; + readonly activeServers: Signal; + readonly hasMissingDefaultServers: Signal; + readonly activeServer: Signal; + + private readonly endpointState = inject(ServerEndpointStateService); + private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService); + private readonly endpointHealth = inject(ServerEndpointHealthService); + private readonly api = inject(ServerDirectoryApiService); + private shouldSearchAllServers = true; + + constructor() { + this.servers = this.endpointState.servers; + this.activeServers = this.endpointState.activeServers; + this.hasMissingDefaultServers = this.endpointState.hasMissingDefaultServers; + this.activeServer = this.endpointState.activeServer; + + this.loadConnectionSettings(); + void this.testAllServers(); + } + + addServer(server: { name: string; url: string }): ServerEndpoint { + return this.endpointState.addServer(server); + } + + ensureServerEndpoint( + server: { name: string; url: string }, + options?: { setActive?: boolean } + ): ServerEndpoint { + return this.endpointState.ensureServerEndpoint(server, options); + } + + findServerByUrl(url: string): ServerEndpoint | undefined { + return this.endpointState.findServerByUrl(url); + } + + removeServer(endpointId: string): void { + this.endpointState.removeServer(endpointId); + } + + restoreDefaultServers(): ServerEndpoint[] { + return this.endpointState.restoreDefaultServers(); + } + + setActiveServer(endpointId: string): void { + this.endpointState.setActiveServer(endpointId); + } + + deactivateServer(endpointId: string): void { + this.endpointState.deactivateServer(endpointId); + } + + updateServerStatus( + endpointId: string, + status: ServerEndpoint['status'], + latency?: number, + versions?: ServerEndpointVersions + ): void { + this.endpointState.updateServerStatus(endpointId, status, latency, versions); + } + + async ensureEndpointVersionCompatibility(selector?: ServerSourceSelector): Promise { + const endpoint = this.api.resolveEndpoint(selector); + + if (!endpoint || endpoint.status === 'incompatible') { + return false; + } + + const clientVersion = await this.endpointCompatibility.getClientVersion(); + + if (!clientVersion) { + return true; + } + + await this.testServer(endpoint.id); + + const refreshedEndpoint = this.servers().find((candidate) => candidate.id === endpoint.id); + + return !!refreshedEndpoint && refreshedEndpoint.status !== 'incompatible'; + } + + setSearchAllServers(enabled: boolean): void { + this.shouldSearchAllServers = enabled; + } + + async testServer(endpointId: string): Promise { + const endpoint = this.servers().find((entry) => entry.id === endpointId); + + if (!endpoint) { + return false; + } + + this.updateServerStatus(endpointId, 'checking'); + const clientVersion = await this.endpointCompatibility.getClientVersion(); + const healthResult = await this.endpointHealth.probeEndpoint(endpoint, clientVersion); + + this.updateServerStatus( + endpointId, + healthResult.status, + healthResult.latency, + healthResult.versions + ); + + return healthResult.status === 'online'; + } + + async testAllServers(): Promise { + await Promise.all(this.servers().map((endpoint) => this.testServer(endpoint.id))); + } + + getApiBaseUrl(selector?: ServerSourceSelector): string { + return this.api.getApiBaseUrl(selector); + } + + getWebSocketUrl(selector?: ServerSourceSelector): string { + return this.api.getWebSocketUrl(selector); + } + + searchServers(query: string): Observable { + return this.api.searchServers(query, this.shouldSearchAllServers); + } + + getServers(): Observable { + return this.api.getServers(this.shouldSearchAllServers); + } + + getServer(serverId: string, selector?: ServerSourceSelector): Observable { + return this.api.getServer(serverId, selector); + } + + registerServer( + server: Omit & { id?: string; password?: string | null }, + selector?: ServerSourceSelector + ): Observable { + return this.api.registerServer(server, selector); + } + + updateServer( + serverId: string, + updates: Partial & { + currentOwnerId: string; + actingRole?: string; + password?: string | null; + }, + selector?: ServerSourceSelector + ): Observable { + return this.api.updateServer(serverId, updates, selector); + } + + unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable { + return this.api.unregisterServer(serverId, selector); + } + + getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable { + return this.api.getServerUsers(serverId, selector); + } + + requestJoin( + request: ServerJoinAccessRequest, + selector?: ServerSourceSelector + ): Observable { + return this.api.requestJoin(request, selector); + } + + createInvite( + serverId: string, + request: CreateServerInviteRequest, + selector?: ServerSourceSelector + ): Observable { + return this.api.createInvite(serverId, request, selector); + } + + getInvite(inviteId: string, selector?: ServerSourceSelector): Observable { + return this.api.getInvite(inviteId, selector); + } + + kickServerMember( + serverId: string, + request: KickServerMemberRequest, + selector?: ServerSourceSelector + ): Observable { + return this.api.kickServerMember(serverId, request, selector); + } + + banServerMember( + serverId: string, + request: BanServerMemberRequest, + selector?: ServerSourceSelector + ): Observable { + return this.api.banServerMember(serverId, request, selector); + } + + unbanServerMember( + serverId: string, + request: UnbanServerMemberRequest, + selector?: ServerSourceSelector + ): Observable { + return this.api.unbanServerMember(serverId, request, selector); + } + + notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable { + return this.api.notifyLeave(serverId, userId, selector); + } + + updateUserCount(serverId: string, count: number): Observable { + return this.api.updateUserCount(serverId, count); + } + + sendHeartbeat(serverId: string): Observable { + return this.api.sendHeartbeat(serverId); + } + + 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; + } + } +} diff --git a/src/app/domains/server-directory/application/server-endpoint-state.service.ts b/src/app/domains/server-directory/application/server-endpoint-state.service.ts new file mode 100644 index 0000000..9573215 --- /dev/null +++ b/src/app/domains/server-directory/application/server-endpoint-state.service.ts @@ -0,0 +1,315 @@ +import { + Injectable, + computed, + inject, + signal, + type Signal +} from '@angular/core'; +import { v4 as uuidv4 } from 'uuid'; +import { environment } from '../../../../environments/environment'; +import { + buildDefaultEndpointTemplates, + buildDefaultServerDefinitions, + ensureAnyActiveEndpoint, + ensureCompatibleActiveEndpoint, + findDefaultEndpointKeyByUrl, + hasEndpointForDefault, + matchDefaultEndpointTemplate, + sanitiseServerBaseUrl +} from '../domain/server-endpoint-defaults'; +import { ServerEndpointStorageService } from '../infrastructure/server-endpoint-storage.service'; +import type { + ConfiguredDefaultServerDefinition, + DefaultEndpointTemplate, + ServerEndpoint, + ServerEndpointVersions +} from '../domain/server-directory.models'; + +function resolveDefaultHttpProtocol(): 'http' | 'https' { + return typeof window !== 'undefined' && window.location?.protocol === 'https:' + ? 'https' + : 'http'; +} + +@Injectable({ providedIn: 'root' }) +export class ServerEndpointStateService { + readonly servers: Signal; + readonly activeServers: Signal; + readonly hasMissingDefaultServers: Signal; + readonly activeServer: Signal; + + private readonly storage = inject(ServerEndpointStorageService); + private readonly _servers = signal([]); + private readonly defaultEndpoints: DefaultEndpointTemplate[]; + private readonly primaryDefaultServerUrl: string; + + constructor() { + const defaultServerDefinitions = buildDefaultServerDefinitions( + Array.isArray(environment.defaultServers) + ? environment.defaultServers as ConfiguredDefaultServerDefinition[] + : [], + environment.defaultServerUrl, + resolveDefaultHttpProtocol() + ); + + this.defaultEndpoints = buildDefaultEndpointTemplates(defaultServerDefinitions); + this.primaryDefaultServerUrl = this.defaultEndpoints[0]?.url ?? 'http://localhost:3001'; + + this.servers = computed(() => this._servers()); + this.activeServers = computed(() => + this._servers().filter((endpoint) => endpoint.isActive && endpoint.status !== 'incompatible') + ); + + this.hasMissingDefaultServers = computed(() => + this.defaultEndpoints.some((endpoint) => !hasEndpointForDefault(this._servers(), endpoint)) + ); + + this.activeServer = computed(() => this.activeServers()[0] ?? null); + + this.loadEndpoints(); + } + + getPrimaryDefaultServerUrl(): string { + return this.primaryDefaultServerUrl; + } + + sanitiseUrl(rawUrl: string): string { + return sanitiseServerBaseUrl(rawUrl); + } + + addServer(server: { name: string; url: string }): ServerEndpoint { + const newEndpoint: ServerEndpoint = { + id: uuidv4(), + name: server.name, + url: this.sanitiseUrl(server.url), + isActive: true, + isDefault: false, + status: 'unknown' + }; + + this._servers.update((endpoints) => [...endpoints, newEndpoint]); + this.saveEndpoints(); + return newEndpoint; + } + + ensureServerEndpoint( + server: { name: string; url: string }, + options?: { setActive?: boolean } + ): ServerEndpoint { + const existing = this.findServerByUrl(server.url); + + if (existing) { + if (options?.setActive) { + this.setActiveServer(existing.id); + } + + return existing; + } + + const created = this.addServer(server); + + if (options?.setActive) { + this.setActiveServer(created.id); + } + + return created; + } + + findServerByUrl(url: string): ServerEndpoint | undefined { + const sanitisedUrl = this.sanitiseUrl(url); + + return this._servers().find((endpoint) => this.sanitiseUrl(endpoint.url) === sanitisedUrl); + } + + removeServer(endpointId: string): void { + const endpoints = this._servers(); + const target = endpoints.find((endpoint) => endpoint.id === endpointId); + + if (!target || endpoints.length <= 1) { + return; + } + + if (target.isDefault) { + this.markDefaultEndpointRemoved(target); + } + + const updatedEndpoints = ensureAnyActiveEndpoint( + endpoints.filter((endpoint) => endpoint.id !== endpointId) + ); + + this._servers.set(updatedEndpoints); + this.saveEndpoints(); + } + + restoreDefaultServers(): ServerEndpoint[] { + const restoredEndpoints = this.defaultEndpoints + .filter((defaultEndpoint) => !hasEndpointForDefault(this._servers(), defaultEndpoint)) + .map((defaultEndpoint) => ({ + ...defaultEndpoint, + id: uuidv4(), + isActive: true + })); + + if (restoredEndpoints.length === 0) { + this.storage.clearRemovedDefaultEndpointKeys(); + return []; + } + + this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints])); + this.storage.clearRemovedDefaultEndpointKeys(); + this.saveEndpoints(); + return restoredEndpoints; + } + + setActiveServer(endpointId: string): void { + this._servers.update((endpoints) => { + const target = endpoints.find((endpoint) => endpoint.id === endpointId); + + if (!target || target.status === 'incompatible') { + return endpoints; + } + + return endpoints.map((endpoint) => + endpoint.id === endpointId + ? { ...endpoint, isActive: true } + : endpoint + ); + }); + + this.saveEndpoints(); + } + + deactivateServer(endpointId: string): void { + if (this.activeServers().length <= 1) { + return; + } + + this._servers.update((endpoints) => + endpoints.map((endpoint) => + endpoint.id === endpointId + ? { ...endpoint, isActive: false } + : endpoint + ) + ); + + this.saveEndpoints(); + } + + updateServerStatus( + endpointId: string, + status: ServerEndpoint['status'], + latency?: number, + versions?: ServerEndpointVersions + ): void { + this._servers.update((endpoints) => ensureCompatibleActiveEndpoint(endpoints.map((endpoint) => { + if (endpoint.id !== endpointId) { + return endpoint; + } + + return { + ...endpoint, + status, + latency, + isActive: status === 'incompatible' ? false : endpoint.isActive, + serverVersion: versions?.serverVersion ?? endpoint.serverVersion, + clientVersion: versions?.clientVersion ?? endpoint.clientVersion + }; + }))); + + this.saveEndpoints(); + } + + private loadEndpoints(): void { + const storedEndpoints = this.storage.loadEndpoints(); + + if (!storedEndpoints) { + this.initialiseDefaultEndpoints(); + return; + } + + this._servers.set(this.reconcileStoredEndpoints(storedEndpoints)); + this.saveEndpoints(); + } + + private initialiseDefaultEndpoints(): void { + this._servers.set(this.defaultEndpoints.map((endpoint) => ({ + ...endpoint, + id: uuidv4() + }))); + + this.saveEndpoints(); + } + + private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] { + const reconciled: ServerEndpoint[] = []; + const claimedDefaultKeys = new Set(); + const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys(); + + for (const endpoint of storedEndpoints) { + if (!endpoint || typeof endpoint.id !== 'string' || typeof endpoint.url !== 'string') { + continue; + } + + const sanitisedUrl = this.sanitiseUrl(endpoint.url); + const matchedDefault = matchDefaultEndpointTemplate( + this.defaultEndpoints, + 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 this.defaultEndpoints) { + if ( + !claimedDefaultKeys.has(defaultEndpoint.defaultKey) + && !removedDefaultKeys.has(defaultEndpoint.defaultKey) + && !hasEndpointForDefault(reconciled, defaultEndpoint) + ) { + reconciled.push({ + ...defaultEndpoint, + id: uuidv4(), + isActive: defaultEndpoint.isActive + }); + } + } + + return ensureAnyActiveEndpoint(reconciled); + } + + private markDefaultEndpointRemoved(endpoint: ServerEndpoint): void { + const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url); + + if (!defaultKey) { + return; + } + + const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys(); + + removedDefaultKeys.add(defaultKey); + this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys); + } + + private saveEndpoints(): void { + this.storage.saveEndpoints(this._servers()); + } +} diff --git a/src/app/domains/server-directory/domain/server-directory.constants.ts b/src/app/domains/server-directory/domain/server-directory.constants.ts new file mode 100644 index 0000000..c34b2e1 --- /dev/null +++ b/src/app/domains/server-directory/domain/server-directory.constants.ts @@ -0,0 +1 @@ +export const CLIENT_UPDATE_REQUIRED_MESSAGE = 'Update the client in order to connect to other users'; diff --git a/src/app/domains/server-directory/domain/server-directory.models.ts b/src/app/domains/server-directory/domain/server-directory.models.ts new file mode 100644 index 0000000..bbcf54b --- /dev/null +++ b/src/app/domains/server-directory/domain/server-directory.models.ts @@ -0,0 +1,115 @@ +import type { ServerInfo } from '../../../core/models'; + +export type ServerEndpointStatus = 'online' | 'offline' | 'checking' | 'unknown' | 'incompatible'; + +export interface ConfiguredDefaultServerDefinition { + key?: string; + name?: string; + url?: string; +} + +export interface DefaultServerDefinition { + key: string; + name: string; + url: string; +} + +export interface ServerEndpointVersions { + serverVersion?: string | null; + clientVersion?: string | null; +} + +export interface ServerEndpoint { + id: string; + name: string; + url: string; + isActive: boolean; + isDefault: boolean; + defaultKey?: string; + status: ServerEndpointStatus; + latency?: number; + serverVersion?: string; + clientVersion?: string; +} + +export type DefaultEndpointTemplate = Omit & { + defaultKey: string; +}; + +export interface ServerSourceSelector { + sourceId?: string; + sourceUrl?: string; +} + +export interface ServerJoinAccessRequest { + roomId: string; + userId: string; + userPublicKey: string; + displayName: string; + password?: string; + inviteId?: string; +} + +export interface ServerJoinAccessResponse { + success: boolean; + signalingUrl: string; + joinedBefore: boolean; + via: 'membership' | 'password' | 'invite' | 'public'; + server: ServerInfo; +} + +export interface CreateServerInviteRequest { + requesterUserId: string; + requesterDisplayName?: string; + requesterRole?: string; +} + +export interface ServerInviteInfo { + id: string; + serverId: string; + createdAt: number; + expiresAt: number; + inviteUrl: string; + browserUrl: string; + appUrl: string; + sourceUrl: string; + createdBy?: string; + createdByDisplayName?: string; + isExpired: boolean; + server: ServerInfo; +} + +export interface KickServerMemberRequest { + actorUserId: string; + actorRole?: string; + targetUserId: string; +} + +export interface BanServerMemberRequest extends KickServerMemberRequest { + banId?: string; + displayName?: string; + reason?: string; + expiresAt?: number; +} + +export interface UnbanServerMemberRequest { + actorUserId: string; + actorRole?: string; + banId?: string; + targetUserId?: string; +} + +export interface ServerVersionCompatibilityResult { + isCompatible: boolean; + serverVersion: string | null; +} + +export interface ServerHealthCheckPayload { + serverVersion?: unknown; +} + +export interface ServerEndpointHealthResult { + status: ServerEndpointStatus; + latency?: number; + versions?: ServerEndpointVersions; +} diff --git a/src/app/domains/server-directory/domain/server-endpoint-defaults.ts b/src/app/domains/server-directory/domain/server-endpoint-defaults.ts new file mode 100644 index 0000000..e83b1f6 --- /dev/null +++ b/src/app/domains/server-directory/domain/server-endpoint-defaults.ts @@ -0,0 +1,187 @@ +import type { + ConfiguredDefaultServerDefinition, + DefaultEndpointTemplate, + DefaultServerDefinition, + ServerEndpoint +} from './server-directory.models'; + +export function sanitiseServerBaseUrl(rawUrl: string): string { + let cleaned = rawUrl.trim().replace(/\/+$/, ''); + + if (cleaned.toLowerCase().endsWith('/api')) { + cleaned = cleaned.slice(0, -4); + } + + return cleaned; +} + +export function normaliseConfiguredServerUrl( + rawUrl: string, + defaultProtocol: 'http' | 'https' +): string { + let cleaned = rawUrl.trim(); + + if (!cleaned) { + return ''; + } + + if (cleaned.toLowerCase().startsWith('ws://')) { + cleaned = `http://${cleaned.slice(5)}`; + } else if (cleaned.toLowerCase().startsWith('wss://')) { + cleaned = `https://${cleaned.slice(6)}`; + } else if (cleaned.startsWith('//')) { + cleaned = `${defaultProtocol}:${cleaned}`; + } else if (!/^[a-z][a-z\d+.-]*:\/\//i.test(cleaned)) { + cleaned = `${defaultProtocol}://${cleaned}`; + } + + return sanitiseServerBaseUrl(cleaned); +} + +export function buildFallbackDefaultServerUrl( + configuredUrl: string | undefined, + defaultProtocol: 'http' | 'https' +): string { + if (configuredUrl?.trim()) { + return normaliseConfiguredServerUrl(configuredUrl, defaultProtocol); + } + + return `${defaultProtocol}://localhost:3001`; +} + +export function buildDefaultServerDefinitions( + configuredDefaults: ConfiguredDefaultServerDefinition[] | undefined, + configuredUrl: string | undefined, + defaultProtocol: 'http' | 'https' +): DefaultServerDefinition[] { + const seenKeys = new Set(); + const seenUrls = new Set(); + const definitions = (configuredDefaults ?? []) + .map((server, index) => { + const key = server.key?.trim() || `default-${index + 1}`; + const url = normaliseConfiguredServerUrl(server.url ?? '', defaultProtocol); + + 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(configuredUrl, defaultProtocol) + } + ]; +} + +export function buildDefaultEndpointTemplates( + definitions: DefaultServerDefinition[] +): DefaultEndpointTemplate[] { + return definitions.map((definition) => ({ + name: definition.name, + url: definition.url, + isActive: true, + isDefault: true, + defaultKey: definition.key, + status: 'unknown' + })); +} + +export function hasEndpointForDefault( + endpoints: ServerEndpoint[], + defaultEndpoint: DefaultEndpointTemplate +): boolean { + return endpoints.some((endpoint) => + endpoint.defaultKey === defaultEndpoint.defaultKey + || sanitiseServerBaseUrl(endpoint.url) === defaultEndpoint.url + ); +} + +export function matchDefaultEndpointTemplate( + defaultEndpoints: DefaultEndpointTemplate[], + endpoint: ServerEndpoint, + sanitisedUrl: string, + claimedDefaultKeys: Set +): DefaultEndpointTemplate | null { + if (endpoint.defaultKey) { + return defaultEndpoints.find( + (candidate) => candidate.defaultKey === endpoint.defaultKey && !claimedDefaultKeys.has(candidate.defaultKey) + ) ?? null; + } + + if (!endpoint.isDefault) { + return null; + } + + const matchingCurrentDefault = defaultEndpoints.find( + (candidate) => candidate.url === sanitisedUrl && !claimedDefaultKeys.has(candidate.defaultKey) + ); + + if (matchingCurrentDefault) { + return matchingCurrentDefault; + } + + return defaultEndpoints.find( + (candidate) => !claimedDefaultKeys.has(candidate.defaultKey) + ) ?? null; +} + +export function findDefaultEndpointKeyByUrl( + defaultEndpoints: DefaultEndpointTemplate[], + url: string +): string | null { + const sanitisedUrl = sanitiseServerBaseUrl(url); + + return defaultEndpoints.find((endpoint) => endpoint.url === sanitisedUrl)?.defaultKey ?? null; +} + +export function ensureAnyActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] { + if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) { + return endpoints; + } + + const nextEndpoints = [...endpoints]; + + nextEndpoints[0] = { + ...nextEndpoints[0], + isActive: true + }; + + return nextEndpoints; +} + +export function ensureCompatibleActiveEndpoint(endpoints: ServerEndpoint[]): ServerEndpoint[] { + if (endpoints.length === 0 || endpoints.some((endpoint) => endpoint.isActive)) { + return endpoints; + } + + const fallbackIndex = endpoints.findIndex((endpoint) => endpoint.status !== 'incompatible'); + + if (fallbackIndex < 0) { + return endpoints; + } + + const nextEndpoints = [...endpoints]; + + nextEndpoints[fallbackIndex] = { + ...nextEndpoints[fallbackIndex], + isActive: true + }; + + return nextEndpoints; +} diff --git a/src/app/domains/server-directory/index.ts b/src/app/domains/server-directory/index.ts new file mode 100644 index 0000000..6af42b5 --- /dev/null +++ b/src/app/domains/server-directory/index.ts @@ -0,0 +1,3 @@ +export * from './application/server-directory.facade'; +export * from './domain/server-directory.constants'; +export * from './domain/server-directory.models'; diff --git a/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts b/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts new file mode 100644 index 0000000..0955c3b --- /dev/null +++ b/src/app/domains/server-directory/infrastructure/server-directory-api.service.ts @@ -0,0 +1,404 @@ +/* eslint-disable @typescript-eslint/no-invalid-void-type */ +import { Injectable, inject } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { + Observable, + forkJoin, + of, + throwError +} from 'rxjs'; +import { catchError, map } from 'rxjs/operators'; +import { ServerInfo, User } from '../../../core/models'; +import { ServerEndpointStateService } from '../application/server-endpoint-state.service'; +import type { + BanServerMemberRequest, + CreateServerInviteRequest, + KickServerMemberRequest, + ServerEndpoint, + ServerInviteInfo, + ServerJoinAccessRequest, + ServerJoinAccessResponse, + ServerSourceSelector, + UnbanServerMemberRequest +} from '../domain/server-directory.models'; + +@Injectable({ providedIn: 'root' }) +export class ServerDirectoryApiService { + private readonly http = inject(HttpClient); + private readonly endpointState = inject(ServerEndpointStateService); + + getApiBaseUrl(selector?: ServerSourceSelector): string { + return `${this.resolveBaseServerUrl(selector)}/api`; + } + + getWebSocketUrl(selector?: ServerSourceSelector): string { + return this.resolveBaseServerUrl(selector).replace(/^http/, 'ws'); + } + + resolveEndpoint(selector?: ServerSourceSelector): ServerEndpoint | null { + if (selector?.sourceId) { + return this.endpointState.servers().find((endpoint) => endpoint.id === selector.sourceId) ?? null; + } + + if (selector?.sourceUrl) { + return this.endpointState.findServerByUrl(selector.sourceUrl) ?? null; + } + + return this.endpointState.activeServer() + ?? this.endpointState.servers().find((endpoint) => endpoint.status !== 'incompatible') + ?? this.endpointState.servers()[0] + ?? null; + } + + searchServers(query: string, shouldSearchAllServers: boolean): Observable { + if (shouldSearchAllServers) { + return this.searchAllEndpoints(query); + } + + return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer()); + } + + getServers(shouldSearchAllServers: boolean): Observable { + if (shouldSearchAllServers) { + return this.getAllServersFromAllEndpoints(); + } + + return this.http + .get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`) + .pipe( + map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), + catchError((error) => { + console.error('Failed to get servers:', error); + return of([]); + }) + ); + } + + getServer(serverId: string, selector?: ServerSourceSelector): Observable { + return this.http + .get(`${this.getApiBaseUrl(selector)}/servers/${serverId}`) + .pipe( + map((server) => this.normalizeServerInfo(server, this.resolveEndpoint(selector))), + catchError((error) => { + console.error('Failed to get server:', error); + return of(null); + }) + ); + } + + registerServer( + server: Omit & { id?: string; password?: string | null }, + selector?: ServerSourceSelector + ): Observable { + return this.http + .post(`${this.getApiBaseUrl(selector)}/servers`, server) + .pipe( + catchError((error) => { + console.error('Failed to register server:', error); + return throwError(() => error); + }) + ); + } + + updateServer( + serverId: string, + updates: Partial & { + currentOwnerId: string; + actingRole?: string; + password?: string | null; + }, + selector?: ServerSourceSelector + ): Observable { + return this.http + .put(`${this.getApiBaseUrl(selector)}/servers/${serverId}`, updates) + .pipe( + catchError((error) => { + console.error('Failed to update server:', error); + return throwError(() => error); + }) + ); + } + + unregisterServer(serverId: string, selector?: ServerSourceSelector): Observable { + return this.http + .delete(`${this.getApiBaseUrl(selector)}/servers/${serverId}`) + .pipe( + catchError((error) => { + console.error('Failed to unregister server:', error); + return throwError(() => error); + }) + ); + } + + getServerUsers(serverId: string, selector?: ServerSourceSelector): Observable { + return this.http + .get(`${this.getApiBaseUrl(selector)}/servers/${serverId}/users`) + .pipe( + catchError((error) => { + console.error('Failed to get server users:', error); + return of([]); + }) + ); + } + + requestJoin( + request: ServerJoinAccessRequest, + selector?: ServerSourceSelector + ): Observable { + return this.http + .post( + `${this.getApiBaseUrl(selector)}/servers/${request.roomId}/join`, + request + ) + .pipe( + catchError((error) => { + console.error('Failed to send join request:', error); + return throwError(() => error); + }) + ); + } + + createInvite( + serverId: string, + request: CreateServerInviteRequest, + selector?: ServerSourceSelector + ): Observable { + return this.http + .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/invites`, request) + .pipe( + catchError((error) => { + console.error('Failed to create invite:', error); + return throwError(() => error); + }) + ); + } + + getInvite(inviteId: string, selector?: ServerSourceSelector): Observable { + return this.http + .get(`${this.getApiBaseUrl(selector)}/invites/${inviteId}`) + .pipe( + catchError((error) => { + console.error('Failed to get invite:', error); + return throwError(() => error); + }) + ); + } + + kickServerMember( + serverId: string, + request: KickServerMemberRequest, + selector?: ServerSourceSelector + ): Observable { + return this.http + .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/kick`, request) + .pipe( + catchError((error) => { + console.error('Failed to kick server member:', error); + return throwError(() => error); + }) + ); + } + + banServerMember( + serverId: string, + request: BanServerMemberRequest, + selector?: ServerSourceSelector + ): Observable { + return this.http + .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/ban`, request) + .pipe( + catchError((error) => { + console.error('Failed to ban server member:', error); + return throwError(() => error); + }) + ); + } + + unbanServerMember( + serverId: string, + request: UnbanServerMemberRequest, + selector?: ServerSourceSelector + ): Observable { + return this.http + .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/moderation/unban`, request) + .pipe( + catchError((error) => { + console.error('Failed to unban server member:', error); + return throwError(() => error); + }) + ); + } + + notifyLeave(serverId: string, userId: string, selector?: ServerSourceSelector): Observable { + return this.http + .post(`${this.getApiBaseUrl(selector)}/servers/${serverId}/leave`, { userId }) + .pipe( + catchError((error) => { + console.error('Failed to notify leave:', error); + return of(undefined); + }) + ); + } + + updateUserCount(serverId: string, count: number): Observable { + return this.http + .patch(`${this.getApiBaseUrl()}/servers/${serverId}/user-count`, { count }) + .pipe( + catchError((error) => { + console.error('Failed to update user count:', error); + return of(undefined); + }) + ); + } + + sendHeartbeat(serverId: string): Observable { + return this.http + .post(`${this.getApiBaseUrl()}/servers/${serverId}/heartbeat`, {}) + .pipe( + catchError((error) => { + console.error('Failed to send heartbeat:', error); + return of(undefined); + }) + ); + } + + private resolveBaseServerUrl(selector?: ServerSourceSelector): string { + if (selector?.sourceUrl) { + return this.endpointState.sanitiseUrl(selector.sourceUrl); + } + + return this.resolveEndpoint(selector)?.url ?? this.endpointState.getPrimaryDefaultServerUrl(); + } + + private unwrapServersResponse( + response: { servers: ServerInfo[]; total: number } | ServerInfo[] + ): ServerInfo[] { + return Array.isArray(response) + ? response + : (response.servers ?? []); + } + + private searchSingleEndpoint( + query: string, + apiBaseUrl: string, + source?: ServerEndpoint | null + ): Observable { + const params = new HttpParams().set('q', query); + + return this.http + .get<{ servers: ServerInfo[]; total: number }>(`${apiBaseUrl}/servers`, { params }) + .pipe( + map((response) => this.normalizeServerList(response, source)), + catchError((error) => { + console.error('Failed to search servers:', error); + return of([]); + }) + ); + } + + private searchAllEndpoints(query: string): Observable { + const onlineEndpoints = this.endpointState.activeServers().filter( + (endpoint) => endpoint.status !== 'offline' + ); + + if (onlineEndpoints.length === 0) { + return this.searchSingleEndpoint(query, this.getApiBaseUrl(), this.endpointState.activeServer()); + } + + return forkJoin( + onlineEndpoints.map((endpoint) => this.searchSingleEndpoint(query, `${endpoint.url}/api`, endpoint)) + ).pipe( + map((resultArrays) => resultArrays.flat()), + map((servers) => this.deduplicateById(servers)) + ); + } + + private getAllServersFromAllEndpoints(): Observable { + const onlineEndpoints = this.endpointState.activeServers().filter( + (endpoint) => endpoint.status !== 'offline' + ); + + if (onlineEndpoints.length === 0) { + return this.http + .get<{ servers: ServerInfo[]; total: number }>(`${this.getApiBaseUrl()}/servers`) + .pipe( + map((response) => this.normalizeServerList(response, this.endpointState.activeServer())), + catchError(() => of([])) + ); + } + + return forkJoin( + onlineEndpoints.map((endpoint) => + this.http + .get<{ servers: ServerInfo[]; total: number }>(`${endpoint.url}/api/servers`) + .pipe( + map((response) => this.normalizeServerList(response, endpoint)), + catchError(() => of([] as ServerInfo[])) + ) + ) + ).pipe(map((resultArrays) => resultArrays.flat())); + } + + private deduplicateById(items: T[]): T[] { + const seen = new Set(); + + return items.filter((item) => { + if (seen.has(item.id)) { + return false; + } + + seen.add(item.id); + return true; + }); + } + + private normalizeServerList( + response: { servers: ServerInfo[]; total: number } | ServerInfo[], + source?: ServerEndpoint | null + ): ServerInfo[] { + return this.unwrapServersResponse(response).map((server) => this.normalizeServerInfo(server, source)); + } + + private normalizeServerInfo( + server: ServerInfo | Record, + source?: ServerEndpoint | null + ): ServerInfo { + const candidate = server as Record; + const sourceName = this.getStringValue(candidate['sourceName']); + const sourceUrl = this.getStringValue(candidate['sourceUrl']); + + return { + id: this.getStringValue(candidate['id']) ?? '', + name: this.getStringValue(candidate['name']) ?? 'Unnamed server', + description: this.getStringValue(candidate['description']), + topic: this.getStringValue(candidate['topic']), + hostName: this.getStringValue(candidate['hostName']) ?? sourceName ?? source?.name ?? 'Unknown API', + ownerId: this.getStringValue(candidate['ownerId']), + ownerName: this.getStringValue(candidate['ownerName']), + ownerPublicKey: this.getStringValue(candidate['ownerPublicKey']), + userCount: this.getNumberValue(candidate['userCount'], this.getNumberValue(candidate['currentUsers'])), + maxUsers: this.getNumberValue(candidate['maxUsers']), + hasPassword: this.getBooleanValue(candidate['hasPassword']), + isPrivate: this.getBooleanValue(candidate['isPrivate']), + tags: Array.isArray(candidate['tags']) ? candidate['tags'] as string[] : [], + createdAt: this.getNumberValue(candidate['createdAt'], Date.now()), + sourceId: this.getStringValue(candidate['sourceId']) ?? source?.id, + sourceName: sourceName ?? source?.name, + sourceUrl: sourceUrl + ? this.endpointState.sanitiseUrl(sourceUrl) + : (source ? this.endpointState.sanitiseUrl(source.url) : undefined) + }; + } + + private getBooleanValue(value: unknown): boolean { + return typeof value === 'boolean' ? value : value === 1; + } + + private getNumberValue(value: unknown, fallback = 0): number { + return typeof value === 'number' ? value : fallback; + } + + private getStringValue(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; + } +} diff --git a/src/app/domains/server-directory/infrastructure/server-directory.infrastructure.constants.ts b/src/app/domains/server-directory/infrastructure/server-directory.infrastructure.constants.ts new file mode 100644 index 0000000..9940614 --- /dev/null +++ b/src/app/domains/server-directory/infrastructure/server-directory.infrastructure.constants.ts @@ -0,0 +1,3 @@ +export const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints'; +export const REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys'; +export const SERVER_HEALTH_CHECK_TIMEOUT_MS = 5000; diff --git a/src/app/domains/server-directory/infrastructure/server-endpoint-compatibility.service.ts b/src/app/domains/server-directory/infrastructure/server-endpoint-compatibility.service.ts new file mode 100644 index 0000000..481fde8 --- /dev/null +++ b/src/app/domains/server-directory/infrastructure/server-endpoint-compatibility.service.ts @@ -0,0 +1,77 @@ +import { Injectable, inject } from '@angular/core'; +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import type { ServerVersionCompatibilityResult } from '../domain/server-directory.models'; + +@Injectable({ providedIn: 'root' }) +export class ServerEndpointCompatibilityService { + private readonly electronBridge = inject(ElectronBridgeService); + private clientVersionPromise: Promise | null = null; + + async getClientVersion(): Promise { + if (!this.clientVersionPromise) { + this.clientVersionPromise = this.resolveClientVersion(); + } + + return await this.clientVersionPromise; + } + + evaluateServerVersion( + rawServerVersion: unknown, + clientVersion: string | null + ): ServerVersionCompatibilityResult { + const serverVersion = normalizeSemanticVersion(rawServerVersion); + + return { + isCompatible: !clientVersion || (serverVersion !== null && serverVersion === clientVersion), + serverVersion + }; + } + + private async resolveClientVersion(): Promise { + const electronApi = this.electronBridge.getApi(); + + if (!electronApi) { + return null; + } + + try { + const state = await electronApi.getAutoUpdateState(); + + return normalizeSemanticVersion(state?.currentVersion); + } catch { + return null; + } + } +} + +function normalizeSemanticVersion(rawVersion: unknown): string | null { + if (typeof rawVersion !== 'string') { + return null; + } + + const trimmed = rawVersion.trim(); + + if (!trimmed) { + return null; + } + + const match = trimmed.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/i); + + if (!match) { + return null; + } + + const major = Number.parseInt(match[1], 10); + const minor = Number.parseInt(match[2], 10); + const patch = Number.parseInt(match[3], 10); + + if ( + Number.isNaN(major) + || Number.isNaN(minor) + || Number.isNaN(patch) + ) { + return null; + } + + return `${major}.${minor}.${patch}`; +} diff --git a/src/app/domains/server-directory/infrastructure/server-endpoint-health.service.ts b/src/app/domains/server-directory/infrastructure/server-endpoint-health.service.ts new file mode 100644 index 0000000..c6f97c0 --- /dev/null +++ b/src/app/domains/server-directory/infrastructure/server-endpoint-health.service.ts @@ -0,0 +1,75 @@ +import { Injectable, inject } from '@angular/core'; +import { SERVER_HEALTH_CHECK_TIMEOUT_MS } from './server-directory.infrastructure.constants'; +import type { + ServerEndpoint, + ServerEndpointHealthResult, + ServerHealthCheckPayload +} from '../domain/server-directory.models'; +import { ServerEndpointCompatibilityService } from './server-endpoint-compatibility.service'; + +@Injectable({ providedIn: 'root' }) +export class ServerEndpointHealthService { + private readonly endpointCompatibility = inject(ServerEndpointCompatibilityService); + + async probeEndpoint( + endpoint: Pick, + clientVersion: string | null + ): Promise { + const startTime = Date.now(); + + try { + const response = await fetch(`${endpoint.url}/api/health`, { + method: 'GET', + signal: AbortSignal.timeout(SERVER_HEALTH_CHECK_TIMEOUT_MS) + }); + const latency = Date.now() - startTime; + + if (response.ok) { + const payload = await response.json() as ServerHealthCheckPayload; + const versionCompatibility = this.endpointCompatibility.evaluateServerVersion( + payload.serverVersion, + clientVersion + ); + + if (!versionCompatibility.isCompatible) { + return { + status: 'incompatible', + latency, + versions: { + serverVersion: versionCompatibility.serverVersion, + clientVersion + } + }; + } + + return { + status: 'online', + latency, + versions: { + serverVersion: versionCompatibility.serverVersion, + clientVersion + } + }; + } + + return { status: 'offline' }; + } catch { + try { + const response = await fetch(`${endpoint.url}/api/servers`, { + method: 'GET', + signal: AbortSignal.timeout(SERVER_HEALTH_CHECK_TIMEOUT_MS) + }); + const latency = Date.now() - startTime; + + if (response.ok) { + return { + status: 'online', + latency + }; + } + } catch { /* both checks failed */ } + + return { status: 'offline' }; + } + } +} diff --git a/src/app/domains/server-directory/infrastructure/server-endpoint-storage.service.ts b/src/app/domains/server-directory/infrastructure/server-endpoint-storage.service.ts new file mode 100644 index 0000000..e9a3048 --- /dev/null +++ b/src/app/domains/server-directory/infrastructure/server-endpoint-storage.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@angular/core'; +import { REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, SERVER_ENDPOINTS_STORAGE_KEY } from './server-directory.infrastructure.constants'; +import type { ServerEndpoint } from '../domain/server-directory.models'; + +@Injectable({ providedIn: 'root' }) +export class ServerEndpointStorageService { + loadEndpoints(): ServerEndpoint[] | null { + const stored = localStorage.getItem(SERVER_ENDPOINTS_STORAGE_KEY); + + if (!stored) { + return null; + } + + try { + const parsed = JSON.parse(stored) as unknown; + + return Array.isArray(parsed) + ? parsed as ServerEndpoint[] + : null; + } catch { + return null; + } + } + + saveEndpoints(endpoints: ServerEndpoint[]): void { + localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints)); + } + + loadRemovedDefaultEndpointKeys(): Set { + const stored = localStorage.getItem(REMOVED_DEFAULT_SERVER_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(); + } + } + + saveRemovedDefaultEndpointKeys(keys: Set): void { + if (keys.size === 0) { + this.clearRemovedDefaultEndpointKeys(); + return; + } + + localStorage.setItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([...keys])); + } + + clearRemovedDefaultEndpointKeys(): void { + localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY); + } +} diff --git a/src/app/core/services/voice-activity.service.ts b/src/app/domains/voice-connection/application/voice-activity.service.ts similarity index 93% rename from src/app/core/services/voice-activity.service.ts rename to src/app/domains/voice-connection/application/voice-activity.service.ts index 658bac6..f3c0f0b 100644 --- a/src/app/core/services/voice-activity.service.ts +++ b/src/app/domains/voice-connection/application/voice-activity.service.ts @@ -25,8 +25,8 @@ import { Signal } from '@angular/core'; import { Subscription } from 'rxjs'; -import { DebuggingService } from './debugging.service'; -import { WebRTCService } from './webrtc.service'; +import { VoiceConnectionFacade } from './voice-connection.facade'; +import { DebuggingService } from '../../../core/services/debugging.service'; /* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/prefer-for-of, max-statements-per-line */ const SPEAKING_THRESHOLD = 0.015; @@ -46,7 +46,7 @@ interface TrackedStream { @Injectable({ providedIn: 'root' }) export class VoiceActivityService implements OnDestroy { - private readonly webrtc = inject(WebRTCService); + private readonly voiceConnection = inject(VoiceConnectionFacade); private readonly debugging = inject(DebuggingService); private readonly tracked = new Map(); @@ -58,8 +58,8 @@ export class VoiceActivityService implements OnDestroy { constructor() { this.subs.push( - this.webrtc.onRemoteStream.subscribe(({ peerId }) => { - const voiceStream = this.webrtc.getRemoteVoiceStream(peerId); + this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => { + const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId); if (!voiceStream) { this.untrackStream(peerId); @@ -71,7 +71,7 @@ export class VoiceActivityService implements OnDestroy { ); this.subs.push( - this.webrtc.onPeerDisconnected.subscribe((peerId) => { + this.voiceConnection.onPeerDisconnected.subscribe((peerId) => { this.untrackStream(peerId); }) ); diff --git a/src/app/domains/voice-connection/application/voice-connection.facade.ts b/src/app/domains/voice-connection/application/voice-connection.facade.ts new file mode 100644 index 0000000..53bf5e8 --- /dev/null +++ b/src/app/domains/voice-connection/application/voice-connection.facade.ts @@ -0,0 +1,94 @@ +import { Injectable, inject } from '@angular/core'; +import { ChatEvent } from '../../../core/models/index'; +import { RealtimeSessionFacade } from '../../../core/realtime'; +import { LatencyProfile } from '../domain/voice-connection.models'; + +@Injectable({ providedIn: 'root' }) +export class VoiceConnectionFacade { + readonly isVoiceConnected = inject(RealtimeSessionFacade).isVoiceConnected; + readonly isMuted = inject(RealtimeSessionFacade).isMuted; + readonly isDeafened = inject(RealtimeSessionFacade).isDeafened; + readonly isNoiseReductionEnabled = inject(RealtimeSessionFacade).isNoiseReductionEnabled; + readonly hasConnectionError = inject(RealtimeSessionFacade).hasConnectionError; + readonly connectionErrorMessage = inject(RealtimeSessionFacade).connectionErrorMessage; + readonly shouldShowConnectionError = inject(RealtimeSessionFacade).shouldShowConnectionError; + readonly peerLatencies = inject(RealtimeSessionFacade).peerLatencies; + readonly onRemoteStream = inject(RealtimeSessionFacade).onRemoteStream; + readonly onPeerConnected = inject(RealtimeSessionFacade).onPeerConnected; + readonly onPeerDisconnected = inject(RealtimeSessionFacade).onPeerDisconnected; + readonly onVoiceConnected = inject(RealtimeSessionFacade).onVoiceConnected; + + private readonly realtime = inject(RealtimeSessionFacade); + + async ensureSignalingConnected(timeoutMs?: number): Promise { + return await this.realtime.ensureSignalingConnected(timeoutMs); + } + + broadcastMessage(event: ChatEvent): void { + this.realtime.broadcastMessage(event); + } + + getConnectedPeers(): string[] { + return this.realtime.getConnectedPeers(); + } + + getRemoteVoiceStream(peerId: string): MediaStream | null { + return this.realtime.getRemoteVoiceStream(peerId); + } + + getLocalStream(): MediaStream | null { + return this.realtime.getLocalStream(); + } + + getRawMicStream(): MediaStream | null { + return this.realtime.getRawMicStream(); + } + + async enableVoice(): Promise { + return await this.realtime.enableVoice(); + } + + disableVoice(): void { + this.realtime.disableVoice(); + } + + async setLocalStream(stream: MediaStream): Promise { + await this.realtime.setLocalStream(stream); + } + + toggleMute(muted?: boolean): void { + this.realtime.toggleMute(muted); + } + + toggleDeafen(deafened?: boolean): void { + this.realtime.toggleDeafen(deafened); + } + + async toggleNoiseReduction(enabled?: boolean): Promise { + await this.realtime.toggleNoiseReduction(enabled); + } + + setOutputVolume(volume: number): void { + this.realtime.setOutputVolume(volume); + } + + setInputVolume(volume: number): void { + this.realtime.setInputVolume(volume); + } + + async setAudioBitrate(kbps: number): Promise { + await this.realtime.setAudioBitrate(kbps); + } + + async setLatencyProfile(profile: LatencyProfile): Promise { + await this.realtime.setLatencyProfile(profile); + } + + startVoiceHeartbeat(roomId?: string, serverId?: string): void { + this.realtime.startVoiceHeartbeat(roomId, serverId); + } + + stopVoiceHeartbeat(): void { + this.realtime.stopVoiceHeartbeat(); + } +} diff --git a/src/app/domains/voice-connection/domain/voice-connection.models.ts b/src/app/domains/voice-connection/domain/voice-connection.models.ts new file mode 100644 index 0000000..ea68f68 --- /dev/null +++ b/src/app/domains/voice-connection/domain/voice-connection.models.ts @@ -0,0 +1,5 @@ +export { + LATENCY_PROFILE_BITRATES, + type LatencyProfile +} from '../../../infrastructure/realtime/realtime.constants'; +export type { VoiceStateSnapshot } from '../../../infrastructure/realtime/realtime.types'; diff --git a/src/app/domains/voice-connection/index.ts b/src/app/domains/voice-connection/index.ts new file mode 100644 index 0000000..0c8fd13 --- /dev/null +++ b/src/app/domains/voice-connection/index.ts @@ -0,0 +1,3 @@ +export * from './application/voice-connection.facade'; +export * from './application/voice-activity.service'; +export * from './domain/voice-connection.models'; diff --git a/src/app/core/services/voice-session.service.ts b/src/app/domains/voice-session/application/voice-session.facade.ts similarity index 68% rename from src/app/core/services/voice-session.service.ts rename to src/app/domains/voice-session/application/voice-session.facade.ts index ef72ec5..97151d3 100644 --- a/src/app/core/services/voice-session.service.ts +++ b/src/app/domains/voice-session/application/voice-session.facade.ts @@ -5,32 +5,10 @@ import { computed, inject } from '@angular/core'; -import { Router } from '@angular/router'; import { Store } from '@ngrx/store'; -import { Room } from '../models'; -import { RoomsActions } from '../../store/rooms/rooms.actions'; - -/** - * Snapshot of an active voice session, retained so that floating - * voice controls can display the connection details when the user - * navigates away from the server view. - */ -export interface VoiceSessionInfo { - /** Unique server identifier. */ - serverId: string; - /** Display name of the server. */ - serverName: string; - /** Room/channel ID within the server. */ - roomId: string; - /** Display name of the room/channel. */ - roomName: string; - /** Optional server icon (data-URL or remote URL). */ - serverIcon?: string; - /** Optional server description. */ - serverDescription?: string; - /** Angular route path to navigate back to the server. */ - serverRoute: string; -} +import { RoomsActions } from '../../../store/rooms/rooms.actions'; +import { buildVoiceSessionRoom, isViewingVoiceSessionServer } from '../domain/voice-session.logic'; +import type { VoiceSessionInfo } from '../domain/voice-session.models'; /** * Tracks the user's current voice session across client-side @@ -41,8 +19,7 @@ export interface VoiceSessionInfo { * voice management lives in {@link WebRTCService} and its managers. */ @Injectable({ providedIn: 'root' }) -export class VoiceSessionService { - private readonly router = inject(Router); +export class VoiceSessionFacade { private readonly store = inject(Store); /** Current voice session metadata, or `null` when disconnected. */ @@ -103,14 +80,9 @@ export class VoiceSessionService { * @param currentServerId - ID of the server the user is currently viewing. */ checkCurrentRoute(currentServerId: string | null): void { - const session = this._voiceSession(); - - if (!session) { - this._isViewingVoiceServer.set(true); - return; - } - - this._isViewingVoiceServer.set(currentServerId === session.serverId); + this._isViewingVoiceServer.set( + isViewingVoiceSessionServer(this._voiceSession(), currentServerId) + ); } /** @@ -123,21 +95,9 @@ export class VoiceSessionService { if (!session) return; - const room: Room = { - id: session.serverId, - name: session.serverName, - description: session.serverDescription, - hostId: '', - isPrivate: false, - createdAt: 0, - userCount: 0, - maxUsers: 50, - icon: session.serverIcon - }; - this.store.dispatch( RoomsActions.viewServer({ - room + room: buildVoiceSessionRoom(session) }) ); diff --git a/src/app/core/services/voice-workspace.service.ts b/src/app/domains/voice-session/application/voice-workspace.service.ts similarity index 96% rename from src/app/core/services/voice-workspace.service.ts rename to src/app/domains/voice-session/application/voice-workspace.service.ts index bb6d5db..449df34 100644 --- a/src/app/core/services/voice-workspace.service.ts +++ b/src/app/domains/voice-session/application/voice-workspace.service.ts @@ -7,7 +7,7 @@ import { signal } from '@angular/core'; -import { VoiceSessionService } from './voice-session.service'; +import { VoiceSessionFacade } from './voice-session.facade'; export type VoiceWorkspaceMode = 'hidden' | 'expanded' | 'minimized'; @@ -23,7 +23,7 @@ const DEFAULT_MINI_WINDOW_POSITION: VoiceWorkspacePosition = { @Injectable({ providedIn: 'root' }) export class VoiceWorkspaceService { - private readonly voiceSession = inject(VoiceSessionService); + private readonly voiceSession = inject(VoiceSessionFacade); private readonly _mode = signal('hidden'); private readonly _focusedStreamId = signal(null); diff --git a/src/app/domains/voice-session/domain/voice-session.logic.ts b/src/app/domains/voice-session/domain/voice-session.logic.ts new file mode 100644 index 0000000..c9b18d7 --- /dev/null +++ b/src/app/domains/voice-session/domain/voice-session.logic.ts @@ -0,0 +1,23 @@ +import type { Room } from '../../../core/models'; +import type { VoiceSessionInfo } from './voice-session.models'; + +export function isViewingVoiceSessionServer( + session: VoiceSessionInfo | null, + currentServerId: string | null +): boolean { + return !session || currentServerId === session.serverId; +} + +export function buildVoiceSessionRoom(session: VoiceSessionInfo): Room { + return { + id: session.serverId, + name: session.serverName, + description: session.serverDescription, + hostId: '', + isPrivate: false, + createdAt: 0, + userCount: 0, + maxUsers: 50, + icon: session.serverIcon + }; +} diff --git a/src/app/domains/voice-session/domain/voice-session.models.ts b/src/app/domains/voice-session/domain/voice-session.models.ts new file mode 100644 index 0000000..54b32ed --- /dev/null +++ b/src/app/domains/voice-session/domain/voice-session.models.ts @@ -0,0 +1,21 @@ +/** + * Snapshot of an active voice session, retained so that floating + * voice controls can display the connection details when the user + * navigates away from the server view. + */ +export interface VoiceSessionInfo { + /** Unique server identifier. */ + serverId: string; + /** Display name of the server. */ + serverName: string; + /** Room/channel ID within the server. */ + roomId: string; + /** Display name of the room/channel. */ + roomName: string; + /** Optional server icon (data-URL or remote URL). */ + serverIcon?: string; + /** Optional server description. */ + serverDescription?: string; + /** Angular route path to navigate back to the server. */ + serverRoute: string; +} diff --git a/src/app/domains/voice-session/index.ts b/src/app/domains/voice-session/index.ts new file mode 100644 index 0000000..582320c --- /dev/null +++ b/src/app/domains/voice-session/index.ts @@ -0,0 +1,4 @@ +export * from './application/voice-session.facade'; +export * from './application/voice-workspace.service'; +export * from './domain/voice-session.models'; +export * from './infrastructure/voice-settings.storage'; diff --git a/src/app/core/services/voice-settings.storage.ts b/src/app/domains/voice-session/infrastructure/voice-settings.storage.ts similarity index 93% rename from src/app/core/services/voice-settings.storage.ts rename to src/app/domains/voice-session/infrastructure/voice-settings.storage.ts index 80bd984..a6d975f 100644 --- a/src/app/core/services/voice-settings.storage.ts +++ b/src/app/domains/voice-session/infrastructure/voice-settings.storage.ts @@ -1,6 +1,6 @@ -import { STORAGE_KEY_VOICE_SETTINGS } from '../constants'; -import { LatencyProfile } from './webrtc/webrtc.constants'; -import { DEFAULT_SCREEN_SHARE_QUALITY, ScreenShareQuality } from './webrtc/screen-share.config'; +import { STORAGE_KEY_VOICE_SETTINGS } from '../../../core/constants'; +import { LatencyProfile } from '../../voice-connection/domain/voice-connection.models'; +import { DEFAULT_SCREEN_SHARE_QUALITY, ScreenShareQuality } from '../../screen-share/domain/screen-share.config'; const LATENCY_PROFILES: LatencyProfile[] = [ 'low', diff --git a/src/app/features/admin/admin-panel/admin-panel.component.ts b/src/app/features/admin/admin-panel/admin-panel.component.ts index f8d500a..ce86aa6 100644 --- a/src/app/features/admin/admin-panel/admin-panel.component.ts +++ b/src/app/features/admin/admin-panel/admin-panel.component.ts @@ -30,7 +30,7 @@ import { selectOnlineUsers } from '../../../store/users/users.selectors'; import { BanEntry, User } from '../../../core/models/index'; -import { WebRTCService } from '../../../core/services/webrtc.service'; +import { RealtimeSessionFacade } from '../../../core/realtime'; import { UserAvatarComponent, ConfirmDialogComponent } from '../../../shared'; type AdminTab = 'settings' | 'members' | 'bans' | 'permissions'; @@ -93,7 +93,7 @@ export class AdminPanelComponent { adminsManageIcon = false; moderatorsManageIcon = false; - private webrtc = inject(WebRTCService); + private webrtc = inject(RealtimeSessionFacade); constructor() { // Initialize from current room diff --git a/src/app/features/auth/login/login.component.ts b/src/app/features/auth/login/login.component.ts index f33dfb8..c42837c 100644 --- a/src/app/features/auth/login/login.component.ts +++ b/src/app/features/auth/login/login.component.ts @@ -11,8 +11,8 @@ import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideLogIn } from '@ng-icons/lucide'; -import { AuthService } from '../../../core/services/auth.service'; -import { ServerDirectoryService } from '../../../core/services/server-directory.service'; +import { AuthService } from '../../../domains/auth'; +import { ServerDirectoryFacade } from '../../../domains/server-directory'; import { UsersActions } from '../../../store/users/users.actions'; import { User } from '../../../core/models/index'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; @@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; * Login form allowing existing users to authenticate against a selected server. */ export class LoginComponent { - serversSvc = inject(ServerDirectoryService); + serversSvc = inject(ServerDirectoryFacade); servers = this.serversSvc.servers; username = ''; diff --git a/src/app/features/auth/register/register.component.ts b/src/app/features/auth/register/register.component.ts index cc48fd9..f7212fe 100644 --- a/src/app/features/auth/register/register.component.ts +++ b/src/app/features/auth/register/register.component.ts @@ -11,8 +11,8 @@ import { Store } from '@ngrx/store'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideUserPlus } from '@ng-icons/lucide'; -import { AuthService } from '../../../core/services/auth.service'; -import { ServerDirectoryService } from '../../../core/services/server-directory.service'; +import { AuthService } from '../../../domains/auth'; +import { ServerDirectoryFacade } from '../../../domains/server-directory'; import { UsersActions } from '../../../store/users/users.actions'; import { User } from '../../../core/models/index'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; @@ -32,7 +32,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../core/constants'; * Registration form allowing new users to create an account on a selected server. */ export class RegisterComponent { - serversSvc = inject(ServerDirectoryService); + serversSvc = inject(ServerDirectoryFacade); servers = this.serversSvc.servers; username = ''; diff --git a/src/app/features/chat/chat-messages/chat-messages.component.ts b/src/app/features/chat/chat-messages/chat-messages.component.ts index 7cf7bc9..ad105a7 100644 --- a/src/app/features/chat/chat-messages/chat-messages.component.ts +++ b/src/app/features/chat/chat-messages/chat-messages.component.ts @@ -8,8 +8,10 @@ import { signal } from '@angular/core'; import { Store } from '@ngrx/store'; -import { Attachment, AttachmentService } from '../../../core/services/attachment.service'; -import { KlipyGif } from '../../../core/services/klipy.service'; +import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; +import { RealtimeSessionFacade } from '../../../core/realtime'; +import { Attachment, AttachmentFacade } from '../../../domains/attachment'; +import { KlipyGif } from '../../../domains/chat'; import { MessagesActions } from '../../../store/messages/messages.actions'; import { selectAllMessages, @@ -19,7 +21,6 @@ import { import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; import { selectActiveChannelId, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { Message } from '../../../core/models'; -import { WebRTCService } from '../../../core/services/webrtc.service'; import { ChatMessageComposerComponent } from './components/message-composer/chat-message-composer.component'; import { KlipyGifPickerComponent } from '../klipy-gif-picker/klipy-gif-picker.component'; import { ChatMessageListComponent } from './components/message-list/chat-message-list.component'; @@ -48,9 +49,10 @@ import { export class ChatMessagesComponent { @ViewChild(ChatMessageComposerComponent) composer?: ChatMessageComposerComponent; + private readonly electronBridge = inject(ElectronBridgeService); private readonly store = inject(Store); - private readonly webrtc = inject(WebRTCService); - private readonly attachmentsSvc = inject(AttachmentService); + private readonly webrtc = inject(RealtimeSessionFacade); + private readonly attachmentsSvc = inject(AttachmentFacade); readonly allMessages = this.store.selectSignal(selectAllMessages); private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId); @@ -252,17 +254,9 @@ export class ChatMessagesComponent { if (!attachment.available || !attachment.objectUrl) return; - const electronWindow = window as Window & { - electronAPI?: { - saveFileAs?: ( - defaultFileName: string, - data: string - ) => Promise<{ saved: boolean; cancelled: boolean }>; - }; - }; - const electronApi = electronWindow.electronAPI; + const electronApi = this.electronBridge.getApi(); - if (electronApi?.saveFileAs) { + if (electronApi) { const blob = await this.getAttachmentBlob(attachment); if (blob) { diff --git a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts index 4369d32..b039ffb 100644 --- a/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts +++ b/src/app/features/chat/chat-messages/components/message-composer/chat-message-composer.component.ts @@ -19,28 +19,14 @@ import { lucideSend, lucideX } from '@ng-icons/lucide'; -import { KlipyGif, KlipyService } from '../../../../../core/services/klipy.service'; +import type { ClipboardFilePayload } from '../../../../../core/platform/electron/electron-api.models'; +import { ElectronBridgeService } from '../../../../../core/platform/electron/electron-bridge.service'; +import { KlipyGif, KlipyService } from '../../../../../domains/chat'; import { Message } from '../../../../../core/models'; import { TypingIndicatorComponent } from '../../../typing-indicator/typing-indicator.component'; import { ChatMarkdownService } from '../../services/chat-markdown.service'; import { ChatMessageComposerSubmitEvent } from '../../models/chat-messages.models'; -interface ClipboardFilePayload { - data: string; - lastModified: number; - mime: string; - name: string; - path?: string; -} - -interface ClipboardElectronApi { - readClipboardFiles?: () => Promise; -} - -type ClipboardWindow = Window & { - electronAPI?: ClipboardElectronApi; -}; - type LocalFileWithPath = File & { path?: string; }; @@ -87,6 +73,7 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { readonly klipy = inject(KlipyService); private readonly markdown = inject(ChatMarkdownService); + private readonly electronBridge = inject(ElectronBridgeService); readonly pendingKlipyGif = signal(null); readonly toolbarVisible = signal(false); @@ -558,9 +545,9 @@ export class ChatMessageComposerComponent implements AfterViewInit, OnDestroy { } private async readFilesFromElectronClipboard(): Promise { - const electronApi = (window as ClipboardWindow).electronAPI; + const electronApi = this.electronBridge.getApi(); - if (!electronApi?.readClipboardFiles) + if (!electronApi) return []; try { diff --git a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts index c744047..229218a 100644 --- a/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts +++ b/src/app/features/chat/chat-messages/components/message-item/chat-message-item.component.ts @@ -31,10 +31,10 @@ import remarkParse from 'remark-parse'; import { unified } from 'unified'; import { Attachment, - AttachmentService, + AttachmentFacade, MAX_AUTO_SAVE_SIZE_BYTES -} from '../../../../../core/services/attachment.service'; -import { KlipyService } from '../../../../../core/services/klipy.service'; +} from '../../../../../domains/attachment'; +import { KlipyService } from '../../../../../domains/chat'; import { DELETED_MESSAGE_CONTENT, Message } from '../../../../../core/models'; import { ChatAudioPlayerComponent, @@ -126,7 +126,7 @@ interface ChatMessageAttachmentViewModel extends Attachment { export class ChatMessageItemComponent { @ViewChild('editTextareaRef') editTextareaRef?: ElementRef; - private readonly attachmentsSvc = inject(AttachmentService); + private readonly attachmentsSvc = inject(AttachmentFacade); private readonly klipy = inject(KlipyService); private readonly attachmentVersion = signal(this.attachmentsSvc.updated()); diff --git a/src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.ts b/src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.ts index 0759565..0374b1a 100644 --- a/src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.ts +++ b/src/app/features/chat/chat-messages/components/message-list/chat-message-list.component.ts @@ -12,7 +12,7 @@ import { output, signal } from '@angular/core'; -import { Attachment } from '../../../../../core/services/attachment.service'; +import { Attachment } from '../../../../../domains/attachment'; import { Message } from '../../../../../core/models'; import { ChatMessageDeleteEvent, diff --git a/src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.ts b/src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.ts index 91f8b14..48a7f8e 100644 --- a/src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.ts +++ b/src/app/features/chat/chat-messages/components/message-overlays/chat-message-overlays.component.ts @@ -10,7 +10,7 @@ import { lucideDownload, lucideX } from '@ng-icons/lucide'; -import { Attachment } from '../../../../../core/services/attachment.service'; +import { Attachment } from '../../../../../domains/attachment'; import { ContextMenuComponent } from '../../../../../shared'; import { ChatMessageImageContextMenuEvent } from '../../models/chat-messages.models'; diff --git a/src/app/features/chat/chat-messages/models/chat-messages.models.ts b/src/app/features/chat/chat-messages/models/chat-messages.models.ts index 50ed11d..114434f 100644 --- a/src/app/features/chat/chat-messages/models/chat-messages.models.ts +++ b/src/app/features/chat/chat-messages/models/chat-messages.models.ts @@ -1,4 +1,4 @@ -import { Attachment } from '../../../../core/services/attachment.service'; +import { Attachment } from '../../../../domains/attachment'; import { Message } from '../../../../core/models'; export interface ChatMessageComposerSubmitEvent { diff --git a/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.ts b/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.ts index e240e60..a3ff1f0 100644 --- a/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.ts +++ b/src/app/features/chat/klipy-gif-picker/klipy-gif-picker.component.ts @@ -20,7 +20,7 @@ import { lucideSearch, lucideX } from '@ng-icons/lucide'; -import { KlipyGif, KlipyService } from '../../../core/services/klipy.service'; +import { KlipyGif, KlipyService } from '../../../domains/chat'; @Component({ selector: 'app-klipy-gif-picker', diff --git a/src/app/features/chat/typing-indicator/typing-indicator.component.ts b/src/app/features/chat/typing-indicator/typing-indicator.component.ts index 7d243f5..6fcf73f 100644 --- a/src/app/features/chat/typing-indicator/typing-indicator.component.ts +++ b/src/app/features/chat/typing-indicator/typing-indicator.component.ts @@ -8,7 +8,7 @@ import { } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { Store } from '@ngrx/store'; -import { WebRTCService } from '../../../core/services/webrtc.service'; +import { RealtimeSessionFacade } from '../../../core/realtime'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { merge, @@ -48,7 +48,7 @@ export class TypingIndicatorComponent { typingOthersCount = signal(0); constructor() { - const webrtc = inject(WebRTCService); + const webrtc = inject(RealtimeSessionFacade); const destroyRef = inject(DestroyRef); const typing$ = webrtc.onSignalingMessage.pipe( filter((msg): msg is TypingSignalingMessage => diff --git a/src/app/features/invite/invite.component.ts b/src/app/features/invite/invite.component.ts index ed5e424..a62ef39 100644 --- a/src/app/features/invite/invite.component.ts +++ b/src/app/features/invite/invite.component.ts @@ -11,9 +11,10 @@ import { Store } from '@ngrx/store'; import { RoomsActions } from '../../store/rooms/rooms.actions'; import { UsersActions } from '../../store/users/users.actions'; import { selectCurrentUser } from '../../store/users/users.selectors'; -import { ServerDirectoryService, ServerInviteInfo } from '../../core/services/server-directory.service'; +import type { ServerInviteInfo } from '../../domains/server-directory'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; -import { DatabaseService } from '../../core/services/database.service'; +import { DatabaseService } from '../../infrastructure/persistence'; +import { ServerDirectoryFacade } from '../../domains/server-directory'; import { User } from '../../core/models/index'; @Component({ @@ -31,7 +32,7 @@ export class InviteComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly store = inject(Store); - private readonly serverDirectory = inject(ServerDirectoryService); + private readonly serverDirectory = inject(ServerDirectoryFacade); private readonly databaseService = inject(DatabaseService); async ngOnInit(): Promise { diff --git a/src/app/features/room/chat-room/chat-room.component.ts b/src/app/features/room/chat-room/chat-room.component.ts index 8c6238c..cea618a 100644 --- a/src/app/features/room/chat-room/chat-room.component.ts +++ b/src/app/features/room/chat-room/chat-room.component.ts @@ -30,7 +30,7 @@ import { } from '../../../store/rooms/rooms.selectors'; import { SettingsModalService } from '../../../core/services/settings-modal.service'; import { selectCurrentUser, selectIsCurrentUserAdmin } from '../../../store/users/users.selectors'; -import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; +import { VoiceWorkspaceService } from '../../../domains/voice-session'; @Component({ selector: 'app-chat-room', diff --git a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts index 2c23978..2f40757 100644 --- a/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts +++ b/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts @@ -34,10 +34,10 @@ import { import { UsersActions } from '../../../store/users/users.actions'; import { RoomsActions } from '../../../store/rooms/rooms.actions'; import { MessagesActions } from '../../../store/messages/messages.actions'; -import { WebRTCService } from '../../../core/services/webrtc.service'; -import { VoiceSessionService } from '../../../core/services/voice-session.service'; -import { VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; -import { VoiceActivityService } from '../../../core/services/voice-activity.service'; +import { RealtimeSessionFacade } from '../../../core/realtime'; +import { ScreenShareFacade } from '../../../domains/screen-share'; +import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection'; +import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session'; import { VoicePlaybackService } from '../../voice/voice-controls/services/voice-playback.service'; import { VoiceControlsComponent } from '../../voice/voice-controls/voice-controls.component'; import { @@ -87,8 +87,10 @@ type TabView = 'channels' | 'users'; }) export class RoomsSidePanelComponent { private store = inject(Store); - private webrtc = inject(WebRTCService); - private voiceSessionService = inject(VoiceSessionService); + private realtime = inject(RealtimeSessionFacade); + private voiceConnection = inject(VoiceConnectionFacade); + private screenShare = inject(ScreenShareFacade); + private voiceSessionService = inject(VoiceSessionFacade); private voiceWorkspace = inject(VoiceWorkspaceService); private voicePlayback = inject(VoicePlaybackService); voiceActivity = inject(VoiceActivityService); @@ -283,12 +285,12 @@ export class RoomsSidePanelComponent { this.store.dispatch(MessagesActions.startSync()); - const peers = this.webrtc.getConnectedPeers(); + const peers = this.realtime.getConnectedPeers(); const inventoryRequest: ChatEvent = { type: 'chat-inventory-request', roomId: room.id }; peers.forEach((pid) => { try { - this.webrtc.sendToPeer(pid, inventoryRequest); + this.realtime.sendToPeer(pid, inventoryRequest); } catch { return; } @@ -362,7 +364,7 @@ export class RoomsSidePanelComponent { if (user) { this.store.dispatch(UsersActions.updateUserRole({ userId: user.id, role })); - this.webrtc.broadcastMessage({ + this.realtime.broadcastMessage({ type: 'role-change', roomId, targetUserId: user.id, @@ -403,7 +405,7 @@ export class RoomsSidePanelComponent { return; if (current?.voiceState?.isConnected && current.voiceState.serverId !== room?.id) { - if (!this.webrtc.isVoiceConnected()) { + if (!this.voiceConnection.isVoiceConnected()) { if (current.id) { this.store.dispatch( UsersActions.updateVoiceState({ @@ -424,7 +426,7 @@ export class RoomsSidePanelComponent { } const isSwitchingChannels = current?.voiceState?.isConnected && current.voiceState.serverId === room?.id && current.voiceState.roomId !== roomId; - const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.webrtc.enableVoice(); + const enableVoicePromise = isSwitchingChannels ? Promise.resolve() : this.voiceConnection.enableVoice(); enableVoicePromise .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null)) @@ -441,7 +443,7 @@ export class RoomsSidePanelComponent { private trackCurrentUserMic(): void { const userId = this.currentUser()?.oderId || this.currentUser()?.id; - const micStream = this.webrtc.getRawMicStream(); + const micStream = this.voiceConnection.getRawMicStream(); if (userId && micStream) { this.voiceActivity.trackLocalMic(userId, micStream); @@ -475,11 +477,11 @@ export class RoomsSidePanelComponent { } private startVoiceHeartbeat(roomId: string, room: Room): void { - this.webrtc.startVoiceHeartbeat(roomId, room.id); + this.voiceConnection.startVoiceHeartbeat(roomId, room.id); } private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void { - this.webrtc.broadcastMessage({ + this.voiceConnection.broadcastMessage({ type: 'voice-state', oderId: current?.oderId || current?.id, displayName: current?.displayName || 'User', @@ -514,11 +516,11 @@ export class RoomsSidePanelComponent { if (!(current?.voiceState?.isConnected && current.voiceState.roomId === roomId)) return; - this.webrtc.stopVoiceHeartbeat(); + this.voiceConnection.stopVoiceHeartbeat(); this.untrackCurrentUserMic(); - this.webrtc.disableVoice(); + this.voiceConnection.disableVoice(); if (current?.id) { this.store.dispatch( @@ -535,7 +537,7 @@ export class RoomsSidePanelComponent { ); } - this.webrtc.broadcastMessage({ + this.voiceConnection.broadcastMessage({ type: 'voice-state', oderId: current?.oderId || current?.id, displayName: current?.displayName || 'User', @@ -573,7 +575,7 @@ export class RoomsSidePanelComponent { const me = this.currentUser(); if (me?.id === userId) { - return this.webrtc.isScreenSharing(); + return this.screenShare.isScreenSharing(); } const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId); @@ -590,7 +592,7 @@ export class RoomsSidePanelComponent { (candidate): candidate is string => !!candidate ); const stream = peerKeys - .map((peerKey) => this.webrtc.getRemoteScreenShareStream(peerKey)) + .map((peerKey) => this.screenShare.getRemoteScreenShareStream(peerKey)) .find((candidate) => !!candidate && candidate.getVideoTracks().length > 0) || null; return !!stream && stream.getVideoTracks().length > 0; @@ -636,7 +638,7 @@ export class RoomsSidePanelComponent { } getPeerLatency(user: User): number | null { - const latencies = this.webrtc.peerLatencies(); + const latencies = this.voiceConnection.peerLatencies(); return latencies.get(user.oderId ?? '') ?? latencies.get(user.id) ?? null; } diff --git a/src/app/features/server-search/server-search.component.ts b/src/app/features/server-search/server-search.component.ts index 4a54f2e..3e360b9 100644 --- a/src/app/features/server-search/server-search.component.ts +++ b/src/app/features/server-search/server-search.component.ts @@ -39,8 +39,8 @@ import { User } from '../../core/models/index'; import { SettingsModalService } from '../../core/services/settings-modal.service'; -import { DatabaseService } from '../../core/services/database.service'; -import { ServerDirectoryService } from '../../core/services/server-directory.service'; +import { DatabaseService } from '../../infrastructure/persistence'; +import { ServerDirectoryFacade } from '../../domains/server-directory'; import { selectCurrentUser } from '../../store/users/users.selectors'; import { ConfirmDialogComponent } from '../../shared'; import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; @@ -75,7 +75,7 @@ export class ServerSearchComponent implements OnInit { private router = inject(Router); private settingsModal = inject(SettingsModalService); private db = inject(DatabaseService); - private serverDirectory = inject(ServerDirectoryService); + private serverDirectory = inject(ServerDirectoryFacade); private searchSubject = new Subject(); private banLookupRequestVersion = 0; diff --git a/src/app/features/servers/servers-rail.component.ts b/src/app/features/servers/servers-rail.component.ts index bc3134c..9ef6f62 100644 --- a/src/app/features/servers/servers-rail.component.ts +++ b/src/app/features/servers/servers-rail.component.ts @@ -15,13 +15,13 @@ import { lucidePlus } from '@ng-icons/lucide'; import { firstValueFrom } from 'rxjs'; import { Room, User } from '../../core/models/index'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { VoiceSessionFacade } from '../../domains/voice-session'; import { selectSavedRooms, selectCurrentRoom } from '../../store/rooms/rooms.selectors'; import { selectCurrentUser } from '../../store/users/users.selectors'; -import { VoiceSessionService } from '../../core/services/voice-session.service'; -import { WebRTCService } from '../../core/services/webrtc.service'; import { RoomsActions } from '../../store/rooms/rooms.actions'; -import { DatabaseService } from '../../core/services/database.service'; -import { ServerDirectoryService } from '../../core/services/server-directory.service'; +import { DatabaseService } from '../../infrastructure/persistence'; +import { ServerDirectoryFacade } from '../../domains/server-directory'; import { hasRoomBanForUser } from '../../core/helpers/room-ban.helpers'; import { ConfirmDialogComponent, @@ -47,10 +47,10 @@ import { export class ServersRailComponent { private store = inject(Store); private router = inject(Router); - private voiceSession = inject(VoiceSessionService); - private webrtc = inject(WebRTCService); + private voiceSession = inject(VoiceSessionFacade); + private webrtc = inject(RealtimeSessionFacade); private db = inject(DatabaseService); - private serverDirectory = inject(ServerDirectoryService); + private serverDirectory = inject(ServerDirectoryFacade); private banLookupRequestVersion = 0; savedRooms = this.store.selectSignal(selectSavedRooms); currentRoom = this.store.selectSignal(selectCurrentRoom); diff --git a/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts b/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts index 0aa20b4..1cab87a 100644 --- a/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts +++ b/src/app/features/settings/settings-modal/bans-settings/bans-settings.component.ts @@ -14,7 +14,7 @@ import { Store } from '@ngrx/store'; import { lucideX } from '@ng-icons/lucide'; import { Room, BanEntry } from '../../../../core/models/index'; -import { DatabaseService } from '../../../../core/services/database.service'; +import { DatabaseService } from '../../../../infrastructure/persistence'; import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { UsersActions } from '../../../../store/users/users.actions'; diff --git a/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts b/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts index 6c88464..0e7c6ce 100644 --- a/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts +++ b/src/app/features/settings/settings-modal/general-settings/general-settings.component.ts @@ -8,20 +8,8 @@ import { CommonModule } from '@angular/common'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePower } from '@ng-icons/lucide'; -import { PlatformService } from '../../../../core/services/platform.service'; - -interface DesktopSettingsSnapshot { - autoStart: boolean; -} - -interface GeneralSettingsElectronApi { - getDesktopSettings?: () => Promise; - setDesktopSettings?: (patch: { autoStart?: boolean }) => Promise; -} - -type GeneralSettingsWindow = Window & { - electronAPI?: GeneralSettingsElectronApi; -}; +import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; +import { PlatformService } from '../../../../core/platform'; @Component({ selector: 'app-general-settings', @@ -36,6 +24,7 @@ type GeneralSettingsWindow = Window & { }) export class GeneralSettingsComponent { private platform = inject(PlatformService); + private electronBridge = inject(ElectronBridgeService); readonly isElectron = this.platform.isElectron; autoStart = signal(false); @@ -50,9 +39,9 @@ export class GeneralSettingsComponent { async onAutoStartChange(event: Event): Promise { const input = event.target as HTMLInputElement; const enabled = !!input.checked; - const api = this.getElectronApi(); + const api = this.electronBridge.getApi(); - if (!this.isElectron || !api?.setDesktopSettings) { + if (!this.isElectron || !api) { input.checked = this.autoStart(); return; } @@ -71,9 +60,9 @@ export class GeneralSettingsComponent { } private async loadDesktopSettings(): Promise { - const api = this.getElectronApi(); + const api = this.electronBridge.getApi(); - if (!api?.getDesktopSettings) { + if (!api) { return; } @@ -83,10 +72,4 @@ export class GeneralSettingsComponent { this.autoStart.set(snapshot.autoStart); } catch {} } - - private getElectronApi(): GeneralSettingsElectronApi | null { - return typeof window !== 'undefined' - ? (window as GeneralSettingsWindow).electronAPI ?? null - : null; - } } diff --git a/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts b/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts index 4922a07..88e81da 100644 --- a/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts +++ b/src/app/features/settings/settings-modal/members-settings/members-settings.component.ts @@ -16,9 +16,9 @@ import { RoomMember, UserRole } from '../../../../core/models/index'; +import { RealtimeSessionFacade } from '../../../../core/realtime'; import { RoomsActions } from '../../../../store/rooms/rooms.actions'; import { UsersActions } from '../../../../store/users/users.actions'; -import { WebRTCService } from '../../../../core/services/webrtc.service'; import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors'; import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors'; import { UserAvatarComponent } from '../../../../shared'; @@ -46,7 +46,7 @@ interface ServerMemberView extends RoomMember { }) export class MembersSettingsComponent { private store = inject(Store); - private webrtcService = inject(WebRTCService); + private webrtcService = inject(RealtimeSessionFacade); /** The currently selected server, passed from the parent. */ server = input(null); diff --git a/src/app/features/settings/settings-modal/network-settings/network-settings.component.ts b/src/app/features/settings/settings-modal/network-settings/network-settings.component.ts index 8e5b3be..c680dd1 100644 --- a/src/app/features/settings/settings-modal/network-settings/network-settings.component.ts +++ b/src/app/features/settings/settings-modal/network-settings/network-settings.component.ts @@ -18,7 +18,7 @@ import { lucideX } from '@ng-icons/lucide'; -import { ServerDirectoryService } from '../../../../core/services/server-directory.service'; +import { ServerDirectoryFacade } from '../../../../domains/server-directory'; import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants'; @Component({ @@ -43,7 +43,7 @@ import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants'; templateUrl: './network-settings.component.html' }) export class NetworkSettingsComponent { - private serverDirectory = inject(ServerDirectoryService); + private serverDirectory = inject(ServerDirectoryFacade); servers = this.serverDirectory.servers; activeServers = this.serverDirectory.activeServers; diff --git a/src/app/features/settings/settings-modal/settings-modal.component.ts b/src/app/features/settings/settings-modal/settings-modal.component.ts index 2182739..5b286d6 100644 --- a/src/app/features/settings/settings-modal/settings-modal.component.ts +++ b/src/app/features/settings/settings-modal/settings-modal.component.ts @@ -25,11 +25,11 @@ import { } from '@ng-icons/lucide'; import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service'; +import { RealtimeSessionFacade } from '../../../core/realtime'; import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { selectCurrentUser } from '../../../store/users/users.selectors'; import { Room, UserRole } from '../../../core/models/index'; import { findRoomMember } from '../../../store/rooms/room-members.helpers'; -import { WebRTCService } from '../../../core/services/webrtc.service'; import { GeneralSettingsComponent } from './general-settings/general-settings.component'; import { NetworkSettingsComponent } from './network-settings/network-settings.component'; @@ -77,7 +77,7 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice export class SettingsModalComponent { readonly modal = inject(SettingsModalService); private store = inject(Store); - private webrtc = inject(WebRTCService); + private webrtc = inject(RealtimeSessionFacade); readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES; private lastRequestedServerId: string | null = null; diff --git a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts index ef73b98..f978b8c 100644 --- a/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts +++ b/src/app/features/settings/settings-modal/voice-settings/voice-settings.component.ts @@ -16,34 +16,20 @@ import { lucideCpu } from '@ng-icons/lucide'; -import { WebRTCService } from '../../../../core/services/webrtc.service'; +import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models'; +import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; +import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; +import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../domains/screen-share'; +import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../domains/voice-session'; import { VoicePlaybackService } from '../../../voice/voice-controls/services/voice-playback.service'; import { NotificationAudioService, AppSound } from '../../../../core/services/notification-audio.service'; -import { PlatformService } from '../../../../core/services/platform.service'; -import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../../core/services/voice-settings.storage'; -import { SCREEN_SHARE_QUALITY_OPTIONS, ScreenShareQuality } from '../../../../core/services/webrtc'; +import { PlatformService } from '../../../../core/platform'; interface AudioDevice { deviceId: string; label: string; } -interface DesktopSettingsSnapshot { - hardwareAcceleration: boolean; - runtimeHardwareAcceleration: boolean; - restartRequired: boolean; -} - -interface DesktopSettingsElectronApi { - getDesktopSettings?: () => Promise; - setDesktopSettings?: (patch: { hardwareAcceleration?: boolean }) => Promise; - relaunchApp?: () => Promise; -} - -type DesktopSettingsWindow = Window & { - electronAPI?: DesktopSettingsElectronApi; -}; - @Component({ selector: 'app-voice-settings', standalone: true, @@ -64,8 +50,9 @@ type DesktopSettingsWindow = Window & { templateUrl: './voice-settings.component.html' }) export class VoiceSettingsComponent { - private webrtcService = inject(WebRTCService); + private voiceConnection = inject(VoiceConnectionFacade); private voicePlayback = inject(VoicePlaybackService); + private electronBridge = inject(ElectronBridgeService); private platform = inject(PlatformService); readonly audioService = inject(NotificationAudioService); readonly isElectron = this.platform.isElectron; @@ -135,13 +122,13 @@ export class VoiceSettingsComponent { this.screenShareQuality.set(settings.screenShareQuality); this.askScreenShareQuality.set(settings.askScreenShareQuality); - if (this.noiseReduction() !== this.webrtcService.isNoiseReductionEnabled()) { - this.webrtcService.toggleNoiseReduction(this.noiseReduction()); + if (this.noiseReduction() !== this.voiceConnection.isNoiseReductionEnabled()) { + this.voiceConnection.toggleNoiseReduction(this.noiseReduction()); } // Apply persisted volume levels to the live audio pipelines - this.webrtcService.setInputVolume(this.inputVolume() / 100); - this.webrtcService.setOutputVolume(this.outputVolume() / 100); + this.voiceConnection.setInputVolume(this.inputVolume() / 100); + this.voiceConnection.setOutputVolume(this.outputVolume() / 100); this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); } @@ -171,7 +158,7 @@ export class VoiceSettingsComponent { const select = event.target as HTMLSelectElement; this.selectedOutputDevice.set(select.value); - this.webrtcService.setOutputVolume(this.outputVolume() / 100); + this.voiceConnection.setOutputVolume(this.outputVolume() / 100); this.saveVoiceSettings(); } @@ -179,7 +166,7 @@ export class VoiceSettingsComponent { const input = event.target as HTMLInputElement; this.inputVolume.set(parseInt(input.value, 10)); - this.webrtcService.setInputVolume(this.inputVolume() / 100); + this.voiceConnection.setInputVolume(this.inputVolume() / 100); this.saveVoiceSettings(); } @@ -187,7 +174,7 @@ export class VoiceSettingsComponent { const input = event.target as HTMLInputElement; this.outputVolume.set(parseInt(input.value, 10)); - this.webrtcService.setOutputVolume(this.outputVolume() / 100); + this.voiceConnection.setOutputVolume(this.outputVolume() / 100); this.voicePlayback.updateOutputVolume(this.outputVolume() / 100); this.saveVoiceSettings(); } @@ -197,7 +184,7 @@ export class VoiceSettingsComponent { const profile = select.value as 'low' | 'balanced' | 'high'; this.latencyProfile.set(profile); - this.webrtcService.setLatencyProfile(profile); + this.voiceConnection.setLatencyProfile(profile); this.saveVoiceSettings(); } @@ -205,7 +192,7 @@ export class VoiceSettingsComponent { const input = event.target as HTMLInputElement; this.audioBitrate.set(parseInt(input.value, 10)); - this.webrtcService.setAudioBitrate(this.audioBitrate()); + this.voiceConnection.setAudioBitrate(this.audioBitrate()); this.saveVoiceSettings(); } @@ -232,7 +219,7 @@ export class VoiceSettingsComponent { async onNoiseReductionChange(): Promise { this.noiseReduction.update((currentValue) => !currentValue); - await this.webrtcService.toggleNoiseReduction(this.noiseReduction()); + await this.voiceConnection.toggleNoiseReduction(this.noiseReduction()); this.saveVoiceSettings(); } @@ -249,9 +236,9 @@ export class VoiceSettingsComponent { async onHardwareAccelerationChange(event: Event): Promise { const input = event.target as HTMLInputElement; const enabled = !!input.checked; - const api = this.getElectronApi(); + const api = this.electronBridge.getApi(); - if (!api?.setDesktopSettings) { + if (!api) { this.hardwareAcceleration.set(enabled); return; } @@ -266,17 +253,17 @@ export class VoiceSettingsComponent { } async restartDesktopApp(): Promise { - const api = this.getElectronApi(); + const api = this.electronBridge.getApi(); - if (api?.relaunchApp) { + if (api) { await api.relaunchApp(); } } private async loadDesktopSettings(): Promise { - const api = this.getElectronApi(); + const api = this.electronBridge.getApi(); - if (!api?.getDesktopSettings) { + if (!api) { return; } @@ -291,10 +278,4 @@ export class VoiceSettingsComponent { this.hardwareAcceleration.set(snapshot.hardwareAcceleration); this.hardwareAccelerationRestartRequired.set(snapshot.restartRequired); } - - private getElectronApi(): DesktopSettingsElectronApi | null { - return typeof window !== 'undefined' - ? (window as DesktopSettingsWindow).electronAPI ?? null - : null; - } } diff --git a/src/app/features/settings/settings.component.ts b/src/app/features/settings/settings.component.ts index 69aaaec..7b90a0b 100644 --- a/src/app/features/settings/settings.component.ts +++ b/src/app/features/settings/settings.component.ts @@ -23,8 +23,8 @@ import { lucideAudioLines } from '@ng-icons/lucide'; -import { ServerDirectoryService } from '../../core/services/server-directory.service'; -import { WebRTCService } from '../../core/services/webrtc.service'; +import { ServerDirectoryFacade } from '../../domains/server-directory'; +import { VoiceConnectionFacade } from '../../domains/voice-connection'; import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service'; import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants'; @@ -56,8 +56,8 @@ import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../ * Settings page for managing signaling servers and connection preferences. */ export class SettingsComponent implements OnInit { - private serverDirectory = inject(ServerDirectoryService); - private webrtcService = inject(WebRTCService); + private serverDirectory = inject(ServerDirectoryFacade); + private voiceConnection = inject(VoiceConnectionFacade); private router = inject(Router); audioService = inject(NotificationAudioService); @@ -184,8 +184,8 @@ export class SettingsComponent implements OnInit { } // Sync the live WebRTC state with the persisted preference - if (this.noiseReduction !== this.webrtcService.isNoiseReductionEnabled()) { - this.webrtcService.toggleNoiseReduction(this.noiseReduction); + if (this.noiseReduction !== this.voiceConnection.isNoiseReductionEnabled()) { + this.voiceConnection.toggleNoiseReduction(this.noiseReduction); } } @@ -217,6 +217,6 @@ export class SettingsComponent implements OnInit { noiseReduction: this.noiseReduction }) ); - await this.webrtcService.toggleNoiseReduction(this.noiseReduction); + await this.voiceConnection.toggleNoiseReduction(this.noiseReduction); } } diff --git a/src/app/features/shell/title-bar.component.ts b/src/app/features/shell/title-bar.component.ts index dc9f0f7..f520403 100644 --- a/src/app/features/shell/title-bar.component.ts +++ b/src/app/features/shell/title-bar.component.ts @@ -26,23 +26,14 @@ import { } from '../../store/rooms/rooms.selectors'; import { RoomsActions } from '../../store/rooms/rooms.actions'; import { selectCurrentUser } from '../../store/users/users.selectors'; -import { ServerDirectoryService } from '../../core/services/server-directory.service'; -import { WebRTCService } from '../../core/services/webrtc.service'; -import { PlatformService } from '../../core/services/platform.service'; +import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { ServerDirectoryFacade } from '../../domains/server-directory'; +import { PlatformService } from '../../core/platform'; import { STORAGE_KEY_CURRENT_USER_ID } from '../../core/constants'; import { LeaveServerDialogComponent } from '../../shared'; import { Room } from '../../core/models/index'; -interface WindowControlsAPI { - minimizeWindow?: () => void; - maximizeWindow?: () => void; - closeWindow?: () => void; -} - -type ElectronWindow = Window & { - electronAPI?: WindowControlsAPI; -}; - @Component({ selector: 'app-title-bar', standalone: true, @@ -67,13 +58,14 @@ type ElectronWindow = Window & { */ export class TitleBarComponent { private store = inject(Store); - private serverDirectory = inject(ServerDirectoryService); + private electronBridge = inject(ElectronBridgeService); + private serverDirectory = inject(ServerDirectoryFacade); private router = inject(Router); - private webrtc = inject(WebRTCService); + private webrtc = inject(RealtimeSessionFacade); private platform = inject(PlatformService); - private getWindowControlsApi(): WindowControlsAPI | undefined { - return (window as ElectronWindow).electronAPI; + private getWindowControlsApi() { + return this.electronBridge.getApi(); } isElectron = computed(() => this.platform.isElectron); diff --git a/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts index cc5fd5b..5a6851b 100644 --- a/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts +++ b/src/app/features/voice/floating-voice-controls/floating-voice-controls.component.ts @@ -19,10 +19,10 @@ import { lucideArrowLeft } from '@ng-icons/lucide'; -import { WebRTCService } from '../../../core/services/webrtc.service'; -import { VoiceSessionService } from '../../../core/services/voice-session.service'; -import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage'; -import { ScreenShareQuality } from '../../../core/services/webrtc'; +import { VoiceSessionFacade } from '../../../domains/voice-session'; +import { VoiceConnectionFacade } from '../../../domains/voice-connection'; +import { ScreenShareFacade, ScreenShareQuality } from '../../../domains/screen-share'; +import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../domains/voice-session'; import { UsersActions } from '../../../store/users/users.actions'; import { selectCurrentUser } from '../../../store/users/users.selectors'; import { DebugConsoleComponent, ScreenShareQualityDialogComponent } from '../../../shared'; @@ -55,10 +55,11 @@ import { VoicePlaybackService } from '../voice-controls/services/voice-playback. * Provides mute, deafen, screen-share, and disconnect actions in a compact overlay. */ export class FloatingVoiceControlsComponent implements OnInit { - private webrtcService = inject(WebRTCService); - private voiceSessionService = inject(VoiceSessionService); - private voicePlayback = inject(VoicePlaybackService); - private store = inject(Store); + private readonly webrtcService = inject(VoiceConnectionFacade); + private readonly screenShareService = inject(ScreenShareFacade); + private readonly voiceSessionService = inject(VoiceSessionFacade); + private readonly voicePlayback = inject(VoicePlaybackService); + private readonly store = inject(Store); currentUser = this.store.selectSignal(selectCurrentUser); @@ -69,7 +70,7 @@ export class FloatingVoiceControlsComponent implements OnInit { isConnected = computed(() => this.webrtcService.isVoiceConnected()); isMuted = signal(false); isDeafened = signal(false); - isScreenSharing = this.webrtcService.isScreenSharing; + isScreenSharing = this.screenShareService.isScreenSharing; includeSystemAudio = signal(false); screenShareQuality = signal('balanced'); askScreenShareQuality = signal(true); @@ -143,7 +144,7 @@ export class FloatingVoiceControlsComponent implements OnInit { /** Toggle screen sharing on or off. */ async toggleScreenShare(): Promise { if (this.isScreenSharing()) { - this.webrtcService.stopScreenShare(); + this.screenShareService.stopScreenShare(); } else { this.syncScreenShareSettings(); @@ -186,7 +187,7 @@ export class FloatingVoiceControlsComponent implements OnInit { // Stop screen sharing if active if (this.isScreenSharing()) { - this.webrtcService.stopScreenShare(); + this.screenShareService.stopScreenShare(); } // Disable voice @@ -281,7 +282,7 @@ export class FloatingVoiceControlsComponent implements OnInit { private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise { try { - await this.webrtcService.startScreenShare({ + await this.screenShareService.startScreenShare({ includeSystemAudio: this.includeSystemAudio(), quality }); diff --git a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts index 0fb5216..5b96256 100644 --- a/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts +++ b/src/app/features/voice/screen-share-viewer/screen-share-viewer.component.ts @@ -19,7 +19,7 @@ import { lucideMonitor } from '@ng-icons/lucide'; -import { WebRTCService } from '../../../core/services/webrtc.service'; +import { ScreenShareFacade } from '../../../domains/screen-share'; import { selectOnlineUsers } from '../../../store/users/users.selectors'; import { User } from '../../../core/models/index'; import { DEFAULT_VOLUME } from '../../../core/constants'; @@ -46,9 +46,9 @@ import { VoicePlaybackService } from '../voice-controls/services/voice-playback. export class ScreenShareViewerComponent implements OnDestroy { @ViewChild('screenVideo') videoRef!: ElementRef; - private webrtcService = inject(WebRTCService); - private voicePlayback = inject(VoicePlaybackService); - private store = inject(Store); + private readonly screenShareService = inject(ScreenShareFacade); + private readonly voicePlayback = inject(VoicePlaybackService); + private readonly store = inject(Store); private remoteStreamSub: Subscription | null = null; onlineUsers = this.store.selectSignal(selectOnlineUsers); @@ -69,7 +69,7 @@ export class ScreenShareViewerComponent implements OnDestroy { if (!userId) return; - const stream = this.webrtcService.getRemoteScreenShareStream(userId); + const stream = this.screenShareService.getRemoteScreenShareStream(userId); const user = this.onlineUsers().find((onlineUser) => onlineUser.id === userId || onlineUser.oderId === userId) || null; if (stream && stream.getVideoTracks().length > 0) { @@ -94,7 +94,7 @@ export class ScreenShareViewerComponent implements OnDestroy { constructor() { // React to screen share stream changes effect(() => { - const screenStream = this.webrtcService.screenStream(); + const screenStream = this.screenShareService.screenStream(); if (screenStream && this.videoRef) { // Local share: always mute to avoid audio feedback @@ -129,7 +129,7 @@ export class ScreenShareViewerComponent implements OnDestroy { } // Also check if the stream's video tracks are still available - const stream = this.webrtcService.getRemoteScreenShareStream(watchingId); + const stream = this.screenShareService.getRemoteScreenShareStream(watchingId); const hasActiveVideo = stream?.getVideoTracks().some(track => track.readyState === 'live'); if (!hasActiveVideo) { @@ -141,12 +141,12 @@ export class ScreenShareViewerComponent implements OnDestroy { // Subscribe to remote streams with video (screen shares) // NOTE: We no longer auto-display remote streams. Users must click "Live" to view. // This subscription is kept for potential future use (e.g., tracking available streams) - this.remoteStreamSub = this.webrtcService.onRemoteStream.subscribe(({ peerId }) => { + this.remoteStreamSub = this.screenShareService.onRemoteStream.subscribe(({ peerId }) => { if (peerId !== this.watchingUserId() || this.isLocalShare()) { return; } - const stream = this.webrtcService.getRemoteScreenShareStream(peerId); + const stream = this.screenShareService.getRemoteScreenShareStream(peerId); const hasActiveVideo = stream?.getVideoTracks().some((track) => track.readyState === 'live') ?? false; if (!hasActiveVideo) { @@ -202,7 +202,7 @@ export class ScreenShareViewerComponent implements OnDestroy { /** Stop the local screen share and reset viewer state. */ stopSharing(): void { - this.webrtcService.stopScreenShare(); + this.screenShareService.stopScreenShare(); this.activeScreenSharer.set(null); this.hasStream.set(false); this.isLocalShare.set(false); diff --git a/src/app/features/voice/screen-share-workspace/screen-share-workspace.component.ts b/src/app/features/voice/screen-share-workspace/screen-share-workspace.component.ts index 3796c91..b7f63d2 100644 --- a/src/app/features/voice/screen-share-workspace/screen-share-workspace.component.ts +++ b/src/app/features/voice/screen-share-workspace/screen-share-workspace.component.ts @@ -30,11 +30,19 @@ import { } from '@ng-icons/lucide'; import { User } from '../../../core/models'; -import { VoiceSessionService } from '../../../core/services/voice-session.service'; -import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage'; -import { VoiceWorkspacePosition, VoiceWorkspaceService } from '../../../core/services/voice-workspace.service'; -import { ScreenShareQuality, ScreenShareStartOptions } from '../../../core/services/webrtc'; -import { WebRTCService } from '../../../core/services/webrtc.service'; +import { + loadVoiceSettingsFromStorage, + saveVoiceSettingsToStorage, + VoiceSessionFacade, + VoiceWorkspacePosition, + VoiceWorkspaceService +} from '../../../domains/voice-session'; +import { VoiceConnectionFacade } from '../../../domains/voice-connection'; +import { + ScreenShareFacade, + ScreenShareQuality, + ScreenShareStartOptions +} from '../../../domains/screen-share'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { UsersActions } from '../../../store/users/users.actions'; import { selectCurrentUser, selectOnlineUsers } from '../../../store/users/users.selectors'; @@ -79,10 +87,11 @@ export class ScreenShareWorkspaceComponent { private readonly destroyRef = inject(DestroyRef); private readonly elementRef = inject>(ElementRef); private readonly store = inject(Store); - private readonly webrtc = inject(WebRTCService); + private readonly webrtc = inject(VoiceConnectionFacade); + private readonly screenShare = inject(ScreenShareFacade); private readonly voicePlayback = inject(VoicePlaybackService); private readonly screenSharePlayback = inject(ScreenSharePlaybackService); - private readonly voiceSession = inject(VoiceSessionService); + private readonly voiceSession = inject(VoiceSessionFacade); private readonly voiceWorkspace = inject(VoiceWorkspaceService); private readonly remoteStreamRevision = signal(0); @@ -116,7 +125,7 @@ export class ScreenShareWorkspaceComponent { readonly isConnected = computed(() => this.webrtc.isVoiceConnected()); readonly isMuted = computed(() => this.webrtc.isMuted()); readonly isDeafened = computed(() => this.webrtc.isDeafened()); - readonly isScreenSharing = computed(() => this.webrtc.isScreenSharing()); + readonly isScreenSharing = computed(() => this.screenShare.isScreenSharing()); readonly includeSystemAudio = signal(false); readonly screenShareQuality = signal('balanced'); @@ -167,7 +176,7 @@ export class ScreenShareWorkspaceComponent { } const shares: ScreenShareWorkspaceStreamItem[] = []; - const localStream = this.webrtc.screenStream(); + const localStream = this.screenShare.screenStream(); const localPeerKey = this.getUserPeerKey(me); if (localStream && localPeerKey) { @@ -306,18 +315,18 @@ export class ScreenShareWorkspaceComponent { this.destroyRef.onDestroy(() => { this.clearHeaderHideTimeout(); this.cleanupObservedRemoteStreams(); - this.webrtc.syncRemoteScreenShareRequests([], false); + this.screenShare.syncRemoteScreenShareRequests([], false); this.screenSharePlayback.teardownAll(); }); - this.webrtc.onRemoteStream + this.screenShare.onRemoteStream .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ peerId }) => { this.observeRemoteStream(peerId); this.bumpRemoteStreamRevision(); }); - this.webrtc.onPeerDisconnected + this.screenShare.onPeerDisconnected .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => this.bumpRemoteStreamRevision()); @@ -363,7 +372,7 @@ export class ScreenShareWorkspaceComponent { .filter((peerKey): peerKey is string => !!peerKey && peerKey !== currentUserPeerKey) )); - this.webrtc.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares); + this.screenShare.syncRemoteScreenShareRequests(peerKeys, shouldConnectRemoteShares); if (!shouldConnectRemoteShares) { this.screenSharePlayback.teardownAll(); @@ -614,7 +623,7 @@ export class ScreenShareWorkspaceComponent { async toggleScreenShare(): Promise { if (this.isScreenSharing()) { - this.webrtc.stopScreenShare(); + this.screenShare.stopScreenShare(); return; } @@ -656,7 +665,7 @@ export class ScreenShareWorkspaceComponent { }); if (this.isScreenSharing()) { - this.webrtc.stopScreenShare(); + this.screenShare.stopScreenShare(); } this.webrtc.disableVoice(); @@ -773,7 +782,7 @@ export class ScreenShareWorkspaceComponent { }; try { - await this.webrtc.startScreenShare(options); + await this.screenShare.startScreenShare(options); this.voiceWorkspace.open(null); } catch { @@ -791,7 +800,7 @@ export class ScreenShareWorkspaceComponent { ); for (const peerKey of peerKeys) { - const stream = this.webrtc.getRemoteScreenShareStream(peerKey); + const stream = this.screenShare.getRemoteScreenShareStream(peerKey); if (stream && this.hasActiveVideo(stream)) { return { peerKey, stream }; @@ -848,7 +857,7 @@ export class ScreenShareWorkspaceComponent { } private observeRemoteStream(peerKey: string): void { - const stream = this.webrtc.getRemoteScreenShareStream(peerKey); + const stream = this.screenShare.getRemoteScreenShareStream(peerKey); const existing = this.observedRemoteStreams.get(peerKey); if (!stream) { diff --git a/src/app/features/voice/voice-controls/services/voice-playback.service.ts b/src/app/features/voice/voice-controls/services/voice-playback.service.ts index c412baa..4967aea 100644 --- a/src/app/features/voice/voice-controls/services/voice-playback.service.ts +++ b/src/app/features/voice/voice-controls/services/voice-playback.service.ts @@ -3,8 +3,9 @@ import { effect, inject } from '@angular/core'; -import { WebRTCService } from '../../../../core/services/webrtc.service'; import { STORAGE_KEY_USER_VOLUMES } from '../../../../core/constants'; +import { ScreenShareFacade } from '../../../../domains/screen-share'; +import { VoiceConnectionFacade } from '../../../../domains/voice-connection'; export interface PlaybackOptions { isConnected: boolean; @@ -33,7 +34,8 @@ interface PeerAudioPipeline { @Injectable({ providedIn: 'root' }) export class VoicePlaybackService { - private webrtc = inject(WebRTCService); + private readonly voiceConnection = inject(VoiceConnectionFacade); + private readonly screenShare = inject(ScreenShareFacade); private peerPipelines = new Map(); private pendingRemoteStreams = new Map(); @@ -50,20 +52,20 @@ export class VoicePlaybackService { this.loadPersistedVolumes(); effect(() => { - this.captureEchoSuppressed = this.webrtc.isScreenShareRemotePlaybackSuppressed(); + this.captureEchoSuppressed = this.screenShare.isScreenShareRemotePlaybackSuppressed(); this.recalcAllGains(); }); effect(() => { - this.temporaryOutputDeviceId = this.webrtc.forceDefaultRemotePlaybackOutput() + this.temporaryOutputDeviceId = this.screenShare.forceDefaultRemotePlaybackOutput() ? 'default' : null; void this.applyEffectiveOutputDeviceToAllPipelines(); }); - this.webrtc.onRemoteStream.subscribe(({ peerId }) => { - const voiceStream = this.webrtc.getRemoteVoiceStream(peerId); + this.voiceConnection.onRemoteStream.subscribe(({ peerId }) => { + const voiceStream = this.voiceConnection.getRemoteVoiceStream(peerId); if (!voiceStream) { this.removeRemoteAudio(peerId); @@ -73,14 +75,14 @@ export class VoicePlaybackService { this.handleRemoteStream(peerId, voiceStream, this.buildPlaybackOptions()); }); - this.webrtc.onVoiceConnected.subscribe(() => { + this.voiceConnection.onVoiceConnected.subscribe(() => { const options = this.buildPlaybackOptions(true); this.playPendingStreams(options); this.ensureAllRemoteStreamsPlaying(options); }); - this.webrtc.onPeerDisconnected.subscribe((peerId) => { + this.voiceConnection.onPeerDisconnected.subscribe((peerId) => { this.removeRemoteAudio(peerId); }); } @@ -122,10 +124,10 @@ export class VoicePlaybackService { if (!options.isConnected) return; - const peers = this.webrtc.getConnectedPeers(); + const peers = this.voiceConnection.getConnectedPeers(); for (const peerId of peers) { - const stream = this.webrtc.getRemoteVoiceStream(peerId); + const stream = this.voiceConnection.getRemoteVoiceStream(peerId); if (stream && this.hasAudio(stream)) { const trackedRaw = this.rawRemoteStreams.get(peerId); @@ -181,7 +183,7 @@ export class VoicePlaybackService { this.pendingRemoteStreams.clear(); } - private buildPlaybackOptions(forceConnected = this.webrtc.isVoiceConnected()): PlaybackOptions { + private buildPlaybackOptions(forceConnected = this.voiceConnection.isVoiceConnected()): PlaybackOptions { return { isConnected: forceConnected, outputVolume: this.masterVolume, diff --git a/src/app/features/voice/voice-controls/voice-controls.component.ts b/src/app/features/voice/voice-controls/voice-controls.component.ts index 2d163a1..016b66e 100644 --- a/src/app/features/voice/voice-controls/voice-controls.component.ts +++ b/src/app/features/voice/voice-controls/voice-controls.component.ts @@ -22,15 +22,14 @@ import { lucideHeadphones } from '@ng-icons/lucide'; -import { WebRTCService } from '../../../core/services/webrtc.service'; -import { VoiceSessionService } from '../../../core/services/voice-session.service'; -import { VoiceActivityService } from '../../../core/services/voice-activity.service'; +import { VoiceSessionFacade } from '../../../domains/voice-session'; +import { VoiceActivityService, VoiceConnectionFacade } from '../../../domains/voice-connection'; +import { ScreenShareFacade, ScreenShareQuality } from '../../../domains/screen-share'; import { UsersActions } from '../../../store/users/users.actions'; import { selectCurrentUser } from '../../../store/users/users.selectors'; import { selectCurrentRoom } from '../../../store/rooms/rooms.selectors'; import { SettingsModalService } from '../../../core/services/settings-modal.service'; -import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../core/services/voice-settings.storage'; -import { ScreenShareQuality } from '../../../core/services/webrtc'; +import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../../domains/voice-session'; import { DebugConsoleComponent, ScreenShareQualityDialogComponent, @@ -69,12 +68,13 @@ interface AudioDevice { templateUrl: './voice-controls.component.html' }) export class VoiceControlsComponent implements OnInit, OnDestroy { - private webrtcService = inject(WebRTCService); - private voiceSessionService = inject(VoiceSessionService); - private voiceActivity = inject(VoiceActivityService); - private voicePlayback = inject(VoicePlaybackService); - private store = inject(Store); - private settingsModal = inject(SettingsModalService); + private readonly webrtcService = inject(VoiceConnectionFacade); + private readonly screenShareService = inject(ScreenShareFacade); + private readonly voiceSessionService = inject(VoiceSessionFacade); + private readonly voiceActivity = inject(VoiceActivityService); + private readonly voicePlayback = inject(VoicePlaybackService); + private readonly store = inject(Store); + private readonly settingsModal = inject(SettingsModalService); currentUser = this.store.selectSignal(selectCurrentUser); currentRoom = this.store.selectSignal(selectCurrentRoom); @@ -84,7 +84,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { connectionErrorMessage = computed(() => this.webrtcService.connectionErrorMessage()); isMuted = signal(false); isDeafened = signal(false); - isScreenSharing = this.webrtcService.isScreenSharing; + isScreenSharing = this.screenShareService.isScreenSharing; showSettings = signal(false); inputDevices = signal([]); @@ -251,7 +251,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { // Stop screen sharing if active if (this.isScreenSharing()) { - this.webrtcService.stopScreenShare(); + this.screenShareService.stopScreenShare(); } // Untrack local mic from voice-activity visualisation @@ -366,7 +366,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { async toggleScreenShare(): Promise { if (this.isScreenSharing()) { - this.webrtcService.stopScreenShare(); + this.screenShareService.stopScreenShare(); } else { this.syncScreenShareSettings(); @@ -533,7 +533,7 @@ export class VoiceControlsComponent implements OnInit, OnDestroy { private async startScreenShareWithOptions(quality: ScreenShareQuality): Promise { try { - await this.webrtcService.startScreenShare({ + await this.screenShareService.startScreenShare({ includeSystemAudio: this.includeSystemAudio(), quality }); diff --git a/src/app/core/services/browser-database.service.ts b/src/app/infrastructure/persistence/browser-database.service.ts similarity index 69% rename from src/app/core/services/browser-database.service.ts rename to src/app/infrastructure/persistence/browser-database.service.ts index 12ffd4c..b8f2725 100644 --- a/src/app/core/services/browser-database.service.ts +++ b/src/app/infrastructure/persistence/browser-database.service.ts @@ -8,7 +8,7 @@ import { Room, Reaction, BanEntry -} from '../models/index'; +} from '../../core/models/index'; /** IndexedDB database name for the MetoYou application. */ const DATABASE_NAME = 'metoyou'; @@ -259,15 +259,15 @@ export class BrowserDatabaseService { async isUserBanned(userId: string, roomId: string): Promise { const activeBans = await this.getBansForRoom(roomId); - return activeBans.some((ban) => ban.userId === userId || (!ban.userId && ban.oderId === userId)); + return activeBans.some((ban) => ban.oderId === userId); } - /** Persist an attachment metadata record. */ + /** Persist attachment metadata associated with a chat message. */ async saveAttachment(attachment: ChatAttachmentMeta): Promise { await this.put(STORE_ATTACHMENTS, attachment); } - /** Return all attachment records for a message. */ + /** Return all attachment records associated with a message. */ async getAttachmentsForMessage(messageId: string): Promise { return this.getAllFromIndex(STORE_ATTACHMENTS, 'messageId', messageId); } @@ -277,15 +277,11 @@ export class BrowserDatabaseService { return this.getAll(STORE_ATTACHMENTS); } - /** Delete all attachment records for a message. */ + /** Delete every attachment record for a specific message. */ async deleteAttachmentsForMessage(messageId: string): Promise { const attachments = await this.getAllFromIndex( STORE_ATTACHMENTS, 'messageId', messageId ); - - if (attachments.length === 0) - return; - const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite'); for (const attachment of attachments) { @@ -295,7 +291,7 @@ export class BrowserDatabaseService { await this.awaitTransaction(transaction); } - /** Wipe every object store, removing all persisted data. */ + /** Wipe all persisted data in every object store. */ async clearAllData(): Promise { const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite'); @@ -306,153 +302,140 @@ export class BrowserDatabaseService { await this.awaitTransaction(transaction); } - // Private helpers - thin wrappers around IndexedDB - - /** - * Open (or upgrade) the IndexedDB database and create any missing - * object stores. - */ private openDatabase(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); - request.onupgradeneeded = () => { - const database = request.result; - - if (!database.objectStoreNames.contains(STORE_MESSAGES)) { - const messagesStore = database.createObjectStore(STORE_MESSAGES, { keyPath: 'id' }); - - messagesStore.createIndex('roomId', 'roomId', { unique: false }); - } - - if (!database.objectStoreNames.contains(STORE_USERS)) { - database.createObjectStore(STORE_USERS, { keyPath: 'id' }); - } - - if (!database.objectStoreNames.contains(STORE_ROOMS)) { - database.createObjectStore(STORE_ROOMS, { keyPath: 'id' }); - } - - if (!database.objectStoreNames.contains(STORE_REACTIONS)) { - const reactionsStore = database.createObjectStore(STORE_REACTIONS, { keyPath: 'id' }); - - reactionsStore.createIndex('messageId', 'messageId', { unique: false }); - } - - if (!database.objectStoreNames.contains(STORE_BANS)) { - const bansStore = database.createObjectStore(STORE_BANS, { keyPath: 'oderId' }); - - bansStore.createIndex('roomId', 'roomId', { unique: false }); - } - - if (!database.objectStoreNames.contains(STORE_META)) { - database.createObjectStore(STORE_META, { keyPath: 'id' }); - } - - if (!database.objectStoreNames.contains(STORE_ATTACHMENTS)) { - const attachmentsStore = database.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' }); - - attachmentsStore.createIndex('messageId', 'messageId', { unique: false }); - } - }; - - request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); + request.onupgradeneeded = () => this.setupSchema(request.result); + request.onsuccess = () => resolve(request.result); }); } - /** Create an IndexedDB transaction on one or more stores. */ - private createTransaction( - stores: string | string[], - mode: IDBTransactionMode = 'readonly' - ): IDBTransaction { - return this.getDatabase().transaction(stores, mode); + private setupSchema(database: IDBDatabase): void { + const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' }); + + this.ensureIndex(messagesStore, 'roomId', 'roomId'); + this.ensureIndex(messagesStore, 'timestamp', 'timestamp'); + + this.ensureStore(database, STORE_USERS, { keyPath: 'id' }); + + const roomsStore = this.ensureStore(database, STORE_ROOMS, { keyPath: 'id' }); + + this.ensureIndex(roomsStore, 'timestamp', 'timestamp'); + + const reactionsStore = this.ensureStore(database, STORE_REACTIONS, { keyPath: 'id' }); + + this.ensureIndex(reactionsStore, 'messageId', 'messageId'); + this.ensureIndex(reactionsStore, 'userId', 'userId'); + + const bansStore = this.ensureStore(database, STORE_BANS, { keyPath: 'oderId' }); + + this.ensureIndex(bansStore, 'roomId', 'roomId'); + this.ensureIndex(bansStore, 'expiresAt', 'expiresAt'); + + this.ensureStore(database, STORE_META, { keyPath: 'id' }); + + const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' }); + + this.ensureIndex(attachmentsStore, 'messageId', 'messageId'); } - private getDatabase(): IDBDatabase { - if (!this.database) { - throw new Error('Browser database is not initialized'); + private ensureStore( + database: IDBDatabase, + name: string, + options?: IDBObjectStoreParameters + ): IDBObjectStore { + if (database.objectStoreNames.contains(name)) { + return (database.transaction(name, 'readonly') as IDBTransaction).objectStore(name); } - return this.database; + return database.createObjectStore(name, options); + } + + private ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void { + if (!store.indexNames.contains(name)) { + store.createIndex(name, keyPath, { unique: false }); + } + } + + private createTransaction( + storeNames: string | string[], + mode: IDBTransactionMode + ): IDBTransaction { + if (!this.database) { + throw new Error('Database has not been initialized'); + } + + return this.database.transaction(storeNames, mode); } - /** Wrap a transaction's completion event as a Promise. */ private awaitTransaction(transaction: IDBTransaction): Promise { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error); }); } - /** Retrieve a single record by primary key. */ - private get(storeName: string, key: IDBValidKey): Promise { - return new Promise((resolve, reject) => { - const transaction = this.createTransaction(storeName); - const request = transaction.objectStore(storeName).get(key); + private async put(storeName: string, value: unknown): Promise { + const transaction = this.createTransaction(storeName, 'readwrite'); + transaction.objectStore(storeName).put(value); + + await this.awaitTransaction(transaction); + } + + private async get(storeName: string, key: IDBValidKey): Promise { + const transaction = this.createTransaction(storeName, 'readonly'); + const request = transaction.objectStore(storeName).get(key); + + return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result as T | undefined); request.onerror = () => reject(request.error); }); } - /** Retrieve every record from an object store. */ - private getAll(storeName: string): Promise { - return new Promise((resolve, reject) => { - const transaction = this.createTransaction(storeName); - const request = transaction.objectStore(storeName).getAll(); + private async getAll(storeName: string): Promise { + const transaction = this.createTransaction(storeName, 'readonly'); + const request = transaction.objectStore(storeName).getAll(); - request.onsuccess = () => resolve(request.result as T[]); + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve((request.result as T[]) ?? []); request.onerror = () => reject(request.error); }); } - /** Retrieve all records from an index that match a key. */ - private getAllFromIndex( + private async getAllFromIndex( storeName: string, indexName: string, - key: IDBValidKey + query: IDBValidKey | IDBKeyRange ): Promise { - return new Promise((resolve, reject) => { - const transaction = this.createTransaction(storeName); - const index = transaction.objectStore(storeName).index(indexName); - const request = index.getAll(key); + const transaction = this.createTransaction(storeName, 'readonly'); + const request = transaction.objectStore(storeName) + .index(indexName) + .getAll(query); - request.onsuccess = () => resolve(request.result as T[]); + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve((request.result as T[]) ?? []); request.onerror = () => reject(request.error); }); } - /** Insert or update a record in the given object store. */ - private put(storeName: string, value: T): Promise { - return new Promise((resolve, reject) => { - const transaction = this.createTransaction(storeName, 'readwrite'); + private async deleteRecord(storeName: string, key: IDBValidKey): Promise { + const transaction = this.createTransaction(storeName, 'readwrite'); - transaction.objectStore(storeName).put(value); - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }); - } + transaction.objectStore(storeName).delete(key); - /** Delete a record by primary key. */ - private deleteRecord(storeName: string, key: IDBValidKey): Promise { - return new Promise((resolve, reject) => { - const transaction = this.createTransaction(storeName, 'readwrite'); - - transaction.objectStore(storeName).delete(key); - transaction.oncomplete = () => resolve(); - transaction.onerror = () => reject(transaction.error); - }); + await this.awaitTransaction(transaction); } private normaliseMessage(message: Message): Message { - if (!message.isDeleted) - return message; + if (message.content === DELETED_MESSAGE_CONTENT) { + return { ...message, + reactions: [] }; + } - return { - ...message, - content: DELETED_MESSAGE_CONTENT, - reactions: [] - }; + return message; } } diff --git a/src/app/core/services/database.service.ts b/src/app/infrastructure/persistence/database.service.ts similarity index 94% rename from src/app/core/services/database.service.ts rename to src/app/infrastructure/persistence/database.service.ts index f68bbdc..c558282 100644 --- a/src/app/core/services/database.service.ts +++ b/src/app/infrastructure/persistence/database.service.ts @@ -11,8 +11,8 @@ import { Reaction, BanEntry, ChatAttachmentMeta -} from '../models/index'; -import { PlatformService } from './platform.service'; +} from '../../core/models/index'; +import { PlatformService } from '../../core/platform'; import { BrowserDatabaseService } from './browser-database.service'; import { ElectronDatabaseService } from './electron-database.service'; @@ -20,10 +20,10 @@ import { ElectronDatabaseService } from './electron-database.service'; * Facade database service that transparently delegates to the correct * storage backend based on the runtime platform. * - * - **Electron** → SQLite via {@link ElectronDatabaseService} (IPC to main process). - * - **Browser** → IndexedDB via {@link BrowserDatabaseService}. + * - Electron -> SQLite via {@link ElectronDatabaseService} (IPC to main process). + * - Browser -> IndexedDB via {@link BrowserDatabaseService}. * - * All consumers inject `DatabaseService` - the underlying storage engine + * All consumers inject `DatabaseService`; the underlying storage engine * is selected automatically. */ @Injectable({ providedIn: 'root' }) diff --git a/src/app/core/services/electron-database.service.ts b/src/app/infrastructure/persistence/electron-database.service.ts similarity index 87% rename from src/app/core/services/electron-database.service.ts rename to src/app/infrastructure/persistence/electron-database.service.ts index fad4c46..eafc622 100644 --- a/src/app/core/services/electron-database.service.ts +++ b/src/app/infrastructure/persistence/electron-database.service.ts @@ -1,35 +1,28 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { Message, User, Room, Reaction, BanEntry -} from '../models/index'; - -/** CQRS API exposed by the Electron preload script via `contextBridge`. */ -interface ElectronAPI { - command(command: { type: string; payload: unknown }): Promise; - query(query: { type: string; payload: unknown }): Promise; -} +} from '../../core/models/index'; +import type { ElectronApi } from '../../core/platform/electron/electron-api.models'; +import { ElectronBridgeService } from '../../core/platform/electron/electron-bridge.service'; /** * Database service for the Electron (desktop) runtime. * - * The SQLite database is managed by TypeORM in the Electron **main process** - * (`electron/main.ts`). This service is a thin CQRS IPC client that dispatches - * structured command/query objects through the unified `cqrs:command` and - * `cqrs:query` channels exposed by the preload script. - * - * No initialisation IPC call is needed - the database is initialised and - * migrations are run in main.ts before the renderer window is created. + * The SQLite database is managed by TypeORM in the Electron main process. + * This service is a thin CQRS IPC client that dispatches structured + * command/query objects through the unified preload channels. */ @Injectable({ providedIn: 'root' }) export class ElectronDatabaseService { + private readonly electronBridge = inject(ElectronBridgeService); + /** Shorthand accessor for the preload-exposed CQRS API. */ - private get api(): ElectronAPI { - // eslint-disable-next-line - return (window as any).electronAPI as ElectronAPI; + private get api(): ElectronApi { + return this.electronBridge.requireApi(); } /** diff --git a/src/app/infrastructure/persistence/index.ts b/src/app/infrastructure/persistence/index.ts new file mode 100644 index 0000000..cd4e1d2 --- /dev/null +++ b/src/app/infrastructure/persistence/index.ts @@ -0,0 +1 @@ +export * from './database.service'; diff --git a/src/app/infrastructure/realtime/index.ts b/src/app/infrastructure/realtime/index.ts new file mode 100644 index 0000000..c31875a --- /dev/null +++ b/src/app/infrastructure/realtime/index.ts @@ -0,0 +1,18 @@ +export { WebRTCService } from './realtime-session.service'; +export * from './realtime.constants'; +export * from './realtime.types'; +export * from './screen-share.config'; +export * from './logging/webrtc-logger'; +export * from './media/media.manager'; +export * from './media/noise-reduction.manager'; +export * from './media/screen-share.manager'; +export * from './media/voice-session-controller'; +export * from './signaling/server-signaling-coordinator'; +export * from './signaling/signaling-message-handler'; +export * from './signaling/server-membership-signaling-handler'; +export * from './signaling/signaling.manager'; +export * from './signaling/signaling-transport-handler'; +export * from './streams/peer-media-facade'; +export * from './streams/remote-screen-share-request-controller'; +export * from './state/webrtc-state-controller'; +export * from './peer-connection.manager'; diff --git a/src/app/core/services/debug-network-metrics.service.ts b/src/app/infrastructure/realtime/logging/debug-network-metrics.ts similarity index 100% rename from src/app/core/services/debug-network-metrics.service.ts rename to src/app/infrastructure/realtime/logging/debug-network-metrics.ts diff --git a/src/app/core/services/webrtc/webrtc-logger.ts b/src/app/infrastructure/realtime/logging/webrtc-logger.ts similarity index 100% rename from src/app/core/services/webrtc/webrtc-logger.ts rename to src/app/infrastructure/realtime/logging/webrtc-logger.ts diff --git a/src/app/core/services/webrtc/media.manager.ts b/src/app/infrastructure/realtime/media/media.manager.ts similarity index 98% rename from src/app/core/services/webrtc/media.manager.ts rename to src/app/infrastructure/realtime/media/media.manager.ts index bc247ed..afd914c 100644 --- a/src/app/core/services/webrtc/media.manager.ts +++ b/src/app/infrastructure/realtime/media/media.manager.ts @@ -5,9 +5,10 @@ * and optional RNNoise-based noise reduction. */ import { Subject } from 'rxjs'; -import { ChatEvent } from '../../models'; -import { WebRTCLogger } from './webrtc-logger'; -import { PeerData } from './webrtc.types'; +import { ChatEvent } from '../../../core/models'; +import { LatencyProfile } from '../realtime.constants'; +import { PeerData } from '../realtime.types'; +import { WebRTCLogger } from '../logging/webrtc-logger'; import { NoiseReductionManager } from './noise-reduction.manager'; import { TRACK_KIND_AUDIO, @@ -23,9 +24,8 @@ import { VOLUME_MAX, VOICE_HEARTBEAT_INTERVAL_MS, DEFAULT_DISPLAY_NAME, - P2P_TYPE_VOICE_STATE, - LatencyProfile -} from './webrtc.constants'; + P2P_TYPE_VOICE_STATE +} from '../realtime.constants'; /** * Callbacks the MediaManager needs from the owning service / peer manager. diff --git a/src/app/core/services/webrtc/noise-reduction.manager.ts b/src/app/infrastructure/realtime/media/noise-reduction.manager.ts similarity index 99% rename from src/app/core/services/webrtc/noise-reduction.manager.ts rename to src/app/infrastructure/realtime/media/noise-reduction.manager.ts index 0b71eb9..9f0d142 100644 --- a/src/app/core/services/webrtc/noise-reduction.manager.ts +++ b/src/app/infrastructure/realtime/media/noise-reduction.manager.ts @@ -14,7 +14,7 @@ * The manager is intentionally stateless w.r.t. Angular signals; * the owning MediaManager / WebRTCService drives signals. */ -import { WebRTCLogger } from './webrtc-logger'; +import { WebRTCLogger } from '../logging/webrtc-logger'; /** Name used to register / instantiate the AudioWorklet processor. */ const WORKLET_PROCESSOR_NAME = 'NoiseSuppressorWorklet'; diff --git a/src/app/core/services/webrtc/screen-share-platforms/browser-screen-share.capture.ts b/src/app/infrastructure/realtime/media/screen-share-platforms/browser-screen-share.capture.ts similarity index 93% rename from src/app/core/services/webrtc/screen-share-platforms/browser-screen-share.capture.ts rename to src/app/infrastructure/realtime/media/screen-share-platforms/browser-screen-share.capture.ts index c71c860..8f073a5 100644 --- a/src/app/core/services/webrtc/screen-share-platforms/browser-screen-share.capture.ts +++ b/src/app/infrastructure/realtime/media/screen-share-platforms/browser-screen-share.capture.ts @@ -1,6 +1,6 @@ -import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config'; -import { WebRTCLogger } from '../webrtc-logger'; -import { ScreenShareWindow } from './shared'; +import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../../screen-share.config'; +import { WebRTCLogger } from '../../logging/webrtc-logger'; +import { getElectronApi } from '../../../../core/platform/electron/get-electron-api'; export class BrowserScreenShareCapture { constructor(private readonly logger: WebRTCLogger) {} @@ -86,7 +86,7 @@ export class BrowserScreenShareCapture { return false; } - return !!(window as ScreenShareWindow).electronAPI + return !!getElectronApi() && /win/i.test(`${navigator.userAgent} ${navigator.platform}`); } } diff --git a/src/app/core/services/webrtc/screen-share-platforms/desktop-electron-screen-share.capture.ts b/src/app/infrastructure/realtime/media/screen-share-platforms/desktop-electron-screen-share.capture.ts similarity index 96% rename from src/app/core/services/webrtc/screen-share-platforms/desktop-electron-screen-share.capture.ts rename to src/app/infrastructure/realtime/media/screen-share-platforms/desktop-electron-screen-share.capture.ts index c8b5de1..98705b2 100644 --- a/src/app/core/services/webrtc/screen-share-platforms/desktop-electron-screen-share.capture.ts +++ b/src/app/infrastructure/realtime/media/screen-share-platforms/desktop-electron-screen-share.capture.ts @@ -1,6 +1,6 @@ -import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config'; -import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../webrtc.constants'; -import { WebRTCLogger } from '../webrtc-logger'; +import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../../screen-share.config'; +import { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../../realtime.constants'; +import { WebRTCLogger } from '../../logging/webrtc-logger'; import { DesktopSource, ElectronDesktopCaptureResult, diff --git a/src/app/core/services/webrtc/screen-share-platforms/linux-electron-screen-share.capture.ts b/src/app/infrastructure/realtime/media/screen-share-platforms/linux-electron-screen-share.capture.ts similarity index 99% rename from src/app/core/services/webrtc/screen-share-platforms/linux-electron-screen-share.capture.ts rename to src/app/infrastructure/realtime/media/screen-share-platforms/linux-electron-screen-share.capture.ts index 0240ffc..e924dad 100644 --- a/src/app/core/services/webrtc/screen-share-platforms/linux-electron-screen-share.capture.ts +++ b/src/app/infrastructure/realtime/media/screen-share-platforms/linux-electron-screen-share.capture.ts @@ -1,5 +1,5 @@ -import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config'; -import { WebRTCLogger } from '../webrtc-logger'; +import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../../screen-share.config'; +import { WebRTCLogger } from '../../logging/webrtc-logger'; import { LinuxScreenShareAudioRoutingInfo, LinuxScreenShareMonitorAudioChunkPayload, diff --git a/src/app/infrastructure/realtime/media/screen-share-platforms/shared.ts b/src/app/infrastructure/realtime/media/screen-share-platforms/shared.ts new file mode 100644 index 0000000..da03c93 --- /dev/null +++ b/src/app/infrastructure/realtime/media/screen-share-platforms/shared.ts @@ -0,0 +1,67 @@ +import type { + ElectronApi, + ElectronWindow, + LinuxScreenShareAudioRoutingInfo, + LinuxScreenShareMonitorAudioChunkPayload, + LinuxScreenShareMonitorAudioEndedPayload, + LinuxScreenShareMonitorCaptureInfo +} from '../../../../core/platform/electron/electron-api.models'; + +export interface DesktopSource { + id: string; + name: string; + thumbnail: string; +} + +export interface ElectronDesktopSourceSelection { + includeSystemAudio: boolean; + source: DesktopSource; +} + +export interface ElectronDesktopCaptureResult { + includeSystemAudio: boolean; + stream: MediaStream; +} + +export type { + LinuxScreenShareAudioRoutingInfo, + LinuxScreenShareMonitorAudioChunkPayload, + LinuxScreenShareMonitorAudioEndedPayload, + LinuxScreenShareMonitorCaptureInfo +}; + +export type ScreenShareElectronApi = Partial>; + +export type ElectronDesktopVideoConstraint = MediaTrackConstraints & { + mandatory: { + chromeMediaSource: 'desktop'; + chromeMediaSourceId: string; + maxWidth: number; + maxHeight: number; + maxFrameRate: number; + }; +}; + +export type ElectronDesktopAudioConstraint = MediaTrackConstraints & { + mandatory: { + chromeMediaSource: 'desktop'; + chromeMediaSourceId: string; + }; +}; + +export interface ElectronDesktopMediaStreamConstraints extends MediaStreamConstraints { + video: ElectronDesktopVideoConstraint; + audio?: false | ElectronDesktopAudioConstraint; +} + +export type ScreenShareWindow = ElectronWindow; diff --git a/src/app/core/services/webrtc/screen-share.manager.ts b/src/app/infrastructure/realtime/media/screen-share.manager.ts similarity index 97% rename from src/app/core/services/webrtc/screen-share.manager.ts rename to src/app/infrastructure/realtime/media/screen-share.manager.ts index a6f4e16..2c4abbf 100644 --- a/src/app/core/services/webrtc/screen-share.manager.ts +++ b/src/app/infrastructure/realtime/media/screen-share.manager.ts @@ -3,24 +3,21 @@ * Manages screen sharing: getDisplayMedia / Electron desktop capturer, * system-audio capture, and attaching screen tracks to peers. */ -import { WebRTCLogger } from './webrtc-logger'; -import { PeerData } from './webrtc.types'; +import { WebRTCLogger } from '../logging/webrtc-logger'; +import { ScreenShareQualityPreset, ScreenShareStartOptions } from '../screen-share.config'; +import { PeerData } from '../realtime.types'; import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO, TRANSCEIVER_SEND_RECV, TRANSCEIVER_RECV_ONLY -} from './webrtc.constants'; -import { - DEFAULT_SCREEN_SHARE_START_OPTIONS, - SCREEN_SHARE_QUALITY_PRESETS, - ScreenShareQualityPreset, - ScreenShareStartOptions -} from './screen-share.config'; +} from '../realtime.constants'; +import { DEFAULT_SCREEN_SHARE_START_OPTIONS, SCREEN_SHARE_QUALITY_PRESETS } from '../screen-share.config'; import { BrowserScreenShareCapture } from './screen-share-platforms/browser-screen-share.capture'; import { DesktopElectronScreenShareCapture } from './screen-share-platforms/desktop-electron-screen-share.capture'; import { LinuxElectronScreenShareCapture } from './screen-share-platforms/linux-electron-screen-share.capture'; -import { ScreenShareElectronApi, ScreenShareWindow } from './screen-share-platforms/shared'; +import { getElectronApi } from '../../../core/platform/electron/get-electron-api'; +import { ScreenShareElectronApi } from './screen-share-platforms/shared'; /** * Callbacks the ScreenShareManager needs from the owning service. @@ -325,9 +322,7 @@ export class ScreenShareManager { } private getElectronApi(): ScreenShareElectronApi | null { - return typeof window !== 'undefined' - ? (window as ScreenShareWindow).electronAPI ?? null - : null; + return getElectronApi(); } private publishLocalScreenShareState( diff --git a/src/app/infrastructure/realtime/media/voice-session-controller.ts b/src/app/infrastructure/realtime/media/voice-session-controller.ts new file mode 100644 index 0000000..3c6a085 --- /dev/null +++ b/src/app/infrastructure/realtime/media/voice-session-controller.ts @@ -0,0 +1,117 @@ +import { MediaManager } from './media.manager'; +import { LatencyProfile } from '../realtime.constants'; +import { VoiceStateSnapshot } from '../realtime.types'; + +interface VoiceSessionControllerDependencies { + mediaManager: MediaManager; + getIsScreenSharing(): boolean; + setVoiceConnected(connected: boolean): void; + setMuted(muted: boolean): void; + setDeafened(deafened: boolean): void; + setNoiseReductionEnabled(enabled: boolean): void; +} + +export class VoiceSessionController { + private voiceServerId: string | null = null; + + constructor( + private readonly dependencies: VoiceSessionControllerDependencies + ) {} + + getVoiceServerId(): string | null { + return this.voiceServerId; + } + + getEffectiveServerId(activeServerId: string | null): string | null { + return this.voiceServerId || activeServerId; + } + + handleVoiceConnected(): void { + this.dependencies.setVoiceConnected(true); + } + + getCurrentVoiceState(): VoiceStateSnapshot { + return { + isConnected: this.dependencies.mediaManager.getIsVoiceActive(), + isMuted: this.dependencies.mediaManager.getIsMicMuted(), + isDeafened: this.dependencies.mediaManager.getIsSelfDeafened(), + isScreenSharing: this.dependencies.getIsScreenSharing(), + roomId: this.dependencies.mediaManager.getCurrentVoiceRoomId(), + serverId: this.dependencies.mediaManager.getCurrentVoiceServerId() + }; + } + + async enableVoice(): Promise { + const stream = await this.dependencies.mediaManager.enableVoice(); + + this.syncMediaSignals(); + return stream; + } + + disableVoice(): void { + this.voiceServerId = null; + this.dependencies.mediaManager.disableVoice(); + this.dependencies.setVoiceConnected(false); + } + + async setLocalStream(stream: MediaStream): Promise { + await this.dependencies.mediaManager.setLocalStream(stream); + this.syncMediaSignals(); + } + + toggleMute(muted?: boolean): void { + this.dependencies.mediaManager.toggleMute(muted); + this.dependencies.setMuted(this.dependencies.mediaManager.getIsMicMuted()); + } + + toggleDeafen(deafened?: boolean): void { + this.dependencies.mediaManager.toggleDeafen(deafened); + this.dependencies.setDeafened(this.dependencies.mediaManager.getIsSelfDeafened()); + } + + async toggleNoiseReduction(enabled?: boolean): Promise { + await this.dependencies.mediaManager.toggleNoiseReduction(enabled); + this.dependencies.setNoiseReductionEnabled(this.dependencies.mediaManager.getIsNoiseReductionEnabled()); + } + + setOutputVolume(volume: number): void { + this.dependencies.mediaManager.setOutputVolume(volume); + } + + setInputVolume(volume: number): void { + this.dependencies.mediaManager.setInputVolume(volume); + } + + async setAudioBitrate(kbps: number): Promise { + return await this.dependencies.mediaManager.setAudioBitrate(kbps); + } + + async setLatencyProfile(profile: LatencyProfile): Promise { + return await this.dependencies.mediaManager.setLatencyProfile(profile); + } + + startVoiceHeartbeat(roomId?: string, serverId?: string): void { + if (serverId) { + this.voiceServerId = serverId; + } + + this.dependencies.mediaManager.startVoiceHeartbeat(roomId, serverId); + } + + stopVoiceHeartbeat(): void { + this.dependencies.mediaManager.stopVoiceHeartbeat(); + } + + resetVoiceSession(): void { + this.voiceServerId = null; + this.dependencies.mediaManager.stopVoiceHeartbeat(); + this.dependencies.mediaManager.disableVoice(); + this.dependencies.setVoiceConnected(false); + } + + private syncMediaSignals(): void { + this.dependencies.setVoiceConnected(this.dependencies.mediaManager.getIsVoiceActive()); + this.dependencies.setMuted(this.dependencies.mediaManager.getIsMicMuted()); + this.dependencies.setDeafened(this.dependencies.mediaManager.getIsSelfDeafened()); + } +} diff --git a/src/app/core/services/webrtc/peer-connection-manager/connection/create-peer-connection.ts b/src/app/infrastructure/realtime/peer-connection-manager/connection/create-peer-connection.ts similarity index 97% rename from src/app/core/services/webrtc/peer-connection-manager/connection/create-peer-connection.ts rename to src/app/infrastructure/realtime/peer-connection-manager/connection/create-peer-connection.ts index 301c1c6..e6be8a9 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/connection/create-peer-connection.ts +++ b/src/app/infrastructure/realtime/peer-connection-manager/connection/create-peer-connection.ts @@ -10,9 +10,9 @@ import { TRACK_KIND_VIDEO, TRANSCEIVER_RECV_ONLY, TRANSCEIVER_SEND_RECV -} from '../../webrtc.constants'; -import { recordDebugNetworkConnectionState } from '../../../debug-network-metrics.service'; -import { PeerData } from '../../webrtc.types'; +} from '../../realtime.constants'; +import { recordDebugNetworkConnectionState } from '../../logging/debug-network-metrics'; +import { PeerData } from '../../realtime.types'; import { ConnectionLifecycleHandlers, PeerConnectionManagerContext } from '../shared'; /** diff --git a/src/app/core/services/webrtc/peer-connection-manager/connection/negotiation.ts b/src/app/infrastructure/realtime/peer-connection-manager/connection/negotiation.ts similarity index 99% rename from src/app/core/services/webrtc/peer-connection-manager/connection/negotiation.ts rename to src/app/infrastructure/realtime/peer-connection-manager/connection/negotiation.ts index c84de1f..6aedf6b 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/connection/negotiation.ts +++ b/src/app/infrastructure/realtime/peer-connection-manager/connection/negotiation.ts @@ -5,7 +5,7 @@ import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO, TRANSCEIVER_SEND_RECV -} from '../../webrtc.constants'; +} from '../../realtime.constants'; import { NegotiationHandlers, PeerConnectionManagerContext, diff --git a/src/app/core/services/webrtc/peer-connection-manager/index.ts b/src/app/infrastructure/realtime/peer-connection-manager/index.ts similarity index 100% rename from src/app/core/services/webrtc/peer-connection-manager/index.ts rename to src/app/infrastructure/realtime/peer-connection-manager/index.ts diff --git a/src/app/core/services/webrtc/peer-connection-manager/messaging/data-channel.ts b/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.ts similarity index 99% rename from src/app/core/services/webrtc/peer-connection-manager/messaging/data-channel.ts rename to src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.ts index 92fe4ef..71726aa 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/messaging/data-channel.ts +++ b/src/app/infrastructure/realtime/peer-connection-manager/messaging/data-channel.ts @@ -1,4 +1,4 @@ -import { ChatEvent } from '../../../../models'; +import { ChatEvent } from '../../../../core/models'; import { DATA_CHANNEL_HIGH_WATER_BYTES, DATA_CHANNEL_LOW_WATER_BYTES, @@ -10,8 +10,8 @@ import { P2P_TYPE_STATE_REQUEST, P2P_TYPE_VOICE_STATE, P2P_TYPE_VOICE_STATE_REQUEST -} from '../../webrtc.constants'; -import { recordDebugNetworkDataChannelPayload, recordDebugNetworkPing } from '../../../debug-network-metrics.service'; +} from '../../realtime.constants'; +import { recordDebugNetworkDataChannelPayload, recordDebugNetworkPing } from '../../logging/debug-network-metrics'; import { PeerConnectionManagerContext } from '../shared'; import { startPingInterval } from './ping'; diff --git a/src/app/core/services/webrtc/peer-connection-manager/messaging/ping.ts b/src/app/infrastructure/realtime/peer-connection-manager/messaging/ping.ts similarity index 95% rename from src/app/core/services/webrtc/peer-connection-manager/messaging/ping.ts rename to src/app/infrastructure/realtime/peer-connection-manager/messaging/ping.ts index a5d9a9c..9533bf4 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/messaging/ping.ts +++ b/src/app/infrastructure/realtime/peer-connection-manager/messaging/ping.ts @@ -2,8 +2,8 @@ import { DATA_CHANNEL_STATE_OPEN, P2P_TYPE_PING, PEER_PING_INTERVAL_MS -} from '../../webrtc.constants'; -import { WebRTCLogger } from '../../webrtc-logger'; +} from '../../realtime.constants'; +import { WebRTCLogger } from '../../logging/webrtc-logger'; import { PeerConnectionManagerState } from '../shared'; /** Start periodic pings to a peer to measure round-trip latency. */ diff --git a/src/app/core/services/webrtc/peer-connection-manager/peer-connection.manager.ts b/src/app/infrastructure/realtime/peer-connection-manager/peer-connection.manager.ts similarity index 98% rename from src/app/core/services/webrtc/peer-connection-manager/peer-connection.manager.ts rename to src/app/infrastructure/realtime/peer-connection-manager/peer-connection.manager.ts index ef7ba1e..8ffe38a 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/peer-connection.manager.ts +++ b/src/app/infrastructure/realtime/peer-connection-manager/peer-connection.manager.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/member-ordering */ -import { ChatEvent } from '../../../models'; -import { recordDebugNetworkDownloadRates } from '../../debug-network-metrics.service'; -import { WebRTCLogger } from '../webrtc-logger'; -import { PeerData } from '../webrtc.types'; +import { ChatEvent } from '../../../core/models'; +import { recordDebugNetworkDownloadRates } from '../logging/debug-network-metrics'; +import { WebRTCLogger } from '../logging/webrtc-logger'; +import { PeerData } from '../realtime.types'; import { createPeerConnection as createManagedPeerConnection } from './connection/create-peer-connection'; import { doCreateAndSendOffer, diff --git a/src/app/core/services/webrtc/peer-connection-manager/recovery/peer-recovery.ts b/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts similarity index 99% rename from src/app/core/services/webrtc/peer-connection-manager/recovery/peer-recovery.ts rename to src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts index 7f9c771..6d1894b 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/recovery/peer-recovery.ts +++ b/src/app/infrastructure/realtime/peer-connection-manager/recovery/peer-recovery.ts @@ -5,7 +5,7 @@ import { PEER_DISCONNECT_GRACE_MS, PEER_RECONNECT_INTERVAL_MS, PEER_RECONNECT_MAX_ATTEMPTS -} from '../../webrtc.constants'; +} from '../../realtime.constants'; import { PeerConnectionManagerContext, PeerConnectionManagerState, diff --git a/src/app/core/services/webrtc/peer-connection-manager/shared.ts b/src/app/infrastructure/realtime/peer-connection-manager/shared.ts similarity index 96% rename from src/app/core/services/webrtc/peer-connection-manager/shared.ts rename to src/app/infrastructure/realtime/peer-connection-manager/shared.ts index 28dcb15..3b1a842 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/shared.ts +++ b/src/app/infrastructure/realtime/peer-connection-manager/shared.ts @@ -1,12 +1,12 @@ import { Subject } from 'rxjs'; -import { ChatEvent } from '../../../models'; -import { WebRTCLogger } from '../webrtc-logger'; +import { ChatEvent } from '../../../core/models'; +import { WebRTCLogger } from '../logging/webrtc-logger'; import { DisconnectedPeerEntry, IdentifyCredentials, PeerData, VoiceStateSnapshot -} from '../webrtc.types'; +} from '../realtime.types'; /** * Callbacks the PeerConnectionManager needs from the owning service. diff --git a/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts b/src/app/infrastructure/realtime/peer-connection-manager/streams/remote-streams.ts similarity index 98% rename from src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts rename to src/app/infrastructure/realtime/peer-connection-manager/streams/remote-streams.ts index 9fba001..2dcba12 100644 --- a/src/app/core/services/webrtc/peer-connection-manager/streams/remote-streams.ts +++ b/src/app/infrastructure/realtime/peer-connection-manager/streams/remote-streams.ts @@ -1,5 +1,5 @@ -import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO } from '../../webrtc.constants'; -import { recordDebugNetworkStreams } from '../../../debug-network-metrics.service'; +import { TRACK_KIND_AUDIO, TRACK_KIND_VIDEO } from '../../realtime.constants'; +import { recordDebugNetworkStreams } from '../../logging/debug-network-metrics'; import { PeerConnectionManagerContext } from '../shared'; export function handleRemoteTrack( diff --git a/src/app/core/services/webrtc/peer-connection.manager.ts b/src/app/infrastructure/realtime/peer-connection.manager.ts similarity index 100% rename from src/app/core/services/webrtc/peer-connection.manager.ts rename to src/app/infrastructure/realtime/peer-connection.manager.ts diff --git a/src/app/infrastructure/realtime/realtime-session.service.ts b/src/app/infrastructure/realtime/realtime-session.service.ts new file mode 100644 index 0000000..f053a0a --- /dev/null +++ b/src/app/infrastructure/realtime/realtime-session.service.ts @@ -0,0 +1,622 @@ +/** + * WebRTCService - thin Angular service that composes specialised managers. + * + * Each concern lives in its own file under `./`: + * • SignalingManager - WebSocket lifecycle & reconnection + * • PeerConnectionManager - RTCPeerConnection, offers/answers, ICE, data channels + * • MediaManager - mic voice, mute, deafen, bitrate + * • ScreenShareManager - screen capture & mixed audio + * • WebRTCLogger - debug / diagnostic logging + * + * This file wires them together and exposes a public API that is + * identical to the old monolithic service so consumers don't change. + */ +/* eslint-disable @typescript-eslint/member-ordering, @typescript-eslint/no-non-null-assertion */ +import { + Injectable, + inject, + OnDestroy +} from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { SignalingMessage, ChatEvent } from '../../core/models/index'; +import { TimeSyncService } from '../../core/services/time-sync.service'; +import { DebuggingService } from '../../core/services/debugging'; +import { ScreenShareSourcePickerService } from '../../domains/screen-share'; +import { MediaManager } from './media/media.manager'; +import { ScreenShareManager } from './media/screen-share.manager'; +import { VoiceSessionController } from './media/voice-session-controller'; +import type { PeerData, VoiceStateSnapshot } from './realtime.types'; +import { LatencyProfile } from './realtime.constants'; +import { ScreenShareStartOptions } from './screen-share.config'; +import { WebRTCLogger } from './logging/webrtc-logger'; +import { PeerConnectionManager } from './peer-connection-manager/peer-connection.manager'; +import { PeerMediaFacade } from './streams/peer-media-facade'; +import { RemoteScreenShareRequestController } from './streams/remote-screen-share-request-controller'; +import { IncomingSignalingMessage, IncomingSignalingMessageHandler } from './signaling/signaling-message-handler'; +import { ServerMembershipSignalingHandler } from './signaling/server-membership-signaling-handler'; +import { ServerSignalingCoordinator } from './signaling/server-signaling-coordinator'; +import { SignalingManager } from './signaling/signaling.manager'; +import { SignalingTransportHandler } from './signaling/signaling-transport-handler'; +import { WebRtcStateController } from './state/webrtc-state-controller'; + +@Injectable({ + providedIn: 'root' +}) +export class WebRTCService implements OnDestroy { + private readonly timeSync = inject(TimeSyncService); + private readonly debugging = inject(DebuggingService); + private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService); + + private readonly logger = new WebRTCLogger(() => this.debugging.enabled()); + private readonly state = new WebRtcStateController(); + + readonly peerId = this.state.peerId; + readonly isConnected = this.state.isConnected; + readonly hasEverConnected = this.state.hasEverConnected; + readonly isVoiceConnected = this.state.isVoiceConnected; + readonly connectedPeers = this.state.connectedPeers; + readonly isMuted = this.state.isMuted; + readonly isDeafened = this.state.isDeafened; + readonly isScreenSharing = this.state.isScreenSharing; + readonly isNoiseReductionEnabled = this.state.isNoiseReductionEnabled; + readonly screenStream = this.state.screenStream; + readonly isScreenShareRemotePlaybackSuppressed = this.state.isScreenShareRemotePlaybackSuppressed; + readonly forceDefaultRemotePlaybackOutput = this.state.forceDefaultRemotePlaybackOutput; + readonly hasConnectionError = this.state.hasConnectionError; + readonly connectionErrorMessage = this.state.connectionErrorMessage; + readonly shouldShowConnectionError = this.state.shouldShowConnectionError; + readonly peerLatencies = this.state.peerLatencies; + + private readonly signalingMessage$ = new Subject(); + readonly onSignalingMessage = this.signalingMessage$.asObservable(); + + // Delegates to managers + get onMessageReceived(): Observable { + return this.peerMediaFacade.onMessageReceived; + } + get onPeerConnected(): Observable { + return this.peerMediaFacade.onPeerConnected; + } + get onPeerDisconnected(): Observable { + return this.peerMediaFacade.onPeerDisconnected; + } + get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { + return this.peerMediaFacade.onRemoteStream; + } + get onVoiceConnected(): Observable { + return this.peerMediaFacade.onVoiceConnected; + } + + private readonly peerManager: PeerConnectionManager; + private readonly mediaManager: MediaManager; + private readonly screenShareManager: ScreenShareManager; + private readonly peerMediaFacade: PeerMediaFacade; + private readonly voiceSessionController: VoiceSessionController; + private readonly signalingCoordinator: ServerSignalingCoordinator; + private readonly signalingTransportHandler: SignalingTransportHandler; + private readonly signalingMessageHandler: IncomingSignalingMessageHandler; + private readonly serverMembershipSignalingHandler: ServerMembershipSignalingHandler; + private readonly remoteScreenShareRequestController: RemoteScreenShareRequestController; + + constructor() { + // Create managers with null callbacks first to break circular initialization + this.peerManager = new PeerConnectionManager(this.logger, null!); + + this.mediaManager = new MediaManager(this.logger, null!); + + this.screenShareManager = new ScreenShareManager(this.logger, null!); + + this.peerMediaFacade = new PeerMediaFacade({ + peerManager: this.peerManager, + mediaManager: this.mediaManager, + screenShareManager: this.screenShareManager + }); + + this.voiceSessionController = new VoiceSessionController({ + mediaManager: this.mediaManager, + getIsScreenSharing: () => this.state.isScreenSharingActive(), + setVoiceConnected: (connected) => this.state.setVoiceConnected(connected), + setMuted: (muted) => this.state.setMuted(muted), + setDeafened: (deafened) => this.state.setDeafened(deafened), + setNoiseReductionEnabled: (enabled) => this.state.setNoiseReductionEnabled(enabled) + }); + + this.signalingCoordinator = new ServerSignalingCoordinator({ + createManager: (_signalUrl, getLastJoinedServer, getMemberServerIds) => new SignalingManager( + this.logger, + () => this.signalingTransportHandler.getIdentifyCredentials(), + getLastJoinedServer, + getMemberServerIds + ), + handleConnectionStatus: (_signalUrl, connected, errorMessage) => + this.handleSignalingConnectionStatus(connected, errorMessage), + handleHeartbeatTick: () => this.peerMediaFacade.broadcastCurrentStates(), + handleMessage: (message, signalUrl) => this.handleSignalingMessage(message, signalUrl) + }); + + this.signalingTransportHandler = new SignalingTransportHandler({ + signalingCoordinator: this.signalingCoordinator, + logger: this.logger, + getLocalPeerId: () => this.state.getLocalPeerId() + }); + + // Now wire up cross-references (all managers are instantiated) + this.peerManager.setCallbacks({ + sendRawMessage: (msg: Record) => this.signalingTransportHandler.sendRawMessage(msg), + getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(), + isSignalingConnected: (): boolean => this.state.isSignalingConnected(), + getVoiceStateSnapshot: (): VoiceStateSnapshot => this.voiceSessionController.getCurrentVoiceState(), + getIdentifyCredentials: () => this.signalingTransportHandler.getIdentifyCredentials(), + getLocalPeerId: (): string => this.state.getLocalPeerId(), + isScreenSharingActive: (): boolean => this.state.isScreenSharingActive() + }); + + this.mediaManager.setCallbacks({ + getActivePeers: (): Map => this.peerMediaFacade.getActivePeers(), + renegotiate: (peerId: string): Promise => this.peerMediaFacade.renegotiate(peerId), + broadcastMessage: (event: ChatEvent): void => this.peerMediaFacade.broadcastMessage(event), + getIdentifyOderId: (): string => this.signalingTransportHandler.getIdentifyOderId(), + getIdentifyDisplayName: (): string => this.signalingTransportHandler.getIdentifyDisplayName() + }); + + this.screenShareManager.setCallbacks({ + getActivePeers: (): Map => this.peerMediaFacade.getActivePeers(), + getLocalMediaStream: (): MediaStream | null => this.peerMediaFacade.getLocalStream(), + renegotiate: (peerId: string): Promise => this.peerMediaFacade.renegotiate(peerId), + broadcastCurrentStates: (): void => this.peerMediaFacade.broadcastCurrentStates(), + selectDesktopSource: async (sources, options) => await this.screenShareSourcePicker.open( + sources, + options.includeSystemAudio + ), + updateLocalScreenShareState: (state): void => this.state.applyLocalScreenShareState(state) + }); + + this.signalingMessageHandler = new IncomingSignalingMessageHandler({ + getEffectiveServerId: () => this.voiceSessionController.getEffectiveServerId(this.state.currentServerId), + peerManager: this.peerManager, + setServerTime: (serverTime) => this.timeSync.setFromServerTime(serverTime), + signalingCoordinator: this.signalingCoordinator, + logger: this.logger + }); + + this.serverMembershipSignalingHandler = new ServerMembershipSignalingHandler({ + signalingCoordinator: this.signalingCoordinator, + signalingTransport: this.signalingTransportHandler, + logger: this.logger, + getActiveServerId: () => this.state.currentServerId, + isVoiceConnected: () => this.state.isVoiceConnectedActive(), + runFullCleanup: () => this.fullCleanup() + }); + + this.remoteScreenShareRequestController = new RemoteScreenShareRequestController({ + getConnectedPeerIds: () => this.peerMediaFacade.getConnectedPeerIds(), + sendToPeer: (peerId, event) => this.peerMediaFacade.sendToPeer(peerId, event), + clearRemoteScreenShareStream: (peerId) => this.peerMediaFacade.clearRemoteScreenShareStream(peerId), + requestScreenShareForPeer: (peerId) => this.peerMediaFacade.requestScreenShareForPeer(peerId), + stopScreenShareForPeer: (peerId) => this.peerMediaFacade.stopScreenShareForPeer(peerId), + clearScreenShareRequest: (peerId) => this.peerMediaFacade.clearScreenShareRequest(peerId) + }); + + this.wireManagerEvents(); + } + + private wireManagerEvents(): void { + // Internal control-plane messages for on-demand screen-share delivery. + this.peerManager.messageReceived$.subscribe((event) => + this.remoteScreenShareRequestController.handlePeerControlMessage(event) + ); + + // Peer manager → connected peers signal + this.peerManager.connectedPeersChanged$.subscribe((peers: string[]) => + this.state.setConnectedPeers(peers) + ); + + // If we are already sharing when a new peer connection finishes, push the + // current screen-share tracks to that peer and renegotiate. + this.peerManager.peerConnected$.subscribe((peerId) => { + if (this.peerMediaFacade.isScreenShareActive()) { + this.peerMediaFacade.syncScreenShareToPeer(peerId); + } + + this.remoteScreenShareRequestController.handlePeerConnected(peerId); + }); + + this.peerManager.peerDisconnected$.subscribe((peerId) => { + this.remoteScreenShareRequestController.handlePeerDisconnected(peerId); + this.signalingCoordinator.deletePeerTracking(peerId); + }); + + // Media manager → voice connected signal + this.mediaManager.voiceConnected$.subscribe(() => { + this.voiceSessionController.handleVoiceConnected(); + }); + + // Peer manager → latency updates + this.peerManager.peerLatencyChanged$.subscribe(() => + this.state.syncPeerLatencies(this.peerManager.peerLatencies) + ); + } + + private handleSignalingConnectionStatus(connected: boolean, errorMessage?: string): void { + this.state.updateSignalingConnectionStatus( + this.signalingCoordinator.isAnySignalingConnected(), + connected, + errorMessage + ); + } + + private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + this.signalingMessage$.next(message); + this.signalingMessageHandler.handleMessage(message, signalUrl); + } + + // PUBLIC API - matches the old monolithic service's interface + + /** + * Connect to a signaling server via WebSocket. + * + * @param serverUrl - The WebSocket URL of the signaling server. + * @returns An observable that emits `true` once connected. + */ + connectToSignalingServer(serverUrl: string): Observable { + return this.signalingTransportHandler.connectToSignalingServer(serverUrl); + } + + /** Returns true when the signaling socket for a given URL is currently open. */ + isSignalingConnectedTo(serverUrl: string): boolean { + return this.signalingTransportHandler.isSignalingConnectedTo(serverUrl); + } + + /** + * Ensure the signaling WebSocket is connected, reconnecting if needed. + * + * @param timeoutMs - Maximum time (ms) to wait for the connection. + * @returns `true` if connected within the timeout. + */ + async ensureSignalingConnected(timeoutMs?: number): Promise { + return await this.signalingTransportHandler.ensureSignalingConnected(timeoutMs); + } + + /** + * Send a signaling-level message (with `from` and `timestamp` auto-populated). + * + * @param message - The signaling message payload (excluding `from` / `timestamp`). + */ + sendSignalingMessage(message: Omit): void { + this.signalingTransportHandler.sendSignalingMessage(message); + } + + /** + * Send a raw JSON payload through the signaling WebSocket. + * + * @param message - Arbitrary JSON message. + */ + sendRawMessage(message: Record): void { + this.signalingTransportHandler.sendRawMessage(message); + } + + /** + * Track the currently-active server ID (for server-scoped operations). + * + * @param serverId - The server to mark as active. + */ + setCurrentServer(serverId: string): void { + this.state.setCurrentServer(serverId); + } + + /** The server ID currently being viewed / active, or `null`. */ + get currentServerId(): string | null { + return this.state.currentServerId; + } + + /** The last signaling URL used by the client, if any. */ + getCurrentSignalingUrl(): string | null { + return this.signalingTransportHandler.getCurrentSignalingUrl(this.state.currentServerId); + } + + /** + * Send an identify message to the signaling server. + * + * The credentials are cached so they can be replayed after a reconnect. + * + * @param oderId - The user's unique order/peer ID. + * @param displayName - The user's display name. + */ + identify(oderId: string, displayName: string, signalUrl?: string): void { + this.signalingTransportHandler.identify(oderId, displayName, signalUrl); + } + + /** + * Join a server (room) on the signaling server. + * + * @param roomId - The server / room ID to join. + * @param userId - The local user ID. + */ + joinRoom(roomId: string, userId: string, signalUrl?: string): void { + this.serverMembershipSignalingHandler.joinRoom(roomId, userId, signalUrl); + } + + /** + * Switch to a different server. If already a member, sends a view event; + * otherwise joins the server. + * + * @param serverId - The target server ID. + * @param userId - The local user ID. + */ + switchServer(serverId: string, userId: string, signalUrl?: string): void { + this.serverMembershipSignalingHandler.switchServer(serverId, userId, signalUrl); + } + + /** + * Leave one or all servers. + * + * If `serverId` is provided, leaves only that server. + * Otherwise leaves every joined server and performs a full cleanup. + * + * @param serverId - Optional server to leave; omit to leave all. + */ + leaveRoom(serverId?: string): void { + this.serverMembershipSignalingHandler.leaveRoom(serverId); + } + + /** + * Check whether the local client has joined a given server. + * + * @param serverId - The server to check. + */ + hasJoinedServer(serverId: string): boolean { + return this.signalingCoordinator.hasJoinedServer(serverId); + } + + /** Returns a read-only set of all currently-joined server IDs. */ + getJoinedServerIds(): ReadonlySet { + return this.signalingCoordinator.getJoinedServerIds(); + } + + /** + * Broadcast a {@link ChatEvent} to every connected peer. + * + * @param event - The chat event to send. + */ + broadcastMessage(event: ChatEvent): void { + this.peerMediaFacade.broadcastMessage(event); + } + + /** + * Send a {@link ChatEvent} to a specific peer. + * + * @param peerId - The target peer ID. + * @param event - The chat event to send. + */ + sendToPeer(peerId: string, event: ChatEvent): void { + this.peerMediaFacade.sendToPeer(peerId, event); + } + + syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void { + this.remoteScreenShareRequestController.syncRemoteScreenShareRequests(peerIds, enabled); + } + + /** + * Send a {@link ChatEvent} to a peer with back-pressure awareness. + * + * @param peerId - The target peer ID. + * @param event - The chat event to send. + */ + async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise { + return await this.peerMediaFacade.sendToPeerBuffered(peerId, event); + } + + /** Returns an array of currently-connected peer IDs. */ + getConnectedPeers(): string[] { + return this.peerMediaFacade.getConnectedPeerIds(); + } + + /** + * Get the composite remote {@link MediaStream} for a connected peer. + * + * @param peerId - The remote peer whose stream to retrieve. + * @returns The stream, or `null` if the peer has no active stream. + */ + getRemoteStream(peerId: string): MediaStream | null { + return this.peerMediaFacade.getRemoteStream(peerId); + } + + /** + * Get the remote voice-only stream for a connected peer. + * + * @param peerId - The remote peer whose voice stream to retrieve. + * @returns The stream, or `null` if the peer has no active voice audio. + */ + getRemoteVoiceStream(peerId: string): MediaStream | null { + return this.peerMediaFacade.getRemoteVoiceStream(peerId); + } + + /** + * Get the remote screen-share stream for a connected peer. + * + * This contains the screen video track and any audio track that belongs to + * the screen share itself, not the peer's normal voice-chat audio. + * + * @param peerId - The remote peer whose screen-share stream to retrieve. + * @returns The stream, or `null` if the peer has no active screen share. + */ + getRemoteScreenShareStream(peerId: string): MediaStream | null { + return this.peerMediaFacade.getRemoteScreenShareStream(peerId); + } + + /** + * Get the current local media stream (microphone audio). + * + * @returns The local {@link MediaStream}, or `null` if voice is not active. + */ + getLocalStream(): MediaStream | null { + return this.peerMediaFacade.getLocalStream(); + } + + /** + * Get the raw local microphone stream before gain / RNNoise processing. + * + * @returns The raw microphone {@link MediaStream}, or `null` if voice is not active. + */ + getRawMicStream(): MediaStream | null { + return this.peerMediaFacade.getRawMicStream(); + } + + /** + * Request microphone access and start sending audio to all peers. + * + * @returns The captured local {@link MediaStream}. + */ + async enableVoice(): Promise { + return await this.voiceSessionController.enableVoice(); + } + + /** Stop local voice capture and remove audio senders from peers. */ + disableVoice(): void { + this.voiceSessionController.disableVoice(); + } + + /** + * Inject an externally-obtained media stream as the local voice source. + * + * @param stream - The media stream to use. + */ + async setLocalStream(stream: MediaStream): Promise { + await this.voiceSessionController.setLocalStream(stream); + } + + /** + * Toggle the local microphone mute state. + * + * @param muted - Explicit state; if omitted, the current state is toggled. + */ + toggleMute(muted?: boolean): void { + this.voiceSessionController.toggleMute(muted); + } + + /** + * Toggle self-deafen (suppress incoming audio playback). + * + * @param deafened - Explicit state; if omitted, the current state is toggled. + */ + toggleDeafen(deafened?: boolean): void { + this.voiceSessionController.toggleDeafen(deafened); + } + + /** + * Toggle RNNoise noise reduction on the local microphone. + * + * When enabled, the raw mic audio is routed through an AudioWorklet + * that applies neural-network noise suppression before being sent + * to peers. + * + * @param enabled - Explicit state; if omitted, the current state is toggled. + */ + async toggleNoiseReduction(enabled?: boolean): Promise { + await this.voiceSessionController.toggleNoiseReduction(enabled); + } + + /** + * Set the output volume for remote audio playback. + * + * @param volume - Normalised volume (0-1). + */ + setOutputVolume(volume: number): void { + this.voiceSessionController.setOutputVolume(volume); + } + + /** + * Set the input (microphone) volume. + * + * Adjusts a Web Audio GainNode on the local mic stream so the level + * sent to peers changes in real time without renegotiation. + * + * @param volume - Normalised volume (0-1). + */ + setInputVolume(volume: number): void { + this.voiceSessionController.setInputVolume(volume); + } + + /** + * Set the maximum audio bitrate for all peer connections. + * + * @param kbps - Target bitrate in kilobits per second. + */ + async setAudioBitrate(kbps: number): Promise { + return await this.voiceSessionController.setAudioBitrate(kbps); + } + + /** + * Apply a predefined latency profile that maps to a specific bitrate. + * + * @param profile - One of `'low'`, `'balanced'`, or `'high'`. + */ + async setLatencyProfile(profile: LatencyProfile): Promise { + return await this.voiceSessionController.setLatencyProfile(profile); + } + + /** + * Start broadcasting voice-presence heartbeats to all peers. + * + * Also marks the given server as the active voice server and closes + * any peer connections that belong to other servers so that audio + * is isolated to the correct voice channel. + * + * @param roomId - The voice channel room ID. + * @param serverId - The voice channel server ID. + */ + startVoiceHeartbeat(roomId?: string, serverId?: string): void { + this.voiceSessionController.startVoiceHeartbeat(roomId, serverId); + } + + /** Stop the voice-presence heartbeat. */ + stopVoiceHeartbeat(): void { + this.voiceSessionController.stopVoiceHeartbeat(); + } + + /** + * Start sharing the screen (or a window) with all connected peers. + * + * @param options - Screen-share capture options. + * @returns The screen-capture {@link MediaStream}. + */ + async startScreenShare(options: ScreenShareStartOptions): Promise { + return await this.peerMediaFacade.startScreenShare(options); + } + + /** Stop screen sharing and restore microphone audio on all peers. */ + stopScreenShare(): void { + this.peerMediaFacade.stopScreenShare(); + } + + /** Disconnect from the signaling server and clean up all state. */ + disconnect(): void { + this.leaveRoom(); + this.destroyAllSignalingManagers(); + this.state.resetConnectionState(); + } + + /** Alias for {@link disconnect}. */ + disconnectAll(): void { + this.disconnect(); + } + + private fullCleanup(): void { + this.signalingCoordinator.clearPeerTracking(); + this.remoteScreenShareRequestController.clear(); + this.peerMediaFacade.closeAllPeers(); + this.state.clearPeerViewState(); + this.voiceSessionController.resetVoiceSession(); + this.peerMediaFacade.stopScreenShare(); + this.state.clearScreenShareState(); + } + + private destroyAllSignalingManagers(): void { + this.signalingCoordinator.destroy(); + } + + ngOnDestroy(): void { + this.disconnect(); + this.peerMediaFacade.destroy(); + } +} diff --git a/src/app/core/services/webrtc/webrtc.constants.ts b/src/app/infrastructure/realtime/realtime.constants.ts similarity index 97% rename from src/app/core/services/webrtc/webrtc.constants.ts rename to src/app/infrastructure/realtime/realtime.constants.ts index 7744fd2..1c75d15 100644 --- a/src/app/core/services/webrtc/webrtc.constants.ts +++ b/src/app/infrastructure/realtime/realtime.constants.ts @@ -41,7 +41,7 @@ export const SCREEN_SHARE_IDEAL_WIDTH = 1920; export const SCREEN_SHARE_IDEAL_HEIGHT = 1080; export const SCREEN_SHARE_IDEAL_FRAME_RATE = 30; /** Electron source name to prefer for whole-screen capture */ -export const ELECTRON_ENTIRE_SCREEN_SOURCE_NAME = 'Entire Screen'; +export { ELECTRON_ENTIRE_SCREEN_SOURCE_NAME } from '../../domains/screen-share/domain/screen-share.config'; /** Minimum audio bitrate (bps) */ export const AUDIO_BITRATE_MIN_BPS = 16_000; diff --git a/src/app/core/services/webrtc/webrtc.types.ts b/src/app/infrastructure/realtime/realtime.types.ts similarity index 100% rename from src/app/core/services/webrtc/webrtc.types.ts rename to src/app/infrastructure/realtime/realtime.types.ts diff --git a/src/app/infrastructure/realtime/screen-share.config.ts b/src/app/infrastructure/realtime/screen-share.config.ts new file mode 100644 index 0000000..15e248b --- /dev/null +++ b/src/app/infrastructure/realtime/screen-share.config.ts @@ -0,0 +1 @@ +export * from '../../domains/screen-share/domain/screen-share.config'; diff --git a/src/app/infrastructure/realtime/signaling/server-membership-signaling-handler.ts b/src/app/infrastructure/realtime/signaling/server-membership-signaling-handler.ts new file mode 100644 index 0000000..08ed5c0 --- /dev/null +++ b/src/app/infrastructure/realtime/signaling/server-membership-signaling-handler.ts @@ -0,0 +1,146 @@ +import { + SIGNALING_TYPE_JOIN_SERVER, + SIGNALING_TYPE_LEAVE_SERVER, + SIGNALING_TYPE_VIEW_SERVER +} from '../realtime.constants'; +import { ServerSignalingCoordinator } from './server-signaling-coordinator'; +import { SignalingTransportHandler } from './signaling-transport-handler'; +import { WebRTCLogger } from '../logging/webrtc-logger'; + +interface ServerMembershipSignalingHandlerDependencies { + signalingCoordinator: ServerSignalingCoordinator; + signalingTransport: SignalingTransportHandler; + logger: WebRTCLogger; + getActiveServerId(): string | null; + isVoiceConnected(): boolean; + runFullCleanup(): void; +} + +export class ServerMembershipSignalingHandler { + constructor( + private readonly dependencies: ServerMembershipSignalingHandlerDependencies + ) {} + + getCurrentSignalingUrl(): string | null { + return this.dependencies.signalingTransport.getCurrentSignalingUrl(this.dependencies.getActiveServerId()); + } + + joinRoom(roomId: string, userId: string, signalUrl?: string): void { + const resolvedSignalUrl = this.resolveSignalUrl(roomId, signalUrl); + + if (!resolvedSignalUrl) { + this.dependencies.logger.warn('[signaling] Cannot join room without a signaling URL', { roomId }); + return; + } + + this.dependencies.signalingCoordinator.setServerSignalUrl(roomId, resolvedSignalUrl); + this.dependencies.signalingCoordinator.setLastJoinedServer(resolvedSignalUrl, { + serverId: roomId, + userId + }); + + this.dependencies.signalingCoordinator.addJoinedServer(resolvedSignalUrl, roomId); + + this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, { + type: SIGNALING_TYPE_JOIN_SERVER, + serverId: roomId + }); + } + + switchServer(serverId: string, userId: string, signalUrl?: string): void { + const resolvedSignalUrl = this.resolveSignalUrl(serverId, signalUrl); + + if (!resolvedSignalUrl) { + this.dependencies.logger.warn('[signaling] Cannot switch server without a signaling URL', { serverId }); + return; + } + + this.dependencies.signalingCoordinator.setServerSignalUrl(serverId, resolvedSignalUrl); + this.dependencies.signalingCoordinator.setLastJoinedServer(resolvedSignalUrl, { + serverId, + userId + }); + + const memberServerIds = this.dependencies.signalingCoordinator.getMemberServerIdsForSignalUrl(resolvedSignalUrl); + + if (memberServerIds.has(serverId)) { + this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, { + type: SIGNALING_TYPE_VIEW_SERVER, + serverId + }); + + this.dependencies.logger.info('Viewed server (already joined)', { + serverId, + signalUrl: resolvedSignalUrl, + userId, + voiceConnected: this.dependencies.isVoiceConnected() + }); + + return; + } + + this.dependencies.signalingCoordinator.addJoinedServer(resolvedSignalUrl, serverId); + this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, { + type: SIGNALING_TYPE_JOIN_SERVER, + serverId + }); + + this.dependencies.logger.info('Joined new server via switch', { + serverId, + signalUrl: resolvedSignalUrl, + userId, + voiceConnected: this.dependencies.isVoiceConnected() + }); + } + + leaveRoom(serverId?: string): void { + if (serverId) { + this.leaveSingleRoom(serverId); + return; + } + + for (const { signalUrl, serverIds } of this.dependencies.signalingCoordinator.getJoinedServerEntries()) { + for (const joinedServerId of serverIds) { + this.dependencies.signalingTransport.sendRawMessageToSignalUrl(signalUrl, { + type: SIGNALING_TYPE_LEAVE_SERVER, + serverId: joinedServerId + }); + } + } + + this.dependencies.signalingCoordinator.clearJoinedServers(); + this.dependencies.runFullCleanup(); + } + + private leaveSingleRoom(serverId: string): void { + const resolvedSignalUrl = this.dependencies.signalingCoordinator.getServerSignalUrl(serverId); + + if (resolvedSignalUrl) { + this.dependencies.signalingCoordinator.removeJoinedServer(resolvedSignalUrl, serverId); + this.dependencies.signalingTransport.sendRawMessageToSignalUrl(resolvedSignalUrl, { + type: SIGNALING_TYPE_LEAVE_SERVER, + serverId + }); + } else { + this.dependencies.signalingTransport.sendRawMessage({ + type: SIGNALING_TYPE_LEAVE_SERVER, + serverId + }); + + this.dependencies.signalingCoordinator.removeJoinedServerEverywhere(serverId); + } + + this.dependencies.signalingCoordinator.deleteServerSignalUrl(serverId); + this.dependencies.logger.info('Left server', { serverId }); + + if (this.dependencies.signalingCoordinator.getJoinedServerCount() === 0) { + this.dependencies.runFullCleanup(); + } + } + + private resolveSignalUrl(serverId: string, signalUrl?: string): string | null { + return signalUrl + ?? this.dependencies.signalingCoordinator.getServerSignalUrl(serverId) + ?? this.getCurrentSignalingUrl(); + } +} diff --git a/src/app/infrastructure/realtime/signaling/server-signaling-coordinator.ts b/src/app/infrastructure/realtime/signaling/server-signaling-coordinator.ts new file mode 100644 index 0000000..a77df59 --- /dev/null +++ b/src/app/infrastructure/realtime/signaling/server-signaling-coordinator.ts @@ -0,0 +1,295 @@ +import { Subscription } from 'rxjs'; +import { JoinedServerInfo } from '../realtime.types'; +import { SignalingManager } from './signaling.manager'; + +export interface ConnectedSignalingManager { + signalUrl: string; + manager: SignalingManager; +} + +export interface ServerSignalingCoordinatorCallbacks { + createManager( + signalUrl: string, + getLastJoinedServer: () => JoinedServerInfo | null, + getMemberServerIds: () => ReadonlySet + ): SignalingManager; + handleConnectionStatus(signalUrl: string, connected: boolean, errorMessage?: string): void; + handleHeartbeatTick(): void; + handleMessage(message: TMessage, signalUrl: string): void; +} + +export class ServerSignalingCoordinator { + 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 peerServerMap = new Map>(); + + constructor( + private readonly callbacks: ServerSignalingCoordinatorCallbacks + ) {} + + ensureSignalingManager(signalUrl: string): SignalingManager { + const existingManager = this.signalingManagers.get(signalUrl); + + if (existingManager) { + return existingManager; + } + + const manager = this.callbacks.createManager( + signalUrl, + () => this.lastJoinedServerBySignalUrl.get(signalUrl) ?? null, + () => this.getMemberServerIdsForSignalUrl(signalUrl) + ); + const subscriptions: Subscription[] = [ + manager.connectionStatus$.subscribe(({ connected, errorMessage }) => + this.callbacks.handleConnectionStatus(signalUrl, connected, errorMessage) + ), + manager.messageReceived$.subscribe((message) => + this.callbacks.handleMessage(message as TMessage, signalUrl) + ), + manager.heartbeatTick$.subscribe(() => this.callbacks.handleHeartbeatTick()) + ]; + + this.signalingManagers.set(signalUrl, manager); + this.signalingSubscriptions.set(signalUrl, subscriptions); + return manager; + } + + getSignalingManager(signalUrl: string): SignalingManager | undefined { + return this.signalingManagers.get(signalUrl); + } + + isSignalingConnectedTo(signalUrl: string): boolean { + return this.signalingManagers.get(signalUrl)?.isSocketOpen() ?? false; + } + + isAnySignalingConnected(): boolean { + for (const manager of this.signalingManagers.values()) { + if (manager.isSocketOpen()) { + return true; + } + } + + return false; + } + + getConnectedSignalingManagers(): ConnectedSignalingManager[] { + const connectedManagers: ConnectedSignalingManager[] = []; + + for (const [signalUrl, manager] of this.signalingManagers.entries()) { + if (!manager.isSocketOpen()) { + continue; + } + + connectedManagers.push({ signalUrl, + manager }); + } + + return connectedManagers; + } + + async ensureAnySignalingConnected(timeoutMs?: number): Promise { + if (this.isAnySignalingConnected()) { + return true; + } + + for (const manager of this.signalingManagers.values()) { + if (await manager.ensureConnected(timeoutMs)) { + return true; + } + } + + return false; + } + + setLastJoinedServer(signalUrl: string, joinedServer: JoinedServerInfo): void { + this.lastJoinedServerBySignalUrl.set(signalUrl, joinedServer); + } + + clearLastJoinedServers(): void { + this.lastJoinedServerBySignalUrl.clear(); + } + + setServerSignalUrl(serverId: string, signalUrl: string): void { + this.serverSignalingUrlMap.set(serverId, signalUrl); + } + + getServerSignalUrl(serverId: string): string | undefined { + return this.serverSignalingUrlMap.get(serverId); + } + + deleteServerSignalUrl(serverId: string): void { + this.serverSignalingUrlMap.delete(serverId); + } + + setPeerSignalUrl(peerId: string, signalUrl: string): void { + this.peerSignalingUrlMap.set(peerId, signalUrl); + } + + getPeerSignalUrl(peerId: string): string | undefined { + return this.peerSignalingUrlMap.get(peerId); + } + + deletePeerSignalUrl(peerId: string): void { + this.peerSignalingUrlMap.delete(peerId); + } + + addJoinedServer(signalUrl: string, serverId: string): void { + this.getOrCreateMemberServerSet(signalUrl).add(serverId); + } + + removeJoinedServer(signalUrl: string, serverId: string): void { + this.getOrCreateMemberServerSet(signalUrl).delete(serverId); + } + + removeJoinedServerEverywhere(serverId: string): void { + for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { + memberServerIds.delete(serverId); + } + } + + getMemberServerIdsForSignalUrl(signalUrl: string): ReadonlySet { + return this.memberServerIdsBySignalUrl.get(signalUrl) ?? new Set(); + } + + getJoinedServerEntries(): { signalUrl: string; serverIds: ReadonlySet }[] { + return Array.from(this.memberServerIdsBySignalUrl.entries()).map(([signalUrl, serverIds]) => ({ + signalUrl, + serverIds + })); + } + + clearJoinedServers(): void { + this.memberServerIdsBySignalUrl.clear(); + this.serverSignalingUrlMap.clear(); + } + + hasJoinedServer(serverId: string): boolean { + for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { + if (memberServerIds.has(serverId)) { + return true; + } + } + + return false; + } + + getJoinedServerCount(): number { + let joinedServerCount = 0; + + for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { + joinedServerCount += memberServerIds.size; + } + + return joinedServerCount; + } + + getJoinedServerIds(): ReadonlySet { + const joinedServerIds = new Set(); + + for (const memberServerIds of this.memberServerIdsBySignalUrl.values()) { + memberServerIds.forEach((serverId) => joinedServerIds.add(serverId)); + } + + return joinedServerIds; + } + + trackPeerInServer(peerId: string, serverId: string): void { + if (!peerId || !serverId) + return; + + const trackedServers = this.peerServerMap.get(peerId) ?? new Set(); + + trackedServers.add(serverId); + this.peerServerMap.set(peerId, trackedServers); + } + + hasTrackedPeerServers(peerId: string): boolean { + return this.peerServerMap.has(peerId); + } + + replacePeerSharedServers(peerId: string, serverIds: string[]): boolean { + const sharedServerIds = serverIds.filter((serverId) => this.hasJoinedServer(serverId)); + + if (sharedServerIds.length === 0) { + this.peerServerMap.delete(peerId); + return false; + } + + this.peerServerMap.set(peerId, new Set(sharedServerIds)); + return true; + } + + untrackPeerFromServer(peerId: string, serverId: string): boolean { + const trackedServers = this.peerServerMap.get(peerId); + + if (!trackedServers) + return false; + + trackedServers.delete(serverId); + + if (trackedServers.size === 0) { + this.peerServerMap.delete(peerId); + return false; + } + + this.peerServerMap.set(peerId, trackedServers); + return true; + } + + deletePeerTracking(peerId: string): void { + this.peerServerMap.delete(peerId); + this.peerSignalingUrlMap.delete(peerId); + } + + clearPeerTracking(): void { + this.peerServerMap.clear(); + this.peerSignalingUrlMap.clear(); + } + + getPeersOutsideServer(serverId: string): string[] { + const peersToClose: string[] = []; + + this.peerServerMap.forEach((peerServerIds, peerId) => { + if (!peerServerIds.has(serverId)) { + peersToClose.push(peerId); + } + }); + + return peersToClose; + } + + destroy(): 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.clearJoinedServers(); + this.clearLastJoinedServers(); + this.clearPeerTracking(); + } + + 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; + } +} diff --git a/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts b/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts new file mode 100644 index 0000000..8a8cddc --- /dev/null +++ b/src/app/infrastructure/realtime/signaling/signaling-message-handler.ts @@ -0,0 +1,256 @@ +import { SignalingMessage } from '../../../core/models'; +import { PeerData } from '../realtime.types'; +import { + SIGNALING_TYPE_ANSWER, + SIGNALING_TYPE_CONNECTED, + SIGNALING_TYPE_ICE_CANDIDATE, + SIGNALING_TYPE_OFFER, + SIGNALING_TYPE_SERVER_USERS, + SIGNALING_TYPE_USER_JOINED, + SIGNALING_TYPE_USER_LEFT +} from '../realtime.constants'; +import { PeerConnectionManager } from '../peer-connection-manager/peer-connection.manager'; +import { ServerSignalingCoordinator } from './server-signaling-coordinator'; +import { WebRTCLogger } from '../logging/webrtc-logger'; + +interface SignalingUserSummary { + oderId: string; + displayName: string; +} + +interface IncomingSignalingPayload { + sdp?: RTCSessionDescriptionInit; + candidate?: RTCIceCandidateInit; +} + +export type IncomingSignalingMessage = Omit, 'type' | 'payload'> & { + type: string; + payload?: IncomingSignalingPayload; + oderId?: string; + serverTime?: number; + serverId?: string; + serverIds?: string[]; + users?: SignalingUserSummary[]; + displayName?: string; + fromUserId?: string; +}; + +interface IncomingSignalingMessageHandlerDependencies { + peerManager: PeerConnectionManager; + signalingCoordinator: ServerSignalingCoordinator; + logger: WebRTCLogger; + getEffectiveServerId(): string | null; + setServerTime(serverTime: number): void; +} + +export class IncomingSignalingMessageHandler { + constructor( + private readonly dependencies: IncomingSignalingMessageHandlerDependencies + ) {} + + handleMessage(message: IncomingSignalingMessage, signalUrl: string): void { + this.dependencies.logger.info('Signaling message', { + signalUrl, + type: message.type + }); + + switch (message.type) { + case SIGNALING_TYPE_CONNECTED: + this.handleConnectedSignalingMessage(message, signalUrl); + return; + + case SIGNALING_TYPE_SERVER_USERS: + this.handleServerUsersSignalingMessage(message, signalUrl); + return; + + case SIGNALING_TYPE_USER_JOINED: + this.handleUserJoinedSignalingMessage(message, signalUrl); + return; + + case SIGNALING_TYPE_USER_LEFT: + this.handleUserLeftSignalingMessage(message, signalUrl); + return; + + case SIGNALING_TYPE_OFFER: + this.handleOfferSignalingMessage(message, signalUrl); + return; + + case SIGNALING_TYPE_ANSWER: + this.handleAnswerSignalingMessage(message, signalUrl); + return; + + case SIGNALING_TYPE_ICE_CANDIDATE: + this.handleIceCandidateSignalingMessage(message, signalUrl); + return; + + default: + return; + } + } + + private handleConnectedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + this.dependencies.logger.info('Server connected', { + oderId: message.oderId, + signalUrl + }); + + if (message.serverId) { + this.dependencies.signalingCoordinator.setServerSignalUrl(message.serverId, signalUrl); + } + + if (typeof message.serverTime === 'number') { + this.dependencies.setServerTime(message.serverTime); + } + } + + private handleServerUsersSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + const users = Array.isArray(message.users) ? message.users : []; + + this.dependencies.logger.info('Server users', { + count: users.length, + signalUrl, + serverId: message.serverId + }); + + if (message.serverId) { + this.dependencies.signalingCoordinator.setServerSignalUrl(message.serverId, signalUrl); + } + + for (const user of users) { + if (!user.oderId) + continue; + + this.dependencies.signalingCoordinator.setPeerSignalUrl(user.oderId, signalUrl); + + if (message.serverId) { + this.dependencies.signalingCoordinator.trackPeerInServer(user.oderId, message.serverId); + } + + const existing = this.dependencies.peerManager.activePeerConnections.get(user.oderId); + + if (this.canReusePeerConnection(existing)) { + this.dependencies.logger.info('Reusing active peer connection', { + connectionState: existing?.connection.connectionState ?? 'unknown', + dataChannelState: existing?.dataChannel?.readyState ?? 'missing', + oderId: user.oderId, + serverId: message.serverId, + signalUrl + }); + + continue; + } + + if (existing) { + this.dependencies.logger.info('Removing failed peer before recreate', { + connectionState: existing.connection.connectionState, + dataChannelState: existing.dataChannel?.readyState ?? 'missing', + oderId: user.oderId, + serverId: message.serverId, + signalUrl + }); + + this.dependencies.peerManager.removePeer(user.oderId); + } + + this.dependencies.logger.info('Create peer connection to existing user', { + oderId: user.oderId, + serverId: message.serverId, + signalUrl + }); + + this.dependencies.peerManager.createPeerConnection(user.oderId, true); + void this.dependencies.peerManager.createAndSendOffer(user.oderId); + } + } + + private handleUserJoinedSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + this.dependencies.logger.info('User joined', { + displayName: message.displayName, + oderId: message.oderId, + signalUrl + }); + + if (message.serverId) { + this.dependencies.signalingCoordinator.setServerSignalUrl(message.serverId, signalUrl); + } + + if (message.oderId) { + this.dependencies.signalingCoordinator.setPeerSignalUrl(message.oderId, signalUrl); + } + + if (message.oderId && message.serverId) { + this.dependencies.signalingCoordinator.trackPeerInServer(message.oderId, message.serverId); + } + } + + private handleUserLeftSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + this.dependencies.logger.info('User left', { + displayName: message.displayName, + oderId: message.oderId, + signalUrl, + serverId: message.serverId + }); + + if (message.oderId) { + const hasRemainingSharedServers = Array.isArray(message.serverIds) + ? this.dependencies.signalingCoordinator.replacePeerSharedServers(message.oderId, message.serverIds) + : (message.serverId + ? this.dependencies.signalingCoordinator.untrackPeerFromServer(message.oderId, message.serverId) + : false); + + if (!hasRemainingSharedServers) { + this.dependencies.peerManager.removePeer(message.oderId); + this.dependencies.signalingCoordinator.deletePeerTracking(message.oderId); + } + } + } + + private handleOfferSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + const fromUserId = message.fromUserId; + const sdp = message.payload?.sdp; + + if (!fromUserId || !sdp) + return; + + this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl); + + const effectiveServerId = this.dependencies.getEffectiveServerId(); + + if (effectiveServerId && !this.dependencies.signalingCoordinator.hasTrackedPeerServers(fromUserId)) { + this.dependencies.signalingCoordinator.trackPeerInServer(fromUserId, effectiveServerId); + } + + this.dependencies.peerManager.handleOffer(fromUserId, sdp); + } + + private handleAnswerSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + const fromUserId = message.fromUserId; + const sdp = message.payload?.sdp; + + if (!fromUserId || !sdp) + return; + + this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl); + this.dependencies.peerManager.handleAnswer(fromUserId, sdp); + } + + private handleIceCandidateSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void { + const fromUserId = message.fromUserId; + const candidate = message.payload?.candidate; + + if (!fromUserId || !candidate) + return; + + this.dependencies.signalingCoordinator.setPeerSignalUrl(fromUserId, signalUrl); + this.dependencies.peerManager.handleIceCandidate(fromUserId, candidate); + } + + private canReusePeerConnection(peer: PeerData | undefined): boolean { + if (!peer) + return false; + + const connectionState = peer.connection?.connectionState; + + return connectionState !== 'closed' && connectionState !== 'failed'; + } +} diff --git a/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts b/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts new file mode 100644 index 0000000..4b62684 --- /dev/null +++ b/src/app/infrastructure/realtime/signaling/signaling-transport-handler.ts @@ -0,0 +1,172 @@ +import { Observable, of } from 'rxjs'; +import { SignalingMessage } from '../../../core/models'; +import { DEFAULT_DISPLAY_NAME, SIGNALING_TYPE_IDENTIFY } from '../realtime.constants'; +import { IdentifyCredentials } from '../realtime.types'; +import { ConnectedSignalingManager, ServerSignalingCoordinator } from './server-signaling-coordinator'; +import { WebRTCLogger } from '../logging/webrtc-logger'; + +interface SignalingTransportHandlerDependencies { + signalingCoordinator: ServerSignalingCoordinator; + logger: WebRTCLogger; + getLocalPeerId(): string; +} + +export class SignalingTransportHandler { + private lastIdentifyCredentials: IdentifyCredentials | null = null; + + constructor( + private readonly dependencies: SignalingTransportHandlerDependencies + ) {} + + getIdentifyCredentials(): IdentifyCredentials | null { + return this.lastIdentifyCredentials; + } + + getIdentifyOderId(): string { + return this.lastIdentifyCredentials?.oderId || this.dependencies.getLocalPeerId(); + } + + getIdentifyDisplayName(): string { + return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME; + } + + getConnectedSignalingManagers(): ConnectedSignalingManager[] { + return this.dependencies.signalingCoordinator.getConnectedSignalingManagers(); + } + + getCurrentSignalingUrl(activeServerId: string | null): string | null { + if (activeServerId) { + const activeServerSignalUrl = this.dependencies.signalingCoordinator.getServerSignalUrl(activeServerId); + + if (activeServerSignalUrl) { + return activeServerSignalUrl; + } + } + + return this.getConnectedSignalingManagers()[0]?.signalUrl ?? null; + } + + connectToSignalingServer(serverUrl: string): Observable { + const manager = this.dependencies.signalingCoordinator.ensureSignalingManager(serverUrl); + + if (manager.isSocketOpen()) { + return of(true); + } + + return manager.connect(serverUrl); + } + + isSignalingConnectedTo(serverUrl: string): boolean { + return this.dependencies.signalingCoordinator.isSignalingConnectedTo(serverUrl); + } + + async ensureSignalingConnected(timeoutMs?: number): Promise { + return await this.dependencies.signalingCoordinator.ensureAnySignalingConnected(timeoutMs); + } + + sendSignalingMessage(message: Omit): void { + const targetPeerId = message.to; + + if (targetPeerId) { + const targetSignalUrl = this.dependencies.signalingCoordinator.getPeerSignalUrl(targetPeerId); + + if (targetSignalUrl) { + const targetManager = this.dependencies.signalingCoordinator.ensureSignalingManager(targetSignalUrl); + + targetManager.sendSignalingMessage(message, this.dependencies.getLocalPeerId()); + return; + } + } + + const connectedManagers = this.getConnectedSignalingManagers(); + + if (connectedManagers.length === 0) { + this.dependencies.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.dependencies.getLocalPeerId()); + } + } + + sendRawMessage(message: Record): void { + const targetPeerId = typeof message['targetUserId'] === 'string' ? message['targetUserId'] : null; + + if (targetPeerId) { + const targetSignalUrl = this.dependencies.signalingCoordinator.getPeerSignalUrl(targetPeerId); + + if (targetSignalUrl && this.sendRawMessageToSignalUrl(targetSignalUrl, message)) { + return; + } + } + + const serverId = typeof message['serverId'] === 'string' ? message['serverId'] : null; + + if (serverId) { + const serverSignalUrl = this.dependencies.signalingCoordinator.getServerSignalUrl(serverId); + + if (serverSignalUrl && this.sendRawMessageToSignalUrl(serverSignalUrl, message)) { + return; + } + } + + const connectedManagers = this.getConnectedSignalingManagers(); + + if (connectedManagers.length === 0) { + this.dependencies.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); + } + } + + sendRawMessageToSignalUrl(signalUrl: string, message: Record): boolean { + const manager = this.dependencies.signalingCoordinator.getSignalingManager(signalUrl); + + if (!manager) { + return false; + } + + manager.sendRawMessage(message); + return true; + } + + identify(oderId: string, displayName: string, signalUrl?: string): void { + const normalizedDisplayName = displayName.trim() || DEFAULT_DISPLAY_NAME; + + this.lastIdentifyCredentials = { + oderId, + displayName: normalizedDisplayName + }; + + const identifyMessage = { + type: SIGNALING_TYPE_IDENTIFY, + oderId, + displayName: normalizedDisplayName + }; + + if (signalUrl) { + this.sendRawMessageToSignalUrl(signalUrl, identifyMessage); + return; + } + + const connectedManagers = this.getConnectedSignalingManagers(); + + if (connectedManagers.length === 0) { + return; + } + + for (const { manager } of connectedManagers) { + manager.sendRawMessage(identifyMessage); + } + } +} diff --git a/src/app/core/services/webrtc/signaling.manager.ts b/src/app/infrastructure/realtime/signaling/signaling.manager.ts similarity index 98% rename from src/app/core/services/webrtc/signaling.manager.ts rename to src/app/infrastructure/realtime/signaling/signaling.manager.ts index 15ba64a..de73231 100644 --- a/src/app/core/services/webrtc/signaling.manager.ts +++ b/src/app/infrastructure/realtime/signaling/signaling.manager.ts @@ -4,10 +4,10 @@ * including automatic reconnection and heartbeats. */ import { Observable, Subject } from 'rxjs'; -import { SignalingMessage } from '../../models'; -import { recordDebugNetworkSignalingPayload } from '../debug-network-metrics.service'; -import { WebRTCLogger } from './webrtc-logger'; -import { IdentifyCredentials, JoinedServerInfo } from './webrtc.types'; +import { SignalingMessage } from '../../../core/models'; +import { recordDebugNetworkSignalingPayload } from '../logging/debug-network-metrics'; +import { IdentifyCredentials, JoinedServerInfo } from '../realtime.types'; +import { WebRTCLogger } from '../logging/webrtc-logger'; import { SIGNALING_RECONNECT_BASE_DELAY_MS, SIGNALING_RECONNECT_MAX_DELAY_MS, @@ -16,7 +16,7 @@ import { SIGNALING_TYPE_IDENTIFY, SIGNALING_TYPE_JOIN_SERVER, SIGNALING_TYPE_VIEW_SERVER -} from './webrtc.constants'; +} from '../realtime.constants'; interface ParsedSignalingPayload { sdp?: RTCSessionDescriptionInit; diff --git a/src/app/infrastructure/realtime/state/webrtc-state-controller.ts b/src/app/infrastructure/realtime/state/webrtc-state-controller.ts new file mode 100644 index 0000000..14ef404 --- /dev/null +++ b/src/app/infrastructure/realtime/state/webrtc-state-controller.ts @@ -0,0 +1,155 @@ +import { + Signal, + computed, + signal +} from '@angular/core'; +import { v4 as uuidv4 } from 'uuid'; +import type { LocalScreenShareState } from '../media/screen-share.manager'; + +export class WebRtcStateController { + readonly peerId: Signal; + readonly isConnected: Signal; + readonly hasEverConnected: Signal; + readonly isVoiceConnected: Signal; + readonly connectedPeers: Signal; + readonly isMuted: Signal; + readonly isDeafened: Signal; + readonly isScreenSharing: Signal; + readonly isNoiseReductionEnabled: Signal; + readonly screenStream: Signal; + readonly isScreenShareRemotePlaybackSuppressed: Signal; + readonly forceDefaultRemotePlaybackOutput: Signal; + readonly hasConnectionError: Signal; + readonly connectionErrorMessage: Signal; + readonly shouldShowConnectionError: Signal; + readonly peerLatencies: Signal>; + + private activeServerId: string | null = null; + private readonly _localPeerId = signal(uuidv4()); + private readonly _isSignalingConnected = signal(false); + private readonly _isVoiceConnected = signal(false); + private readonly _connectedPeers = signal([]); + private readonly _isMuted = signal(false); + private readonly _isDeafened = signal(false); + private readonly _isScreenSharing = signal(false); + private readonly _isNoiseReductionEnabled = signal(false); + private readonly _screenStreamSignal = signal(null); + private readonly _isScreenShareRemotePlaybackSuppressed = signal(false); + private readonly _forceDefaultRemotePlaybackOutput = signal(false); + private readonly _hasConnectionError = signal(false); + private readonly _connectionErrorMessage = signal(null); + private readonly _hasEverConnected = signal(false); + private readonly _peerLatencies = signal>(new Map()); + + constructor() { + this.peerId = computed(() => this._localPeerId()); + this.isConnected = computed(() => this._isSignalingConnected()); + this.hasEverConnected = computed(() => this._hasEverConnected()); + this.isVoiceConnected = computed(() => this._isVoiceConnected()); + this.connectedPeers = computed(() => this._connectedPeers()); + this.isMuted = computed(() => this._isMuted()); + this.isDeafened = computed(() => this._isDeafened()); + this.isScreenSharing = computed(() => this._isScreenSharing()); + this.isNoiseReductionEnabled = computed(() => this._isNoiseReductionEnabled()); + this.screenStream = computed(() => this._screenStreamSignal()); + this.isScreenShareRemotePlaybackSuppressed = computed(() => this._isScreenShareRemotePlaybackSuppressed()); + this.forceDefaultRemotePlaybackOutput = computed(() => this._forceDefaultRemotePlaybackOutput()); + this.hasConnectionError = computed(() => this._hasConnectionError()); + this.connectionErrorMessage = computed(() => this._connectionErrorMessage()); + this.shouldShowConnectionError = computed(() => { + if (!this._hasConnectionError()) + return false; + + if (this._isVoiceConnected() && this._connectedPeers().length > 0) + return false; + + return true; + }); + + this.peerLatencies = computed(() => this._peerLatencies()); + } + + get currentServerId(): string | null { + return this.activeServerId; + } + + getLocalPeerId(): string { + return this._localPeerId(); + } + + isSignalingConnected(): boolean { + return this._isSignalingConnected(); + } + + isVoiceConnectedActive(): boolean { + return this._isVoiceConnected(); + } + + isScreenSharingActive(): boolean { + return this._isScreenSharing(); + } + + setCurrentServer(serverId: string): void { + this.activeServerId = serverId; + } + + setVoiceConnected(connected: boolean): void { + this._isVoiceConnected.set(connected); + } + + setMuted(muted: boolean): void { + this._isMuted.set(muted); + } + + setDeafened(deafened: boolean): void { + this._isDeafened.set(deafened); + } + + setNoiseReductionEnabled(enabled: boolean): void { + this._isNoiseReductionEnabled.set(enabled); + } + + setConnectedPeers(peers: string[]): void { + this._connectedPeers.set(peers); + } + + syncPeerLatencies(latencies: ReadonlyMap): void { + this._peerLatencies.set(new Map(latencies)); + } + + applyLocalScreenShareState(state: LocalScreenShareState): void { + this._isScreenSharing.set(state.active); + this._screenStreamSignal.set(state.stream); + this._isScreenShareRemotePlaybackSuppressed.set(state.suppressRemotePlayback); + this._forceDefaultRemotePlaybackOutput.set(state.forceDefaultRemotePlaybackOutput); + } + + clearPeerViewState(): void { + this._connectedPeers.set([]); + this._peerLatencies.set(new Map()); + } + + clearScreenShareState(): void { + this._isScreenSharing.set(false); + this._screenStreamSignal.set(null); + this._isScreenShareRemotePlaybackSuppressed.set(false); + this._forceDefaultRemotePlaybackOutput.set(false); + } + + resetConnectionState(): void { + this._isSignalingConnected.set(false); + this._hasEverConnected.set(false); + this._hasConnectionError.set(false); + this._connectionErrorMessage.set(null); + } + + updateSignalingConnectionStatus(anyConnected: boolean, markHasEverConnected: boolean, errorMessage?: string): void { + if (markHasEverConnected) { + this._hasEverConnected.set(true); + } + + this._isSignalingConnected.set(anyConnected); + this._hasConnectionError.set(!anyConnected); + this._connectionErrorMessage.set(anyConnected ? null : (errorMessage ?? 'Disconnected from signaling server')); + } +} diff --git a/src/app/infrastructure/realtime/streams/peer-media-facade.ts b/src/app/infrastructure/realtime/streams/peer-media-facade.ts new file mode 100644 index 0000000..e03c749 --- /dev/null +++ b/src/app/infrastructure/realtime/streams/peer-media-facade.ts @@ -0,0 +1,129 @@ +import { Observable } from 'rxjs'; +import { ChatEvent } from '../../../core/models'; +import { ScreenShareStartOptions } from '../screen-share.config'; +import { PeerData } from '../realtime.types'; +import { MediaManager } from '../media/media.manager'; +import { PeerConnectionManager } from '../peer-connection-manager/peer-connection.manager'; +import { ScreenShareManager } from '../media/screen-share.manager'; + +interface PeerMediaFacadeDependencies { + peerManager: PeerConnectionManager; + mediaManager: MediaManager; + screenShareManager: ScreenShareManager; +} + +export class PeerMediaFacade { + constructor( + private readonly dependencies: PeerMediaFacadeDependencies + ) {} + + get onMessageReceived(): Observable { + return this.dependencies.peerManager.messageReceived$.asObservable(); + } + + get onPeerConnected(): Observable { + return this.dependencies.peerManager.peerConnected$.asObservable(); + } + + get onPeerDisconnected(): Observable { + return this.dependencies.peerManager.peerDisconnected$.asObservable(); + } + + get onRemoteStream(): Observable<{ peerId: string; stream: MediaStream }> { + return this.dependencies.peerManager.remoteStream$.asObservable(); + } + + get onVoiceConnected(): Observable { + return this.dependencies.mediaManager.voiceConnected$.asObservable(); + } + + getActivePeers(): Map { + return this.dependencies.peerManager.activePeerConnections; + } + + async renegotiate(peerId: string): Promise { + return await this.dependencies.peerManager.renegotiate(peerId); + } + + broadcastMessage(event: ChatEvent): void { + this.dependencies.peerManager.broadcastMessage(event); + } + + sendToPeer(peerId: string, event: ChatEvent): void { + this.dependencies.peerManager.sendToPeer(peerId, event); + } + + async sendToPeerBuffered(peerId: string, event: ChatEvent): Promise { + return await this.dependencies.peerManager.sendToPeerBuffered(peerId, event); + } + + broadcastCurrentStates(): void { + this.dependencies.peerManager.broadcastCurrentStates(); + } + + getConnectedPeerIds(): string[] { + return this.dependencies.peerManager.getConnectedPeerIds(); + } + + getRemoteStream(peerId: string): MediaStream | null { + return this.dependencies.peerManager.remotePeerStreams.get(peerId) ?? null; + } + + getRemoteVoiceStream(peerId: string): MediaStream | null { + return this.dependencies.peerManager.remotePeerVoiceStreams.get(peerId) ?? null; + } + + getRemoteScreenShareStream(peerId: string): MediaStream | null { + return this.dependencies.peerManager.remotePeerScreenShareStreams.get(peerId) ?? null; + } + + clearRemoteScreenShareStream(peerId: string): void { + this.dependencies.peerManager.clearRemoteScreenShareStream(peerId); + } + + closeAllPeers(): void { + this.dependencies.peerManager.closeAllPeers(); + } + + getLocalStream(): MediaStream | null { + return this.dependencies.mediaManager.getLocalStream(); + } + + getRawMicStream(): MediaStream | null { + return this.dependencies.mediaManager.getRawMicStream(); + } + + isScreenShareActive(): boolean { + return this.dependencies.screenShareManager.getIsScreenActive(); + } + + async startScreenShare(options: ScreenShareStartOptions): Promise { + return await this.dependencies.screenShareManager.startScreenShare(options); + } + + stopScreenShare(): void { + this.dependencies.screenShareManager.stopScreenShare(); + } + + requestScreenShareForPeer(peerId: string): void { + this.dependencies.screenShareManager.requestScreenShareForPeer(peerId); + } + + stopScreenShareForPeer(peerId: string): void { + this.dependencies.screenShareManager.stopScreenShareForPeer(peerId); + } + + clearScreenShareRequest(peerId: string): void { + this.dependencies.screenShareManager.clearScreenShareRequest(peerId); + } + + syncScreenShareToPeer(peerId: string): void { + this.dependencies.screenShareManager.syncScreenShareToPeer(peerId); + } + + destroy(): void { + this.dependencies.peerManager.destroy(); + this.dependencies.mediaManager.destroy(); + this.dependencies.screenShareManager.destroy(); + } +} diff --git a/src/app/infrastructure/realtime/streams/remote-screen-share-request-controller.ts b/src/app/infrastructure/realtime/streams/remote-screen-share-request-controller.ts new file mode 100644 index 0000000..94d139b --- /dev/null +++ b/src/app/infrastructure/realtime/streams/remote-screen-share-request-controller.ts @@ -0,0 +1,113 @@ +import { ChatEvent } from '../../../core/models'; +import { + P2P_TYPE_SCREEN_SHARE_REQUEST, + P2P_TYPE_SCREEN_SHARE_STOP, + P2P_TYPE_SCREEN_STATE +} from '../realtime.constants'; + +interface RemoteScreenShareRequestControllerDependencies { + getConnectedPeerIds(): string[]; + sendToPeer(peerId: string, event: ChatEvent): void; + clearRemoteScreenShareStream(peerId: string): void; + requestScreenShareForPeer(peerId: string): void; + stopScreenShareForPeer(peerId: string): void; + clearScreenShareRequest(peerId: string): void; +} + +export class RemoteScreenShareRequestController { + private remoteScreenShareRequestsEnabled = false; + private readonly desiredRemoteScreenSharePeers = new Set(); + private readonly activeRemoteScreenSharePeers = new Set(); + + constructor( + private readonly dependencies: RemoteScreenShareRequestControllerDependencies + ) {} + + handlePeerConnected(peerId: string): void { + if (this.remoteScreenShareRequestsEnabled && this.desiredRemoteScreenSharePeers.has(peerId)) { + this.requestRemoteScreenShares([peerId]); + } + } + + handlePeerDisconnected(peerId: string): void { + this.activeRemoteScreenSharePeers.delete(peerId); + this.dependencies.clearScreenShareRequest(peerId); + } + + handlePeerControlMessage(event: ChatEvent): void { + if (!event.fromPeerId) { + return; + } + + if (event.type === P2P_TYPE_SCREEN_STATE && event.isScreenSharing === false) { + this.dependencies.clearRemoteScreenShareStream(event.fromPeerId); + return; + } + + if (event.type === P2P_TYPE_SCREEN_SHARE_REQUEST) { + this.dependencies.requestScreenShareForPeer(event.fromPeerId); + return; + } + + if (event.type === P2P_TYPE_SCREEN_SHARE_STOP) { + this.dependencies.stopScreenShareForPeer(event.fromPeerId); + } + } + + syncRemoteScreenShareRequests(peerIds: string[], enabled: boolean): void { + const nextDesiredPeers = new Set( + peerIds.filter((peerId): peerId is string => !!peerId) + ); + + if (!enabled) { + this.remoteScreenShareRequestsEnabled = false; + this.desiredRemoteScreenSharePeers.clear(); + this.stopRemoteScreenShares([...this.activeRemoteScreenSharePeers]); + return; + } + + this.remoteScreenShareRequestsEnabled = true; + + for (const activePeerId of [...this.activeRemoteScreenSharePeers]) { + if (!nextDesiredPeers.has(activePeerId)) { + this.stopRemoteScreenShares([activePeerId]); + } + } + + this.desiredRemoteScreenSharePeers.clear(); + nextDesiredPeers.forEach((peerId) => this.desiredRemoteScreenSharePeers.add(peerId)); + this.requestRemoteScreenShares([...nextDesiredPeers]); + } + + clear(): void { + this.remoteScreenShareRequestsEnabled = false; + this.desiredRemoteScreenSharePeers.clear(); + this.activeRemoteScreenSharePeers.clear(); + } + + private requestRemoteScreenShares(peerIds: string[]): void { + const connectedPeerIds = new Set(this.dependencies.getConnectedPeerIds()); + + for (const peerId of peerIds) { + if (!connectedPeerIds.has(peerId) || this.activeRemoteScreenSharePeers.has(peerId)) { + continue; + } + + this.dependencies.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_REQUEST }); + this.activeRemoteScreenSharePeers.add(peerId); + } + } + + private stopRemoteScreenShares(peerIds: string[]): void { + const connectedPeerIds = new Set(this.dependencies.getConnectedPeerIds()); + + for (const peerId of peerIds) { + if (this.activeRemoteScreenSharePeers.has(peerId) && connectedPeerIds.has(peerId)) { + this.dependencies.sendToPeer(peerId, { type: P2P_TYPE_SCREEN_SHARE_STOP }); + } + + this.activeRemoteScreenSharePeers.delete(peerId); + this.dependencies.clearRemoteScreenShareStream(peerId); + } + } +} diff --git a/src/app/shared/components/debug-console/services/debug-console-environment.service.ts b/src/app/shared/components/debug-console/services/debug-console-environment.service.ts index f5240db..d39f513 100644 --- a/src/app/shared/components/debug-console/services/debug-console-environment.service.ts +++ b/src/app/shared/components/debug-console/services/debug-console-environment.service.ts @@ -2,7 +2,8 @@ import { Injectable, inject } from '@angular/core'; import { Store } from '@ngrx/store'; import { selectCurrentUser } from '../../../../store/users/users.selectors'; -import { PlatformService } from '../../../../core/services/platform.service'; +import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service'; +import { PlatformService } from '../../../../core/platform'; export interface DebugExportEnvironment { appVersion: string; @@ -15,13 +16,10 @@ export interface DebugExportEnvironment { userId: string; } -interface DebugConsoleElectronApi { - linuxDisplayServer?: string; -} - @Injectable({ providedIn: 'root' }) export class DebugConsoleEnvironmentService { private readonly store = inject(Store); + private readonly electronBridge = inject(ElectronBridgeService); private readonly platformService = inject(PlatformService); private readonly currentUser = this.store.selectSignal(selectCurrentUser); @@ -144,7 +142,7 @@ export class DebugConsoleEnvironmentService { private readElectronDisplayServer(): string | null { try { - const displayServer = this.getElectronApi()?.linuxDisplayServer; + const displayServer = this.electronBridge.getApi()?.linuxDisplayServer; return typeof displayServer === 'string' && displayServer.trim().length > 0 ? displayServer @@ -157,7 +155,7 @@ export class DebugConsoleEnvironmentService { private detectDisplayServerFromEnv(): string { try { // Electron may expose env vars - const api = this.getElectronApi(); + const api = this.electronBridge.getApi(); if (!api) return 'Unknown (Linux)'; @@ -220,15 +218,4 @@ export class DebugConsoleEnvironmentService { return null; } } - - private getElectronApi(): DebugConsoleElectronApi | null { - try { - const win = window as Window & - { electronAPI?: DebugConsoleElectronApi }; - - return win.electronAPI ?? null; - } catch { - return null; - } - } } diff --git a/src/app/shared/components/screen-share-quality-dialog/screen-share-quality-dialog.component.ts b/src/app/shared/components/screen-share-quality-dialog/screen-share-quality-dialog.component.ts index e7b5bf3..bc93f7b 100644 --- a/src/app/shared/components/screen-share-quality-dialog/screen-share-quality-dialog.component.ts +++ b/src/app/shared/components/screen-share-quality-dialog/screen-share-quality-dialog.component.ts @@ -7,7 +7,7 @@ import { signal } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { ScreenShareQuality, SCREEN_SHARE_QUALITY_OPTIONS } from '../../../core/services/webrtc/screen-share.config'; +import { ScreenShareQuality, SCREEN_SHARE_QUALITY_OPTIONS } from '../../../domains/screen-share'; @Component({ selector: 'app-screen-share-quality-dialog', diff --git a/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.ts b/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.ts index c58b1a2..8994eb9 100644 --- a/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.ts +++ b/src/app/shared/components/screen-share-source-picker/screen-share-source-picker.component.ts @@ -13,7 +13,7 @@ import { ScreenShareSourceKind, ScreenShareSourceOption, ScreenShareSourcePickerService -} from '../../../core/services/screen-share-source-picker.service'; +} from '../../../domains/screen-share'; @Component({ selector: 'app-screen-share-source-picker', diff --git a/src/app/store/messages/messages-incoming.handlers.ts b/src/app/store/messages/messages-incoming.handlers.ts index fe915eb..4a43763 100644 --- a/src/app/store/messages/messages-incoming.handlers.ts +++ b/src/app/store/messages/messages-incoming.handlers.ts @@ -24,11 +24,11 @@ import { type Room, type User } from '../../core/models/index'; +import type { RealtimeSessionFacade } from '../../core/realtime'; import type { DebuggingService } from '../../core/services'; -import { DatabaseService } from '../../core/services/database.service'; +import { AttachmentFacade, type AttachmentMeta } from '../../domains/attachment'; +import { DatabaseService } from '../../infrastructure/persistence'; import { trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; -import { WebRTCService } from '../../core/services/webrtc.service'; -import { AttachmentService, type AttachmentMeta } from '../../core/services/attachment.service'; import { MessagesActions } from './messages.actions'; import { INVENTORY_LIMIT, @@ -91,8 +91,8 @@ function hasAttachmentMetaMap( /** Shared context injected into each handler function. */ export interface IncomingMessageContext { db: DatabaseService; - webrtc: WebRTCService; - attachments: AttachmentService; + webrtc: RealtimeSessionFacade; + attachments: AttachmentFacade; debugging: DebuggingService; currentUser: User | null; currentRoom: Room | null; @@ -275,7 +275,7 @@ function handleSyncBatch( async function processSyncBatch( event: SyncBatchEvent, db: DatabaseService, - attachments: AttachmentService + attachments: AttachmentFacade ): Promise { const toUpsert: Message[] = []; @@ -306,7 +306,7 @@ async function processSyncBatch( /** Queue best-effort auto-downloads for watched-room attachments. */ function queueWatchedAttachmentDownloads( attachmentMap: AttachmentMetaMap, - attachments: AttachmentService + attachments: AttachmentFacade ): void { for (const msgId of Object.keys(attachmentMap)) { attachments.queueAutoDownloadsForMessage(msgId); diff --git a/src/app/store/messages/messages-sync.effects.ts b/src/app/store/messages/messages-sync.effects.ts index 97b73e3..9137782 100644 --- a/src/app/store/messages/messages-sync.effects.ts +++ b/src/app/store/messages/messages-sync.effects.ts @@ -39,9 +39,9 @@ import { MessagesActions } from './messages.actions'; import { RoomsActions } from '../rooms/rooms.actions'; import { selectMessagesSyncing } from './messages.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors'; -import { DatabaseService } from '../../core/services/database.service'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { DatabaseService } from '../../infrastructure/persistence'; import { DebuggingService } from '../../core/services/debugging.service'; -import { WebRTCService } from '../../core/services/webrtc.service'; import { INVENTORY_LIMIT, FULL_SYNC_LIMIT, @@ -57,7 +57,7 @@ export class MessagesSyncEffects { private readonly store = inject(Store); private readonly db = inject(DatabaseService); private readonly debugging = inject(DebuggingService); - private readonly webrtc = inject(WebRTCService); + private readonly webrtc = inject(RealtimeSessionFacade); /** Tracks whether the last sync cycle found no new messages. */ private lastSyncClean = false; diff --git a/src/app/store/messages/messages.effects.ts b/src/app/store/messages/messages.effects.ts index 8ca4bf6..f717cc0 100644 --- a/src/app/store/messages/messages.effects.ts +++ b/src/app/store/messages/messages.effects.ts @@ -31,12 +31,12 @@ import { v4 as uuidv4 } from 'uuid'; import { MessagesActions } from './messages.actions'; import { selectCurrentUser } from '../users/users.selectors'; import { selectCurrentRoom } from '../rooms/rooms.selectors'; -import { DatabaseService } from '../../core/services/database.service'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { DatabaseService } from '../../infrastructure/persistence'; import { reportDebuggingError, trackDebuggingTaskFailure } from '../../core/helpers/debugging-helpers'; import { DebuggingService } from '../../core/services'; -import { WebRTCService } from '../../core/services/webrtc.service'; +import { AttachmentFacade } from '../../domains/attachment'; import { TimeSyncService } from '../../core/services/time-sync.service'; -import { AttachmentService } from '../../core/services/attachment.service'; import { DELETED_MESSAGE_CONTENT, Message, @@ -51,9 +51,9 @@ export class MessagesEffects { private readonly store = inject(Store); private readonly db = inject(DatabaseService); private readonly debugging = inject(DebuggingService); - private readonly webrtc = inject(WebRTCService); + private readonly attachments = inject(AttachmentFacade); + private readonly webrtc = inject(RealtimeSessionFacade); private readonly timeSync = inject(TimeSyncService); - private readonly attachments = inject(AttachmentService); /** Loads messages for a room from the local database, hydrating reactions. */ loadMessages$ = createEffect(() => diff --git a/src/app/store/messages/messages.helpers.ts b/src/app/store/messages/messages.helpers.ts index ef8b1a8..6f67262 100644 --- a/src/app/store/messages/messages.helpers.ts +++ b/src/app/store/messages/messages.helpers.ts @@ -6,7 +6,7 @@ */ import { Message } from '../../core/models/index'; import { DELETED_MESSAGE_CONTENT } from '../../core/models/index'; -import { DatabaseService } from '../../core/services/database.service'; +import { DatabaseService } from '../../infrastructure/persistence'; /** Maximum number of recent messages to include in sync inventories. */ export const INVENTORY_LIMIT = 1000; diff --git a/src/app/store/rooms/room-members-sync.effects.ts b/src/app/store/rooms/room-members-sync.effects.ts index cdfb0a1..a92ef3a 100644 --- a/src/app/store/rooms/room-members-sync.effects.ts +++ b/src/app/store/rooms/room-members-sync.effects.ts @@ -19,7 +19,7 @@ import { RoomMember, User } from '../../core/models/index'; -import { WebRTCService } from '../../core/services/webrtc.service'; +import { RealtimeSessionFacade } from '../../core/realtime'; import { UsersActions } from '../users/users.actions'; import { selectCurrentUser } from '../users/users.selectors'; import { RoomsActions } from './rooms.actions'; @@ -41,7 +41,7 @@ import { export class RoomMembersSyncEffects { private readonly actions$ = inject(Actions); private readonly store = inject(Store); - private readonly webrtc = inject(WebRTCService); + private readonly webrtc = inject(RealtimeSessionFacade); /** Ensure the local user is recorded in a room as soon as it becomes active. */ ensureCurrentMemberOnRoomEntry$ = createEffect(() => diff --git a/src/app/store/rooms/rooms.effects.ts b/src/app/store/rooms/rooms.effects.ts index d86763e..57477fd 100644 --- a/src/app/store/rooms/rooms.effects.ts +++ b/src/app/store/rooms/rooms.effects.ts @@ -30,13 +30,13 @@ import { UsersActions } from '../users/users.actions'; import { MessagesActions } from '../messages/messages.actions'; import { selectCurrentUser, selectAllUsers } from '../users/users.selectors'; import { selectCurrentRoom, selectSavedRooms } from './rooms.selectors'; -import { DatabaseService } from '../../core/services/database.service'; -import { WebRTCService } from '../../core/services/webrtc.service'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { DatabaseService } from '../../infrastructure/persistence'; import { CLIENT_UPDATE_REQUIRED_MESSAGE, - ServerDirectoryService, - ServerSourceSelector -} from '../../core/services/server-directory.service'; + type ServerSourceSelector, + ServerDirectoryFacade +} from '../../domains/server-directory'; import { ChatEvent, Room, @@ -127,8 +127,8 @@ export class RoomsEffects { private store = inject(Store); private router = inject(Router); private db = inject(DatabaseService); - private webrtc = inject(WebRTCService); - private serverDirectory = inject(ServerDirectoryService); + private webrtc = inject(RealtimeSessionFacade); + private serverDirectory = inject(ServerDirectoryFacade); private audioService = inject(NotificationAudioService); /** diff --git a/src/app/store/users/users.effects.ts b/src/app/store/users/users.effects.ts index a7c157c..f3ccef6 100644 --- a/src/app/store/users/users.effects.ts +++ b/src/app/store/users/users.effects.ts @@ -32,9 +32,9 @@ import { selectHostId } from './users.selectors'; import { selectCurrentRoom, selectSavedRooms } from '../rooms/rooms.selectors'; -import { DatabaseService } from '../../core/services/database.service'; -import { ServerDirectoryService } from '../../core/services/server-directory.service'; -import { WebRTCService } from '../../core/services/webrtc.service'; +import { RealtimeSessionFacade } from '../../core/realtime'; +import { DatabaseService } from '../../infrastructure/persistence'; +import { ServerDirectoryFacade } from '../../domains/server-directory'; import { BanEntry, ChatEvent, @@ -57,8 +57,8 @@ export class UsersEffects { private actions$ = inject(Actions); private store = inject(Store); private db = inject(DatabaseService); - private serverDirectory = inject(ServerDirectoryService); - private webrtc = inject(WebRTCService); + private serverDirectory = inject(ServerDirectoryFacade); + private webrtc = inject(RealtimeSessionFacade); // Load current user from storage /** Loads the persisted current user from the local database on startup. */ @@ -438,24 +438,24 @@ export class UsersEffects { { dispatch: false } ); - /** Keep signaling identity aligned with the current profile to avoid stale fallback names. */ - syncSignalingIdentity$ = createEffect( - () => - this.actions$.pipe( - ofType( - UsersActions.setCurrentUser, - UsersActions.loadCurrentUserSuccess - ), - withLatestFrom(this.store.select(selectCurrentUser)), - tap(([, user]) => { - if (!user) - return; - - this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user)); - }) + /** Keep signaling identity aligned with the current profile to avoid stale fallback names. */ + syncSignalingIdentity$ = createEffect( + () => + this.actions$.pipe( + ofType( + UsersActions.setCurrentUser, + UsersActions.loadCurrentUserSuccess ), - { dispatch: false } - ); + withLatestFrom(this.store.select(selectCurrentUser)), + tap(([, user]) => { + if (!user) + return; + + this.webrtc.identify(user.oderId || user.id, this.resolveDisplayName(user)); + }) + ), + { dispatch: false } + ); private resolveRoom(roomId: string | undefined, currentRoom: Room | null, savedRooms: Room[]): Room | null { if (!roomId)