diff --git a/.gitignore b/.gitignore index 4171d5c..c81291f 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,8 @@ dist-server/* AGENTS.md doc/** + +metoyou.sqlite* +metoyou.sqlite + +vitest/ diff --git a/README.md b/README.md index 94fc0fa..0ec69b9 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,88 @@ +# MetoYou / Toju -# Toju / Zoracord +MetoYou is a desktop-first chat stack managed as an npm monorepo. The repository contains the Angular 21 product client, the Electron desktop shell, the Node/TypeScript signaling server, the Playwright E2E suite, and the Angular 19 marketing website. -Desktop chat app with four parts: +## Packages -- `src/` Angular client -- `electron/` desktop shell, IPC, and local database -- `server/` directory server, join request API, and websocket events -- `website/` Toju website served at toju.app +| Path | Purpose | Docs | +| --- | --- | --- | +| `toju-app/` | Angular 21 product client | [toju-app/README.md](toju-app/README.md) | +| `electron/` | Electron main process, preload bridge, IPC, and desktop integrations | [electron/README.md](electron/README.md) | +| `server/` | Signaling server, server-directory API, and websocket runtime | [server/README.md](server/README.md) | +| `e2e/` | Playwright end-to-end coverage for the product client | [e2e/README.md](e2e/README.md) | +| `website/` | Angular 19 marketing site served separately from the product client | [website/README.md](website/README.md) | ## Install -1. Run `npm install` -2. Run `cd server && npm install` -3. Copy `.env.example` to `.env` +1. Run `npm install` from the repository root. +2. Run `cd server && npm install` for the server package. +3. If you need to work on the marketing site, run `cd website && npm install`. +4. Copy `.env.example` to `.env`. -## Config +## Configuration -Root `.env`: +- Root `.env` controls local SSL with `SSL=true|false`. +- The server also honors an optional `PORT` environment override at runtime. +- When `SSL=true`, run `./generate-cert.sh` once or let `./dev.sh` generate local certificates on first launch. +- `server/data/variables.json` stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. The server normalizes this file on startup. +- When `serverProtocol` is `https`, the certificates in `.certs/` must exist and match the configured host or IP. -- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode -- `PORT=3001` changes the server port in local development and overrides the server app setting +## Main Commands -If `SSL=true`, run `./generate-cert.sh` once. +- `npm run dev` starts the full desktop stack: server, product client, and Electron. +- `npm run start` starts only the Angular product client in `toju-app/`. +- `npm run electron:dev` starts the Angular product client and Electron together. +- `npm run server:dev` starts only the server with reload. +- `npm run build` builds the Angular product client to `dist/client`. +- `npm run build:electron` builds the Electron code to `dist/electron`. +- `npm run build:all` builds the product client, Electron, and server. +- `npm run test` runs the product-client Vitest suite. +- `npm run lint` runs ESLint across the repo. +- `npm run lint:fix` formats Angular templates, sorts template properties, and applies ESLint fixes. +- `npm run test:e2e`, `npm run test:e2e:ui`, `npm run test:e2e:debug`, and `npm run test:e2e:report` run the Playwright suite and report tooling. -Server files: - -- `server/data/variables.json` holds `klipyApiKey` -- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates -- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to) -- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`) -- `server/data/variables.json` can now also hold `serverPort` (1-65535) -- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP - -## Main commands - -- `npm run dev` starts Angular, the server, and Electron -- `npm run electron:dev` starts Angular and Electron -- `npm run server:dev` starts only the server -- `npm run build` builds the Angular client -- `npm run build:electron` builds the Electron code -- `npm run build:all` builds client, Electron, and server -- `npm run lint` runs ESLint -- `npm run lint:fix` formats templates, sorts template props, and fixes lint issues -- `npm run test` runs Angular tests - -## Server project - -The code in `server/` is a small Node and TypeScript service. -It handles the public server directory, join requests, websocket updates, and Klipy routes. - -Inside `server/`: - -- `npm run dev` starts the server with reload -- `npm run build` compiles to `dist/` -- `npm run start` runs the compiled server - -# Images - - - -## Main Toju app Structure +## Repository Map | Path | Description | -|------|-------------| -| `src/app/` | Main application root | -| `src/app/core/` | Core utilities, services, models | -| `src/app/domains/` | Domain-driven modules | -| `src/app/features/` | UI feature modules | -| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) | -| `src/app/shared/` | Shared UI components | -| `src/app/shared-kernel/` | Shared domain contracts & models | -| `src/app/store/` | Global state management | -| `src/assets/` | Static assets | -| `src/environments/` | Environment configs | +| --- | --- | +| `toju-app/src/app/domains/` | Product-client bounded contexts and domain facades | +| `toju-app/src/app/infrastructure/` | Shared client-side technical runtime such as persistence and realtime | +| `toju-app/src/app/shared-kernel/` | Cross-domain contracts shared inside the product client | +| `electron/` | Electron bootstrap, preload surface, IPC handlers, CQRS, and desktop adapters | +| `server/src/` | Express app, websocket runtime, config, CQRS, and persistence layers | +| `e2e/` | Playwright tests, helpers, fixtures, and page objects | +| `website/src/` | Marketing-site pages, assets, and SSR entry points | +| `tools/` | Build, release, formatting, and packaging scripts | ---- +## Product Client Docs -### Domains +| Area | Docs | +| --- | --- | +| Domains index | [toju-app/src/app/domains/README.md](toju-app/src/app/domains/README.md) | +| Access Control | [toju-app/src/app/domains/access-control/README.md](toju-app/src/app/domains/access-control/README.md) | +| Attachment | [toju-app/src/app/domains/attachment/README.md](toju-app/src/app/domains/attachment/README.md) | +| Authentication | [toju-app/src/app/domains/authentication/README.md](toju-app/src/app/domains/authentication/README.md) | +| Chat | [toju-app/src/app/domains/chat/README.md](toju-app/src/app/domains/chat/README.md) | +| Notifications | [toju-app/src/app/domains/notifications/README.md](toju-app/src/app/domains/notifications/README.md) | +| Profile Avatar | [toju-app/src/app/domains/profile-avatar/README.md](toju-app/src/app/domains/profile-avatar/README.md) | +| Screen Share | [toju-app/src/app/domains/screen-share/README.md](toju-app/src/app/domains/screen-share/README.md) | +| Server Directory | [toju-app/src/app/domains/server-directory/README.md](toju-app/src/app/domains/server-directory/README.md) | +| Theme | [toju-app/src/app/domains/theme/README.md](toju-app/src/app/domains/theme/README.md) | +| Voice Connection | [toju-app/src/app/domains/voice-connection/README.md](toju-app/src/app/domains/voice-connection/README.md) | +| Voice Session | [toju-app/src/app/domains/voice-session/README.md](toju-app/src/app/domains/voice-session/README.md) | +| Persistence | [toju-app/src/app/infrastructure/persistence/README.md](toju-app/src/app/infrastructure/persistence/README.md) | +| Realtime | [toju-app/src/app/infrastructure/realtime/README.md](toju-app/src/app/infrastructure/realtime/README.md) | +| Shared Kernel | [toju-app/src/app/shared-kernel/README.md](toju-app/src/app/shared-kernel/README.md) | -| Path | Link | -|------|------| -| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) | -| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) | -| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) | -| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) | -| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) | -| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) | -| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) | -| Domains Root | [app/domains/README.md](src/app/domains/README.md) | +## Supporting Docs ---- +- [doc/monorepo.md](doc/monorepo.md) +- [doc/typescript.md](doc/typescript.md) +- [docs/architecture.md](docs/architecture.md) -### Infrastructure +## Screenshots -| Path | Link | -|------|------| -| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) | -| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) | - ---- - -### Shared Kernel - -| Path | Link | -|------|------| -| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) | - ---- - -### Entry Points - -| File | Link | -|------|------| -| Main | [main.ts](src/main.ts) | -| Index HTML | [index.html](src/index.html) | -| App Root | [app/app.ts](src/app/app.ts) | + + diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..932d820 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,36 @@ +# End-to-End Tests + +Playwright suite for the MetoYou / Toju product client. The tests exercise browser flows such as authentication, chat, voice, screen sharing, and settings with reusable page objects and helpers. + +## Commands + +Run these from the repository root: + +- `npm run test:e2e` runs the full Playwright suite. +- `npm run test:e2e:ui` opens Playwright UI mode. +- `npm run test:e2e:debug` runs the suite in debug mode. +- `npm run test:e2e:report` opens the HTML report in `test-results/html-report`. + +You can also run `npx playwright test` from `e2e/` directly. + +## Runtime + +- `playwright.config.ts` starts `cd ../toju-app && npx ng serve` as the test web server. +- The suite targets `http://localhost:4200`. +- Tests currently run with a single Chromium worker. +- The browser launches with fake media-device flags and grants microphone/camera permissions. +- Artifacts are written to `../test-results/artifacts`, and the HTML report is written to `../test-results/html-report`. + +## Structure + +| Path | Description | +| --- | --- | +| `tests/` | Test specs grouped by feature area such as `auth/`, `chat/`, `voice/`, `screen-share/`, and `settings/` | +| `pages/` | Reusable Playwright page objects | +| `helpers/` | Test helpers, fake-server utilities, and WebRTC helpers | +| `fixtures/` | Shared test fixtures | + +## Notes + +- The suite is product-client focused; it does not currently spin up the marketing website. +- Keep reusable browser flows in `pages/` and cross-test utilities in `helpers/`. \ No newline at end of file diff --git a/e2e/helpers/seed-test-endpoint.ts b/e2e/helpers/seed-test-endpoint.ts index 2b1a69a..eff3d97 100644 --- a/e2e/helpers/seed-test-endpoint.ts +++ b/e2e/helpers/seed-test-endpoint.ts @@ -26,7 +26,7 @@ interface SeededEndpointStorageState { } function buildSeededEndpointStorageState( - endpointsOrPort: ReadonlyArray | number = Number(process.env.TEST_SERVER_PORT) || 3099 + endpointsOrPort: readonly SeededEndpointInput[] | number = Number(process.env.TEST_SERVER_PORT) || 3099 ): SeededEndpointStorageState { const endpoints = Array.isArray(endpointsOrPort) ? endpointsOrPort.map((endpoint) => ({ @@ -81,7 +81,7 @@ export async function installTestServerEndpoint( export async function installTestServerEndpoints( context: BrowserContext, - endpoints: ReadonlyArray + endpoints: readonly SeededEndpointInput[] ): Promise { const storageState = buildSeededEndpointStorageState(endpoints); @@ -111,7 +111,7 @@ export async function seedTestServerEndpoint( export async function seedTestServerEndpoints( page: Page, - endpoints: ReadonlyArray + endpoints: readonly SeededEndpointInput[] ): Promise { const storageState = buildSeededEndpointStorageState(endpoints); diff --git a/e2e/helpers/webrtc-helpers.ts b/e2e/helpers/webrtc-helpers.ts index 1b0fa11..7df6b43 100644 --- a/e2e/helpers/webrtc-helpers.ts +++ b/e2e/helpers/webrtc-helpers.ts @@ -129,6 +129,48 @@ export async function installWebRTCTracking(page: Page): Promise { /** * Wait until at least one RTCPeerConnection reaches the 'connected' state. */ + +/** + * Ensure every `AudioContext` created by the page auto-resumes so that + * the input-gain Web Audio pipeline (`source -> gain -> destination`) never + * stalls in the "suspended" state. + * + * On Linux with multiple headless Chromium instances, `new AudioContext()` + * can start suspended without a user-gesture gate, causing the media + * pipeline to emit only a single RTP packet. + * + * Call once per page, BEFORE navigating, alongside `installWebRTCTracking`. + */ +export async function installAutoResumeAudioContext(page: Page): Promise { + await page.addInitScript(() => { + const OrigAudioContext = window.AudioContext; + + (window as any).AudioContext = function(this: AudioContext, ...args: any[]) { + const ctx: AudioContext = new OrigAudioContext(...args); + // Track all created AudioContexts for test diagnostics + const tracked = ((window as any).__trackedAudioContexts ??= []) as AudioContext[]; + + tracked.push(ctx); + + if (ctx.state === 'suspended') { + ctx.resume().catch(() => { /* noop */ }); + } + + // Also catch transitions to suspended after creation + ctx.addEventListener('statechange', () => { + if (ctx.state === 'suspended') { + ctx.resume().catch(() => { /* noop */ }); + } + }); + + return ctx; + } as any; + + (window as any).AudioContext.prototype = OrigAudioContext.prototype; + Object.setPrototypeOf((window as any).AudioContext, OrigAudioContext); + }); +} + export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise { await page.waitForFunction( () => (window as any).__rtcConnections?.some( @@ -172,7 +214,7 @@ export async function waitForConnectedPeerCount(page: Page, expectedCount: numbe /** * Resume all suspended AudioContext instances created by the synthetic * media patch. Uses CDP `Runtime.evaluate` with `userGesture: true` so - * Chrome treats the call as a user-gesture — this satisfies the autoplay + * Chrome treats the call as a user-gesture - this satisfies the autoplay * policy that otherwise blocks `AudioContext.resume()`. */ export async function resumeSyntheticAudioContexts(page: Page): Promise { diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index c1ac927..0d46214 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -22,7 +22,11 @@ export default defineConfig({ ...devices['Desktop Chrome'], permissions: ['microphone', 'camera'], launchOptions: { - args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] + args: [ + '--use-fake-device-for-media-stream', + '--use-fake-ui-for-media-stream', + '--autoplay-policy=no-user-gesture-required' + ] } } } diff --git a/e2e/tests/screen-share/screen-share.spec.ts b/e2e/tests/screen-share/screen-share.spec.ts index d21a3d0..b05f949 100644 --- a/e2e/tests/screen-share/screen-share.spec.ts +++ b/e2e/tests/screen-share/screen-share.spec.ts @@ -8,7 +8,8 @@ import { waitForVideoFlow, waitForOutboundVideoFlow, waitForInboundVideoFlow, - dumpRtcDiagnostics + dumpRtcDiagnostics, + installAutoResumeAudioContext } from '../../helpers/webrtc-helpers'; import { RegisterPage } from '../../pages/register.page'; import { ServerSearchPage } from '../../pages/server-search.page'; @@ -38,7 +39,7 @@ async function registerUser(page: import('@playwright/test').Page, user: typeof await expect(page).toHaveURL(/\/search/, { timeout: 15_000 }); } -/** Both users register → Alice creates server → Bob joins. */ +/** Both users register -> Alice creates server -> Bob joins. */ async function setupServerWithBothUsers( alice: { page: import('@playwright/test').Page }, bob: { page: import('@playwright/test').Page } @@ -80,19 +81,45 @@ async function joinVoiceTogether( await expect(existingChannel).toBeVisible({ timeout: 10_000 }); } - await aliceRoom.joinVoiceChannel(VOICE_CHANNEL); - await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); - const bobRoom = new ChatRoomPage(bob.page); + const doJoin = async () => { + await aliceRoom.joinVoiceChannel(VOICE_CHANNEL); + await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); - await bobRoom.joinVoiceChannel(VOICE_CHANNEL); - await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); + await bobRoom.joinVoiceChannel(VOICE_CHANNEL); + await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); - // Wait for WebRTC + audio pipeline - await waitForPeerConnected(alice.page, 30_000); - await waitForPeerConnected(bob.page, 30_000); - await waitForAudioStatsPresent(alice.page, 20_000); - await waitForAudioStatsPresent(bob.page, 20_000); + // Wait for WebRTC + audio pipeline + await waitForPeerConnected(alice.page, 30_000); + await waitForPeerConnected(bob.page, 30_000); + await waitForAudioStatsPresent(alice.page, 20_000); + await waitForAudioStatsPresent(bob.page, 20_000); + }; + + await doJoin(); + + // Chromium's --use-fake-device-for-media-stream can produce a silent + // capture track on the very first getUserMedia call. If bidirectional + // audio doesn't flow within a short window, leave and rejoin voice to + // re-acquire the mic (second getUserMedia on a warm device works). + const aliceDelta = await waitForAudioFlow(alice.page, 10_000); + const bobDelta = await waitForAudioFlow(bob.page, 10_000); + const aliceFlowing = + (aliceDelta.outboundBytesDelta > 0 || aliceDelta.outboundPacketsDelta > 0) && + (aliceDelta.inboundBytesDelta > 0 || aliceDelta.inboundPacketsDelta > 0); + const bobFlowing = + (bobDelta.outboundBytesDelta > 0 || bobDelta.outboundPacketsDelta > 0) && + (bobDelta.inboundBytesDelta > 0 || bobDelta.inboundPacketsDelta > 0); + + if (!aliceFlowing || !bobFlowing) { + // Leave voice + await aliceRoom.disconnectButton.click(); + await bobRoom.disconnectButton.click(); + await alice.page.waitForTimeout(2_000); + + // Rejoin + await doJoin(); + } // Expand voice workspace on both clients so the demand-driven screen // share request flow can fire (requires connectRemoteShares = true). @@ -142,6 +169,20 @@ test.describe('Screen sharing', () => { await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); + await installAutoResumeAudioContext(alice.page); + await installAutoResumeAudioContext(bob.page); + + // Seed deterministic voice settings so noise reduction doesn't + // swallow the fake audio tone. + const voiceSettings = JSON.stringify({ + inputVolume: 100, outputVolume: 100, audioBitrate: 96, + latencyProfile: 'balanced', includeSystemAudio: false, + noiseReduction: false, screenShareQuality: 'balanced', + askScreenShareQuality: false + }); + + await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings); + await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings); alice.page.on('console', msg => console.log('[Alice]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text())); @@ -251,6 +292,18 @@ test.describe('Screen sharing', () => { await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); + await installAutoResumeAudioContext(alice.page); + await installAutoResumeAudioContext(bob.page); + + const voiceSettings = JSON.stringify({ + inputVolume: 100, outputVolume: 100, audioBitrate: 96, + latencyProfile: 'balanced', includeSystemAudio: false, + noiseReduction: false, screenShareQuality: 'balanced', + askScreenShareQuality: false + }); + + await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings); + await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings); alice.page.on('console', msg => console.log('[Alice]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text())); @@ -323,6 +376,18 @@ test.describe('Screen sharing', () => { await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); + await installAutoResumeAudioContext(alice.page); + await installAutoResumeAudioContext(bob.page); + + const voiceSettings = JSON.stringify({ + inputVolume: 100, outputVolume: 100, audioBitrate: 96, + latencyProfile: 'balanced', includeSystemAudio: false, + noiseReduction: false, screenShareQuality: 'balanced', + askScreenShareQuality: false + }); + + await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings); + await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings); alice.page.on('console', msg => console.log('[Alice]', msg.text())); bob.page.on('console', msg => console.log('[Bob]', msg.text())); diff --git a/e2e/tests/settings/connectivity-warning.spec.ts b/e2e/tests/settings/connectivity-warning.spec.ts new file mode 100644 index 0000000..cea0f04 --- /dev/null +++ b/e2e/tests/settings/connectivity-warning.spec.ts @@ -0,0 +1,227 @@ +import { test, expect } from '../../fixtures/multi-client'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; +import { ChatRoomPage } from '../../pages/chat-room.page'; +import { + installAutoResumeAudioContext, + installWebRTCTracking, + waitForConnectedPeerCount +} from '../../helpers/webrtc-helpers'; + +const VOICE_SETTINGS = JSON.stringify({ + inputVolume: 100, + outputVolume: 100, + audioBitrate: 96, + latencyProfile: 'balanced', + includeSystemAudio: false, + noiseReduction: false, + screenShareQuality: 'balanced', + askScreenShareQuality: false +}); + +/** + * Seed deterministic voice settings on a page so noise reduction and + * input gating don't interfere with the fake audio tone. + */ +async function seedVoiceSettings(page: import('@playwright/test').Page): Promise { + await page.addInitScript((settings: string) => { + localStorage.setItem('metoyou_voice_settings', settings); + }, VOICE_SETTINGS); +} + +/** + * Close all of a client's RTCPeerConnections and prevent any + * reconnection by sabotaging the SDP negotiation methods on the + * prototype - new connections get created but can never complete ICE. + * + * Chromium doesn't fire `connectionstatechange` on programmatic + * `close()`, so we dispatch the event manually so the app's recovery + * code runs and updates the connected-peers signal. + */ +async function killAndBlockPeerConnections(page: import('@playwright/test').Page): Promise { + await page.evaluate(() => { + // Sabotage SDP methods so no NEW connections can negotiate. + const proto = RTCPeerConnection.prototype; + + proto.createOffer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError')); + proto.createAnswer = () => Promise.reject(new DOMException('blocked', 'NotAllowedError')); + proto.setLocalDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError')); + proto.setRemoteDescription = () => Promise.reject(new DOMException('blocked', 'NotAllowedError')); + + // Close every existing connection and manually fire the event + // Chromium omits when close() is called from JS. + const connections = (window as { __rtcConnections?: RTCPeerConnection[] }).__rtcConnections ?? []; + + for (const pc of connections) { + try { + pc.close(); + pc.dispatchEvent(new Event('connectionstatechange')); + } catch { /* already closed */ } + } + }); +} + +test.describe('Connectivity warning', () => { + test.describe.configure({ timeout: 180_000 }); + + test('shows warning icon when a peer loses all connections', async ({ createClient }) => { + const suffix = `connwarn_${Date.now()}`; + const serverName = `ConnWarn ${suffix}`; + const alice = await createClient(); + const bob = await createClient(); + const charlie = await createClient(); + + // ── Install WebRTC tracking & AudioContext auto-resume ── + for (const client of [ + alice, + bob, + charlie + ]) { + await installWebRTCTracking(client.page); + await installAutoResumeAudioContext(client.page); + await seedVoiceSettings(client.page); + } + + // ── Register all three users ── + await test.step('Register Alice', async () => { + const register = new RegisterPage(alice.page); + + await register.goto(); + await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); + await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('Register Bob', async () => { + const register = new RegisterPage(bob.page); + + await register.goto(); + await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); + await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('Register Charlie', async () => { + const register = new RegisterPage(charlie.page); + + await register.goto(); + await register.register(`charlie_${suffix}`, 'Charlie', 'TestPass123!'); + await expect(charlie.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + }); + + // ── Create server and have everyone join ── + await test.step('Alice creates a server', async () => { + const search = new ServerSearchPage(alice.page); + + await search.createServer(serverName); + }); + + await test.step('Bob joins the server', async () => { + const search = new ServerSearchPage(bob.page); + + await search.searchInput.fill(serverName); + const card = bob.page.locator('button', { hasText: serverName }).first(); + + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + }); + + await test.step('Charlie joins the server', async () => { + const search = new ServerSearchPage(charlie.page); + + await search.searchInput.fill(serverName); + const card = charlie.page.locator('button', { hasText: serverName }).first(); + + await expect(card).toBeVisible({ timeout: 15_000 }); + await card.click(); + await expect(charlie.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + }); + + const aliceRoom = new ChatRoomPage(alice.page); + const bobRoom = new ChatRoomPage(bob.page); + const charlieRoom = new ChatRoomPage(charlie.page); + + // ── Everyone joins voice ── + await test.step('All three join voice', async () => { + await aliceRoom.joinVoiceChannel('General'); + await bobRoom.joinVoiceChannel('General'); + await charlieRoom.joinVoiceChannel('General'); + }); + + await test.step('All users see each other in voice', async () => { + // Each user should see the other two in the voice channel list. + await expect( + aliceRoom.channelsSidePanel.getByText('Bob') + ).toBeVisible({ timeout: 20_000 }); + + await expect( + aliceRoom.channelsSidePanel.getByText('Charlie') + ).toBeVisible({ timeout: 20_000 }); + + await expect( + bobRoom.channelsSidePanel.getByText('Alice') + ).toBeVisible({ timeout: 20_000 }); + + await expect( + bobRoom.channelsSidePanel.getByText('Charlie') + ).toBeVisible({ timeout: 20_000 }); + + await expect( + charlieRoom.channelsSidePanel.getByText('Alice') + ).toBeVisible({ timeout: 20_000 }); + + await expect( + charlieRoom.channelsSidePanel.getByText('Bob') + ).toBeVisible({ timeout: 20_000 }); + }); + + // ── Wait for full mesh to establish ── + await test.step('All peer connections establish', async () => { + // Each client should have 2 connected peers (full mesh of 3). + await waitForConnectedPeerCount(alice.page, 2, 30_000); + await waitForConnectedPeerCount(bob.page, 2, 30_000); + await waitForConnectedPeerCount(charlie.page, 2, 30_000); + }); + + // ── Break Charlie's connections ── + await test.step('Kill Charlie peer connections and block reconnection', async () => { + await killAndBlockPeerConnections(charlie.page); + + // Give the health service time to detect the desync. + // Peer latency pings stop -> connectedPeers updates -> desyncPeerIds recalculates. + await alice.page.waitForTimeout(15_000); + }); + + // ── Assert connectivity warnings ── + // + // The warning icon (lucideAlertTriangle) is a direct sibling of the + // user-name span inside the same voice-row div. Using the CSS + // general-sibling combinator (~) avoids accidentally matching a + // parent container that holds multiple rows. + await test.step('Alice sees warning icon next to Charlie', async () => { + const charlieWarning = aliceRoom.channelsSidePanel + .locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]'); + + await expect(charlieWarning).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('Bob sees warning icon next to Charlie', async () => { + const charlieWarning = bobRoom.channelsSidePanel + .locator('span.truncate:has-text("Charlie") ~ ng-icon[name="lucideAlertTriangle"]'); + + await expect(charlieWarning).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('Alice does NOT see warning icon next to Bob', async () => { + const bobWarning = aliceRoom.channelsSidePanel + .locator('span.truncate:has-text("Bob") ~ ng-icon[name="lucideAlertTriangle"]'); + + await expect(bobWarning).not.toBeVisible(); + }); + + await test.step('Charlie sees local desync banner', async () => { + const desyncBanner = charlie.page.locator('text=You may have connectivity issues'); + + await expect(desyncBanner).toBeVisible({ timeout: 30_000 }); + }); + }); +}); diff --git a/e2e/tests/settings/ice-server-settings.spec.ts b/e2e/tests/settings/ice-server-settings.spec.ts new file mode 100644 index 0000000..e13b4c7 --- /dev/null +++ b/e2e/tests/settings/ice-server-settings.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from '../../fixtures/multi-client'; +import { RegisterPage } from '../../pages/register.page'; + +test.describe('ICE server settings', () => { + test.describe.configure({ timeout: 120_000 }); + + async function registerAndOpenNetworkSettings(page: import('@playwright/test').Page, suffix: string) { + const register = new RegisterPage(page); + + await register.goto(); + await register.register(`user_${suffix}`, 'IceTestUser', 'TestPass123!'); + await expect(page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + await page.getByTitle('Settings').click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Network' }).click(); + } + + test('allows adding, removing, and reordering ICE servers', async ({ createClient }) => { + const client = await createClient(); + const { page } = client; + const suffix = `ice_${Date.now()}`; + + await test.step('Register and open Network settings', async () => { + await registerAndOpenNetworkSettings(page, suffix); + }); + + const iceSection = page.getByTestId('ice-server-settings'); + + await test.step('Default STUN servers are listed', async () => { + await expect(iceSection).toBeVisible({ timeout: 5_000 }); + const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]'); + + await expect(entries.first()).toBeVisible({ timeout: 5_000 }); + const count = await entries.count(); + + expect(count).toBeGreaterThanOrEqual(1); + }); + + await test.step('Add a STUN server', async () => { + await page.getByTestId('ice-type-select').selectOption('stun'); + await page.getByTestId('ice-url-input').fill('stun:custom.example.com:3478'); + await page.getByTestId('ice-add-button').click(); + await expect(page.getByText('stun:custom.example.com:3478')).toBeVisible({ timeout: 5_000 }); + }); + + await test.step('Add a TURN server with credentials', async () => { + await page.getByTestId('ice-type-select').selectOption('turn'); + await page.getByTestId('ice-url-input').fill('turn:relay.example.com:443'); + await page.getByTestId('ice-username-input').fill('testuser'); + await page.getByTestId('ice-credential-input').fill('testpass'); + await page.getByTestId('ice-add-button').click(); + await expect(page.getByText('turn:relay.example.com:443')).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText('User: testuser')).toBeVisible({ timeout: 5_000 }); + }); + + await test.step('Remove first entry and verify count decreases', async () => { + const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]'); + const countBefore = await entries.count(); + + await entries.first().getByTitle('Remove') + .click(); + + await expect(entries).toHaveCount(countBefore - 1, { timeout: 5_000 }); + }); + + await test.step('Reorder: move second entry up', async () => { + const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]'); + const count = await entries.count(); + + if (count >= 2) { + const secondText = await entries.nth(1).locator('p') + .first() + .textContent(); + + if (!secondText) { + throw new Error('Expected ICE server entry text before reordering'); + } + + await entries.nth(1).getByTitle('Move up (higher priority)') + .click(); + + // Wait for the moved entry text to appear at position 0 + await expect(entries.first().locator('p') + .first()).toHaveText(secondText, { timeout: 5_000 }); + } + }); + + await test.step('Restore defaults resets list', async () => { + await page.getByTestId('ice-restore-defaults').click(); + await expect(page.getByText('turn:relay.example.com:443')).not.toBeVisible({ timeout: 3_000 }); + const entries = page.getByTestId('ice-server-list').locator('[data-testid^="ice-entry-"]'); + + await expect(entries.first()).toBeVisible({ timeout: 5_000 }); + }); + + await test.step('Settings persist after page reload', async () => { + await page.getByTestId('ice-type-select').selectOption('stun'); + await page.getByTestId('ice-url-input').fill('stun:persist-test.example.com:3478'); + await page.getByTestId('ice-add-button').click(); + await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 5_000 }); + + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.getByTitle('Settings').click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 }); + await page.getByRole('button', { name: 'Network' }).click(); + await expect(page.getByText('stun:persist-test.example.com:3478')).toBeVisible({ timeout: 10_000 }); + }); + }); + + test('validates TURN entries require credentials', async ({ createClient }) => { + const client = await createClient(); + const { page } = client; + const suffix = `iceval_${Date.now()}`; + + await test.step('Register and open Network settings', async () => { + await registerAndOpenNetworkSettings(page, suffix); + }); + + await test.step('Adding TURN without credentials shows error', async () => { + await page.getByTestId('ice-type-select').selectOption('turn'); + await page.getByTestId('ice-url-input').fill('turn:noncred.example.com:443'); + await page.getByTestId('ice-add-button').click(); + await expect(page.getByText('Username is required for TURN servers')).toBeVisible({ timeout: 5_000 }); + }); + }); +}); diff --git a/e2e/tests/settings/stun-turn-fallback.spec.ts b/e2e/tests/settings/stun-turn-fallback.spec.ts new file mode 100644 index 0000000..fa2cc8e --- /dev/null +++ b/e2e/tests/settings/stun-turn-fallback.spec.ts @@ -0,0 +1,216 @@ +import { test, expect } from '../../fixtures/multi-client'; +import { RegisterPage } from '../../pages/register.page'; +import { ServerSearchPage } from '../../pages/server-search.page'; +import { ChatRoomPage } from '../../pages/chat-room.page'; +import { + dumpRtcDiagnostics, + installAutoResumeAudioContext, + installWebRTCTracking, + waitForAllPeerAudioFlow, + waitForPeerConnected, + waitForConnectedPeerCount, + waitForAudioStatsPresent +} from '../../helpers/webrtc-helpers'; + +const ICE_STORAGE_KEY = 'metoyou_ice_servers'; + +interface StoredIceServerEntry { + type?: string; + urls?: string; +} + +/** + * Tests that user-configured ICE servers are persisted and used by peer connections. + * + * On localhost TURN relay is never needed (direct always succeeds), so this test: + * 1. Seeds Bob's browser with an additional TURN entry via localStorage. + * 2. Has both users join voice with differing ICE configs. + * 3. Verifies both can connect and Bob's TURN entry is still in storage. + */ +test.describe('STUN/TURN fallback behaviour', () => { + test.describe.configure({ timeout: 180_000 }); + + test('users with different ICE configs can voice chat together', async ({ createClient }) => { + const suffix = `turnfb_${Date.now()}`; + const serverName = `Fallback ${suffix}`; + const alice = await createClient(); + const bob = await createClient(); + + // Install WebRTC tracking before any navigation so we can inspect + // peer connections and audio stats. + await installWebRTCTracking(alice.page); + await installWebRTCTracking(bob.page); + + // Ensure AudioContexts auto-resume so the input-gain pipeline + // (source -> gain -> destination) never stalls in "suspended" state. + await installAutoResumeAudioContext(alice.page); + await installAutoResumeAudioContext(bob.page); + + // Set deterministic voice settings so noise reduction and input gating + // don't swallow the fake audio tone. + const voiceSettings = JSON.stringify({ + inputVolume: 100, + outputVolume: 100, + audioBitrate: 96, + latencyProfile: 'balanced', + includeSystemAudio: false, + noiseReduction: false, + screenShareQuality: 'balanced', + askScreenShareQuality: false + }); + + await alice.page.addInitScript((settings: string) => { + localStorage.setItem('metoyou_voice_settings', settings); + }, voiceSettings); + + await bob.page.addInitScript((settings: string) => { + localStorage.setItem('metoyou_voice_settings', settings); + }, voiceSettings); + + // Seed Bob with an extra TURN entry before the app reads localStorage. + await bob.context.addInitScript((key: string) => { + try { + const existing = JSON.parse(localStorage.getItem(key) || '[]'); + + existing.push({ + id: 'e2e-turn', + type: 'turn', + urls: 'turn:localhost:3478', + username: 'e2euser', + credential: 'e2epass' + }); + + localStorage.setItem(key, JSON.stringify(existing)); + } catch { /* noop */ } + }, ICE_STORAGE_KEY); + + await test.step('Register Alice', async () => { + const register = new RegisterPage(alice.page); + + await register.goto(); + await register.register(`alice_${suffix}`, 'Alice', 'TestPass123!'); + await expect(alice.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('Register Bob', async () => { + const register = new RegisterPage(bob.page); + + await register.goto(); + await register.register(`bob_${suffix}`, 'Bob', 'TestPass123!'); + await expect(bob.page.getByPlaceholder('Search servers...')).toBeVisible({ timeout: 30_000 }); + }); + + await test.step('Alice creates a server', async () => { + const search = new ServerSearchPage(alice.page); + + await search.createServer(serverName); + }); + + await test.step('Bob joins Alice server', async () => { + const search = new ServerSearchPage(bob.page); + + await search.searchInput.fill(serverName); + const serverCard = bob.page.locator('button', { hasText: serverName }).first(); + + await expect(serverCard).toBeVisible({ timeout: 15_000 }); + await serverCard.click(); + await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 }); + }); + + const aliceRoom = new ChatRoomPage(alice.page); + const bobRoom = new ChatRoomPage(bob.page); + + await test.step('Both join voice', async () => { + await aliceRoom.joinVoiceChannel('General'); + await bobRoom.joinVoiceChannel('General'); + }); + + await test.step('Both users see each other in voice', async () => { + await expect( + aliceRoom.channelsSidePanel.getByText('Bob') + ).toBeVisible({ timeout: 20_000 }); + + await expect( + bobRoom.channelsSidePanel.getByText('Alice') + ).toBeVisible({ timeout: 20_000 }); + }); + + await test.step('Peer connections establish and audio flows bidirectionally', async () => { + await waitForPeerConnected(alice.page, 30_000); + await waitForPeerConnected(bob.page, 30_000); + await waitForConnectedPeerCount(alice.page, 1, 30_000); + await waitForConnectedPeerCount(bob.page, 1, 30_000); + + // Wait for audio RTP stats to appear (tracks negotiated) + await waitForAudioStatsPresent(alice.page, 30_000); + await waitForAudioStatsPresent(bob.page, 30_000); + + // Allow mesh to settle - voice routing and renegotiation can + // cause a second offer/answer cycle after the initial connection. + await alice.page.waitForTimeout(5_000); + + // Chromium's --use-fake-device-for-media-stream can produce a + // silent capture track on the very first getUserMedia call. If + // bidirectional audio does not flow within a short window, leave + // and rejoin voice to re-acquire the mic (the second getUserMedia + // on a warm device always works). + let audioFlowing = false; + + try { + await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 15_000), waitForAllPeerAudioFlow(bob.page, 1, 15_000)]); + + audioFlowing = true; + } catch { + // Silent sender detected - rejoin voice to work around Chromium bug + } + + if (!audioFlowing) { + // Leave voice + await aliceRoom.disconnectButton.click(); + await bobRoom.disconnectButton.click(); + await alice.page.waitForTimeout(2_000); + + // Rejoin + await aliceRoom.joinVoiceChannel('General'); + await bobRoom.joinVoiceChannel('General'); + + await expect( + aliceRoom.channelsSidePanel.getByText('Bob') + ).toBeVisible({ timeout: 20_000 }); + + await expect( + bobRoom.channelsSidePanel.getByText('Alice') + ).toBeVisible({ timeout: 20_000 }); + + await waitForPeerConnected(alice.page, 30_000); + await waitForPeerConnected(bob.page, 30_000); + await waitForConnectedPeerCount(alice.page, 1, 30_000); + await waitForConnectedPeerCount(bob.page, 1, 30_000); + await waitForAudioStatsPresent(alice.page, 30_000); + await waitForAudioStatsPresent(bob.page, 30_000); + await alice.page.waitForTimeout(3_000); + } + + // Final assertion - must succeed after the (optional) rejoin. + try { + await Promise.all([waitForAllPeerAudioFlow(alice.page, 1, 60_000), waitForAllPeerAudioFlow(bob.page, 1, 60_000)]); + } catch (error) { + console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page)); + console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page)); + throw error; + } + }); + + await test.step('Bob still has TURN entry in localStorage', async () => { + const stored: StoredIceServerEntry[] = await bob.page.evaluate( + (key) => JSON.parse(localStorage.getItem(key) || '[]') as StoredIceServerEntry[], + ICE_STORAGE_KEY + ); + const hasTurn = stored.some( + (entry) => entry.type === 'turn' && entry.urls === 'turn:localhost:3478' + ); + + expect(hasTurn).toBe(true); + }); + }); +}); diff --git a/e2e/tests/voice/mixed-signal-config-voice.spec.ts b/e2e/tests/voice/mixed-signal-config-voice.spec.ts index 36f3942..ba7337f 100644 --- a/e2e/tests/voice/mixed-signal-config-voice.spec.ts +++ b/e2e/tests/voice/mixed-signal-config-voice.spec.ts @@ -1,9 +1,6 @@ import { expect, type Page } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; -import { - installTestServerEndpoints, - type SeededEndpointInput -} from '../../helpers/seed-test-endpoint'; +import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint'; import { startTestServer } from '../../helpers/test-server'; import { dumpRtcDiagnostics, @@ -22,12 +19,10 @@ import { ChatMessagesPage } from '../../pages/chat-messages.page'; // ── Signal endpoint identifiers ────────────────────────────────────── const PRIMARY_SIGNAL_ID = 'e2e-mixed-signal-a'; const SECONDARY_SIGNAL_ID = 'e2e-mixed-signal-b'; - // ── Room / channel names ───────────────────────────────────────────── const VOICE_ROOM_NAME = `Mixed Signal Voice ${Date.now()}`; const SECONDARY_ROOM_NAME = `Mixed Signal Chat ${Date.now()}`; const VOICE_CHANNEL = 'General'; - // ── User constants ─────────────────────────────────────────────────── const USER_PASSWORD = 'TestPass123!'; const USER_COUNT = 8; @@ -37,7 +32,7 @@ const STABILITY_WINDOW_MS = 20_000; // ── User signal configuration groups ───────────────────────────────── // // Group A (users 0-1): Both signal servers in network config (normal) -// Group B (users 2-3): Only primary signal — secondary NOT in config. +// Group B (users 2-3): Only primary signal - secondary NOT in config. // They join the secondary room via invite link, // which auto-adds the endpoint. // Group C (users 4-5): Both signals initially, but secondary is removed @@ -66,23 +61,43 @@ function endpointsForGroup( switch (group) { case 'both': return [ - { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }, - { id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' } + { + id: PRIMARY_SIGNAL_ID, + name: 'E2E Signal A', + url: primaryUrl, + isActive: true, + status: 'online' + }, + { + id: SECONDARY_SIGNAL_ID, + name: 'E2E Signal B', + url: secondaryUrl, + isActive: true, + status: 'online' + } ]; case 'primary-only': - return [ - { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' } - ]; + return [{ id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }]; case 'both-then-remove-secondary': // Seed both initially; test will remove secondary after registration. return [ - { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: primaryUrl, isActive: true, status: 'online' }, - { id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' } + { + id: PRIMARY_SIGNAL_ID, + name: 'E2E Signal A', + url: primaryUrl, + isActive: true, + status: 'online' + }, + { + id: SECONDARY_SIGNAL_ID, + name: 'E2E Signal B', + url: secondaryUrl, + isActive: true, + status: 'online' + } ]; case 'secondary-only': - return [ - { id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' } - ]; + return [{ id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryUrl, isActive: true, status: 'online' }]; } } @@ -96,11 +111,6 @@ test.describe('Mixed signal-config voice', () => { const secondaryServer = await startTestServer(); try { - const allEndpoints: SeededEndpointInput[] = [ - { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, status: 'online' }, - { id: SECONDARY_SIGNAL_ID, name: 'E2E Signal B', url: secondaryServer.url, isActive: true, status: 'online' } - ]; - const users = buildUsers(); const clients: TestClient[] = []; @@ -139,12 +149,14 @@ test.describe('Mixed signal-config voice', () => { description: 'Voice room on primary signal', sourceId: PRIMARY_SIGNAL_ID }); + await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); await searchPage.createServer(SECONDARY_ROOM_NAME, { description: 'Chat room on secondary signal', sourceId: SECONDARY_SIGNAL_ID }); + await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); }); @@ -164,7 +176,6 @@ test.describe('Mixed signal-config voice', () => { // Navigate to secondary room to get its ID await openSavedRoomByName(clients[0].page, SECONDARY_ROOM_NAME); const secondaryRoomId = await getCurrentRoomId(clients[0].page); - // Create invite for primary room (voice) via API const primaryInvite = await createInviteViaApi( testServer.url, @@ -172,6 +183,7 @@ test.describe('Mixed signal-config voice', () => { userId, clients[0].user.displayName ); + primaryRoomInviteUrl = `/invite/${primaryInvite.id}?server=${encodeURIComponent(testServer.url)}`; // Create invite for secondary room (chat) via API @@ -181,12 +193,13 @@ test.describe('Mixed signal-config voice', () => { userId, clients[0].user.displayName ); + secondaryRoomInviteUrl = `/invite/${secondaryInvite.id}?server=${encodeURIComponent(secondaryServer.url)}`; }); // ── Remove secondary endpoint for group C ─────────────────── await test.step('Remove secondary signal from group C users', async () => { - for (const client of clients.filter((c) => c.user.group === 'both-then-remove-secondary')) { + for (const client of clients.filter((clientItem) => clientItem.user.group === 'both-then-remove-secondary')) { await client.page.evaluate((primaryEndpoint) => { localStorage.setItem('metoyou_server_endpoints', JSON.stringify([primaryEndpoint])); }, { id: PRIMARY_SIGNAL_ID, name: 'E2E Signal A', url: testServer.url, isActive: true, isDefault: false, status: 'online' }); @@ -197,11 +210,11 @@ test.describe('Mixed signal-config voice', () => { await test.step('All users join the voice room (some via search, some via invite)', async () => { for (const client of clients.slice(1)) { if (client.user.group === 'secondary-only') { - // Group D: no primary signal → join voice room via invite + // Group D: no primary signal -> join voice room via invite await client.page.goto(primaryRoomInviteUrl); await waitForInviteJoin(client.page); } else { - // Groups A, B, C: have primary signal → join via search + // Groups A, B, C: have primary signal -> join via search await joinRoomFromSearch(client.page, VOICE_ROOM_NAME); } } @@ -213,11 +226,11 @@ test.describe('Mixed signal-config voice', () => { await test.step('All users also join the secondary chat room', async () => { for (const client of clients.slice(1)) { if (client.user.group === 'primary-only') { - // Group B: no secondary signal → join chat room via invite + // Group B: no secondary signal -> join chat room via invite await client.page.goto(secondaryRoomInviteUrl); await waitForInviteJoin(client.page); } else if (client.user.group === 'secondary-only') { - // Group D: has secondary → join via search + // Group D: has secondary -> join via search await openSearchView(client.page); await joinRoomFromSearch(client.page, SECONDARY_ROOM_NAME); } else { @@ -285,7 +298,7 @@ test.describe('Mixed signal-config voice', () => { await test.step('Voice stays stable 20s while some users navigate and chat on other servers', async () => { // Pick 2 users from different groups to navigate away and chat const chatters = [clients[2], clients[6]]; // group C + group D - const stayers = clients.filter((c) => !chatters.includes(c)); + const stayers = clients.filter((clientItem) => !chatters.includes(clientItem)); // Chatters navigate to secondary room and send messages for (const chatter of chatters) { @@ -303,11 +316,12 @@ test.describe('Mixed signal-config voice', () => { await expect( chatPage0.getMessageItemByText(`Reply from ${chatters[1].user.displayName}`) ).toBeVisible({ timeout: 15_000 }); + await expect( chatPage1.getMessageItemByText(`Hello from ${chatters[0].user.displayName}`) ).toBeVisible({ timeout: 15_000 }); - // Meanwhile stability loop on all clients (including chatters — voice still active) + // Meanwhile stability loop on all clients (including chatters - voice still active) const deadline = Date.now() + STABILITY_WINDOW_MS; while (Date.now() < deadline) { @@ -391,7 +405,7 @@ test.describe('Mixed signal-config voice', () => { await room.deafenButton.click(); await client.page.waitForTimeout(500); - // Un-deafen does NOT restore mute – user stays muted + // Un-deafen does NOT restore mute - user stays muted await waitForVoiceStateAcrossPages(clients, client.user.displayName, { isMuted: true, isDeafened: false @@ -429,10 +443,14 @@ test.describe('Mixed signal-config voice', () => { function buildUsers(): TestUser[] { const groups: SignalGroup[] = [ - 'both', 'both', // 0-1 - 'primary-only', 'primary-only', // 2-3 - 'both-then-remove-secondary', 'both-then-remove-secondary', // 4-5 - 'secondary-only', 'secondary-only' // 6-7 + 'both', + 'both', // 0-1 + 'primary-only', + 'primary-only', // 2-3 + 'both-then-remove-secondary', + 'both-then-remove-secondary', // 4-5 + 'secondary-only', + 'secondary-only' // 6-7 ]; return groups.map((group, index) => ({ @@ -574,7 +592,7 @@ async function openSavedRoomByName(page: Page, roomName: string): Promise } async function waitForInviteJoin(page: Page): Promise { - // Invite page loads → auto-joins → redirects to room + // Invite page loads -> auto-joins -> redirects to room await expect(page).toHaveURL(/\/room\//, { timeout: 30_000 }); await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 }); } @@ -605,11 +623,13 @@ async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20 } async function openVoiceWorkspace(page: Page): Promise { - if (await page.locator('app-voice-workspace').isVisible().catch(() => false)) { + if (await page.locator('app-voice-workspace').isVisible() + .catch(() => false)) { return; } - const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }).first(); + const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }) + .first(); await expect(viewButton).toBeVisible({ timeout: 10_000 }); await viewButton.click(); @@ -619,6 +639,7 @@ async function openVoiceWorkspace(page: Page): Promise { async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise { const room = new ChatRoomPage(page); + let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { @@ -634,10 +655,11 @@ async function joinVoiceChannelUntilConnected(page: Page, channelName: string, a } } - throw new Error([ - `Failed to connect ${page.url()} to voice channel ${channelName}.`, - lastError instanceof Error ? `Last error: ${lastError.message}` : 'Last error: unavailable' - ].join('\n')); + const lastErrorMessage = lastError instanceof Error + ? `Last error: ${lastError.message}` + : 'Last error: unavailable'; + + throw new Error(`Failed to connect ${page.url()} to voice channel ${channelName}.\n${lastErrorMessage}`); } async function waitForLocalVoiceChannelConnection(page: Page, channelName: string, timeout = 20_000): Promise { @@ -691,7 +713,7 @@ async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number) } const component = debugApi.getComponent(host); - const connectedUsers = (component['connectedVoiceUsers'] as (() => Array) | undefined)?.() ?? []; + const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? []; return connectedUsers.length === count; }, @@ -724,7 +746,7 @@ async function waitForVoiceRosterCount(page: Page, channelName: string, expected return false; } - const roster = (component['voiceUsersInRoom'] as ((roomId: string) => Array) | undefined)?.(channelId) ?? []; + const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? []; return roster.length === expected; }, @@ -734,7 +756,7 @@ async function waitForVoiceRosterCount(page: Page, channelName: string, expected } async function waitForVoiceStateAcrossPages( - clients: ReadonlyArray, + clients: readonly TestClient[], displayName: string, expectedState: { isMuted: boolean; isDeafened: boolean } ): Promise { @@ -765,7 +787,7 @@ async function waitForVoiceStateAcrossPages( } const roster = (component['voiceUsersInRoom'] as ((roomId: string) => UserShape[]) | undefined)?.(voiceChannel.id) ?? []; - const entry = roster.find((u) => u.displayName === expectedDisplayName); + const entry = roster.find((userEntry) => userEntry.displayName === expectedDisplayName); return entry?.voiceState?.isMuted === expectedMuted && entry?.voiceState?.isDeafened === expectedDeafened; diff --git a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts index ce5f0d4..c4e0c8b 100644 --- a/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts +++ b/e2e/tests/voice/multi-signal-eight-user-voice.spec.ts @@ -1,9 +1,6 @@ import { expect, type Page } from '@playwright/test'; import { test, type Client } from '../../fixtures/multi-client'; -import { - installTestServerEndpoints, - type SeededEndpointInput -} from '../../helpers/seed-test-endpoint'; +import { installTestServerEndpoints, type SeededEndpointInput } from '../../helpers/seed-test-endpoint'; import { startTestServer } from '../../helpers/test-server'; import { dumpRtcDiagnostics, @@ -28,11 +25,11 @@ const USER_COUNT = 8; const EXPECTED_REMOTE_PEERS = USER_COUNT - 1; const STABILITY_WINDOW_MS = 20_000; -type TestUser = { +interface TestUser { username: string; displayName: string; password: string; -}; +} type TestClient = Client & { user: TestUser; @@ -64,7 +61,6 @@ test.describe('Dual-signal multi-user voice', () => { status: 'online' } ]; - const users = buildUsers(); const clients = await createTrackedClients(createClient, users, endpoints); @@ -86,12 +82,14 @@ test.describe('Dual-signal multi-user voice', () => { description: 'Primary signal room for 8-user voice mesh', sourceId: PRIMARY_SIGNAL_ID }); + await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); await searchPage.createServer(SECONDARY_ROOM_NAME, { description: 'Secondary signal room for dual-socket coverage', sourceId: SECONDARY_SIGNAL_ID }); + await expect(clients[0].page).toHaveURL(/\/room\//, { timeout: 20_000 }); }); @@ -141,7 +139,7 @@ test.describe('Dual-signal multi-user voice', () => { waitForAudioStatsPresent(client.page, 30_000) )); - // Allow the mesh to settle — voice routing, allowed-peer-id + // Allow the mesh to settle - voice routing, allowed-peer-id // propagation and renegotiation all need time after the last // user joins. await clients[0].page.waitForTimeout(5_000); @@ -173,6 +171,7 @@ test.describe('Dual-signal multi-user voice', () => { timeout: 10_000, intervals: [500, 1_000] }).toBe(EXPECTED_REMOTE_PEERS); + await expect.poll(async () => await getConnectedSignalManagerCount(client.page), { timeout: 10_000, intervals: [500, 1_000] @@ -236,7 +235,7 @@ test.describe('Dual-signal multi-user voice', () => { await room.deafenButton.click(); await client.page.waitForTimeout(500); - // Un-deafen does NOT restore mute – the user stays muted + // Un-deafen does NOT restore mute - the user stays muted await waitForVoiceStateAcrossPages(clients, client.user.displayName, { isMuted: true, isDeafened: false @@ -245,7 +244,7 @@ test.describe('Dual-signal multi-user voice', () => { }); await test.step('Unmute all users and verify audio flows end-to-end', async () => { - // Every user is left muted after deafen cycling — unmute them all + // Every user is left muted after deafen cycling - unmute them all for (const client of clients) { const room = new ChatRoomPage(client.page); @@ -256,7 +255,7 @@ test.describe('Dual-signal multi-user voice', () => { }); } - // Final audio flow check on every peer — confirms the full + // Final audio flow check on every peer - confirms the full // send/receive pipeline still works after mute+deafen cycling for (const client of clients) { try { @@ -284,7 +283,7 @@ function buildUsers(): TestUser[] { async function createTrackedClients( createClient: () => Promise, users: TestUser[], - endpoints: ReadonlyArray + endpoints: readonly SeededEndpointInput[] ): Promise { const clients: TestClient[] = []; @@ -384,9 +383,11 @@ async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20 } async function openVoiceWorkspace(page: Page): Promise { - const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }).first(); + const viewButton = page.locator('app-rooms-side-panel').getByRole('button', { name: /view|open/i }) + .first(); - if (await page.locator('app-voice-workspace').isVisible().catch(() => false)) { + if (await page.locator('app-voice-workspace').isVisible() + .catch(() => false)) { return; } @@ -396,6 +397,7 @@ async function openVoiceWorkspace(page: Page): Promise { async function joinVoiceChannelUntilConnected(page: Page, channelName: string, attempts = 3): Promise { const room = new ChatRoomPage(page); + let lastError: unknown; for (let attempt = 1; attempt <= attempts; attempt++) { @@ -559,7 +561,7 @@ async function getVoiceJoinDiagnostics(page: Page, channelName: string): Promise const realtime = component['realtime'] as { connectionErrorMessage?: () => string | null; signalingTransportHandler?: { - getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>; + getConnectedSignalingManagers?: () => { signalUrl: string }[]; }; } | undefined; @@ -596,7 +598,7 @@ async function waitForConnectedSignalManagerCount(page: Page, expectedCount: num const component = debugApi.getComponent(host); const realtime = component['realtime'] as { signalingTransportHandler?: { - getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>; + getConnectedSignalingManagers?: () => { signalUrl: string }[]; }; } | undefined; const countValue = realtime?.signalingTransportHandler?.getConnectedSignalingManagers?.().length ?? 0; @@ -624,7 +626,7 @@ async function getConnectedSignalManagerCount(page: Page): Promise { const component = debugApi.getComponent(host); const realtime = component['realtime'] as { signalingTransportHandler?: { - getConnectedSignalingManagers?: () => Array<{ signalUrl: string }>; + getConnectedSignalingManagers?: () => { signalUrl: string }[]; }; } | undefined; @@ -647,7 +649,7 @@ async function waitForVoiceWorkspaceUserCount(page: Page, expectedCount: number) } const component = debugApi.getComponent(host); - const connectedUsers = (component['connectedVoiceUsers'] as (() => Array) | undefined)?.() ?? []; + const connectedUsers = (component['connectedVoiceUsers'] as (() => unknown[]) | undefined)?.() ?? []; return connectedUsers.length === count; }, @@ -688,7 +690,7 @@ async function waitForVoiceRosterCount(page: Page, channelName: string, expected return false; } - const roster = (component['voiceUsersInRoom'] as ((roomId: string) => Array) | undefined)?.(channelId) ?? []; + const roster = (component['voiceUsersInRoom'] as ((roomId: string) => unknown[]) | undefined)?.(channelId) ?? []; return roster.length === expected; }, @@ -698,7 +700,7 @@ async function waitForVoiceRosterCount(page: Page, channelName: string, expected } async function waitForVoiceStateAcrossPages( - clients: ReadonlyArray, + clients: readonly TestClient[], displayName: string, expectedState: { isMuted: boolean; isDeafened: boolean } ): Promise { diff --git a/e2e/tests/voice/voice-full-journey.spec.ts b/e2e/tests/voice/voice-full-journey.spec.ts index 7a7741f..91f7244 100644 --- a/e2e/tests/voice/voice-full-journey.spec.ts +++ b/e2e/tests/voice/voice-full-journey.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '../../fixtures/multi-client'; import { installWebRTCTracking, + installAutoResumeAudioContext, waitForPeerConnected, isPeerStillConnected, getAudioStatsDelta, @@ -13,7 +14,7 @@ import { ServerSearchPage } from '../../pages/server-search.page'; import { ChatRoomPage } from '../../pages/chat-room.page'; /** - * Full user journey: register → create server → join → voice → verify audio + * Full user journey: register -> create server -> join -> voice -> verify audio * for 10+ seconds of stable connectivity. * * Uses two independent browser contexts (Alice & Bob) to simulate real @@ -25,7 +26,7 @@ const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'Test const SERVER_NAME = `E2E Test Server ${Date.now()}`; const VOICE_CHANNEL = 'General'; -test.describe('Full user journey: register → server → voice chat', () => { +test.describe('Full user journey: register -> server -> voice chat', () => { test('two users register, create server, join voice, and stay connected 10+ seconds with audio', async ({ createClient }) => { test.setTimeout(180_000); // 3 min - covers registration, server creation, voice establishment, and 10s stability check @@ -35,6 +36,20 @@ test.describe('Full user journey: register → server → voice chat', () => { // Install WebRTC tracking before any navigation await installWebRTCTracking(alice.page); await installWebRTCTracking(bob.page); + await installAutoResumeAudioContext(alice.page); + await installAutoResumeAudioContext(bob.page); + + // Seed deterministic voice settings so noise reduction doesn't + // swallow the fake audio tone. + const voiceSettings = JSON.stringify({ + inputVolume: 100, outputVolume: 100, audioBitrate: 96, + latencyProfile: 'balanced', includeSystemAudio: false, + noiseReduction: false, screenShareQuality: 'balanced', + askScreenShareQuality: false + }); + + await alice.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings); + await bob.page.addInitScript((settingsValue: string) => localStorage.setItem('metoyou_voice_settings', settingsValue), voiceSettings); // Forward browser console for debugging alice.page.on('console', msg => console.log('[Alice]', msg.text())); @@ -146,8 +161,38 @@ test.describe('Full user journey: register → server → voice chat', () => { // ── Step 7: Verify audio is flowing in both directions ─────────── await test.step('Audio packets are flowing between Alice and Bob', async () => { - const aliceDelta = await waitForAudioFlow(alice.page, 30_000); - const bobDelta = await waitForAudioFlow(bob.page, 30_000); + // Chromium's --use-fake-device-for-media-stream can produce a + // silent capture track on the very first getUserMedia call. If + // bidirectional audio doesn't flow within a short window, leave + // and rejoin voice to re-acquire the mic. + let aliceDelta = await waitForAudioFlow(alice.page, 15_000); + let bobDelta = await waitForAudioFlow(bob.page, 15_000); + + const isFlowing = (delta: typeof aliceDelta) => + (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) && + (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0); + + if (!isFlowing(aliceDelta) || !isFlowing(bobDelta)) { + const aliceRoom = new ChatRoomPage(alice.page); + const bobRoom = new ChatRoomPage(bob.page); + + await aliceRoom.disconnectButton.click(); + await bobRoom.disconnectButton.click(); + await alice.page.waitForTimeout(2_000); + + await aliceRoom.joinVoiceChannel(VOICE_CHANNEL); + await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); + await bobRoom.joinVoiceChannel(VOICE_CHANNEL); + await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 }); + + await waitForPeerConnected(alice.page, 30_000); + await waitForPeerConnected(bob.page, 30_000); + await waitForAudioStatsPresent(alice.page, 20_000); + await waitForAudioStatsPresent(bob.page, 20_000); + + aliceDelta = await waitForAudioFlow(alice.page, 30_000); + bobDelta = await waitForAudioFlow(bob.page, 30_000); + } if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0 || bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) { diff --git a/electron/README.md b/electron/README.md new file mode 100644 index 0000000..e658ae7 --- /dev/null +++ b/electron/README.md @@ -0,0 +1,31 @@ +# Electron Shell + +Electron main-process package for MetoYou / Toju. This directory owns desktop bootstrap, the preload bridge, IPC handlers, desktop persistence glue, updater integration, and window-level behavior. + +## Commands + +- `npm run build:electron` builds the Electron TypeScript output to `dist/electron`. +- `npm run electron` builds the product client and Electron, then launches the desktop app. +- `npm run electron:dev` starts the Angular client and Electron together. +- `npm run dev` starts the full desktop stack: server, Angular client, and Electron. +- `npm run electron:build`, `npm run electron:build:win`, `npm run electron:build:mac`, and `npm run electron:build:linux` create packaged desktop builds. + +## Structure + +| Path | Description | +| --- | --- | +| `main.ts` | Electron app bootstrap and process entry point | +| `preload.ts` | Typed renderer-facing preload bridge | +| `app/` | App lifecycle and startup composition | +| `ipc/` | Renderer-invoked IPC handlers | +| `cqrs/` | Local database command/query handlers and mappings | +| `db/`, `entities/`, `migrations/` | Desktop persistence and schema evolution | +| `audio/` | Desktop audio integrations | +| `update/` | Desktop updater flow | +| `window/` | Window creation and window-level behavior | + +## Notes + +- When adding a renderer-facing capability, update the Electron implementation, `preload.ts`, and the renderer bridge in `toju-app/` together. +- Treat `dist/electron/` and `dist-electron/` as generated output. +- See [AGENTS.md](AGENTS.md) for package-level editing rules. \ No newline at end of file diff --git a/electron/update/desktop-updater.ts b/electron/update/desktop-updater.ts index 58eccfe..cd954ce 100644 --- a/electron/update/desktop-updater.ts +++ b/electron/update/desktop-updater.ts @@ -500,7 +500,7 @@ async function performUpdateCheck( setDesktopUpdateState({ lastCheckedAt: Date.now(), status: 'checking', - statusMessage: `Checking for MetoYou ${targetRelease.version}…`, + statusMessage: `Checking for MetoYou ${targetRelease.version}...`, targetVersion: targetRelease.version }); @@ -687,7 +687,7 @@ export function initializeDesktopUpdater(): void { setDesktopUpdateState({ status: 'checking', - statusMessage: 'Checking for desktop updates…' + statusMessage: 'Checking for desktop updates...' }); }); @@ -698,7 +698,7 @@ export function initializeDesktopUpdater(): void { setDesktopUpdateState({ lastCheckedAt: Date.now(), status: 'downloading', - statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}…`, + statusMessage: `Downloading MetoYou ${nextVersion ?? 'update'}...`, targetVersion: nextVersion }); }); diff --git a/eslint.config.js b/eslint.config.js index 603053f..1beacde 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,41 +5,7 @@ const angular = require('angular-eslint'); const stylisticTs = require('@stylistic/eslint-plugin-ts'); const stylisticJs = require('@stylistic/eslint-plugin-js'); const newlines = require('eslint-plugin-import-newlines'); - -// Inline plugin: ban en dash (–, U+2013) and em dash (—, U+2014) from source files -const noDashPlugin = { - rules: { - 'no-unicode-dashes': { - meta: { fixable: 'code' }, - create(context) { - const BANNED = [ - { char: '\u2013', name: 'en dash (–)' }, - { char: '\u2014', name: 'em dash (—)' } - ]; - return { - Program() { - const src = context.getSourceCode().getText(); - for (const { char, name } of BANNED) { - let idx = src.indexOf(char); - while (idx !== -1) { - const start = idx; - const end = idx + char.length; - context.report({ - loc: context.getSourceCode().getLocFromIndex(idx), - message: `Unicode ${name} is not allowed. Use a regular hyphen (-) instead.`, - fix(fixer) { - return fixer.replaceTextRange([start, end], '-'); - } - }); - idx = src.indexOf(char, idx + 1); - } - } - } - }; - } - } - } -}; +const metoyouEslintRules = require('./tools/eslint-rules'); module.exports = tseslint.config( { @@ -51,7 +17,7 @@ module.exports = tseslint.config( '@stylistic/ts': stylisticTs, '@stylistic/js': stylisticJs, 'import-newlines': newlines, - 'no-dashes': noDashPlugin + 'metoyou': metoyouEslintRules }, extends: [ eslint.configs.recommended, @@ -69,7 +35,7 @@ module.exports = tseslint.config( styles: 0 } ], - 'no-dashes/no-unicode-dashes': 'error', + 'metoyou/no-unicode-symbols': 'error', '@typescript-eslint/no-extraneous-class': 'off', '@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Component','Page','Stub'] } ], '@angular-eslint/directive-class-suffix': 'error', @@ -200,10 +166,10 @@ module.exports = tseslint.config( // HTML template formatting rules (external Angular templates only) { files: ['toju-app/src/app/**/*.html'], - plugins: { 'no-dashes': noDashPlugin }, + plugins: { 'metoyou': metoyouEslintRules }, extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility], rules: { - 'no-dashes/no-unicode-dashes': 'error', + 'metoyou/no-unicode-symbols': 'error', // Angular template best practices '@angular-eslint/template/button-has-type': 'warn', '@angular-eslint/template/cyclomatic-complexity': ['warn', { maxComplexity: 10 }], diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..0ad24bb --- /dev/null +++ b/server/README.md @@ -0,0 +1,40 @@ +# Server + +Node/TypeScript signaling server for MetoYou / Toju. This package owns the public server-directory API, join-request flows, websocket runtime, and server-side persistence. + +## Install + +1. Run `cd server`. +2. Run `npm install`. + +## Commands + +- `npm run dev` starts the server with `ts-node-dev` reload. +- `npm run build` compiles TypeScript to `dist/`. +- `npm run start` runs the compiled server. +- From the repository root, `npm run server:dev`, `npm run server:build`, and `npm run server:start` call the same package commands. + +## Runtime Config + +- The server loads the repository-root `.env` file on startup. +- `SSL` can override the effective HTTP protocol, and `PORT` can override the effective port. +- `data/variables.json` is normalized on startup and stores `klipyApiKey`, `releaseManifestUrl`, `serverPort`, `serverProtocol`, `serverHost`, and `linkPreview`. +- When HTTPS is enabled, certificates are read from the repository `.certs/` directory. + +## Structure + +| Path | Description | +| --- | --- | +| `src/index.ts` | Bootstrap and server startup | +| `src/app/` | Express app composition | +| `src/routes/` | REST API routes | +| `src/websocket/` | WebSocket runtime and signaling transport | +| `src/cqrs/` | Command/query handlers | +| `src/config/` | Runtime config loading and normalization | +| `src/db/`, `src/entities/`, `src/migrations/` | Persistence layer | +| `data/` | Runtime data files such as `variables.json` | + +## Notes + +- `dist/` and `../dist-server/` are generated output. +- See [AGENTS.md](AGENTS.md) for package-specific editing guidance. \ No newline at end of file diff --git a/server/src/index.ts b/server/src/index.ts index d27d4a5..e9f3b91 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -137,7 +137,7 @@ async function gracefulShutdown(signal: string): Promise { staleJoinRequestInterval = null; } - console.log(`\n[Shutdown] ${signal} received - closing database…`); + console.log(`\n[Shutdown] ${signal} received - closing database...`); if (listeningServer?.listening) { try { diff --git a/server/src/websocket/handler-status.spec.ts b/server/src/websocket/handler-status.spec.ts index 903ed37..ff834ba 100644 --- a/server/src/websocket/handler-status.spec.ts +++ b/server/src/websocket/handler-status.spec.ts @@ -154,7 +154,7 @@ describe('server websocket handler - status_update', () => { // Identify first (required for handler) await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' }); - // user-2 joins server → should receive server_users with user-1's status + // user-2 joins server -> should receive server_users with user-1's status getSentMessagesStore(user2).sentMessages.length = 0; await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' }); diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index f691840..51a3a27 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -276,7 +276,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI user.status = status as ConnectedUser['status']; connectedUsers.set(connectionId, user); - console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status → ${status}`); + console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status -> ${status}`); for (const serverId of user.serverIds) { broadcastToServer(serverId, { diff --git a/toju-app/README.md b/toju-app/README.md new file mode 100644 index 0000000..8ed5588 --- /dev/null +++ b/toju-app/README.md @@ -0,0 +1,42 @@ +# Product Client + +Angular 21 renderer for MetoYou / Toju. This package is managed from the repository root, so the main build, test, lint, and Electron integration commands are run there rather than from a local `package.json`. + +## Commands + +- `npm run start` starts the Angular dev server. +- `npm run build` builds the client to `dist/client`. +- `npm run watch` runs the Angular build in watch mode. +- `npm run test` runs the product-client Vitest suite. +- `npm run lint` runs ESLint across the repo. +- `npm run format` formats Angular HTML templates. +- `npm run sort:props` sorts Angular template properties. +- `npm run electron:dev` or `npm run dev` runs the client with Electron. + +## Structure + +| Path | Description | +| --- | --- | +| `src/app/domains/` | Bounded contexts and public domain entry points | +| `src/app/infrastructure/` | Shared technical runtime such as persistence and realtime | +| `src/app/shared-kernel/` | Cross-domain contracts and shared models | +| `src/app/features/` | App-level composition and transitional feature shells | +| `src/app/core/` | Platform adapters, compatibility entry points, and cross-domain technical helpers | +| `src/app/shared/` | Shared UI primitives and utilities | +| `src/app/store/` | NgRx reducers, effects, selectors, and actions | +| `public/` | Static assets copied into the Angular build | + +## Key Docs + +- [src/app/domains/README.md](src/app/domains/README.md) +- [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) +- [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) +- [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) +- [../docs/architecture.md](../docs/architecture.md) +- [AGENTS.md](AGENTS.md) + +## Notes + +- `angular.json` defines build, serve, and lint targets for the product client. +- Product-client tests currently run through the root Vitest setup instead of an Angular `test` architect target. +- If the renderer-to-desktop contract changes, update the Angular bridge, Electron preload API, and IPC handlers together. \ No newline at end of file diff --git a/toju-app/angular.json b/toju-app/angular.json index 5fbe615..8f71e07 100644 --- a/toju-app/angular.json +++ b/toju-app/angular.json @@ -97,7 +97,7 @@ { "type": "initial", "maximumWarning": "2.2MB", - "maximumError": "2.32MB" + "maximumError": "2.35MB" }, { "type": "anyComponentStyle", diff --git a/toju-app/src/app/app.html b/toju-app/src/app/app.html index a63ac6f..65d399b 100644 --- a/toju-app/src/app/app.html +++ b/toju-app/src/app/app.html @@ -28,7 +28,7 @@ @if (themeStudioFullscreenComponent()) { } @else { -
Loading Theme Studio…
+
Loading Theme Studio...
} } @else { @if (showDesktopUpdateNotice()) { diff --git a/toju-app/src/app/core/constants.ts b/toju-app/src/app/core/constants.ts index 1f28577..26aed9b 100644 --- a/toju-app/src/app/core/constants.ts +++ b/toju-app/src/app/core/constants.ts @@ -5,6 +5,7 @@ export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings'; export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings'; export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings'; export const STORAGE_KEY_DEBUGGING_SETTINGS = 'metoyou_debugging_settings'; +export const STORAGE_KEY_ICE_SERVERS = 'metoyou_ice_servers'; export const STORAGE_KEY_THEME_ACTIVE = 'metoyou_theme_active'; export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; diff --git a/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts b/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts index a55568c..3f0faac 100644 --- a/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts +++ b/toju-app/src/app/core/services/debugging/debugging-network-snapshot.builder.ts @@ -1508,7 +1508,7 @@ class DebugNetworkSnapshotBuilder { if (value.length <= 12) return value; - return `${value.slice(0, 6)}…${value.slice(-4)}`; + return `${value.slice(0, 6)}...${value.slice(-4)}`; } private getEntryPayloadRecord(payload: unknown): Record | null { diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html index 984004f..e8c99b1 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.html @@ -190,7 +190,7 @@ [class.text-destructive]="!!att.requestError" [class.text-muted-foreground]="!att.requestError" > - {{ att.requestError || 'Waiting for image source…' }} + {{ att.requestError || 'Waiting for image source...' }} diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts index d8f534e..280d501 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-item/chat-message-item.component.ts @@ -419,8 +419,8 @@ export class ChatMessageItemComponent { } return this.isVideoAttachment(attachment) - ? 'Waiting for video source…' - : 'Waiting for audio source…'; + ? 'Waiting for video source...' + : 'Waiting for audio source...'; } getMediaAttachmentActionLabel(attachment: Attachment): string { @@ -502,8 +502,8 @@ export class ChatMessageItemComponent { ? 'Large video. Accept the download to watch it in chat.' : 'Large audio file. Accept the download to play it in chat.' : isVideo - ? 'Waiting for video source…' - : 'Waiting for audio source…', + ? 'Waiting for video source...' + : 'Waiting for audio source...', progressPercent: attachment.size > 0 ? ((attachment.receivedBytes || 0) * 100) / attachment.size : 0 diff --git a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html index 3b64d66..7b05e38 100644 --- a/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html +++ b/toju-app/src/app/domains/chat/feature/chat-messages/components/message-list/chat-message-list.component.html @@ -7,7 +7,7 @@ @if (syncing() && !loading()) {
- Syncing messages… + Syncing messages...
} diff --git a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html index 729a250..e7c109e 100644 --- a/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html +++ b/toju-app/src/app/domains/chat/feature/klipy-gif-picker/klipy-gif-picker.component.html @@ -62,7 +62,7 @@ @if (loading() && results().length === 0) {
-

Loading GIFs from KLIPY…

+

Loading GIFs from KLIPY...

} @else if (results().length === 0) {
- {{ loading() ? 'Loading…' : 'Load more' }} + {{ loading() ? 'Loading...' : 'Load more' }} }
diff --git a/toju-app/src/app/domains/notifications/domain/logic/notification.logic.ts b/toju-app/src/app/domains/notifications/domain/logic/notification.logic.ts index 8669be2..b3f8137 100644 --- a/toju-app/src/app/domains/notifications/domain/logic/notification.logic.ts +++ b/toju-app/src/app/domains/notifications/domain/logic/notification.logic.ts @@ -151,7 +151,7 @@ function formatMessagePreview(senderName: string, content: string): string { } const preview = normalisedContent.length > MESSAGE_PREVIEW_LIMIT - ? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}…` + ? `${normalisedContent.slice(0, MESSAGE_PREVIEW_LIMIT - 1)}...` : normalisedContent; return `${senderName}: ${preview}`; diff --git a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts index 965a42b..32708ab 100644 --- a/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts +++ b/toju-app/src/app/domains/server-directory/feature/invite/invite.component.ts @@ -27,7 +27,7 @@ export class InviteComponent implements OnInit { readonly currentUser = inject(Store).selectSignal(selectCurrentUser); readonly invite = signal(null); readonly status = signal<'loading' | 'redirecting' | 'joining' | 'error'>('loading'); - readonly message = signal('Loading invite…'); + readonly message = signal('Loading invite...'); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -121,7 +121,7 @@ export class InviteComponent implements OnInit { this.invite.set(invite); this.status.set('joining'); - this.message.set(`Joining ${invite.server.name}…`); + this.message.set(`Joining ${invite.server.name}...`); const currentUser = await this.hydrateCurrentUser(); const joinResponse = await firstValueFrom(this.serverDirectory.requestJoin({ @@ -163,7 +163,7 @@ export class InviteComponent implements OnInit { private async redirectToLogin(): Promise { this.status.set('redirecting'); - this.message.set('Redirecting to login…'); + this.message.set('Redirecting to login...'); await this.router.navigate(['/login'], { queryParams: { diff --git a/toju-app/src/app/domains/voice-connection/application/services/voice-connectivity-health.service.ts b/toju-app/src/app/domains/voice-connection/application/services/voice-connectivity-health.service.ts new file mode 100644 index 0000000..cde050e --- /dev/null +++ b/toju-app/src/app/domains/voice-connection/application/services/voice-connectivity-health.service.ts @@ -0,0 +1,139 @@ +import { + Injectable, + inject, + computed, + type Signal +} from '@angular/core'; +import { Store } from '@ngrx/store'; +import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { RealtimeSessionFacade } from '../../../../core/realtime'; +import type { User } from '../../../../shared-kernel'; + +/** + * Connectivity health status for a single peer in voice. + */ +export interface PeerConnectivityHealth { + peerId: string; + /** Number of voice peers this peer can send/receive audio to/from. */ + connectedPeerCount: number; + /** Total peers expected in voice. */ + totalVoicePeers: number; + /** true when this peer has the fewest connections -> warning target. */ + hasDesync: boolean; +} + +/** + * Tracks per-peer voice connectivity health by comparing the number + * of connected audio streams each peer has. Peers with fewest + * bidirectional audio connections are flagged. + * + * Uses peer latency data as proxy for healthy bidirectional connection. + */ +@Injectable({ providedIn: 'root' }) +export class VoiceConnectivityHealthService { + readonly currentUser: Signal; + readonly onlineUsers: Signal; + readonly desyncPeerIds: Signal>; + readonly localUserHasDesync: Signal; + + private readonly webrtc = inject(RealtimeSessionFacade); + + constructor() { + const store = inject(Store); + + this.currentUser = toSignal(store.select(selectCurrentUser)); + this.onlineUsers = toSignal(store.select(selectOnlineUsers), { initialValue: [] }); + + /** + * Map of peerId -> true for peers that have connectivity issues. + * A peer is flagged when it has fewer healthy connections than the + * majority of users in the same voice channel. + */ + this.desyncPeerIds = computed>(() => { + const me = this.currentUser(); + const myVoice = me?.voiceState; + + if (!myVoice?.isConnected || !myVoice.roomId || !myVoice.serverId) { + return new Set(); + } + + // Find all users in same voice room + const voiceUsers = this.onlineUsers().filter( + (user) => + user.voiceState?.isConnected + && user.voiceState.roomId === myVoice.roomId + && user.voiceState.serverId === myVoice.serverId + ); + + if (voiceUsers.length < 2) { + return new Set(); + } + + // Use peer latencies as proxy. A peer we can ping has a working + // data-channel (= working RTCPeerConnection). Peers without latency + // measurements are considered unreachable. + const connectedPeers = this.webrtc.connectedPeers(); + const connectedSet = new Set(connectedPeers); + const myKey = me?.oderId || me?.id; + + if (!myKey) { + return new Set(); + } + + // Count how many voice peers each voice user is connected to (from + // the local perspective). We can only see our own connections - but + // if WE can't reach peer X while we CAN reach peers Y and Z, peer X + // is the one with issues. + const unreachableFromUs = new Set(); + + for (const user of voiceUsers) { + const key = user.oderId || user.id; + + if (key === myKey) { + continue; + } + + const hasConnection = connectedSet.has(key) + || connectedSet.has(user.id) + || connectedSet.has(user.oderId ?? ''); + + if (!hasConnection) { + unreachableFromUs.add(key); + } + } + + // If we can reach everyone, no desync + if (unreachableFromUs.size === 0) { + return new Set(); + } + + // If we can't reach ANYONE, the problem is likely on our end + const reachableCount = voiceUsers.length - 1 - unreachableFromUs.size; + + if (reachableCount === 0 && voiceUsers.length > 2) { + // Everyone unreachable from us -> WE are the problem + return new Set([myKey]); + } + + return unreachableFromUs; + }); + + /** + * Whether the LOCAL user is the one with connectivity issues. + */ + this.localUserHasDesync = computed(() => { + const me = this.currentUser(); + const myKey = me?.oderId || me?.id; + + return !!myKey && this.desyncPeerIds().has(myKey); + }); + } + + /** + * Check if a specific peer has a desync warning. + */ + hasPeerDesync(peerKey: string): boolean { + return this.desyncPeerIds().has(peerKey); + } +} diff --git a/toju-app/src/app/domains/voice-connection/application/services/voice-playback.service.ts b/toju-app/src/app/domains/voice-connection/application/services/voice-playback.service.ts index 8ee19dd..e4373c5 100644 --- a/toju-app/src/app/domains/voice-connection/application/services/voice-playback.service.ts +++ b/toju-app/src/app/domains/voice-connection/application/services/voice-playback.service.ts @@ -209,7 +209,7 @@ export class VoicePlaybackService { * ↓ * muted