30 Commits

Author SHA1 Message Date
Myx
28797a0141 perf: use lookup for chats
Some checks failed
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 15m8s
Queue Release Build / build-linux (push) Successful in 26m49s
Queue Release Build / build-windows (push) Failing after 12m6s
Queue Release Build / finalize (push) Has been skipped
2026-04-17 03:53:53 +02:00
Myx
17738ec484 feat: Add profile images 2026-04-17 03:06:44 +02:00
Myx
35b616fb77 refactor: Clean lint errors and organise files 2026-04-17 01:06:01 +02:00
Myx
2927a86fbb feat: Add user statuses and cards 2026-04-16 22:52:45 +02:00
Myx
b4ac0cdc92 fix: Windows audio mute fix 2026-04-16 19:07:44 +02:00
Myx
f3b56fb1cc fix: Db corruption fix
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 10m0s
Queue Release Build / build-linux (push) Successful in 25m59s
Queue Release Build / build-windows (push) Successful in 21m44s
Queue Release Build / finalize (push) Successful in 18s
2026-04-13 02:23:09 +02:00
Myx
315820d487 ci: attempt to fix
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 11m21s
Queue Release Build / build-linux (push) Successful in 25m31s
Queue Release Build / build-windows (push) Successful in 21m42s
Queue Release Build / finalize (push) Successful in 22s
2026-04-12 22:05:39 +02:00
Myx
878fd1c766 ci: Attempt to speed up build
Some checks failed
Queue Release Build / prepare (push) Successful in 50s
Deploy Web Apps / deploy (push) Failing after 7m46s
Queue Release Build / build-windows (push) Failing after 11m49s
Queue Release Build / build-linux (push) Successful in 34m1s
Queue Release Build / finalize (push) Has been skipped
2026-04-12 03:19:51 +02:00
Myx
391d9235f1 test: Add playwright main usage test
Some checks failed
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / prepare (push) Successful in 21s
Queue Release Build / build-linux (push) Successful in 27m44s
Queue Release Build / build-windows (push) Successful in 32m16s
Queue Release Build / finalize (push) Successful in 1m54s
2026-04-12 03:02:29 +02:00
Myx
f33440a827 test: Initate instructions 2026-04-11 15:38:16 +02:00
Myx
52912327ae refactor: stricter domain: voice-session 2026-04-11 15:12:54 +02:00
Myx
7a0664b3c4 refactor: stricter domain: voice-connection 2026-04-11 15:08:04 +02:00
Myx
cea3dccef1 refactor: stricter domain: theme 2026-04-11 15:01:39 +02:00
Myx
c8bb82feb5 refactor: stricter domain: server-directory 2026-04-11 14:50:38 +02:00
Myx
3fb5515c3a refactor: stricter domain: screen-share 2026-04-11 14:42:12 +02:00
Myx
a6bdac1a25 refactor: true facades 2026-04-11 14:35:02 +02:00
Myx
db7e683504 refactor: stricer domain: notifications 2026-04-11 14:23:50 +02:00
Myx
98ed8eeb68 refactor: stricter domain: chat 2026-04-11 14:01:19 +02:00
Myx
39b85e2e3a refactor: stricter domain: auth 2026-04-11 13:52:59 +02:00
Myx
58e338246f refactor: stricter domain: attachments 2026-04-11 13:39:27 +02:00
Myx
0b9a9f311e refactor: stricter domain: access-control 2026-04-11 13:31:25 +02:00
Myx
6800c73292 refactor: Cleaning rooms store 2026-04-11 13:07:46 +02:00
Myx
ef1182d46f fix: Broken voice states and connectivity drops 2026-04-11 12:32:22 +02:00
Myx
0865c2fe33 feat: Basic general context menu
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Deploy Web Apps / deploy (push) Successful in 14m39s
Queue Release Build / build-linux (push) Successful in 40m59s
Queue Release Build / build-windows (push) Successful in 28m59s
Queue Release Build / finalize (push) Successful in 1m58s
2026-04-04 05:38:05 +02:00
Myx
4a41de79d6 fix: debugger lagging from too many logs 2026-04-04 04:55:13 +02:00
Myx
84fa45985a feat: Add chat embeds v1
Youtube and Website metadata embeds
2026-04-04 04:47:04 +02:00
Myx
35352923a5 feat: Youtube embed support 2026-04-04 03:30:21 +02:00
Myx
b9df9c92f2 fix: links not getting recognised in chat 2026-04-04 03:14:25 +02:00
Myx
8674579b19 fix: leave and reconnect sound randomly playing, also fix leave sound when muting 2026-04-04 03:09:44 +02:00
Myx
de2d3300d4 fix: Fix users unable to see or hear each other in voice channels due to
stale server sockets, passive non-initiators, and race conditions
during peer connection setup.

Fix users unable to see or hear each other in voice channels due to
stale server sockets, passive non-initiators, and race conditions
during peer connection setup.

Server:
- Close stale WebSocket connections sharing the same oderId in
  handleIdentify instead of letting them linger up to 45s
- Make user_joined/user_left broadcasts identity-aware so duplicate
  sockets don't produce phantom join/leave events
- Include serverIds in user_left payload for multi-room presence
- Simplify findUserByOderId now that stale sockets are cleaned up

Client - signaling:
- Add fallback offer system with 1s timer for missed user_joined races
- Add non-initiator takeover after 5s when the initiator fails to send
  an offer (NON_INITIATOR_GIVE_UP_MS)
- Scope peerServerMap per signaling URL to prevent cross-server
  collisions
- Add socket identity guards on all signaling event handlers
- Replace canReusePeerConnection with hasActivePeerConnection and
  isPeerConnectionNegotiating with extended grace periods

Client - peer connections:
- Extract replaceUnusablePeer helper to deduplicate stale peer
  replacement in offer and ICE handlers
- Add stale connectionstatechange guard to ignore events from replaced
  RTCPeerConnection instances
- Use deterministic initiator election in peer recovery reconnects
- Track createdAt on PeerData for staleness detection

Client - presence:
- Add multi-room presence tracking via presenceServerIds on User
- Replace clearUsers + individual userJoined with syncServerPresence
  for atomic server roster updates
- Make userLeft handle partial server removal instead of full eviction

Documentation:
- Add server-side connection hygiene, non-initiator takeover, and stale
  peer replacement sections to the realtime README
2026-04-04 02:47:58 +02:00
298 changed files with 15397 additions and 2787 deletions

View File

@@ -0,0 +1,63 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman
while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra,
wenyan-lite, wenyan-full, wenyan-ultra.
Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens",
"be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested.
---
Respond terse like smart caveman. All technical substance stay. Only fluff die.
Default: **full**. Switch: `/caveman lite|full|ultra`.
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Technical terms exact. Code blocks unchanged. Errors quoted exact.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
## Intensity
| Level | What change |
|-------|------------|
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
| **full** | Drop articles, fragments OK, short synonyms. Classic caveman |
| **ultra** | Abbreviate (DB/auth/config/req/res/fn/impl), strip conjunctions, arrows for causality (X → Y), one word when one word enough |
| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
Example — "Why React component re-render?"
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
- ultra: "Inline obj prop → new ref → re-render. `useMemo`."
- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
- wenyan-full: "物出新參照致重繪。useMemo .Wrap之。"
- wenyan-ultra: "新參照→重繪。useMemo Wrap。"
Example — "Explain database connection pooling."
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
- ultra: "Pool = reuse DB conn. Skip handshake → fast under load."
- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。"
- wenyan-ultra: "池reuse conn。skip handshake → fast。"
## Auto-Clarity
Drop caveman for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user confused. Resume caveman after clear part done.
Example — destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
> ```sql
> DROP TABLE users;
> ```
> Caveman resume. Verify backup exist first.
## Boundaries
Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end.

View File

@@ -0,0 +1,283 @@
---
name: playwright-e2e
description: >
Write and run Playwright E2E tests for MetoYou (Angular chat + WebRTC voice/video app).
Handles multi-browser-context WebRTC voice testing, fake media devices, signaling validation,
audio channel verification, and UI state assertions. Use when: "E2E test", "Playwright",
"end-to-end", "browser test", "test voice", "test call", "test WebRTC", "test chat",
"test login", "test video", "test screen share", "integration test", "multi-client test".
---
# Playwright E2E Testing — MetoYou
## Step 1 — Check Project Setup
Before writing any test, verify the Playwright infrastructure exists:
```
e2e/ # Test root (lives at repo root)
├── playwright.config.ts # Config
├── fixtures/ # Custom fixtures (multi-client, auth, etc.)
├── pages/ # Page Object Models
├── tests/ # Test specs
│ ├── auth/
│ ├── chat/
│ ├── voice/
│ └── settings/
└── helpers/ # Shared utilities (WebRTC introspection, etc.)
```
If missing, scaffold it. See [reference/project-setup.md](./reference/project-setup.md).
## Step 2 — Identify Test Category
| Request | Category | Key Patterns |
| ------------------------------- | ---------------- | ---------------------------------------------- |
| Login, register, invite | **Auth** | Single browser context, form interaction |
| Send message, rooms, chat UI | **Chat** | May need 2 clients for real-time sync |
| Voice call, mute, deafen, audio | **Voice/WebRTC** | Multi-client, fake media, WebRTC introspection |
| Camera, video tiles | **Video** | Multi-client, fake video, stream validation |
| Screen share | **Screen Share** | Multi-client, display media mocking |
| Settings, themes | **Settings** | Single client, preference persistence |
For **Voice/WebRTC** and **Multi-client** tests, read [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) immediately.
## Step 3 — Core Conventions
### Config Essentials
```typescript
// e2e/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60_000, // WebRTC needs longer timeouts
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1, // Sequential — shared server state
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
permissions: ['microphone', 'camera']
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream'
// Feed a specific audio file as fake mic input:
// '--use-file-for-fake-audio-capture=/path/to/audio.wav',
]
}
}
}
],
webServer: [
{
command: 'cd server && npm run dev',
port: 3001,
reuseExistingServer: !process.env.CI,
timeout: 30_000
},
{
command: 'cd toju-app && npx ng serve',
port: 4200,
reuseExistingServer: !process.env.CI,
timeout: 60_000
}
]
});
```
### Selector Strategy
Use in this order — stop at the first that works:
1. `getByRole('button', { name: 'Mute' })` — accessible, resilient
2. `getByLabel('Email')` — form fields
3. `getByPlaceholder('Enter email')` — when label missing
4. `getByText('Welcome')` — visible text
5. `getByTestId('voice-controls')` — last resort, needs `data-testid`
6. `locator('app-voice-controls')` — Angular component selectors (acceptable in this project)
Angular component selectors (`app-*`) are stable in this project and acceptable as locators when semantic selectors are not feasible.
### Assertions — Always Web-First
```typescript
// ✅ Auto-retries until timeout
await expect(page.getByRole('heading')).toBeVisible();
await expect(page.getByRole('alert')).toHaveText('Saved');
await expect(page).toHaveURL(/\/room\//);
// ❌ No auto-retry — races with DOM
const text = await page.textContent('.msg');
expect(text).toBe('Saved');
```
### Anti-Patterns
| ❌ Don't | ✅ Do | Why |
| ------------------------------ | --------------------------------------------------- | ------------------------- |
| `page.waitForTimeout(3000)` | `await expect(locator).toBeVisible()` | Hard waits are flaky |
| `expect(await el.isVisible())` | `await expect(el).toBeVisible()` | No auto-retry |
| `page.$('.btn')` | `page.getByRole('button')` | Fragile selector |
| `page.click('.submit')` | `page.getByRole('button', {name:'Submit'}).click()` | Not accessible |
| Shared state between tests | `test.beforeEach` for setup | Tests must be independent |
| `try/catch` around assertions | Let Playwright handle retries | Swallows real failures |
### Test Structure
```typescript
import { test, expect } from '../fixtures/base';
test.describe('Feature Name', () => {
test('should do specific thing', async ({ page }) => {
await test.step('Navigate to page', async () => {
await page.goto('/login');
});
await test.step('Fill form', async () => {
await page.getByLabel('Email').fill('test@example.com');
});
await test.step('Verify result', async () => {
await expect(page).toHaveURL(/\/search/);
});
});
});
```
Use `test.step()` for readability in complex flows.
## Step 4 — Page Object Models
Use POM for any test file with more than 2 tests. Match the app's route structure:
```typescript
// e2e/pages/login.page.ts
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: /sign in|log in/i });
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}
```
**Key pages to model (match `app.routes.ts`):**
| Route | Page Object | Component |
| ------------------- | ------------------ | ----------------------- |
| `/login` | `LoginPage` | `LoginComponent` |
| `/register` | `RegisterPage` | `RegisterComponent` |
| `/search` | `ServerSearchPage` | `ServerSearchComponent` |
| `/room/:roomId` | `ChatRoomPage` | `ChatRoomComponent` |
| `/settings` | `SettingsPage` | `SettingsComponent` |
| `/invite/:inviteId` | `InvitePage` | `InviteComponent` |
## Step 5 — MetoYou App Architecture Context
The agent writing tests MUST understand these domain boundaries:
### Voice/WebRTC Stack
| Layer | What It Does | Test Relevance |
| ----------------------- | ----------------------------------------------------- | -------------------------------- |
| `VoiceConnectionFacade` | High-level voice API (connect/disconnect/mute/deafen) | State signals to assert against |
| `VoiceSessionFacade` | Session lifecycle, workspace layout | UI mode changes |
| `VoiceActivityService` | Speaking detection (RMS threshold 0.015) | `isSpeaking()` signal validation |
| `VoicePlaybackService` | Per-peer GainNode (0200% volume) | Volume level assertions |
| `PeerConnectionManager` | RTCPeerConnection lifecycle | Connection state introspection |
| `MediaManager` | getUserMedia, mute, gain chain | Track state validation |
| `SignalingManager` | WebSocket per signal URL | Connection establishment |
### Voice UI Components
| Component | Selector | Contains |
| ----------------------------------- | --------------------------------- | ------------------------------------------- |
| `VoiceWorkspaceComponent` | `app-voice-workspace` | Stream tiles, layout |
| `VoiceControlsComponent` | `app-voice-controls` | Mute, camera, screen share, hang-up buttons |
| `FloatingVoiceControlsComponent` | `app-floating-voice-controls` | Floating variant of controls |
| `VoiceWorkspaceStreamTileComponent` | `app-voice-workspace-stream-tile` | Per-peer audio/video tile |
### Voice UI Icons (Lucide)
| Icon | Meaning |
| ------------------------------------ | -------------------- |
| `lucideMic` / `lucideMicOff` | Mute toggle |
| `lucideVideo` / `lucideVideoOff` | Camera toggle |
| `lucideMonitor` / `lucideMonitorOff` | Screen share toggle |
| `lucidePhoneOff` | Hang up / disconnect |
| `lucideHeadphones` | Deafen state |
| `lucideVolume2` / `lucideVolumeX` | Volume indicator |
### Server & Signaling
- **Signaling server**: Port `3001` (HTTP by default, HTTPS if `SSL=true`)
- **Angular dev server**: Port `4200`
- **WebSocket signaling**: Upgrades on same port as server
- **Protocol**: `identify``server_users` → SDP offer/answer → ICE candidates
- **PING/PONG**: Every 30s, 45s timeout
## Step 6 — Validation Workflow
After generating any test:
```
1. npx playwright install --with-deps chromium # First time only
2. npx playwright test --project=chromium # Run tests
3. npx playwright test --ui # Interactive debug
4. npx playwright show-report # HTML report
```
If the test involves WebRTC, always verify:
- Fake media flags are set in config
- Timeouts are sufficient (60s+ for connection establishment)
- `workers: 1` if tests share server state
- Browser permissions granted for microphone/camera
## Quick Reference — Commands
```bash
npx playwright test # Run all
npx playwright test --ui # Interactive UI
npx playwright test --debug # Step-through debugger
npx playwright test tests/voice/ # Voice tests only
npx playwright test --project=chromium # Single browser
npx playwright test -g "voice connects" # By test name
npx playwright show-report # HTML report
npx playwright codegen http://localhost:4200 # Record test
```
## Reference Files
| File | When to Read |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------ |
| [reference/multi-client-webrtc.md](./reference/multi-client-webrtc.md) | Voice/video/WebRTC tests, multi-browser contexts, audio validation |
| [reference/project-setup.md](./reference/project-setup.md) | First-time scaffold, dependency installation, config creation |

View File

@@ -0,0 +1,536 @@
# Multi-Client WebRTC Testing
This reference covers the hardest E2E testing scenario in MetoYou: verifying that voice/video connections actually work between multiple clients.
## Core Concept: Multiple Browser Contexts
Playwright can create multiple **independent** browser contexts within a single test. Each context is an isolated session (separate cookies, storage, WebRTC state). This is how we simulate multiple users.
```typescript
import { test, expect, chromium } from '@playwright/test';
test('two users can voice chat', async () => {
const browser = await chromium.launch({
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
'--use-file-for-fake-audio-capture=e2e/fixtures/test-tone-440hz.wav',
],
});
const contextA = await browser.newContext({
permissions: ['microphone', 'camera'],
});
const contextB = await browser.newContext({
permissions: ['microphone', 'camera'],
});
const alice = await contextA.newPage();
const bob = await contextB.newPage();
// ... test logic with alice and bob ...
await contextA.close();
await contextB.close();
await browser.close();
});
```
## Custom Fixture: Multi-Client
Create a reusable fixture for multi-client tests:
```typescript
// e2e/fixtures/multi-client.ts
import { test as base, chromium, type Page, type BrowserContext, type Browser } from '@playwright/test';
type Client = {
page: Page;
context: BrowserContext;
};
type MultiClientFixture = {
createClient: () => Promise<Client>;
browser: Browser;
};
export const test = base.extend<MultiClientFixture>({
browser: async ({}, use) => {
const browser = await chromium.launch({
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
],
});
await use(browser);
await browser.close();
},
createClient: async ({ browser }, use) => {
const clients: Client[] = [];
const factory = async (): Promise<Client> => {
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200',
});
const page = await context.newPage();
clients.push({ page, context });
return { page, context };
};
await use(factory);
// Cleanup
for (const client of clients) {
await client.context.close();
}
},
});
export { expect } from '@playwright/test';
```
**Usage:**
```typescript
import { test, expect } from '../fixtures/multi-client';
test('voice call connects between two users', async ({ createClient }) => {
const alice = await createClient();
const bob = await createClient();
// Login both users
await alice.page.goto('/login');
await bob.page.goto('/login');
// ... login flows ...
});
```
## Fake Media Devices
Chromium's fake device flags are essential for headless WebRTC testing:
| Flag | Purpose |
|------|---------|
| `--use-fake-device-for-media-stream` | Provides fake mic/camera devices (no real hardware needed) |
| `--use-fake-ui-for-media-stream` | Auto-grants device permission prompts |
| `--use-file-for-fake-audio-capture=<path>` | Feeds a WAV/WebM file as fake mic input |
| `--use-file-for-fake-video-capture=<path>` | Feeds a Y4M/MJPEG file as fake video input |
### Test Audio File
Create a 440Hz sine wave test tone for audio verification:
```bash
# Generate a 5-second 440Hz mono WAV test tone
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav
```
Or use Playwright's built-in fake device which generates a test pattern (beep) automatically when `--use-fake-device-for-media-stream` is set without specifying a file.
## WebRTC Connection Introspection
### Checking RTCPeerConnection State
Inject JavaScript to inspect WebRTC internals via `page.evaluate()`:
```typescript
// e2e/helpers/webrtc-helpers.ts
/**
* Get all RTCPeerConnection instances and their states.
* Requires the app to expose connections (or use a monkey-patch approach).
*/
export async function getPeerConnectionStates(page: Page): Promise<Array<{
connectionState: string;
iceConnectionState: string;
signalingState: string;
}>> {
return page.evaluate(() => {
// Approach 1: Use chrome://webrtc-internals equivalent
// Approach 2: Monkey-patch RTCPeerConnection (install before app loads)
// Approach 3: Expose from app (preferred for this project)
// MetoYou exposes via Angular — access the injector
const appRef = (window as any).ng?.getComponent(document.querySelector('app-root'));
// This needs adaptation based on actual exposure method
// Fallback: Use performance.getEntriesByType or WebRTC stats
return [];
});
}
/**
* Wait for at least one peer connection to reach 'connected' state.
*/
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction(() => {
// Check if any RTCPeerConnection reached 'connected'
return (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false;
}, { timeout });
}
/**
* Get WebRTC stats for audio tracks (inbound/outbound).
*/
export async function getAudioStats(page: Page): Promise<{
outbound: { bytesSent: number; packetsSent: number } | null;
inbound: { bytesReceived: number; packetsReceived: number } | null;
}> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length) return { outbound: null, inbound: null };
const pc = connections[0];
const stats = await pc.getStats();
let outbound: any = null;
let inbound: any = null;
stats.forEach((report) => {
if (report.type === 'outbound-rtp' && report.kind === 'audio') {
outbound = { bytesSent: report.bytesSent, packetsSent: report.packetsSent };
}
if (report.type === 'inbound-rtp' && report.kind === 'audio') {
inbound = { bytesReceived: report.bytesReceived, packetsReceived: report.packetsReceived };
}
});
return { outbound, inbound };
});
}
```
### RTCPeerConnection Monkey-Patch
To track all peer connections created by the app, inject a monkey-patch **before** navigation:
```typescript
/**
* Install RTCPeerConnection tracking on a page BEFORE navigating.
* Call this immediately after page creation, before any goto().
*/
export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => {
const connections: RTCPeerConnection[] = [];
(window as any).__rtcConnections = connections;
const OriginalRTCPeerConnection = window.RTCPeerConnection;
(window as any).RTCPeerConnection = function (...args: any[]) {
const pc = new OriginalRTCPeerConnection(...args);
connections.push(pc);
pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState;
});
// Track remote streams
pc.addEventListener('track', (event) => {
if (!((window as any).__rtcRemoteTracks)) {
(window as any).__rtcRemoteTracks = [];
}
(window as any).__rtcRemoteTracks.push({
kind: event.track.kind,
id: event.track.id,
readyState: event.track.readyState,
});
});
return pc;
} as any;
// Preserve prototype chain
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
});
}
```
## Voice Call Test Pattern — Full Example
This is the canonical pattern for testing that voice actually connects between two clients:
```typescript
import { test, expect } from '../fixtures/multi-client';
import { installWebRTCTracking, getAudioStats } from '../helpers/webrtc-helpers';
test.describe('Voice Call', () => {
test('two users connect voice and exchange audio', async ({ createClient }) => {
const alice = await createClient();
const bob = await createClient();
// Install WebRTC tracking BEFORE navigation
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
await test.step('Both users log in', async () => {
// Login Alice
await alice.page.goto('/login');
await alice.page.getByLabel('Email').fill('alice@test.com');
await alice.page.getByLabel('Password').fill('password123');
await alice.page.getByRole('button', { name: /sign in/i }).click();
await expect(alice.page).toHaveURL(/\/search/);
// Login Bob
await bob.page.goto('/login');
await bob.page.getByLabel('Email').fill('bob@test.com');
await bob.page.getByLabel('Password').fill('password123');
await bob.page.getByRole('button', { name: /sign in/i }).click();
await expect(bob.page).toHaveURL(/\/search/);
});
await test.step('Both users join the same room', async () => {
// Navigate to a shared room (adapt URL to actual room ID)
const roomUrl = '/room/test-room-id';
await alice.page.goto(roomUrl);
await bob.page.goto(roomUrl);
// Verify both are in the room
await expect(alice.page.locator('app-chat-room')).toBeVisible();
await expect(bob.page.locator('app-chat-room')).toBeVisible();
});
await test.step('Alice starts voice', async () => {
// Click the voice/call join button (adapt selector to actual UI)
await alice.page.getByRole('button', { name: /join voice|connect/i }).click();
// Voice workspace should appear
await expect(alice.page.locator('app-voice-workspace')).toBeVisible();
// Voice controls should be visible
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
});
await test.step('Bob joins voice', async () => {
await bob.page.getByRole('button', { name: /join voice|connect/i }).click();
await expect(bob.page.locator('app-voice-workspace')).toBeVisible();
});
await test.step('WebRTC connection establishes', async () => {
// Wait for peer connection to reach 'connected' on both sides
await alice.page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: any) => pc.connectionState === 'connected'
),
{ timeout: 30_000 }
);
await bob.page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: any) => pc.connectionState === 'connected'
),
{ timeout: 30_000 }
);
});
await test.step('Audio is flowing in both directions', async () => {
// Wait a moment for audio stats to accumulate
await alice.page.waitForTimeout(3_000); // Acceptable here: waiting for stats accumulation
// Check Alice is sending audio
const aliceStats = await getAudioStats(alice.page);
expect(aliceStats.outbound).not.toBeNull();
expect(aliceStats.outbound!.bytesSent).toBeGreaterThan(0);
expect(aliceStats.outbound!.packetsSent).toBeGreaterThan(0);
// Check Bob is sending audio
const bobStats = await getAudioStats(bob.page);
expect(bobStats.outbound).not.toBeNull();
expect(bobStats.outbound!.bytesSent).toBeGreaterThan(0);
// Check Alice receives Bob's audio
expect(aliceStats.inbound).not.toBeNull();
expect(aliceStats.inbound!.bytesReceived).toBeGreaterThan(0);
// Check Bob receives Alice's audio
expect(bobStats.inbound).not.toBeNull();
expect(bobStats.inbound!.bytesReceived).toBeGreaterThan(0);
});
await test.step('Voice UI states are correct', async () => {
// Both should see stream tiles for each other
await expect(alice.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2);
await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(2);
// Mute button should be visible and in unmuted state
// (lucideMic icon visible, lucideMicOff NOT visible)
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
});
await test.step('Mute toggles correctly', async () => {
// Alice mutes
await alice.page.getByRole('button', { name: /mute/i }).click();
// Alice's local UI shows muted state
// Bob should see Alice as muted (mute indicator on her tile)
// Verify no audio being sent from Alice after mute
const preStats = await getAudioStats(alice.page);
await alice.page.waitForTimeout(2_000);
const postStats = await getAudioStats(alice.page);
// Bytes sent should not increase (or increase minimally — comfort noise)
// The exact assertion depends on whether mute stops the track or sends silence
});
await test.step('Alice hangs up', async () => {
await alice.page.getByRole('button', { name: /hang up|disconnect|leave/i }).click();
// Voice workspace should disappear for Alice
await expect(alice.page.locator('app-voice-workspace')).not.toBeVisible();
// Bob should see Alice's tile disappear
await expect(bob.page.locator('app-voice-workspace-stream-tile')).toHaveCount(1);
});
});
});
```
## Verifying Audio Is Actually Received
Beyond checking `bytesReceived > 0`, you can verify actual audio energy:
```typescript
/**
* Check if audio energy is present on a received stream.
* Uses Web Audio AnalyserNode — same approach as MetoYou's VoiceActivityService.
*/
export async function hasAudioEnergy(page: Page): Promise<boolean> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[];
if (!connections?.length) return false;
for (const pc of connections) {
const receivers = pc.getReceivers();
for (const receiver of receivers) {
if (receiver.track.kind !== 'audio' || receiver.track.readyState !== 'live') continue;
const audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(new MediaStream([receiver.track]));
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
// Sample over 500ms
const dataArray = new Float32Array(analyser.frequencyBinCount);
await new Promise(resolve => setTimeout(resolve, 500));
analyser.getFloatTimeDomainData(dataArray);
// Calculate RMS (same as VoiceActivityService)
let sum = 0;
for (const sample of dataArray) {
sum += sample * sample;
}
const rms = Math.sqrt(sum / dataArray.length);
audioCtx.close();
// MetoYou uses threshold 0.015 — use lower threshold for test
if (rms > 0.005) return true;
}
}
return false;
});
}
```
## Testing Speaker/Playback Volume
MetoYou uses per-peer GainNode chains (0200%). To verify:
```typescript
await test.step('Bob adjusts Alice volume to 50%', async () => {
// Interact with volume slider in stream tile
// (adapt selector to actual volume control UI)
const volumeSlider = bob.page.locator('app-voice-workspace-stream-tile')
.filter({ hasText: 'Alice' })
.getByRole('slider');
await volumeSlider.fill('50');
// Verify the gain was set (check via WebRTC or app state)
const gain = await bob.page.evaluate(() => {
// Access VoicePlaybackService through Angular DI if exposed
// Or check audio element volume
const audioElements = document.querySelectorAll('audio');
return audioElements[0]?.volume;
});
});
```
## Testing Screen Share
Screen share requires `getDisplayMedia()` which cannot be auto-granted. Options:
1. **Mock at the browser level** — use `page.addInitScript()` to replace `getDisplayMedia` with a fake stream
2. **Use Chromium flags**`--auto-select-desktop-capture-source=Entire screen`
```typescript
// Mock getDisplayMedia before navigation
await page.addInitScript(() => {
navigator.mediaDevices.getDisplayMedia = async () => {
// Create a simple canvas stream as fake screen share
const canvas = document.createElement('canvas');
canvas.width = 1280;
canvas.height = 720;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#4a90d9';
ctx.fillRect(0, 0, 1280, 720);
ctx.fillStyle = 'white';
ctx.font = '48px sans-serif';
ctx.fillText('Fake Screen Share', 400, 380);
return canvas.captureStream(30);
};
});
```
## Debugging Tips
### Trace Viewer
When a WebRTC test fails, the trace captures network requests, console logs, and screenshots:
```bash
npx playwright show-trace test-results/trace.zip
```
### Console Log Forwarding
Forward browser console to Node for real-time debugging:
```typescript
page.on('console', msg => console.log(`[${name}]`, msg.text()));
page.on('pageerror', err => console.error(`[${name}] PAGE ERROR:`, err.message));
```
### WebRTC Internals
Chromium exposes WebRTC stats at `chrome://webrtc-internals/`. In Playwright, access the same data via:
```typescript
const stats = await page.evaluate(async () => {
const pcs = (window as any).__rtcConnections;
return Promise.all(pcs.map(async (pc: any) => {
const stats = await pc.getStats();
const result: any[] = [];
stats.forEach((report: any) => result.push(report));
return result;
}));
});
```
## Timeout Guidelines
| Operation | Recommended Timeout |
|-----------|-------------------|
| Page navigation | 10s (default) |
| Login flow | 15s |
| WebRTC connection establishment | 30s |
| ICE negotiation (TURN fallback) | 45s |
| Audio stats accumulation | 35s after connection |
| Full voice test (end-to-end) | 90s |
| Screen share setup | 15s |
## Parallelism Warning
WebRTC multi-client tests **must** run with `workers: 1` (sequential) because:
- All clients share the same signaling server instance
- Server state (rooms, users) is mutable
- ICE candidates reference `localhost` — port conflicts possible with parallel launches
If you need parallelism, use separate server instances with different ports per worker.

View File

@@ -0,0 +1,175 @@
# Project Setup — Playwright E2E for MetoYou
Before creating any tests or changing them read AGENTS.md for a understanding how the application works.
## First-Time Scaffold
### 1. Install Dependencies
From the **repository root**:
```bash
npm install -D @playwright/test
npx playwright install --with-deps chromium
```
Only Chromium is needed — WebRTC fake media flags are Chromium-only. Add Firefox/WebKit later if needed for non-WebRTC tests.
### 2. Create Directory Structure
```bash
mkdir -p e2e/{tests/{auth,chat,voice,settings},fixtures,pages,helpers}
```
### 3. Create Config
Create `e2e/playwright.config.ts`:
```typescript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
outputDir: '../test-results/artifacts',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
permissions: ['microphone', 'camera'],
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
launchOptions: {
args: [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
],
},
},
},
],
webServer: [
{
command: 'cd ../server && npm run dev',
port: 3001,
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
{
command: 'cd ../toju-app && npx ng serve',
port: 4200,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
],
});
```
### 4. Create Base Fixture
Create `e2e/fixtures/base.ts`:
```typescript
import { test as base } from '@playwright/test';
export const test = base.extend({
// Add common fixtures here as the test suite grows
// Examples: authenticated page, test data seeding, etc.
});
export { expect } from '@playwright/test';
```
### 5. Create Multi-Client Fixture
Create `e2e/fixtures/multi-client.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for the full fixture code.
### 6. Create WebRTC Helpers
Create `e2e/helpers/webrtc-helpers.ts` — see [multi-client-webrtc.md](./multi-client-webrtc.md) for helper functions.
### 7. Create Isolated Test Server Launcher
The app requires a signal server. Tests use an isolated instance with its own temporary database so test data never pollutes the dev environment.
Create `e2e/helpers/start-test-server.js` — a Node.js script that:
1. Creates a temp directory under the OS tmpdir
2. Writes a `data/variables.json` with `serverPort: 3099`, `serverProtocol: "http"`
3. Spawns `ts-node server/src/index.ts` with `cwd` set to the temp dir
4. Cleans up the temp dir on exit
The server's `getRuntimeBaseDir()` returns `process.cwd()`, so setting cwd to the temp dir makes the database go to `<tmpdir>/data/metoyou.sqlite`. Module resolution (`require`/`import`) uses `__dirname`, so server source and `node_modules` resolve correctly from the real `server/` directory.
Playwright's `webServer` config calls this script and waits for port 3099 to be ready.
### 8. Create Test Endpoint Seeder
The Angular app reads signal endpoints from `localStorage['metoyou_server_endpoints']`. By default it falls back to production URLs in `environment.ts`. For tests, seed localStorage with a single endpoint pointing at `http://localhost:3099`.
Create `e2e/helpers/seed-test-endpoint.ts` — called automatically by the multi-client fixture after creating each browser context. The flow is:
1. Navigate to `/` (establishes the origin for localStorage)
2. Set `metoyou_server_endpoints` to `[{ id: 'e2e-test-server', url: 'http://localhost:3099', ... }]`
3. Set `metoyou_removed_default_server_keys` to suppress production endpoints
4. Reload the page so the app picks up the test endpoint
### 9. Add npm Scripts
Add to root `package.json`:
```json
{
"scripts": {
"test:e2e": "cd e2e && npx playwright test",
"test:e2e:ui": "cd e2e && npx playwright test --ui",
"test:e2e:debug": "cd e2e && npx playwright test --debug",
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report"
}
}
```
### 8. Update .gitignore
Add to `.gitignore`:
```
# Playwright
test-results/
e2e/playwright-report/
```
### 9. Generate Test Audio Fixture (Optional)
For voice tests with controlled audio input:
```bash
# Requires ffmpeg
ffmpeg -f lavfi -i "sine=frequency=440:duration=5" -ar 48000 -ac 1 e2e/fixtures/test-tone-440hz.wav
```
## Existing Dev Stack Integration
The tests assume the standard MetoYou dev stack:
- **Signaling server** at `http://localhost:3001` (via `server/npm run dev`)
- **Angular dev server** at `http://localhost:4200` (via `toju-app/npx ng serve`)
The `webServer` config in `playwright.config.ts` starts these automatically if not already running. When running `npm run dev` (full Electron stack) separately, tests will reuse the existing servers.
## Test Data Requirements
E2E tests need user accounts to log in with. Options:
1. **Seed via API** — create users in `test.beforeAll` via the server REST API
2. **Pre-seeded database** — maintain a test SQLite database with known accounts
3. **Register in test** — use the `/register` flow as a setup step (slower but self-contained)
Recommended: Option 3 for initial setup, migrate to Option 1 as the test suite grows.

View File

@@ -17,6 +17,13 @@ jobs:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Restore npm cache
uses: https://github.com/actions/cache@v4
with:
path: ~/AppData/Local/npm-cache
key: npm-windows-${{ hashFiles('package-lock.json', 'website/package-lock.json') }}
restore-keys: npm-windows-
- name: Install root dependencies - name: Install root dependencies
env: env:
NODE_ENV: development NODE_ENV: development

View File

@@ -48,18 +48,30 @@ jobs:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Restore npm cache
uses: https://github.com/actions/cache@v4
with:
path: /root/.npm
key: npm-linux-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
restore-keys: npm-linux-
- name: Restore Electron cache
uses: https://github.com/actions/cache@v4
with:
path: |
/root/.cache/electron
/root/.cache/electron-builder
key: electron-linux-${{ hashFiles('package.json') }}
restore-keys: electron-linux-
- name: Install dependencies - name: Install dependencies
env: env:
NODE_ENV: development NODE_ENV: development
run: | run: |
apt-get update && apt-get install -y --no-install-recommends zip
npm ci npm ci
cd server && npm ci cd server && npm ci
- name: Install zip utility
run: |
apt-get update
apt-get install -y zip
- name: Set CI release version - name: Set CI release version
run: > run: >
node tools/set-release-version.js node tools/set-release-version.js
@@ -108,6 +120,22 @@ jobs:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Restore npm cache
uses: https://github.com/actions/cache@v4
with:
path: ~/AppData/Local/npm-cache
key: npm-windows-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
restore-keys: npm-windows-
- name: Restore Electron cache
uses: https://github.com/actions/cache@v4
with:
path: |
~/AppData/Local/electron/Cache
~/AppData/Local/electron-builder/Cache
key: electron-windows-${{ hashFiles('package.json') }}
restore-keys: electron-windows-
- name: Install dependencies - name: Install dependencies
env: env:
NODE_ENV: development NODE_ENV: development
@@ -217,9 +245,6 @@ jobs:
- name: Checkout - name: Checkout
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
- name: Install dependencies
run: npm ci --omit=dev
- name: Download previous manifest - name: Download previous manifest
env: env:
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}

4
.gitignore vendored
View File

@@ -44,6 +44,10 @@ testem.log
/typings /typings
__screenshots__/ __screenshots__/
# Playwright
test-results/
e2e/playwright-report/
# System files # System files
.DS_Store .DS_Store
Thumbs.db Thumbs.db

4
e2e/fixtures/base.ts Normal file
View File

@@ -0,0 +1,4 @@
import { test as base } from '@playwright/test';
export const test = base;
export { expect } from '@playwright/test';

View File

@@ -0,0 +1,202 @@
import {
test as base,
chromium,
type Page,
type BrowserContext,
type Browser
} from '@playwright/test';
import { spawn, type ChildProcess } from 'node:child_process';
import { once } from 'node:events';
import { createServer } from 'node:net';
import { join } from 'node:path';
import { installTestServerEndpoint } from '../helpers/seed-test-endpoint';
export interface Client {
page: Page;
context: BrowserContext;
}
interface TestServerHandle {
port: number;
url: string;
stop: () => Promise<void>;
}
interface MultiClientFixture {
createClient: () => Promise<Client>;
testServer: TestServerHandle;
}
const FAKE_AUDIO_FILE = join(__dirname, 'test-tone.wav');
const CHROMIUM_FAKE_MEDIA_ARGS = [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
`--use-file-for-fake-audio-capture=${FAKE_AUDIO_FILE}`
];
const E2E_DIR = join(__dirname, '..');
const START_SERVER_SCRIPT = join(E2E_DIR, 'helpers', 'start-test-server.js');
export const test = base.extend<MultiClientFixture>({
testServer: async ({ playwright: _playwright }, use: (testServer: TestServerHandle) => Promise<void>) => {
const testServer = await startTestServer();
await use(testServer);
await testServer.stop();
},
createClient: async ({ testServer }, use) => {
const browsers: Browser[] = [];
const clients: Client[] = [];
const factory = async (): Promise<Client> => {
// Launch a dedicated browser per client so each gets its own fake
// audio device - shared browsers can starve the first context's
// audio capture under load.
const browser = await chromium.launch({ args: CHROMIUM_FAKE_MEDIA_ARGS });
browsers.push(browser);
const context = await browser.newContext({
permissions: ['microphone', 'camera'],
baseURL: 'http://localhost:4200'
});
await installTestServerEndpoint(context, testServer.port);
const page = await context.newPage();
clients.push({ page, context });
return { page, context };
};
await use(factory);
for (const client of clients) {
await client.context.close();
}
for (const browser of browsers) {
await browser.close();
}
}
});
export { expect } from '@playwright/test';
async function startTestServer(retries = 3): Promise<TestServerHandle> {
for (let attempt = 1; attempt <= retries; attempt++) {
const port = await allocatePort();
const child = spawn(process.execPath, [START_SERVER_SCRIPT], {
cwd: E2E_DIR,
env: {
...process.env,
TEST_SERVER_PORT: String(port)
},
stdio: 'pipe'
});
child.stdout?.on('data', (chunk: Buffer | string) => {
process.stdout.write(chunk.toString());
});
child.stderr?.on('data', (chunk: Buffer | string) => {
process.stderr.write(chunk.toString());
});
try {
await waitForServerReady(port, child);
} catch (error) {
await stopServer(child);
if (attempt < retries) {
console.log(`[E2E Server] Attempt ${attempt} failed, retrying...`);
continue;
}
throw error;
}
return {
port,
url: `http://localhost:${port}`,
stop: async () => {
await stopServer(child);
}
};
}
throw new Error('startTestServer: unreachable');
}
async function allocatePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const probe = createServer();
probe.once('error', reject);
probe.listen(0, '127.0.0.1', () => {
const address = probe.address();
if (!address || typeof address === 'string') {
probe.close();
reject(new Error('Failed to resolve an ephemeral test server port'));
return;
}
const { port } = address;
probe.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
});
}
async function waitForServerReady(port: number, child: ChildProcess, timeoutMs = 30_000): Promise<void> {
const readyUrl = `http://127.0.0.1:${port}/api/servers?limit=1`;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (child.exitCode !== null) {
throw new Error(`Test server exited before becoming ready (exit code ${child.exitCode})`);
}
try {
const response = await fetch(readyUrl);
if (response.ok) {
return;
}
} catch {
// Server still starting.
}
await wait(250);
}
throw new Error(`Timed out waiting for test server on port ${port}`);
}
async function stopServer(child: ChildProcess): Promise<void> {
if (child.exitCode !== null) {
return;
}
child.kill('SIGTERM');
const exited = await Promise.race([once(child, 'exit').then(() => true), wait(3_000).then(() => false)]);
if (!exited && child.exitCode === null) {
child.kill('SIGKILL');
await once(child, 'exit');
}
}
function wait(durationMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, durationMs);
});
}

BIN
e2e/fixtures/test-tone.wav Normal file

Binary file not shown.

View File

@@ -0,0 +1,81 @@
import { type BrowserContext, type Page } from '@playwright/test';
const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
const REMOVED_DEFAULT_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
interface SeededEndpointStorageState {
key: string;
removedKey: string;
endpoints: {
id: string;
name: string;
url: string;
isActive: boolean;
isDefault: boolean;
status: string;
}[];
}
function buildSeededEndpointStorageState(
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
): SeededEndpointStorageState {
const endpoint = {
id: 'e2e-test-server',
name: 'E2E Test Server',
url: `http://localhost:${port}`,
isActive: true,
isDefault: false,
status: 'unknown'
};
return {
key: SERVER_ENDPOINTS_STORAGE_KEY,
removedKey: REMOVED_DEFAULT_KEYS_STORAGE_KEY,
endpoints: [endpoint]
};
}
function applySeededEndpointStorageState(storageState: SeededEndpointStorageState): void {
try {
const storage = window.localStorage;
storage.setItem(storageState.key, JSON.stringify(storageState.endpoints));
storage.setItem(storageState.removedKey, JSON.stringify([
'default',
'toju-primary',
'toju-sweden'
]));
} catch {
// about:blank and some Playwright UI pages deny localStorage access.
}
}
export async function installTestServerEndpoint(
context: BrowserContext,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
): Promise<void> {
const storageState = buildSeededEndpointStorageState(port);
await context.addInitScript(applySeededEndpointStorageState, storageState);
}
/**
* Seed localStorage with a single signal endpoint pointing at the test server.
* Must be called AFTER navigating to the app origin (localStorage is per-origin)
* but BEFORE the app reads from storage (i.e. before the Angular bootstrap is
* relied upon - calling it in the first goto() landing page is fine since the
* page will re-read on next navigation/reload).
*
* Typical usage:
* await page.goto('/');
* await seedTestServerEndpoint(page);
* await page.reload(); // App now picks up the test endpoint
*/
export async function seedTestServerEndpoint(
page: Page,
port: number = Number(process.env.TEST_SERVER_PORT) || 3099
): Promise<void> {
const storageState = buildSeededEndpointStorageState(port);
await page.evaluate(applySeededEndpointStorageState, storageState);
}

View File

@@ -0,0 +1,107 @@
/**
* Launches an isolated MetoYou signaling server for E2E tests.
*
* Creates a temporary data directory so the test server gets its own
* fresh SQLite database. The server process inherits stdio so Playwright
* can watch stdout for readiness and the developer can see logs.
*
* Cleanup: the temp directory is removed when the process exits.
*/
const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = require('fs');
const { join } = require('path');
const { tmpdir } = require('os');
const { spawn } = require('child_process');
const TEST_PORT = process.env.TEST_SERVER_PORT || '3099';
const SERVER_DIR = join(__dirname, '..', '..', 'server');
const SERVER_ENTRY = join(SERVER_DIR, 'src', 'index.ts');
const SERVER_TSCONFIG = join(SERVER_DIR, 'tsconfig.json');
const TS_NODE_BIN = join(SERVER_DIR, 'node_modules', 'ts-node', 'dist', 'bin.js');
// ── Create isolated temp data directory ──────────────────────────────
const tmpDir = mkdtempSync(join(tmpdir(), 'metoyou-e2e-'));
const dataDir = join(tmpDir, 'data');
mkdirSync(dataDir, { recursive: true });
writeFileSync(
join(dataDir, 'variables.json'),
JSON.stringify({
serverPort: parseInt(TEST_PORT, 10),
serverProtocol: 'http',
serverHost: '',
klipyApiKey: '',
releaseManifestUrl: '',
linkPreview: { enabled: false, cacheTtlMinutes: 60, maxCacheSizeMb: 10 },
})
);
console.log(`[E2E Server] Temp data dir: ${tmpDir}`);
console.log(`[E2E Server] Starting on port ${TEST_PORT}...`);
// ── Spawn the server with cwd = temp dir ─────────────────────────────
// process.cwd() is used by getRuntimeBaseDir() in the server, so data/
// (database, variables.json) will resolve to our temp directory.
// Module resolution (require/import) uses __dirname, so server source
// and node_modules are found from the real server/ directory.
const child = spawn(
process.execPath,
[TS_NODE_BIN, '--project', SERVER_TSCONFIG, SERVER_ENTRY],
{
cwd: tmpDir,
env: {
...process.env,
PORT: TEST_PORT,
SSL: 'false',
NODE_ENV: 'test',
DB_SYNCHRONIZE: 'true',
},
stdio: 'inherit',
}
);
let shuttingDown = false;
child.on('error', (err) => {
console.error('[E2E Server] Failed to start:', err.message);
cleanup();
process.exit(1);
});
child.on('exit', (code) => {
console.log(`[E2E Server] Exited with code ${code}`);
cleanup();
if (shuttingDown) {
process.exit(0);
}
});
// ── Cleanup on signals ───────────────────────────────────────────────
function cleanup() {
try {
rmSync(tmpDir, { recursive: true, force: true });
console.log(`[E2E Server] Cleaned up temp dir: ${tmpDir}`);
} catch {
// already gone
}
}
function shutdown() {
if (shuttingDown) {
return;
}
shuttingDown = true;
child.kill('SIGTERM');
// Give child 3s to exit, then force kill
setTimeout(() => {
if (child.exitCode === null) {
child.kill('SIGKILL');
}
}, 3_000);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
process.on('exit', cleanup);

View File

@@ -0,0 +1,772 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { type Page } from '@playwright/test';
/**
* Install RTCPeerConnection monkey-patch on a page BEFORE navigating.
* Tracks all created peer connections and their remote tracks so tests
* can inspect WebRTC state via `page.evaluate()`.
*
* Call immediately after page creation, before any `goto()`.
*/
export async function installWebRTCTracking(page: Page): Promise<void> {
await page.addInitScript(() => {
const connections: RTCPeerConnection[] = [];
const syntheticMediaResources: {
audioCtx: AudioContext;
source?: AudioScheduledSourceNode;
drawIntervalId?: number;
}[] = [];
(window as any).__rtcConnections = connections;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
(window as any).__rtcSyntheticMediaResources = syntheticMediaResources;
const OriginalRTCPeerConnection = window.RTCPeerConnection;
(window as any).RTCPeerConnection = function(this: RTCPeerConnection, ...args: any[]) {
const pc: RTCPeerConnection = new OriginalRTCPeerConnection(...args);
connections.push(pc);
pc.addEventListener('connectionstatechange', () => {
(window as any).__lastRtcState = pc.connectionState;
});
pc.addEventListener('track', (event: RTCTrackEvent) => {
(window as any).__rtcRemoteTracks.push({
kind: event.track.kind,
id: event.track.id,
readyState: event.track.readyState
});
});
return pc;
} as any;
(window as any).RTCPeerConnection.prototype = OriginalRTCPeerConnection.prototype;
Object.setPrototypeOf((window as any).RTCPeerConnection, OriginalRTCPeerConnection);
// Patch getUserMedia to use an AudioContext oscillator for audio
// instead of the hardware capture device. Chromium's fake audio
// device intermittently fails to produce frames after renegotiation.
const origGetUserMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = async (constraints?: MediaStreamConstraints) => {
const wantsAudio = !!constraints?.audio;
if (!wantsAudio) {
return origGetUserMedia(constraints);
}
// Get the original stream (may include video)
const originalStream = await origGetUserMedia(constraints);
const audioCtx = new AudioContext();
const noiseBuffer = audioCtx.createBuffer(1, audioCtx.sampleRate * 2, audioCtx.sampleRate);
const noiseData = noiseBuffer.getChannelData(0);
for (let sampleIndex = 0; sampleIndex < noiseData.length; sampleIndex++) {
noiseData[sampleIndex] = (Math.random() * 2 - 1) * 0.18;
}
const source = audioCtx.createBufferSource();
const gain = audioCtx.createGain();
source.buffer = noiseBuffer;
source.loop = true;
gain.gain.value = 0.12;
const dest = audioCtx.createMediaStreamDestination();
source.connect(gain);
gain.connect(dest);
source.start();
if (audioCtx.state === 'suspended') {
try {
await audioCtx.resume();
} catch {}
}
const synthAudioTrack = dest.stream.getAudioTracks()[0];
const resultStream = new MediaStream();
syntheticMediaResources.push({ audioCtx, source });
resultStream.addTrack(synthAudioTrack);
// Keep any video tracks from the original stream
for (const videoTrack of originalStream.getVideoTracks()) {
resultStream.addTrack(videoTrack);
}
// Stop original audio tracks since we're not using them
for (const track of originalStream.getAudioTracks()) {
track.stop();
}
synthAudioTrack.addEventListener('ended', () => {
try {
source.stop();
} catch {}
void audioCtx.close().catch(() => {});
}, { once: true });
return resultStream;
};
// Patch getDisplayMedia to return a synthetic screen share stream
// (canvas-based video + 880Hz oscillator audio) so the browser
// picker dialog is never shown.
navigator.mediaDevices.getDisplayMedia = async (_constraints?: DisplayMediaStreamOptions) => {
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 480;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Canvas 2D context unavailable');
}
let frameCount = 0;
// Draw animated frames so video stats show increasing bytes
const drawFrame = () => {
frameCount++;
ctx.fillStyle = `hsl(${frameCount % 360}, 70%, 50%)`;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#fff';
ctx.font = '24px monospace';
ctx.fillText(`Screen Share Frame ${frameCount}`, 40, 60);
};
drawFrame();
const drawInterval = setInterval(drawFrame, 100);
const videoStream = canvas.captureStream(10); // 10 fps
const videoTrack = videoStream.getVideoTracks()[0];
// Stop drawing when the track ends
videoTrack.addEventListener('ended', () => clearInterval(drawInterval));
// Create 880Hz oscillator for screen share audio (distinct from 440Hz voice)
const audioCtx = new AudioContext();
const osc = audioCtx.createOscillator();
osc.frequency.value = 880;
const dest = audioCtx.createMediaStreamDestination();
osc.connect(dest);
osc.start();
if (audioCtx.state === 'suspended') {
try {
await audioCtx.resume();
} catch {}
}
const audioTrack = dest.stream.getAudioTracks()[0];
// Combine video + audio into one stream
const resultStream = new MediaStream([videoTrack, audioTrack]);
syntheticMediaResources.push({
audioCtx,
source: osc,
drawIntervalId: drawInterval as unknown as number
});
audioTrack.addEventListener('ended', () => {
clearInterval(drawInterval);
try {
osc.stop();
} catch {}
void audioCtx.close().catch(() => {});
}, { once: true });
// Tag the stream so tests can identify it
(resultStream as any).__isScreenShare = true;
return resultStream;
};
});
}
/**
* Wait until at least one RTCPeerConnection reaches the 'connected' state.
*/
export async function waitForPeerConnected(page: Page, timeout = 30_000): Promise<void> {
await page.waitForFunction(
() => (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false,
{ timeout }
);
}
/**
* Check that a peer connection is still in 'connected' state (not failed/disconnected).
*/
export async function isPeerStillConnected(page: Page): Promise<boolean> {
return page.evaluate(
() => (window as any).__rtcConnections?.some(
(pc: RTCPeerConnection) => pc.connectionState === 'connected'
) ?? false
);
}
/**
* Get outbound and inbound audio RTP stats aggregated across all peer
* connections. Uses a per-connection high water mark stored on `window` so
* that connections that close mid-measurement still contribute their last
* known counters, preventing the aggregate from going backwards.
*/
export async function getAudioStats(page: Page): Promise<{
outbound: { bytesSent: number; packetsSent: number } | null;
inbound: { bytesReceived: number; packetsReceived: number } | null;
}> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return { outbound: null, inbound: null };
interface HWMEntry {
outBytesSent: number;
outPacketsSent: number;
inBytesReceived: number;
inPacketsReceived: number;
hasOutbound: boolean;
hasInbound: boolean;
};
const hwm: Record<number, HWMEntry> = (window as any).__rtcStatsHWM =
((window as any).__rtcStatsHWM as Record<number, HWMEntry> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) {
let stats: RTCStatsReport;
try {
stats = await connections[idx].getStats();
} catch {
continue; // closed connection - keep its last HWM
}
let obytes = 0;
let opackets = 0;
let ibytes = 0;
let ipackets = 0;
let hasOut = false;
let hasIn = false;
stats.forEach((report: any) => {
const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio') {
hasOut = true;
obytes += report.bytesSent ?? 0;
opackets += report.packetsSent ?? 0;
}
if (report.type === 'inbound-rtp' && kind === 'audio') {
hasIn = true;
ibytes += report.bytesReceived ?? 0;
ipackets += report.packetsReceived ?? 0;
}
});
if (hasOut || hasIn) {
hwm[idx] = {
outBytesSent: obytes,
outPacketsSent: opackets,
inBytesReceived: ibytes,
inPacketsReceived: ipackets,
hasOutbound: hasOut,
hasInbound: hasIn
};
}
}
let totalOutBytes = 0;
let totalOutPackets = 0;
let totalInBytes = 0;
let totalInPackets = 0;
let anyOutbound = false;
let anyInbound = false;
for (const entry of Object.values(hwm)) {
totalOutBytes += entry.outBytesSent;
totalOutPackets += entry.outPacketsSent;
totalInBytes += entry.inBytesReceived;
totalInPackets += entry.inPacketsReceived;
if (entry.hasOutbound)
anyOutbound = true;
if (entry.hasInbound)
anyInbound = true;
}
return {
outbound: anyOutbound
? { bytesSent: totalOutBytes, packetsSent: totalOutPackets }
: null,
inbound: anyInbound
? { bytesReceived: totalInBytes, packetsReceived: totalInPackets }
: null
};
});
}
/**
* Snapshot audio stats, wait `durationMs`, snapshot again, and return the delta.
* Useful for verifying audio is actively flowing (bytes increasing).
*/
export async function getAudioStatsDelta(page: Page, durationMs = 3_000): Promise<{
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}> {
const before = await getAudioStats(page);
await page.waitForTimeout(durationMs);
const after = await getAudioStats(page);
return {
outboundBytesDelta: (after.outbound?.bytesSent ?? 0) - (before.outbound?.bytesSent ?? 0),
inboundBytesDelta: (after.inbound?.bytesReceived ?? 0) - (before.inbound?.bytesReceived ?? 0),
outboundPacketsDelta: (after.outbound?.packetsSent ?? 0) - (before.outbound?.packetsSent ?? 0),
inboundPacketsDelta: (after.inbound?.packetsReceived ?? 0) - (before.inbound?.packetsReceived ?? 0)
};
}
/**
* Wait until at least one connection has both outbound-rtp and inbound-rtp
* audio reports. Call after `waitForPeerConnected` to ensure the audio
* pipeline is ready before measuring deltas.
*/
export async function waitForAudioStatsPresent(page: Page, timeout = 15_000): Promise<void> {
await page.waitForFunction(
async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return false;
for (const pc of connections) {
let stats: RTCStatsReport;
try {
stats = await pc.getStats();
} catch {
continue;
}
let hasOut = false;
let hasIn = false;
stats.forEach((report: any) => {
const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'audio')
hasOut = true;
if (report.type === 'inbound-rtp' && kind === 'audio')
hasIn = true;
});
if (hasOut && hasIn)
return true;
}
return false;
},
{ timeout }
);
}
interface AudioFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
function snapshotToDelta(
curr: Awaited<ReturnType<typeof getAudioStats>>,
prev: Awaited<ReturnType<typeof getAudioStats>>
): AudioFlowDelta {
return {
outboundBytesDelta: (curr.outbound?.bytesSent ?? 0) - (prev.outbound?.bytesSent ?? 0),
inboundBytesDelta: (curr.inbound?.bytesReceived ?? 0) - (prev.inbound?.bytesReceived ?? 0),
outboundPacketsDelta: (curr.outbound?.packetsSent ?? 0) - (prev.outbound?.packetsSent ?? 0),
inboundPacketsDelta: (curr.inbound?.packetsReceived ?? 0) - (prev.inbound?.packetsReceived ?? 0)
};
}
function isDeltaFlowing(delta: AudioFlowDelta): boolean {
const outFlowing = delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
const inFlowing = delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
return outFlowing && inFlowing;
}
/**
* Poll until two consecutive HWM-based reads show both outbound and inbound
* audio byte counts increasing. Combines per-connection high-water marks
* (which prevent totals from going backwards after connection churn) with
* consecutive comparison (which avoids a stale single baseline).
*/
export async function waitForAudioFlow(
page: Page,
timeoutMs = 30_000,
pollIntervalMs = 1_000
): Promise<AudioFlowDelta> {
const deadline = Date.now() + timeoutMs;
let prev = await getAudioStats(page);
while (Date.now() < deadline) {
await page.waitForTimeout(pollIntervalMs);
const curr = await getAudioStats(page);
const delta = snapshotToDelta(curr, prev);
if (isDeltaFlowing(delta)) {
return delta;
}
prev = curr;
}
// Timeout - return zero deltas so the caller's assertion reports the failure.
return {
outboundBytesDelta: 0,
inboundBytesDelta: 0,
outboundPacketsDelta: 0,
inboundPacketsDelta: 0
};
}
/**
* Get outbound and inbound video RTP stats aggregated across all peer
* connections. Uses the same HWM pattern as {@link getAudioStats}.
*/
export async function getVideoStats(page: Page): Promise<{
outbound: { bytesSent: number; packetsSent: number } | null;
inbound: { bytesReceived: number; packetsReceived: number } | null;
}> {
return page.evaluate(async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return { outbound: null, inbound: null };
interface VHWM {
outBytesSent: number;
outPacketsSent: number;
inBytesReceived: number;
inPacketsReceived: number;
hasOutbound: boolean;
hasInbound: boolean;
}
const hwm: Record<number, VHWM> = (window as any).__rtcVideoStatsHWM =
((window as any).__rtcVideoStatsHWM as Record<number, VHWM> | undefined) ?? {};
for (let idx = 0; idx < connections.length; idx++) {
let stats: RTCStatsReport;
try {
stats = await connections[idx].getStats();
} catch {
continue;
}
let obytes = 0;
let opackets = 0;
let ibytes = 0;
let ipackets = 0;
let hasOut = false;
let hasIn = false;
stats.forEach((report: any) => {
const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'video') {
hasOut = true;
obytes += report.bytesSent ?? 0;
opackets += report.packetsSent ?? 0;
}
if (report.type === 'inbound-rtp' && kind === 'video') {
hasIn = true;
ibytes += report.bytesReceived ?? 0;
ipackets += report.packetsReceived ?? 0;
}
});
if (hasOut || hasIn) {
hwm[idx] = {
outBytesSent: obytes,
outPacketsSent: opackets,
inBytesReceived: ibytes,
inPacketsReceived: ipackets,
hasOutbound: hasOut,
hasInbound: hasIn
};
}
}
let totalOutBytes = 0;
let totalOutPackets = 0;
let totalInBytes = 0;
let totalInPackets = 0;
let anyOutbound = false;
let anyInbound = false;
for (const entry of Object.values(hwm)) {
totalOutBytes += entry.outBytesSent;
totalOutPackets += entry.outPacketsSent;
totalInBytes += entry.inBytesReceived;
totalInPackets += entry.inPacketsReceived;
if (entry.hasOutbound)
anyOutbound = true;
if (entry.hasInbound)
anyInbound = true;
}
return {
outbound: anyOutbound
? { bytesSent: totalOutBytes, packetsSent: totalOutPackets }
: null,
inbound: anyInbound
? { bytesReceived: totalInBytes, packetsReceived: totalInPackets }
: null
};
});
}
/**
* Wait until at least one connection has both outbound-rtp and inbound-rtp
* video reports.
*/
export async function waitForVideoStatsPresent(page: Page, timeout = 15_000): Promise<void> {
await page.waitForFunction(
async () => {
const connections = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!connections?.length)
return false;
for (const pc of connections) {
let stats: RTCStatsReport;
try {
stats = await pc.getStats();
} catch {
continue;
}
let hasOut = false;
let hasIn = false;
stats.forEach((report: any) => {
const kind = report.kind ?? report.mediaType;
if (report.type === 'outbound-rtp' && kind === 'video')
hasOut = true;
if (report.type === 'inbound-rtp' && kind === 'video')
hasIn = true;
});
if (hasOut && hasIn)
return true;
}
return false;
},
{ timeout }
);
}
interface VideoFlowDelta {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}
function videoSnapshotToDelta(
curr: Awaited<ReturnType<typeof getVideoStats>>,
prev: Awaited<ReturnType<typeof getVideoStats>>
): VideoFlowDelta {
return {
outboundBytesDelta: (curr.outbound?.bytesSent ?? 0) - (prev.outbound?.bytesSent ?? 0),
inboundBytesDelta: (curr.inbound?.bytesReceived ?? 0) - (prev.inbound?.bytesReceived ?? 0),
outboundPacketsDelta: (curr.outbound?.packetsSent ?? 0) - (prev.outbound?.packetsSent ?? 0),
inboundPacketsDelta: (curr.inbound?.packetsReceived ?? 0) - (prev.inbound?.packetsReceived ?? 0)
};
}
function isVideoDeltaFlowing(delta: VideoFlowDelta): boolean {
const outFlowing = delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0;
const inFlowing = delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0;
return outFlowing && inFlowing;
}
/**
* Poll until two consecutive HWM-based reads show both outbound and inbound
* video byte counts increasing - proving screen share video is flowing.
*/
export async function waitForVideoFlow(
page: Page,
timeoutMs = 30_000,
pollIntervalMs = 1_000
): Promise<VideoFlowDelta> {
const deadline = Date.now() + timeoutMs;
let prev = await getVideoStats(page);
while (Date.now() < deadline) {
await page.waitForTimeout(pollIntervalMs);
const curr = await getVideoStats(page);
const delta = videoSnapshotToDelta(curr, prev);
if (isVideoDeltaFlowing(delta)) {
return delta;
}
prev = curr;
}
return {
outboundBytesDelta: 0,
inboundBytesDelta: 0,
outboundPacketsDelta: 0,
inboundPacketsDelta: 0
};
}
/**
* Wait until outbound video bytes are increasing (sender side).
* Use on the page that is sharing its screen.
*/
export async function waitForOutboundVideoFlow(
page: Page,
timeoutMs = 30_000,
pollIntervalMs = 1_000
): Promise<VideoFlowDelta> {
const deadline = Date.now() + timeoutMs;
let prev = await getVideoStats(page);
while (Date.now() < deadline) {
await page.waitForTimeout(pollIntervalMs);
const curr = await getVideoStats(page);
const delta = videoSnapshotToDelta(curr, prev);
if (delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0) {
return delta;
}
prev = curr;
}
return {
outboundBytesDelta: 0,
inboundBytesDelta: 0,
outboundPacketsDelta: 0,
inboundPacketsDelta: 0
};
}
/**
* Wait until inbound video bytes are increasing (receiver side).
* Use on the page that is viewing someone else's screen share.
*/
export async function waitForInboundVideoFlow(
page: Page,
timeoutMs = 30_000,
pollIntervalMs = 1_000
): Promise<VideoFlowDelta> {
const deadline = Date.now() + timeoutMs;
let prev = await getVideoStats(page);
while (Date.now() < deadline) {
await page.waitForTimeout(pollIntervalMs);
const curr = await getVideoStats(page);
const delta = videoSnapshotToDelta(curr, prev);
if (delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0) {
return delta;
}
prev = curr;
}
return {
outboundBytesDelta: 0,
inboundBytesDelta: 0,
outboundPacketsDelta: 0,
inboundPacketsDelta: 0
};
}
/**
* Dump full RTC connection diagnostics for debugging audio flow failures.
*/
export async function dumpRtcDiagnostics(page: Page): Promise<string> {
return page.evaluate(async () => {
const conns = (window as any).__rtcConnections as RTCPeerConnection[] | undefined;
if (!conns?.length)
return 'No connections tracked';
const lines: string[] = [`Total connections: ${conns.length}`];
for (let idx = 0; idx < conns.length; idx++) {
const pc = conns[idx];
lines.push(`PC[${idx}]: connection=${pc.connectionState}, signaling=${pc.signalingState}`);
const senders = pc.getSenders().map(
(sender) => `${sender.track?.kind ?? 'none'}:enabled=${sender.track?.enabled}:${sender.track?.readyState ?? 'null'}`
);
const receivers = pc.getReceivers().map(
(recv) => `${recv.track?.kind ?? 'none'}:enabled=${recv.track?.enabled}:${recv.track?.readyState ?? 'null'}`
);
lines.push(` senders=[${senders.join(', ')}]`);
lines.push(` receivers=[${receivers.join(', ')}]`);
try {
const stats = await pc.getStats();
stats.forEach((report: any) => {
if (report.type !== 'outbound-rtp' && report.type !== 'inbound-rtp')
return;
const kind = report.kind ?? report.mediaType;
const bytes = report.type === 'outbound-rtp' ? report.bytesSent : report.bytesReceived;
const packets = report.type === 'outbound-rtp' ? report.packetsSent : report.packetsReceived;
lines.push(` ${report.type}: kind=${kind}, bytes=${bytes}, packets=${packets}`);
});
} catch (err: any) {
lines.push(` getStats() failed: ${err?.message ?? err}`);
}
}
return lines.join('\n');
});
}

View File

@@ -0,0 +1,144 @@
import {
expect,
type Locator,
type Page
} from '@playwright/test';
export interface ChatDropFilePayload {
name: string;
mimeType: string;
base64: string;
}
export class ChatMessagesPage {
readonly composer: Locator;
readonly composerInput: Locator;
readonly sendButton: Locator;
readonly typingIndicator: Locator;
readonly gifButton: Locator;
readonly gifPicker: Locator;
readonly messageItems: Locator;
constructor(private page: Page) {
this.composer = page.locator('app-chat-message-composer');
this.composerInput = page.getByPlaceholder('Type a message...');
this.sendButton = page.getByRole('button', { name: 'Send message' });
this.typingIndicator = page.locator('app-typing-indicator');
this.gifButton = page.getByRole('button', { name: 'Search KLIPY GIFs' });
this.gifPicker = page.getByRole('dialog', { name: 'KLIPY GIF picker' });
this.messageItems = page.locator('[data-message-id]');
}
async waitForReady(): Promise<void> {
await expect(this.composerInput).toBeVisible({ timeout: 30_000 });
}
async sendMessage(content: string): Promise<void> {
await this.waitForReady();
await this.composerInput.fill(content);
await this.sendButton.click();
}
async typeDraft(content: string): Promise<void> {
await this.waitForReady();
await this.composerInput.fill(content);
}
async clearDraft(): Promise<void> {
await this.waitForReady();
await this.composerInput.fill('');
}
async attachFiles(files: ChatDropFilePayload[]): Promise<void> {
await this.waitForReady();
await this.composerInput.evaluate((element, payloads: ChatDropFilePayload[]) => {
const dataTransfer = new DataTransfer();
for (const payload of payloads) {
const binary = atob(payload.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
dataTransfer.items.add(new File([bytes], payload.name, { type: payload.mimeType }));
}
element.dispatchEvent(new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer
}));
}, files);
}
async openGifPicker(): Promise<void> {
await this.waitForReady();
await this.gifButton.click();
await expect(this.gifPicker).toBeVisible({ timeout: 10_000 });
}
async selectFirstGif(): Promise<void> {
const gifCard = this.gifPicker.getByRole('button', { name: /click to select/i }).first();
await expect(gifCard).toBeVisible({ timeout: 10_000 });
await gifCard.click();
}
getMessageItemByText(text: string): Locator {
return this.messageItems.filter({
has: this.page.getByText(text, { exact: false })
}).last();
}
getMessageImageByAlt(altText: string): Locator {
return this.page.locator(`[data-message-id] img[alt="${altText}"]`).last();
}
async expectMessageImageLoaded(altText: string): Promise<void> {
const image = this.getMessageImageByAlt(altText);
await expect(image).toBeVisible({ timeout: 20_000 });
await expect.poll(async () =>
image.evaluate((element) => {
const img = element as HTMLImageElement;
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
}), {
timeout: 20_000,
message: `Image ${altText} should fully load in chat`
}).toBe(true);
}
getEmbedCardByTitle(title: string): Locator {
return this.page.locator('app-chat-link-embed').filter({
has: this.page.getByText(title, { exact: true })
})
.last();
}
async editOwnMessage(originalText: string, updatedText: string): Promise<void> {
const messageItem = this.getMessageItemByText(originalText);
const editButton = messageItem.locator('button:has(ng-icon[name="lucideEdit"])').first();
const editTextarea = this.page.locator('textarea.edit-textarea').first();
const saveButton = this.page.locator('button:has(ng-icon[name="lucideCheck"])').first();
await expect(messageItem).toBeVisible({ timeout: 15_000 });
await messageItem.hover();
await editButton.click();
await expect(editTextarea).toBeVisible({ timeout: 10_000 });
await editTextarea.fill(updatedText);
await saveButton.click();
}
async deleteOwnMessage(text: string): Promise<void> {
const messageItem = this.getMessageItemByText(text);
const deleteButton = messageItem.locator('button:has(ng-icon[name="lucideTrash2"])').first();
await expect(messageItem).toBeVisible({ timeout: 15_000 });
await messageItem.hover();
await deleteButton.click();
}
}

390
e2e/pages/chat-room.page.ts Normal file
View File

@@ -0,0 +1,390 @@
import {
expect,
type Page,
type Locator
} from '@playwright/test';
export class ChatRoomPage {
readonly chatMessages: Locator;
readonly voiceWorkspace: Locator;
readonly channelsSidePanel: Locator;
readonly usersSidePanel: Locator;
constructor(private page: Page) {
this.chatMessages = page.locator('app-chat-messages');
this.voiceWorkspace = page.locator('app-voice-workspace');
this.channelsSidePanel = page.locator('app-rooms-side-panel').first();
this.usersSidePanel = page.locator('app-rooms-side-panel').last();
}
/** Click a voice channel by name in the channels sidebar to join voice. */
async joinVoiceChannel(channelName: string) {
const channelButton = this.page.locator('app-rooms-side-panel')
.getByRole('button', { name: channelName, exact: true });
await expect(channelButton).toBeVisible({ timeout: 15_000 });
await channelButton.click();
}
/** Click a text channel by name in the channels sidebar to switch chat rooms. */
async joinTextChannel(channelName: string) {
const channelButton = this.getTextChannelButton(channelName);
if (await channelButton.count() === 0) {
await this.refreshRoomMetadata();
}
await expect(channelButton).toBeVisible({ timeout: 15_000 });
await channelButton.click();
}
/** Creates a text channel and waits until it appears locally. */
async ensureTextChannelExists(channelName: string) {
const channelButton = this.getTextChannelButton(channelName);
if (await channelButton.count() > 0) {
return;
}
await this.openCreateTextChannelDialog();
await this.createChannel(channelName);
try {
await expect(channelButton).toBeVisible({ timeout: 5_000 });
} catch {
await this.createTextChannelThroughComponent(channelName);
}
await this.persistCurrentChannelsToServer(channelName);
await expect(channelButton).toBeVisible({ timeout: 15_000 });
}
/** Click "Create Voice Channel" button in the channels sidebar. */
async openCreateVoiceChannelDialog() {
await this.page.locator('button[title="Create Voice Channel"]').click();
}
/** Click "Create Text Channel" button in the channels sidebar. */
async openCreateTextChannelDialog() {
await this.page.locator('button[title="Create Text Channel"]').click();
}
/** Fill the channel name in the create channel dialog and confirm. */
async createChannel(name: string) {
const dialog = this.page.locator('app-confirm-dialog');
const channelNameInput = dialog.getByPlaceholder('Channel name');
const createButton = dialog.getByRole('button', { name: 'Create', exact: true });
await expect(channelNameInput).toBeVisible({ timeout: 10_000 });
await channelNameInput.fill(name);
await channelNameInput.press('Enter');
if (await dialog.isVisible()) {
try {
await createButton.click();
} catch {
// Enter may already have confirmed and removed the dialog.
}
}
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
}
/** Get the voice controls component. */
get voiceControls() {
return this.page.locator('app-voice-controls');
}
/** Get the mute toggle button inside voice controls. */
get muteButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucideMic"]), button:has(ng-icon[name="lucideMicOff"])').first();
}
/** Get the disconnect/hang-up button (destructive styled). */
get disconnectButton() {
return this.voiceControls.locator('button:has(ng-icon[name="lucidePhoneOff"])').first();
}
/** Get all voice stream tiles. */
get streamTiles() {
return this.page.locator('app-voice-workspace-stream-tile');
}
/** Get the count of voice users listed under a voice channel. */
async getVoiceUserCountInChannel(channelName: string): Promise<number> {
const channelSection = this.page.locator('app-rooms-side-panel')
.getByRole('button', { name: channelName })
.locator('..');
const userAvatars = channelSection.locator('app-user-avatar');
return userAvatars.count();
}
/** Get the screen share toggle button inside voice controls. */
get screenShareButton() {
return this.voiceControls.locator(
'button:has(ng-icon[name="lucideMonitor"]), button:has(ng-icon[name="lucideMonitorOff"])'
).first();
}
/** Start screen sharing. Bypasses the quality dialog via localStorage preset. */
async startScreenShare() {
// Disable quality dialog so clicking the button starts sharing immediately
await this.page.evaluate(() => {
const key = 'metoyou_voice_settings';
const raw = localStorage.getItem(key);
const settings = raw ? JSON.parse(raw) : {};
settings.askScreenShareQuality = false;
settings.screenShareQuality = 'balanced';
localStorage.setItem(key, JSON.stringify(settings));
});
await this.screenShareButton.click();
}
/** Stop screen sharing by clicking the active screen share button. */
async stopScreenShare() {
await this.screenShareButton.click();
}
/** Check whether the screen share button shows the active (MonitorOff) icon. */
get isScreenShareActive() {
return this.voiceControls.locator('button:has(ng-icon[name="lucideMonitorOff"])').first();
}
private getTextChannelButton(channelName: string): Locator {
const channelPattern = new RegExp(`#\\s*${escapeRegExp(channelName)}$`, 'i');
return this.channelsSidePanel.getByRole('button', { name: channelPattern }).first();
}
private async createTextChannelThroughComponent(channelName: string): Promise<void> {
await this.page.evaluate((name) => {
interface ChannelSidebarComponent {
createChannel: (type: 'text' | 'voice') => void;
newChannelName: string;
confirmCreateChannel: () => void;
}
interface AngularDebugApi {
getComponent: (element: Element) => ChannelSidebarComponent;
}
interface WindowWithAngularDebug extends Window {
ng?: AngularDebugApi;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as WindowWithAngularDebug).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API unavailable for text channel fallback');
}
const component = debugApi.getComponent(host);
component.createChannel('text');
component.newChannelName = name;
component.confirmCreateChannel();
}, channelName);
}
private async persistCurrentChannelsToServer(channelName: string): Promise<void> {
const result = await this.page.evaluate(async (requestedChannelName) => {
interface ServerEndpoint {
isActive?: boolean;
url: string;
}
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
position: number;
}
interface RoomShape {
id: string;
sourceUrl?: string;
channels?: ChannelShape[];
}
interface UserShape {
id: string;
}
interface ChannelSidebarComponent {
currentRoom: () => RoomShape | null;
currentUser: () => UserShape | null;
}
interface AngularDebugApi {
getComponent: (element: Element) => ChannelSidebarComponent;
}
interface WindowWithAngularDebug extends Window {
ng?: AngularDebugApi;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as WindowWithAngularDebug).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API unavailable for channel persistence');
}
const component = debugApi.getComponent(host);
const room = component.currentRoom();
const currentUser = component.currentUser();
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as ServerEndpoint[];
const activeEndpoint = endpoints.find((endpoint) => endpoint.isActive) || endpoints[0] || null;
const apiBaseUrl = room?.sourceUrl || activeEndpoint?.url;
const normalizedChannelName = requestedChannelName.trim().replace(/\s+/g, ' ');
const existingChannels = Array.isArray(room?.channels) ? room.channels : [];
const hasTextChannel = existingChannels.some((channel) =>
channel.type === 'text' && channel.name.trim().toLowerCase() === normalizedChannelName.toLowerCase()
);
const nextChannels = hasTextChannel
? existingChannels
: [
...existingChannels,
{
id: globalThis.crypto.randomUUID(),
name: normalizedChannelName,
type: 'text' as const,
position: existingChannels.length
}
];
if (!room?.id || !currentUser?.id || !apiBaseUrl) {
throw new Error('Missing room, user, or endpoint when persisting channels');
}
const response = await fetch(`${apiBaseUrl}/api/servers/${room.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
currentOwnerId: currentUser.id,
channels: nextChannels
})
});
if (!response.ok) {
throw new Error(`Failed to persist channels: ${response.status}`);
}
return { roomId: room.id, channels: nextChannels };
}, channelName);
// Update NGRX store directly so the UI reflects the new channel
// immediately, without waiting for an async effect round-trip.
await this.dispatchRoomChannelsUpdate(result.roomId, result.channels);
}
private async dispatchRoomChannelsUpdate(
roomId: string,
channels: { id: string; name: string; type: string; position: number }[]
): Promise<void> {
await this.page.evaluate(({ rid, chs }) => {
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as { ng?: AngularDebugApi }).ng;
if (!host || !debugApi?.getComponent) {
return;
}
const component = debugApi.getComponent(host);
const store = component['store'] as { dispatch: (a: Record<string, unknown>) => void } | undefined;
if (store?.dispatch) {
store.dispatch({
type: '[Rooms] Update Room',
roomId: rid,
changes: { channels: chs }
});
}
}, { rid: roomId, chs: channels });
}
private async refreshRoomMetadata(): Promise<void> {
await this.page.evaluate(async () => {
interface ServerEndpoint {
isActive?: boolean;
url: string;
}
interface ChannelShape {
id: string;
name: string;
type: 'text' | 'voice';
position: number;
}
interface AngularDebugApi {
getComponent: (element: Element) => Record<string, unknown>;
}
interface WindowWithAngularDebug extends Window {
ng?: AngularDebugApi;
}
const host = document.querySelector('app-rooms-side-panel');
const debugApi = (window as WindowWithAngularDebug).ng;
if (!host || !debugApi?.getComponent) {
throw new Error('Angular debug API unavailable for room refresh');
}
const component = debugApi.getComponent(host);
const currentRoom = typeof component['currentRoom'] === 'function'
? (component['currentRoom'] as () => { id: string; sourceUrl?: string; channels?: ChannelShape[] } | null)()
: null;
if (!currentRoom) {
throw new Error('No current room to refresh');
}
const store = component['store'] as { dispatch: (action: Record<string, unknown>) => void } | undefined;
if (!store?.dispatch) {
throw new Error('NGRX store not available on component');
}
// Fetch server data directly via REST API instead of triggering
// an async NGRX effect that can race with pending writes.
const endpoints = JSON.parse(localStorage.getItem('metoyou_server_endpoints') || '[]') as ServerEndpoint[];
const activeEndpoint = endpoints.find((ep) => ep.isActive) || endpoints[0] || null;
const apiBaseUrl = currentRoom.sourceUrl || activeEndpoint?.url;
if (!apiBaseUrl) {
throw new Error('No API base URL available for room refresh');
}
const response = await fetch(`${apiBaseUrl}/api/servers/${currentRoom.id}`);
if (response.ok) {
const serverData = await response.json() as { channels?: ChannelShape[] };
if (serverData.channels?.length) {
store.dispatch({
type: '[Rooms] Update Room',
roomId: currentRoom.id,
changes: { channels: serverData.channels }
});
}
}
});
// Brief wait for Angular change detection to propagate
await this.page.waitForTimeout(500);
}
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

29
e2e/pages/login.page.ts Normal file
View File

@@ -0,0 +1,29 @@
import { type Page, type Locator } from '@playwright/test';
export class LoginPage {
readonly usernameInput: Locator;
readonly passwordInput: Locator;
readonly serverSelect: Locator;
readonly submitButton: Locator;
readonly errorText: Locator;
readonly registerLink: Locator;
constructor(private page: Page) {
this.usernameInput = page.locator('#login-username');
this.passwordInput = page.locator('#login-password');
this.serverSelect = page.locator('#login-server');
this.submitButton = page.getByRole('button', { name: 'Login' });
this.errorText = page.locator('.text-destructive');
this.registerLink = page.getByRole('button', { name: 'Register' });
}
async goto() {
await this.page.goto('/login');
}
async login(username: string, password: string) {
await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}

View File

@@ -0,0 +1,50 @@
import {
expect,
type Page,
type Locator
} from '@playwright/test';
export class RegisterPage {
readonly usernameInput: Locator;
readonly displayNameInput: Locator;
readonly passwordInput: Locator;
readonly serverSelect: Locator;
readonly submitButton: Locator;
readonly errorText: Locator;
readonly loginLink: Locator;
constructor(private page: Page) {
this.usernameInput = page.locator('#register-username');
this.displayNameInput = page.locator('#register-display-name');
this.passwordInput = page.locator('#register-password');
this.serverSelect = page.locator('#register-server');
this.submitButton = page.getByRole('button', { name: 'Create Account' });
this.errorText = page.locator('.text-destructive');
this.loginLink = page.getByRole('button', { name: 'Login' });
}
async goto() {
await this.page.goto('/register', { waitUntil: 'domcontentloaded' });
try {
await expect(this.usernameInput).toBeVisible({ timeout: 10_000 });
} catch {
// Angular router may redirect to /login on first load; use the
// visible login-form action instead of broad text matching.
const registerButton = this.page.getByRole('button', { name: 'Register', exact: true }).last();
await expect(registerButton).toBeVisible({ timeout: 10_000 });
await registerButton.click();
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
}
await expect(this.submitButton).toBeVisible({ timeout: 30_000 });
}
async register(username: string, displayName: string, password: string) {
await this.usernameInput.fill(username);
await this.displayNameInput.fill(displayName);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
}

View File

@@ -0,0 +1,65 @@
import {
type Page,
type Locator,
expect
} from '@playwright/test';
export class ServerSearchPage {
readonly searchInput: Locator;
readonly createServerButton: Locator;
readonly settingsButton: Locator;
// Create server dialog
readonly serverNameInput: Locator;
readonly serverDescriptionInput: Locator;
readonly serverTopicInput: Locator;
readonly signalEndpointSelect: Locator;
readonly privateCheckbox: Locator;
readonly serverPasswordInput: Locator;
readonly dialogCreateButton: Locator;
readonly dialogCancelButton: Locator;
constructor(private page: Page) {
this.searchInput = page.getByPlaceholder('Search servers...');
this.createServerButton = page.getByRole('button', { name: 'Create New Server' });
this.settingsButton = page.locator('button[title="Settings"]');
// Create dialog elements
this.serverNameInput = page.locator('#create-server-name');
this.serverDescriptionInput = page.locator('#create-server-description');
this.serverTopicInput = page.locator('#create-server-topic');
this.signalEndpointSelect = page.locator('#create-server-signal-endpoint');
this.privateCheckbox = page.locator('#private');
this.serverPasswordInput = page.locator('#create-server-password');
this.dialogCreateButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Create' });
this.dialogCancelButton = page.locator('div[role="dialog"]').getByRole('button', { name: 'Cancel' });
}
async goto() {
await this.page.goto('/search');
}
async createServer(name: string, options?: { description?: string; topic?: string }) {
await this.createServerButton.click();
await expect(this.serverNameInput).toBeVisible();
await this.serverNameInput.fill(name);
if (options?.description) {
await this.serverDescriptionInput.fill(options.description);
}
if (options?.topic) {
await this.serverTopicInput.fill(options.topic);
}
await this.dialogCreateButton.click();
}
async joinSavedRoom(name: string) {
await this.page.getByRole('button', { name }).click();
}
async joinServerFromSearch(name: string) {
await this.page.locator('button', { hasText: name }).click();
}
}

36
e2e/playwright.config.ts Normal file
View File

@@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 90_000,
expect: { timeout: 10_000 },
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: [['html', { outputFolder: '../test-results/html-report' }], ['list']],
outputDir: '../test-results/artifacts',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
actionTimeout: 15_000
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
permissions: ['microphone', 'camera'],
launchOptions: {
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream']
}
}
}
],
webServer: {
command: 'cd ../toju-app && npx ng serve',
url: 'http://localhost:4200',
reuseExistingServer: !process.env.CI,
timeout: 120_000
}
});

View File

@@ -0,0 +1,301 @@
import { type Page } from '@playwright/test';
import {
test,
expect,
type Client
} 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 { ChatMessagesPage, type ChatDropFilePayload } from '../../pages/chat-messages.page';
const MOCK_EMBED_URL = 'https://example.test/mock-embed';
const MOCK_EMBED_TITLE = 'Mock Embed Title';
const MOCK_EMBED_DESCRIPTION = 'Mock embed description for chat E2E coverage.';
const MOCK_GIF_IMAGE_URL = 'data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
test.describe('Chat messaging features', () => {
test.describe.configure({ timeout: 180_000 });
test('syncs messages in a newly created text channel', async ({ createClient }) => {
const scenario = await createChatScenario(createClient);
const channelName = uniqueName('updates');
const aliceMessage = `Alice text channel message ${uniqueName('msg')}`;
const bobMessage = `Bob text channel reply ${uniqueName('msg')}`;
await test.step('Alice creates a new text channel and both users join it', async () => {
await scenario.aliceRoom.ensureTextChannelExists(channelName);
await scenario.aliceRoom.joinTextChannel(channelName);
await scenario.bobRoom.joinTextChannel(channelName);
});
await test.step('Alice and Bob see synced messages in the new text channel', async () => {
await scenario.aliceMessages.sendMessage(aliceMessage);
await expect(scenario.bobMessages.getMessageItemByText(aliceMessage)).toBeVisible({ timeout: 20_000 });
await scenario.bobMessages.sendMessage(bobMessage);
await expect(scenario.aliceMessages.getMessageItemByText(bobMessage)).toBeVisible({ timeout: 20_000 });
});
});
test('shows typing indicators to other users', async ({ createClient }) => {
const scenario = await createChatScenario(createClient);
const draftMessage = `Typing indicator draft ${uniqueName('draft')}`;
await test.step('Alice starts typing in general channel', async () => {
await scenario.aliceMessages.typeDraft(draftMessage);
});
await test.step('Bob sees Alice typing', async () => {
await expect(scenario.bob.page.getByText('Alice is typing...')).toBeVisible({ timeout: 10_000 });
});
});
test('edits and removes messages for both users', async ({ createClient }) => {
const scenario = await createChatScenario(createClient);
const originalMessage = `Editable message ${uniqueName('edit')}`;
const updatedMessage = `Edited message ${uniqueName('edit')}`;
await test.step('Alice sends a message and Bob receives it', async () => {
await scenario.aliceMessages.sendMessage(originalMessage);
await expect(scenario.bobMessages.getMessageItemByText(originalMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Alice edits the message and both users see updated content', async () => {
await scenario.aliceMessages.editOwnMessage(originalMessage, updatedMessage);
await expect(scenario.aliceMessages.getMessageItemByText(updatedMessage)).toBeVisible({ timeout: 20_000 });
await expect(scenario.alice.page.getByText('(edited)')).toBeVisible({ timeout: 10_000 });
await expect(scenario.bobMessages.getMessageItemByText(updatedMessage)).toBeVisible({ timeout: 20_000 });
});
await test.step('Alice deletes the message and both users see deletion state', async () => {
await scenario.aliceMessages.deleteOwnMessage(updatedMessage);
await expect(scenario.aliceMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bobMessages.getMessageItemByText(DELETED_MESSAGE_CONTENT)).toBeVisible({ timeout: 20_000 });
});
});
test('syncs image and file attachments between users', async ({ createClient }) => {
const scenario = await createChatScenario(createClient);
const imageName = `${uniqueName('diagram')}.svg`;
const fileName = `${uniqueName('notes')}.txt`;
const imageCaption = `Image upload ${uniqueName('caption')}`;
const fileCaption = `File upload ${uniqueName('caption')}`;
const imageAttachment = createTextFilePayload(imageName, 'image/svg+xml', buildMockSvgMarkup(imageName));
const fileAttachment = createTextFilePayload(fileName, 'text/plain', `Attachment body for ${fileName}`);
await test.step('Alice sends image attachment and Bob receives it', async () => {
await scenario.aliceMessages.attachFiles([imageAttachment]);
await scenario.aliceMessages.sendMessage(imageCaption);
await scenario.aliceMessages.expectMessageImageLoaded(imageName);
await expect(scenario.bobMessages.getMessageItemByText(imageCaption)).toBeVisible({ timeout: 20_000 });
await scenario.bobMessages.expectMessageImageLoaded(imageName);
});
await test.step('Alice sends generic file attachment and Bob receives it', async () => {
await scenario.aliceMessages.attachFiles([fileAttachment]);
await scenario.aliceMessages.sendMessage(fileCaption);
await expect(scenario.bobMessages.getMessageItemByText(fileCaption)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(fileName, { exact: false })).toBeVisible({ timeout: 20_000 });
});
});
test('renders link embeds for shared links', async ({ createClient }) => {
const scenario = await createChatScenario(createClient);
const messageText = `Useful docs ${MOCK_EMBED_URL}`;
await test.step('Alice shares a link in chat', async () => {
await scenario.aliceMessages.sendMessage(messageText);
await expect(scenario.bobMessages.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
});
await test.step('Both users see mocked link embed metadata', async () => {
await expect(scenario.aliceMessages.getEmbedCardByTitle(MOCK_EMBED_TITLE)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bobMessages.getEmbedCardByTitle(MOCK_EMBED_TITLE)).toBeVisible({ timeout: 20_000 });
await expect(scenario.bob.page.getByText(MOCK_EMBED_DESCRIPTION)).toBeVisible({ timeout: 20_000 });
});
});
test('sends KLIPY GIF messages with mocked API responses', async ({ createClient }) => {
const scenario = await createChatScenario(createClient);
await test.step('Alice opens GIF picker and sends mocked GIF', async () => {
await scenario.aliceMessages.openGifPicker();
await scenario.aliceMessages.selectFirstGif();
});
await test.step('Bob sees GIF message sync', async () => {
await scenario.aliceMessages.expectMessageImageLoaded('KLIPY GIF');
await scenario.bobMessages.expectMessageImageLoaded('KLIPY GIF');
});
});
});
interface ChatScenario {
alice: Client;
bob: Client;
aliceRoom: ChatRoomPage;
bobRoom: ChatRoomPage;
aliceMessages: ChatMessagesPage;
bobMessages: ChatMessagesPage;
}
async function createChatScenario(createClient: () => Promise<Client>): Promise<ChatScenario> {
const suffix = uniqueName('chat');
const serverName = `Chat Server ${suffix}`;
const aliceCredentials = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobCredentials = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const alice = await createClient();
const bob = await createClient();
await installChatFeatureMocks(alice.page);
await installChatFeatureMocks(bob.page);
const aliceRegisterPage = new RegisterPage(alice.page);
const bobRegisterPage = new RegisterPage(bob.page);
await aliceRegisterPage.goto();
await aliceRegisterPage.register(
aliceCredentials.username,
aliceCredentials.displayName,
aliceCredentials.password
);
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
await bobRegisterPage.goto();
await bobRegisterPage.register(
bobCredentials.username,
bobCredentials.displayName,
bobCredentials.password
);
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
const aliceSearchPage = new ServerSearchPage(alice.page);
await aliceSearchPage.createServer(serverName, {
description: 'E2E chat server for messaging feature coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
const bobSearchPage = new ServerSearchPage(bob.page);
const serverCard = bob.page.locator('button', { hasText: serverName }).first();
await bobSearchPage.searchInput.fill(serverName);
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);
const aliceMessages = new ChatMessagesPage(alice.page);
const bobMessages = new ChatMessagesPage(bob.page);
await aliceMessages.waitForReady();
await bobMessages.waitForReady();
return {
alice,
bob,
aliceRoom,
bobRoom,
aliceMessages,
bobMessages
};
}
async function installChatFeatureMocks(page: Page): Promise<void> {
await page.route('**/api/klipy/config', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ enabled: true })
});
});
await page.route('**/api/klipy/gifs**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
enabled: true,
hasNext: false,
results: [
{
id: 'mock-gif-1',
slug: 'mock-gif-1',
title: 'Mock Celebration GIF',
url: MOCK_GIF_IMAGE_URL,
previewUrl: MOCK_GIF_IMAGE_URL,
width: 64,
height: 64
}
]
})
});
});
await page.route('**/api/link-metadata**', async (route) => {
const requestUrl = new URL(route.request().url());
const requestedTargetUrl = requestUrl.searchParams.get('url') ?? '';
if (requestedTargetUrl === MOCK_EMBED_URL) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
title: MOCK_EMBED_TITLE,
description: MOCK_EMBED_DESCRIPTION,
imageUrl: MOCK_GIF_IMAGE_URL,
siteName: 'Mock Docs'
})
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ failed: true })
});
});
}
function createTextFilePayload(name: string, mimeType: string, content: string): ChatDropFilePayload {
return {
name,
mimeType,
base64: Buffer.from(content, 'utf8').toString('base64')
};
}
function buildMockSvgMarkup(label: string): string {
return [
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120" viewBox="0 0 160 120">',
'<rect width="160" height="120" rx="18" fill="#0f172a" />',
'<circle cx="38" cy="36" r="18" fill="#38bdf8" />',
'<rect x="66" y="28" width="64" height="16" rx="8" fill="#f8fafc" />',
'<rect x="24" y="74" width="112" height="12" rx="6" fill="#22c55e" />',
`<text x="24" y="104" fill="#e2e8f0" font-size="12" font-family="Arial, sans-serif">${label}</text>`,
'</svg>'
].join('');
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36)
.slice(2, 8)}`;
}

View File

@@ -0,0 +1,458 @@
import {
mkdtemp,
rm
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
chromium,
type BrowserContext,
type Page
} from '@playwright/test';
import {
test,
expect
} from '../../fixtures/multi-client';
import { installTestServerEndpoint } from '../../helpers/seed-test-endpoint';
import { LoginPage } from '../../pages/login.page';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatMessagesPage } from '../../pages/chat-messages.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
interface TestUser {
displayName: string;
password: string;
username: string;
}
interface AvatarUploadPayload {
buffer: Buffer;
dataUrl: string;
mimeType: string;
name: string;
}
interface PersistentClient {
context: BrowserContext;
page: Page;
user: TestUser;
userDataDir: string;
}
const STATIC_GIF_BASE64 = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
const GIF_FRAME_MARKER = Buffer.from([0x21, 0xF9, 0x04]);
const NETSCAPE_LOOP_EXTENSION = Buffer.from([
0x21, 0xFF, 0x0B,
0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45, 0x32, 0x2E, 0x30,
0x03, 0x01, 0x00, 0x00, 0x00
]);
const CLIENT_LAUNCH_ARGS = [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream'
];
const VOICE_CHANNEL = 'General';
test.describe('Profile avatar sync', () => {
test.describe.configure({ timeout: 240_000 });
test('syncs avatar changes for online and late-joining users and persists after restart', async ({ testServer }) => {
const suffix = uniqueName('avatar');
const serverName = `Avatar Sync Server ${suffix}`;
const messageText = `Avatar sync message ${suffix}`;
const avatarA = buildAnimatedGifUpload('alpha');
const avatarB = buildAnimatedGifUpload('beta');
const aliceUser: TestUser = {
username: `alice_${suffix}`,
displayName: 'Alice',
password: 'TestPass123!'
};
const bobUser: TestUser = {
username: `bob_${suffix}`,
displayName: 'Bob',
password: 'TestPass123!'
};
const carolUser: TestUser = {
username: `carol_${suffix}`,
displayName: 'Carol',
password: 'TestPass123!'
};
const clients: PersistentClient[] = [];
try {
const alice = await createPersistentClient(aliceUser, testServer.port);
const bob = await createPersistentClient(bobUser, testServer.port);
clients.push(alice, bob);
await test.step('Alice and Bob register, create a server, and join the same room', async () => {
await registerUser(alice);
await registerUser(bob);
const aliceSearchPage = new ServerSearchPage(alice.page);
await aliceSearchPage.createServer(serverName, {
description: 'Avatar synchronization E2E coverage'
});
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
await joinServerFromSearch(bob.page, serverName);
await waitForRoomReady(alice.page);
await waitForRoomReady(bob.page);
await expectUserRowVisible(bob.page, aliceUser.displayName);
});
const roomUrl = alice.page.url();
await test.step('Alice uploads the first avatar while Bob is online and Bob sees it live', async () => {
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarA);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarA.dataUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarA.dataUrl);
});
await test.step('Alice sees the updated avatar in voice controls', async () => {
await ensureVoiceChannelExists(alice.page, VOICE_CHANNEL);
await joinVoiceChannel(alice.page, VOICE_CHANNEL);
await expectVoiceControlsAvatar(alice.page, avatarA.dataUrl);
});
const carol = await createPersistentClient(carolUser, testServer.port);
clients.push(carol);
await test.step('Carol joins after the first change and sees the updated avatar', async () => {
await registerUser(carol);
await joinServerFromSearch(carol.page, serverName);
await waitForRoomReady(carol.page);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarA.dataUrl);
});
await test.step('Alice avatar is used in chat messages for everyone in the room', async () => {
const aliceMessagesPage = new ChatMessagesPage(alice.page);
await aliceMessagesPage.sendMessage(messageText);
await expectChatMessageAvatar(alice.page, messageText, avatarA.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarA.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarA.dataUrl);
});
await test.step('Alice changes the avatar again and all three users see the update in real time', async () => {
await uploadAvatarFromRoomSidebar(alice.page, aliceUser.displayName, avatarB);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
await expectVoiceControlsAvatar(alice.page, avatarB.dataUrl);
});
await test.step('Bob, Carol, and Alice each keep the updated avatar after a full app restart', async () => {
await restartPersistentClient(bob, testServer.port);
await openRoomAfterRestart(bob, roomUrl);
await expectSidebarAvatar(bob.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(bob.page, messageText, avatarB.dataUrl);
await restartPersistentClient(carol, testServer.port);
await openRoomAfterRestart(carol, roomUrl);
await expectSidebarAvatar(carol.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(carol.page, messageText, avatarB.dataUrl);
await restartPersistentClient(alice, testServer.port);
await openRoomAfterRestart(alice, roomUrl);
await expectSidebarAvatar(alice.page, aliceUser.displayName, avatarB.dataUrl);
await expectChatMessageAvatar(alice.page, messageText, avatarB.dataUrl);
});
} finally {
await Promise.all(clients.map(async (client) => {
await closePersistentClient(client);
await rm(client.userDataDir, { recursive: true, force: true });
}));
}
});
});
async function createPersistentClient(user: TestUser, testServerPort: number): Promise<PersistentClient> {
const userDataDir = await mkdtemp(join(tmpdir(), 'metoyou-avatar-e2e-'));
const session = await launchPersistentSession(userDataDir, testServerPort);
return {
context: session.context,
page: session.page,
user,
userDataDir
};
}
async function restartPersistentClient(client: PersistentClient, testServerPort: number): Promise<void> {
await closePersistentClient(client);
const session = await launchPersistentSession(client.userDataDir, testServerPort);
client.context = session.context;
client.page = session.page;
}
async function closePersistentClient(client: PersistentClient): Promise<void> {
try {
await client.context.close();
} catch {
// Ignore repeated cleanup attempts during finally.
}
}
async function launchPersistentSession(
userDataDir: string,
testServerPort: number
): Promise<{ context: BrowserContext; page: Page }> {
const context = await chromium.launchPersistentContext(userDataDir, {
args: CLIENT_LAUNCH_ARGS,
baseURL: 'http://localhost:4200',
permissions: ['microphone', 'camera']
});
await installTestServerEndpoint(context, testServerPort);
const page = context.pages()[0] ?? await context.newPage();
return { context, page };
}
async function registerUser(client: PersistentClient): Promise<void> {
const registerPage = new RegisterPage(client.page);
await retryTransientNavigation(() => registerPage.goto());
await registerPage.register(client.user.username, client.user.displayName, client.user.password);
await expect(client.page).toHaveURL(/\/search/, { timeout: 15_000 });
}
async function joinServerFromSearch(page: Page, serverName: string): Promise<void> {
const searchPage = new ServerSearchPage(page);
const serverCard = page.locator('button', { hasText: serverName }).first();
await searchPage.searchInput.fill(serverName);
await expect(serverCard).toBeVisible({ timeout: 15_000 });
await serverCard.click();
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
async function ensureVoiceChannelExists(page: Page, channelName: string): Promise<void> {
const chatRoom = new ChatRoomPage(page);
const existingVoiceChannel = page.locator('app-rooms-side-panel').getByRole('button', { name: channelName, exact: true });
if (await existingVoiceChannel.count() > 0) {
return;
}
await chatRoom.openCreateVoiceChannelDialog();
await chatRoom.createChannel(channelName);
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
}
async function joinVoiceChannel(page: Page, channelName: string): Promise<void> {
const chatRoom = new ChatRoomPage(page);
await chatRoom.joinVoiceChannel(channelName);
await expect(page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
}
async function uploadAvatarFromRoomSidebar(
page: Page,
displayName: string,
avatar: AvatarUploadPayload
): Promise<void> {
const currentUserRow = getUserRow(page, displayName);
const profileFileInput = page.locator('app-profile-card input[type="file"]');
const applyButton = page.getByRole('button', { name: 'Apply picture' });
await expect(currentUserRow).toBeVisible({ timeout: 15_000 });
if (await profileFileInput.count() === 0) {
await currentUserRow.click();
await expect(profileFileInput).toBeAttached({ timeout: 10_000 });
}
await profileFileInput.setInputFiles({
name: avatar.name,
mimeType: avatar.mimeType,
buffer: avatar.buffer
});
await expect(applyButton).toBeVisible({ timeout: 10_000 });
await applyButton.click();
await expect(applyButton).not.toBeVisible({ timeout: 10_000 });
}
async function openRoomAfterRestart(client: PersistentClient, roomUrl: string): Promise<void> {
await retryTransientNavigation(() => client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' }));
if (client.page.url().includes('/login')) {
const loginPage = new LoginPage(client.page);
await loginPage.login(client.user.username, client.user.password);
await expect(client.page).toHaveURL(/\/(search|room)\//, { timeout: 15_000 });
await client.page.goto(roomUrl, { waitUntil: 'domcontentloaded' });
}
await waitForRoomReady(client.page);
}
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
return await navigate();
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const isTransientNavigationError = message.includes('ERR_EMPTY_RESPONSE') || message.includes('ERR_CONNECTION_RESET');
if (!isTransientNavigationError || attempt === attempts) {
throw error;
}
}
}
throw lastError instanceof Error
? lastError
: new Error(`Navigation failed after ${attempts} attempts`);
}
async function waitForRoomReady(page: Page): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
await messagesPage.waitForReady();
await expect(page.locator('app-rooms-side-panel').last()).toBeVisible({ timeout: 15_000 });
}
function getUserRow(page: Page, displayName: string) {
const usersSidePanel = page.locator('app-rooms-side-panel').last();
return usersSidePanel.locator('[role="button"]').filter({
has: page.getByText(displayName, { exact: true })
}).first();
}
async function expectUserRowVisible(page: Page, displayName: string): Promise<void> {
await expect(getUserRow(page, displayName)).toBeVisible({ timeout: 20_000 });
}
async function expectSidebarAvatar(page: Page, displayName: string, expectedDataUrl: string): Promise<void> {
const row = getUserRow(page, displayName);
await expect(row).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = row.locator('img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: `${displayName} avatar src should update`
}).toBe(expectedDataUrl);
await expect.poll(async () => {
const image = row.locator('img').first();
if (await image.count() === 0) {
return false;
}
return image.evaluate((element) => {
const img = element as HTMLImageElement;
return img.complete && img.naturalWidth > 0 && img.naturalHeight > 0;
});
}, {
timeout: 20_000,
message: `${displayName} avatar image should load`
}).toBe(true);
}
async function expectChatMessageAvatar(page: Page, messageText: string, expectedDataUrl: string): Promise<void> {
const messagesPage = new ChatMessagesPage(page);
const messageItem = messagesPage.getMessageItemByText(messageText);
await expect(messageItem).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = messageItem.locator('app-user-avatar img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: `Chat message avatar for "${messageText}" should update`
}).toBe(expectedDataUrl);
}
async function expectVoiceControlsAvatar(page: Page, expectedDataUrl: string): Promise<void> {
const voiceControls = page.locator('app-voice-controls');
await expect(voiceControls).toBeVisible({ timeout: 20_000 });
await expect.poll(async () => {
const image = voiceControls.locator('app-user-avatar img').first();
if (await image.count() === 0) {
return null;
}
return image.getAttribute('src');
}, {
timeout: 20_000,
message: 'Voice controls avatar should update'
}).toBe(expectedDataUrl);
}
function buildAnimatedGifUpload(label: string): AvatarUploadPayload {
const baseGif = Buffer.from(STATIC_GIF_BASE64, 'base64');
const frameStart = baseGif.indexOf(GIF_FRAME_MARKER);
if (frameStart < 0) {
throw new Error('Failed to locate GIF frame marker for animated avatar payload');
}
const header = baseGif.subarray(0, frameStart);
const frame = baseGif.subarray(frameStart, baseGif.length - 1);
const commentData = Buffer.from(label, 'ascii');
const commentExtension = Buffer.concat([
Buffer.from([0x21, 0xFE, commentData.length]),
commentData,
Buffer.from([0x00])
]);
const buffer = Buffer.concat([
header,
NETSCAPE_LOOP_EXTENSION,
commentExtension,
frame,
frame,
Buffer.from([0x3B])
]);
const base64 = buffer.toString('base64');
return {
buffer,
dataUrl: `data:image/gif;base64,${base64}`,
mimeType: 'image/gif',
name: `animated-avatar-${label}.gif`
};
}
function uniqueName(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

View File

@@ -0,0 +1,396 @@
import { test, expect } from '../../fixtures/multi-client';
import {
installWebRTCTracking,
waitForPeerConnected,
isPeerStillConnected,
waitForAudioFlow,
waitForAudioStatsPresent,
waitForVideoFlow,
waitForOutboundVideoFlow,
waitForInboundVideoFlow,
dumpRtcDiagnostics
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
/**
* Screen sharing E2E tests: verify video, screen-share audio, and voice audio
* flow correctly between users during screen sharing.
*
* Uses the same dedicated-browser-per-client infrastructure as voice tests.
* getDisplayMedia is monkey-patched to return a synthetic canvas video stream
* + 880 Hz oscillator audio, bypassing the browser picker dialog.
*/
const ALICE = { username: `alice_ss_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' };
const BOB = { username: `bob_ss_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' };
const SERVER_NAME = `SS Test ${Date.now()}`;
const VOICE_CHANNEL = 'General';
/** Register a user and navigate to /search. */
async function registerUser(page: import('@playwright/test').Page, user: typeof ALICE) {
const registerPage = new RegisterPage(page);
await registerPage.goto();
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(user.username, user.displayName, user.password);
await expect(page).toHaveURL(/\/search/, { timeout: 15_000 });
}
/** Both users register → Alice creates server → Bob joins. */
async function setupServerWithBothUsers(
alice: { page: import('@playwright/test').Page },
bob: { page: import('@playwright/test').Page }
) {
await registerUser(alice.page, ALICE);
await registerUser(bob.page, BOB);
// Alice creates server
const aliceSearch = new ServerSearchPage(alice.page);
await aliceSearch.createServer(SERVER_NAME, { description: 'Screen share E2E' });
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
// Bob joins server
const bobSearch = new ServerSearchPage(bob.page);
await bobSearch.searchInput.fill(SERVER_NAME);
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
await expect(serverCard).toBeVisible({ timeout: 10_000 });
await serverCard.click();
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
}
/** Ensure voice channel exists and both users join it. */
async function joinVoiceTogether(
alice: { page: import('@playwright/test').Page },
bob: { page: import('@playwright/test').Page }
) {
const aliceRoom = new ChatRoomPage(alice.page);
const existingChannel = alice.page
.locator('app-rooms-side-panel')
.getByRole('button', { name: VOICE_CHANNEL, exact: true });
if (await existingChannel.count() === 0) {
await aliceRoom.openCreateVoiceChannelDialog();
await aliceRoom.createChannel(VOICE_CHANNEL);
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);
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);
// Expand voice workspace on both clients so the demand-driven screen
// share request flow can fire (requires connectRemoteShares = true).
// Click the "VIEW" badge that appears next to the active voice channel.
const aliceView = alice.page.locator('app-rooms-side-panel')
.getByRole('button', { name: /view/i })
.first();
const bobView = bob.page.locator('app-rooms-side-panel')
.getByRole('button', { name: /view/i })
.first();
await expect(aliceView).toBeVisible({ timeout: 10_000 });
await aliceView.click();
await expect(alice.page.locator('app-voice-workspace')).toBeVisible({ timeout: 10_000 });
await expect(bobView).toBeVisible({ timeout: 10_000 });
await bobView.click();
await expect(bob.page.locator('app-voice-workspace')).toBeVisible({ timeout: 10_000 });
// Re-verify audio stats are present after workspace expansion (the VIEW
// click can trigger renegotiation which briefly disrupts audio).
await waitForAudioStatsPresent(alice.page, 20_000);
await waitForAudioStatsPresent(bob.page, 20_000);
}
function expectFlowing(
delta: { outboundBytesDelta: number; inboundBytesDelta: number; outboundPacketsDelta: number; inboundPacketsDelta: number },
label: string
) {
expect(
delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0,
`${label} should be sending`
).toBe(true);
expect(
delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0,
`${label} should be receiving`
).toBe(true);
}
test.describe('Screen sharing', () => {
test('single user screen share: video and audio flow to receiver, voice audio continues', async ({ createClient }) => {
test.setTimeout(180_000);
const alice = await createClient();
const bob = await createClient();
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
// ── Setup: register, server, voice ────────────────────────────
await test.step('Setup server and voice channel', async () => {
await setupServerWithBothUsers(alice, bob);
await joinVoiceTogether(alice, bob);
});
// ── Verify voice audio before screen share ────────────────────
await test.step('Voice audio flows before screen share', async () => {
const aliceDelta = await waitForAudioFlow(alice.page, 30_000);
const bobDelta = await waitForAudioFlow(bob.page, 30_000);
expectFlowing(aliceDelta, 'Alice voice');
expectFlowing(bobDelta, 'Bob voice');
});
// ── Alice starts screen sharing ───────────────────────────────
await test.step('Alice starts screen sharing', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.startScreenShare();
// Screen share button should show active state (MonitorOff icon)
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
});
// ── Verify screen share video flows ───────────────────────────
await test.step('Screen share video flows from Alice to Bob', async () => {
// Screen share is unidirectional: Alice sends video, Bob receives it.
const aliceVideo = await waitForOutboundVideoFlow(alice.page, 30_000);
const bobVideo = await waitForInboundVideoFlow(bob.page, 30_000);
if (aliceVideo.outboundBytesDelta === 0 || bobVideo.inboundBytesDelta === 0) {
console.log('[Alice RTC]\n' + await dumpRtcDiagnostics(alice.page));
console.log('[Bob RTC]\n' + await dumpRtcDiagnostics(bob.page));
}
expect(
aliceVideo.outboundBytesDelta > 0 || aliceVideo.outboundPacketsDelta > 0,
'Alice should be sending screen share video'
).toBe(true);
expect(
bobVideo.inboundBytesDelta > 0 || bobVideo.inboundPacketsDelta > 0,
'Bob should be receiving screen share video'
).toBe(true);
});
// ── Verify voice audio continues during screen share ──────────
await test.step('Voice audio continues during screen share', async () => {
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
expectFlowing(aliceAudio, 'Alice voice during screen share');
expectFlowing(bobAudio, 'Bob voice during screen share');
});
// ── Bob can hear Alice talk while she screen shares ───────────
await test.step('Bob receives audio from Alice during screen share', async () => {
// Specifically check Bob is receiving audio (from Alice's voice)
const bobAudio = await waitForAudioFlow(bob.page, 15_000);
expect(
bobAudio.inboundBytesDelta > 0,
'Bob should receive voice audio while Alice screen shares'
).toBe(true);
});
// ── Alice stops screen sharing ────────────────────────────────
await test.step('Alice stops screen sharing', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.stopScreenShare();
// Active icon should disappear - regular Monitor icon shown instead
await expect(
aliceRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
).toBeVisible({ timeout: 10_000 });
});
// ── Voice audio still works after screen share ends ───────────
await test.step('Voice audio resumes normally after screen share stops', async () => {
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
expectFlowing(aliceAudio, 'Alice voice after screen share');
expectFlowing(bobAudio, 'Bob voice after screen share');
});
});
test('multiple users screen share simultaneously', async ({ createClient }) => {
test.setTimeout(180_000);
const alice = await createClient();
const bob = await createClient();
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
await test.step('Setup server and voice channel', async () => {
await setupServerWithBothUsers(alice, bob);
await joinVoiceTogether(alice, bob);
});
// ── Both users start screen sharing ───────────────────────────
await test.step('Alice starts screen sharing', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.startScreenShare();
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
});
await test.step('Bob starts screen sharing', async () => {
const bobRoom = new ChatRoomPage(bob.page);
await bobRoom.startScreenShare();
await expect(bobRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
});
// ── Verify video flows in both directions ─────────────────────
await test.step('Video flows bidirectionally with both screen shares active', async () => {
// Both sharing: each page sends and receives video
const aliceVideo = await waitForVideoFlow(alice.page, 30_000);
const bobVideo = await waitForVideoFlow(bob.page, 30_000);
expectFlowing(aliceVideo, 'Alice screen share video');
expectFlowing(bobVideo, 'Bob screen share video');
});
// ── Voice audio continues with dual screen shares ─────────────
await test.step('Voice audio continues with both users screen sharing', async () => {
const aliceAudio = await waitForAudioFlow(alice.page, 20_000);
const bobAudio = await waitForAudioFlow(bob.page, 20_000);
expectFlowing(aliceAudio, 'Alice voice during dual screen share');
expectFlowing(bobAudio, 'Bob voice during dual screen share');
});
// ── Both stop screen sharing ──────────────────────────────────
await test.step('Both users stop screen sharing', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
await aliceRoom.stopScreenShare();
await expect(
aliceRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
).toBeVisible({ timeout: 10_000 });
await bobRoom.stopScreenShare();
await expect(
bobRoom.voiceControls.locator('button:has(ng-icon[name="lucideMonitor"])').first()
).toBeVisible({ timeout: 10_000 });
});
});
test('screen share connection stays stable for 10+ seconds', async ({ createClient }) => {
test.setTimeout(180_000);
const alice = await createClient();
const bob = await createClient();
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
await test.step('Setup server and voice channel', async () => {
await setupServerWithBothUsers(alice, bob);
await joinVoiceTogether(alice, bob);
});
await test.step('Alice starts screen sharing', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.startScreenShare();
await expect(aliceRoom.isScreenShareActive).toBeVisible({ timeout: 10_000 });
// Wait for video pipeline to fully establish
await waitForOutboundVideoFlow(alice.page, 30_000);
await waitForInboundVideoFlow(bob.page, 30_000);
});
// ── Stability checkpoints at 0s, 5s, 10s ─────────────────────
await test.step('Connection stays stable for 10+ seconds during screen share', async () => {
for (const checkpoint of [
0,
5_000,
5_000
]) {
if (checkpoint > 0) {
await alice.page.waitForTimeout(checkpoint);
}
const aliceConnected = await isPeerStillConnected(alice.page);
const bobConnected = await isPeerStillConnected(bob.page);
expect(aliceConnected, 'Alice should still be connected').toBe(true);
expect(bobConnected, 'Bob should still be connected').toBe(true);
}
// After 10s - verify both video and audio still flowing
const aliceVideo = await waitForOutboundVideoFlow(alice.page, 15_000);
const bobVideo = await waitForInboundVideoFlow(bob.page, 15_000);
expect(
aliceVideo.outboundBytesDelta > 0,
'Alice still sending screen share video after 10s'
).toBe(true);
expect(
bobVideo.inboundBytesDelta > 0,
'Bob still receiving screen share video after 10s'
).toBe(true);
const aliceAudio = await waitForAudioFlow(alice.page, 15_000);
const bobAudio = await waitForAudioFlow(bob.page, 15_000);
expectFlowing(aliceAudio, 'Alice voice after 10s screen share');
expectFlowing(bobAudio, 'Bob voice after 10s screen share');
});
// ── Clean disconnect ──────────────────────────────────────────
await test.step('Alice stops screen share and disconnects', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
await aliceRoom.stopScreenShare();
await aliceRoom.disconnectButton.click();
await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 });
});
});
});

View File

@@ -0,0 +1,260 @@
import { test, expect } from '../../fixtures/multi-client';
import {
installWebRTCTracking,
waitForPeerConnected,
isPeerStillConnected,
getAudioStatsDelta,
waitForAudioFlow,
waitForAudioStatsPresent,
dumpRtcDiagnostics
} from '../../helpers/webrtc-helpers';
import { RegisterPage } from '../../pages/register.page';
import { ServerSearchPage } from '../../pages/server-search.page';
import { ChatRoomPage } from '../../pages/chat-room.page';
/**
* 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
* multi-user WebRTC voice chat.
*/
const ALICE = { username: `alice_${Date.now()}`, displayName: 'Alice', password: 'TestPass123!' };
const BOB = { username: `bob_${Date.now()}`, displayName: 'Bob', password: 'TestPass123!' };
const SERVER_NAME = `E2E Test Server ${Date.now()}`;
const VOICE_CHANNEL = 'General';
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
const alice = await createClient();
const bob = await createClient();
// Install WebRTC tracking before any navigation
await installWebRTCTracking(alice.page);
await installWebRTCTracking(bob.page);
// Forward browser console for debugging
alice.page.on('console', msg => console.log('[Alice]', msg.text()));
bob.page.on('console', msg => console.log('[Bob]', msg.text()));
// ── Step 1: Register both users ──────────────────────────────────
await test.step('Alice registers an account', async () => {
const registerPage = new RegisterPage(alice.page);
await registerPage.goto();
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(ALICE.username, ALICE.displayName, ALICE.password);
// After registration, app should navigate to /search
await expect(alice.page).toHaveURL(/\/search/, { timeout: 15_000 });
});
await test.step('Bob registers an account', async () => {
const registerPage = new RegisterPage(bob.page);
await registerPage.goto();
await expect(registerPage.submitButton).toBeVisible();
await registerPage.register(BOB.username, BOB.displayName, BOB.password);
await expect(bob.page).toHaveURL(/\/search/, { timeout: 15_000 });
});
// ── Step 2: Alice creates a server ───────────────────────────────
await test.step('Alice creates a new server', async () => {
const searchPage = new ServerSearchPage(alice.page);
await searchPage.createServer(SERVER_NAME, {
description: 'E2E test server for voice testing'
});
// After server creation, app navigates to the room
await expect(alice.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
// ── Step 3: Bob joins the server ─────────────────────────────────
await test.step('Bob finds and joins the server', async () => {
const searchPage = new ServerSearchPage(bob.page);
// Search for the server
await searchPage.searchInput.fill(SERVER_NAME);
// Wait for search results and click the server
const serverCard = bob.page.locator('button', { hasText: SERVER_NAME }).first();
await expect(serverCard).toBeVisible({ timeout: 10_000 });
await serverCard.click();
// Bob should be in the room now
await expect(bob.page).toHaveURL(/\/room\//, { timeout: 15_000 });
});
// ── Step 4: Create a voice channel (if one doesn't exist) ────────
await test.step('Alice ensures a voice channel is available', async () => {
const chatRoom = new ChatRoomPage(alice.page);
const existingVoiceChannel = alice.page.locator('app-rooms-side-panel')
.getByRole('button', { name: VOICE_CHANNEL, exact: true });
const voiceChannelExists = await existingVoiceChannel.count() > 0;
if (!voiceChannelExists) {
// Click "Create Voice Channel" plus button
await chatRoom.openCreateVoiceChannelDialog();
await chatRoom.createChannel(VOICE_CHANNEL);
// Wait for the channel to appear
await expect(existingVoiceChannel).toBeVisible({ timeout: 10_000 });
}
});
// ── Step 5: Both users join the voice channel ────────────────────
await test.step('Alice joins the voice channel', async () => {
const chatRoom = new ChatRoomPage(alice.page);
await chatRoom.joinVoiceChannel(VOICE_CHANNEL);
// Voice controls should appear (indicates voice is connected)
await expect(alice.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
});
await test.step('Bob joins the voice channel', async () => {
const chatRoom = new ChatRoomPage(bob.page);
await chatRoom.joinVoiceChannel(VOICE_CHANNEL);
await expect(bob.page.locator('app-voice-controls')).toBeVisible({ timeout: 15_000 });
});
// ── Step 6: Verify WebRTC connection establishes ─────────────────
await test.step('WebRTC peer connection reaches "connected" state', async () => {
await waitForPeerConnected(alice.page, 30_000);
await waitForPeerConnected(bob.page, 30_000);
// Wait for audio RTP pipeline to appear before measuring deltas -
// renegotiation after initial connect can temporarily remove stats.
await waitForAudioStatsPresent(alice.page, 20_000);
await waitForAudioStatsPresent(bob.page, 20_000);
});
// ── 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);
if (aliceDelta.outboundBytesDelta === 0 || aliceDelta.inboundBytesDelta === 0
|| bobDelta.outboundBytesDelta === 0 || bobDelta.inboundBytesDelta === 0) {
console.log('[Alice RTC Diagnostics]\n' + await dumpRtcDiagnostics(alice.page));
console.log('[Bob RTC Diagnostics]\n' + await dumpRtcDiagnostics(bob.page));
}
expectAudioFlow(aliceDelta, 'Alice');
expectAudioFlow(bobDelta, 'Bob');
});
// ── Step 8: Verify UI states are correct ─────────────────────────
await test.step('Voice UI shows correct state for both users', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
const bobRoom = new ChatRoomPage(bob.page);
// Both should see voice controls with "Connected" status
await expect(alice.page.locator('app-voice-controls')).toBeVisible();
await expect(bob.page.locator('app-voice-controls')).toBeVisible();
// Both should see the voice workspace or at least voice users listed
// Check that both users appear in the voice channel user list
const aliceSeesBob = aliceRoom.channelsSidePanel.getByText(BOB.displayName).first();
const bobSeesAlice = bobRoom.channelsSidePanel.getByText(ALICE.displayName).first();
await expect(aliceSeesBob).toBeVisible({ timeout: 10_000 });
await expect(bobSeesAlice).toBeVisible({ timeout: 10_000 });
});
// ── Step 9: Stay connected for 10+ seconds, verify stability ─────
await test.step('Connection remains stable for 10+ seconds', async () => {
// Check connectivity at 0s, 5s, and 10s intervals
for (const checkpoint of [
0,
5_000,
5_000
]) {
if (checkpoint > 0) {
await alice.page.waitForTimeout(checkpoint);
}
const aliceConnected = await isPeerStillConnected(alice.page);
const bobConnected = await isPeerStillConnected(bob.page);
expect(aliceConnected, 'Alice should still be connected').toBe(true);
expect(bobConnected, 'Bob should still be connected').toBe(true);
}
// After 10s total, verify audio is still flowing
const aliceDelta = await waitForAudioFlow(alice.page, 15_000);
const bobDelta = await waitForAudioFlow(bob.page, 15_000);
expectAudioFlow(aliceDelta, 'Alice after 10s');
expectAudioFlow(bobDelta, 'Bob after 10s');
});
// ── Step 10: Verify mute/unmute works correctly ──────────────────
await test.step('Mute toggle works correctly', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
// Alice mutes - click the first button in voice controls (mute button)
await aliceRoom.muteButton.click();
// After muting, Alice's outbound audio should stop increasing
// When muted, bytesSent may still show small comfort noise or zero growth
// The key assertion is that Bob's inbound for Alice's stream stops or reduces
await getAudioStatsDelta(alice.page, 2_000);
// Alice unmutes
await aliceRoom.muteButton.click();
// After unmuting, outbound should resume
const unmutedDelta = await waitForAudioFlow(alice.page, 15_000);
expectAudioFlow(unmutedDelta, 'Alice after unmuting');
});
// ── Step 11: Clean disconnect ────────────────────────────────────
await test.step('Alice disconnects from voice', async () => {
const aliceRoom = new ChatRoomPage(alice.page);
// Click the disconnect/hang-up button
await aliceRoom.disconnectButton.click();
// Connected controls should collapse for Alice after disconnect
await expect(aliceRoom.disconnectButton).not.toBeVisible({ timeout: 10_000 });
});
});
});
function expectAudioFlow(delta: {
outboundBytesDelta: number;
inboundBytesDelta: number;
outboundPacketsDelta: number;
inboundPacketsDelta: number;
}, label: string): void {
expect(
delta.outboundBytesDelta > 0 || delta.outboundPacketsDelta > 0,
`${label} should be sending audio`
).toBe(true);
expect(
delta.inboundBytesDelta > 0 || delta.inboundPacketsDelta > 0,
`${label} should be receiving audio`
).toBe(true);
}

View File

@@ -19,6 +19,7 @@ import {
setupSystemHandlers, setupSystemHandlers,
setupWindowControlHandlers setupWindowControlHandlers
} from '../ipc'; } from '../ipc';
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
export function registerAppLifecycle(): void { export function registerAppLifecycle(): void {
app.whenReady().then(async () => { app.whenReady().then(async () => {
@@ -34,6 +35,7 @@ export function registerAppLifecycle(): void {
await synchronizeAutoStartSetting(); await synchronizeAutoStartSetting();
initializeDesktopUpdater(); initializeDesktopUpdater();
await createWindow(); await createWindow();
startIdleMonitor();
app.on('activate', () => { app.on('activate', () => {
if (getMainWindow()) { if (getMainWindow()) {
@@ -57,6 +59,7 @@ export function registerAppLifecycle(): void {
if (getDataSource()?.isInitialized) { if (getDataSource()?.isInitialized) {
event.preventDefault(); event.preventDefault();
shutdownDesktopUpdater(); shutdownDesktopUpdater();
stopIdleMonitor();
await cleanupLinuxScreenShareAudioRouting(); await cleanupLinuxScreenShareAudioRouting();
await destroyDatabase(); await destroyDatabase();
app.quit(); app.quit();

View File

@@ -18,7 +18,8 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
timestamp: message.timestamp, timestamp: message.timestamp,
editedAt: message.editedAt ?? null, editedAt: message.editedAt ?? null,
isDeleted: message.isDeleted ? 1 : 0, isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null replyToId: message.replyToId ?? null,
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
}); });
await repo.save(entity); await repo.save(entity);

View File

@@ -11,6 +11,9 @@ export async function handleSaveUser(command: SaveUserCommand, dataSource: DataS
username: user.username ?? null, username: user.username ?? null,
displayName: user.displayName ?? null, displayName: user.displayName ?? null,
avatarUrl: user.avatarUrl ?? null, avatarUrl: user.avatarUrl ?? null,
avatarHash: user.avatarHash ?? null,
avatarMime: user.avatarMime ?? null,
avatarUpdatedAt: user.avatarUpdatedAt ?? null,
status: user.status ?? null, status: user.status ?? null,
role: user.role ?? null, role: user.role ?? null,
joinedAt: user.joinedAt ?? null, joinedAt: user.joinedAt ?? null,

View File

@@ -13,29 +13,35 @@ export async function handleUpdateMessage(command: UpdateMessageCommand, dataSou
if (!existing) if (!existing)
return; return;
if (updates.channelId !== undefined) const directFields = [
existing.channelId = updates.channelId ?? null; 'senderId',
'senderName',
'content',
'timestamp'
] as const;
const entity = existing as unknown as Record<string, unknown>;
if (updates.senderId !== undefined) for (const field of directFields) {
existing.senderId = updates.senderId; if (updates[field] !== undefined)
entity[field] = updates[field];
}
if (updates.senderName !== undefined) const nullableFields = [
existing.senderName = updates.senderName; 'channelId',
'editedAt',
'replyToId'
] as const;
if (updates.content !== undefined) for (const field of nullableFields) {
existing.content = updates.content; if (updates[field] !== undefined)
entity[field] = updates[field] ?? null;
if (updates.timestamp !== undefined) }
existing.timestamp = updates.timestamp;
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.isDeleted !== undefined) if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0; existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.replyToId !== undefined) if (updates.linkMetadata !== undefined)
existing.replyToId = updates.replyToId ?? null; existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
await repo.save(existing); await repo.save(existing);

View File

@@ -35,7 +35,8 @@ export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] =
editedAt: row.editedAt ?? undefined, editedAt: row.editedAt ?? undefined,
reactions: isDeleted ? [] : reactions, reactions: isDeleted ? [] : reactions,
isDeleted, isDeleted,
replyToId: row.replyToId ?? undefined replyToId: row.replyToId ?? undefined,
linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined
}; };
} }
@@ -46,6 +47,9 @@ export function rowToUser(row: UserEntity) {
username: row.username ?? '', username: row.username ?? '',
displayName: row.displayName ?? '', displayName: row.displayName ?? '',
avatarUrl: row.avatarUrl ?? undefined, avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
status: row.status ?? 'offline', status: row.status ?? 'offline',
role: row.role ?? 'member', role: row.role ?? 'member',
joinedAt: row.joinedAt ?? 0, joinedAt: row.joinedAt ?? 0,

View File

@@ -67,6 +67,9 @@ export interface RoomMemberRecord {
username: string; username: string;
displayName: string; displayName: string;
avatarUrl?: string; avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
role: RoomMemberRole; role: RoomMemberRole;
roleIds?: string[]; roleIds?: string[];
joinedAt: number; joinedAt: number;
@@ -336,6 +339,9 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
const username = trimmedString(rawMember, 'username'); const username = trimmedString(rawMember, 'username');
const displayName = trimmedString(rawMember, 'displayName'); const displayName = trimmedString(rawMember, 'displayName');
const avatarUrl = trimmedString(rawMember, 'avatarUrl'); const avatarUrl = trimmedString(rawMember, 'avatarUrl');
const avatarHash = trimmedString(rawMember, 'avatarHash');
const avatarMime = trimmedString(rawMember, 'avatarMime');
const avatarUpdatedAt = isFiniteNumber(rawMember['avatarUpdatedAt']) ? rawMember['avatarUpdatedAt'] : undefined;
return { return {
id: normalizedId || normalizedKey, id: normalizedId || normalizedKey,
@@ -343,6 +349,9 @@ function normalizeRoomMember(rawMember: Record<string, unknown>, now: number): R
username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }), username: username || fallbackUsername({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }), displayName: displayName || fallbackDisplayName({ id: normalizedId || normalizedKey, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined, avatarUrl: avatarUrl || undefined,
avatarHash: avatarHash || undefined,
avatarMime: avatarMime || undefined,
avatarUpdatedAt,
role: normalizeRoomMemberRole(rawMember['role']), role: normalizeRoomMemberRole(rawMember['role']),
roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined), roleIds: uniqueStrings(Array.isArray(rawMember['roleIds']) ? rawMember['roleIds'] as string[] : undefined),
joinedAt, joinedAt,
@@ -356,6 +365,11 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
} }
const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt; const preferIncoming = incomingMember.lastSeenAt >= existingMember.lastSeenAt;
const existingAvatarUpdatedAt = existingMember.avatarUpdatedAt ?? 0;
const incomingAvatarUpdatedAt = incomingMember.avatarUpdatedAt ?? 0;
const preferIncomingAvatar = incomingAvatarUpdatedAt === existingAvatarUpdatedAt
? preferIncoming
: incomingAvatarUpdatedAt > existingAvatarUpdatedAt;
return { return {
id: existingMember.id || incomingMember.id, id: existingMember.id || incomingMember.id,
@@ -366,9 +380,16 @@ function mergeRoomMembers(existingMember: RoomMemberRecord | undefined, incoming
displayName: preferIncoming displayName: preferIncoming
? (incomingMember.displayName || existingMember.displayName) ? (incomingMember.displayName || existingMember.displayName)
: (existingMember.displayName || incomingMember.displayName), : (existingMember.displayName || incomingMember.displayName),
avatarUrl: preferIncoming avatarUrl: preferIncomingAvatar
? (incomingMember.avatarUrl || existingMember.avatarUrl) ? (incomingMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || incomingMember.avatarUrl), : (existingMember.avatarUrl || incomingMember.avatarUrl),
avatarHash: preferIncomingAvatar
? (incomingMember.avatarHash || existingMember.avatarHash)
: (existingMember.avatarHash || incomingMember.avatarHash),
avatarMime: preferIncomingAvatar
? (incomingMember.avatarMime || existingMember.avatarMime)
: (existingMember.avatarMime || incomingMember.avatarMime),
avatarUpdatedAt: Math.max(existingAvatarUpdatedAt, incomingAvatarUpdatedAt) || undefined,
role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming), role: mergeRoomMemberRole(existingMember.role, incomingMember.role, preferIncoming),
roleIds: preferIncoming roleIds: preferIncoming
? (incomingMember.roleIds || existingMember.roleIds) ? (incomingMember.roleIds || existingMember.roleIds)
@@ -760,6 +781,9 @@ export async function replaceRoomRelations(
username: member.username, username: member.username,
displayName: member.displayName, displayName: member.displayName,
avatarUrl: member.avatarUrl ?? null, avatarUrl: member.avatarUrl ?? null,
avatarHash: member.avatarHash ?? null,
avatarMime: member.avatarMime ?? null,
avatarUpdatedAt: member.avatarUpdatedAt ?? null,
role: member.role, role: member.role,
joinedAt: member.joinedAt, joinedAt: member.joinedAt,
lastSeenAt: member.lastSeenAt lastSeenAt: member.lastSeenAt
@@ -907,6 +931,9 @@ export async function loadRoomRelationsMap(
username: row.username, username: row.username,
displayName: row.displayName, displayName: row.displayName,
avatarUrl: row.avatarUrl ?? undefined, avatarUrl: row.avatarUrl ?? undefined,
avatarHash: row.avatarHash ?? undefined,
avatarMime: row.avatarMime ?? undefined,
avatarUpdatedAt: row.avatarUpdatedAt ?? undefined,
role: row.role, role: row.role,
joinedAt: row.joinedAt, joinedAt: row.joinedAt,
lastSeenAt: row.lastSeenAt lastSeenAt: row.lastSeenAt

View File

@@ -50,6 +50,7 @@ export interface MessagePayload {
reactions?: ReactionPayload[]; reactions?: ReactionPayload[];
isDeleted?: boolean; isDeleted?: boolean;
replyToId?: string; replyToId?: string;
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
} }
export interface ReactionPayload { export interface ReactionPayload {
@@ -105,6 +106,9 @@ export interface UserPayload {
username?: string; username?: string;
displayName?: string; displayName?: string;
avatarUrl?: string; avatarUrl?: string;
avatarHash?: string;
avatarMime?: string;
avatarUpdatedAt?: number;
status?: string; status?: string;
role?: string; role?: string;
joinedAt?: number; joinedAt?: number;

View File

@@ -35,4 +35,7 @@ export class MessageEntity {
@Column('text', { nullable: true }) @Column('text', { nullable: true })
replyToId!: string | null; replyToId!: string | null;
@Column('text', { nullable: true })
linkMetadata!: string | null;
} }

View File

@@ -27,6 +27,15 @@ export class RoomMemberEntity {
@Column('text', { nullable: true }) @Column('text', { nullable: true })
avatarUrl!: string | null; avatarUrl!: string | null;
@Column('text', { nullable: true })
avatarHash!: string | null;
@Column('text', { nullable: true })
avatarMime!: string | null;
@Column('integer', { nullable: true })
avatarUpdatedAt!: number | null;
@Column('text') @Column('text')
role!: 'host' | 'admin' | 'moderator' | 'member'; role!: 'host' | 'admin' | 'moderator' | 'member';

View File

@@ -21,6 +21,15 @@ export class UserEntity {
@Column('text', { nullable: true }) @Column('text', { nullable: true })
avatarUrl!: string | null; avatarUrl!: string | null;
@Column('text', { nullable: true })
avatarHash!: string | null;
@Column('text', { nullable: true })
avatarMime!: string | null;
@Column('integer', { nullable: true })
avatarUpdatedAt!: number | null;
@Column('text', { nullable: true }) @Column('text', { nullable: true })
status!: string | null; status!: string | null;

View File

@@ -0,0 +1,124 @@
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach
} from 'vitest';
// Mock Electron modules before importing the module under test
const mockGetSystemIdleTime = vi.fn(() => 0);
const mockSend = vi.fn();
const mockGetMainWindow = vi.fn(() => ({
isDestroyed: () => false,
webContents: { send: mockSend }
}));
vi.mock('electron', () => ({
powerMonitor: {
getSystemIdleTime: mockGetSystemIdleTime
}
}));
vi.mock('../window/create-window', () => ({
getMainWindow: mockGetMainWindow
}));
import {
startIdleMonitor,
stopIdleMonitor,
getIdleState
} from './idle-monitor';
describe('idle-monitor', () => {
beforeEach(() => {
vi.useFakeTimers();
mockGetSystemIdleTime.mockReturnValue(0);
mockSend.mockClear();
});
afterEach(() => {
stopIdleMonitor();
vi.useRealTimers();
});
it('returns active when idle time is below threshold', () => {
mockGetSystemIdleTime.mockReturnValue(0);
expect(getIdleState()).toBe('active');
});
it('returns idle when idle time exceeds 15 minutes', () => {
mockGetSystemIdleTime.mockReturnValue(15 * 60);
expect(getIdleState()).toBe('idle');
});
it('sends idle-state-changed to renderer when transitioning to idle', () => {
startIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'idle');
});
it('sends idle-state-changed to renderer when transitioning back to active', () => {
startIdleMonitor();
// Go idle
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
mockSend.mockClear();
// Go active
mockGetSystemIdleTime.mockReturnValue(5);
vi.advanceTimersByTime(10_000);
expect(mockSend).toHaveBeenCalledWith('idle-state-changed', 'active');
});
it('does not fire duplicates when state stays the same', () => {
startIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
vi.advanceTimersByTime(10_000);
vi.advanceTimersByTime(10_000);
// Only one transition, so only one call
const idleCalls = mockSend.mock.calls.filter(
([channel, state]: [string, string]) => channel === 'idle-state-changed' && state === 'idle'
);
expect(idleCalls.length).toBe(1);
});
it('stops polling after stopIdleMonitor', () => {
startIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
mockSend.mockClear();
stopIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(0);
vi.advanceTimersByTime(10_000);
expect(mockSend).not.toHaveBeenCalled();
});
it('does not notify when main window is null', () => {
mockGetMainWindow.mockReturnValue(null);
startIdleMonitor();
mockGetSystemIdleTime.mockReturnValue(15 * 60);
vi.advanceTimersByTime(10_000);
expect(mockSend).not.toHaveBeenCalled();
mockGetMainWindow.mockReturnValue({
isDestroyed: () => false,
webContents: { send: mockSend }
});
});
});

View File

@@ -0,0 +1,49 @@
import { powerMonitor } from 'electron';
import { getMainWindow } from '../window/create-window';
const IDLE_THRESHOLD_SECONDS = 15 * 60; // 15 minutes
const POLL_INTERVAL_MS = 10_000; // Check every 10 seconds
let pollTimer: ReturnType<typeof setInterval> | null = null;
let wasIdle = false;
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
export type IdleState = 'active' | 'idle';
/**
* Starts polling `powerMonitor.getSystemIdleTime()` and notifies the
* renderer whenever the user transitions between active and idle.
*/
export function startIdleMonitor(): void {
if (pollTimer)
return;
pollTimer = setInterval(() => {
const idleSeconds = powerMonitor.getSystemIdleTime();
const isIdle = idleSeconds >= IDLE_THRESHOLD_SECONDS;
if (isIdle !== wasIdle) {
wasIdle = isIdle;
const state: IdleState = isIdle ? 'idle' : 'active';
const mainWindow = getMainWindow();
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send(IDLE_STATE_CHANGED_CHANNEL, state);
}
}
}, POLL_INTERVAL_MS);
}
export function stopIdleMonitor(): void {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
export function getIdleState(): IdleState {
const idleSeconds = powerMonitor.getSystemIdleTime();
return idleSeconds >= IDLE_THRESHOLD_SECONDS ? 'idle' : 'active';
}

View File

@@ -4,6 +4,8 @@ import {
desktopCapturer, desktopCapturer,
dialog, dialog,
ipcMain, ipcMain,
nativeImage,
net,
Notification, Notification,
shell shell
} from 'electron'; } from 'electron';
@@ -34,6 +36,7 @@ import {
} from '../update/desktop-updater'; } from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links'; import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start'; import { synchronizeAutoStartSetting } from '../app/auto-start';
import { getIdleState } from '../idle/idle-monitor';
import { import {
getMainWindow, getMainWindow,
getWindowIconPath, getWindowIconPath,
@@ -503,4 +506,74 @@ export function setupSystemHandlers(): void {
await fsp.mkdir(dirPath, { recursive: true }); await fsp.mkdir(dirPath, { recursive: true });
return true; return true;
}); });
ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => {
if (typeof srcURL !== 'string' || !srcURL) {
return false;
}
return new Promise<boolean>((resolve) => {
const request = net.request(srcURL);
request.on('response', (response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => {
const image = nativeImage.createFromBuffer(Buffer.concat(chunks));
if (!image.isEmpty()) {
clipboard.writeImage(image);
resolve(true);
} else {
resolve(false);
}
});
response.on('error', () => resolve(false));
});
request.on('error', () => resolve(false));
request.end();
});
});
ipcMain.handle('context-menu-command', (_event, command: string) => {
const allowedCommands = [
'cut',
'copy',
'paste',
'selectAll'
] as const;
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
return;
}
const mainWindow = getMainWindow();
const webContents = mainWindow?.webContents;
if (!webContents) {
return;
}
switch (command) {
case 'cut':
webContents.cut();
break;
case 'copy':
webContents.copy();
break;
case 'paste':
webContents.paste();
break;
case 'selectAll':
webContents.selectAll();
break;
}
});
ipcMain.handle('get-idle-state', () => {
return getIdleState();
});
} }

View File

@@ -0,0 +1,11 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLinkMetadata1000000000005 implements MigrationInterface {
async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "messages" ADD COLUMN "linkMetadata" text`);
}
async down(queryRunner: QueryRunner): Promise<void> {
// SQLite does not support DROP COLUMN; column is nullable and harmless.
}
}

View File

@@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddProfileAvatarMetadata1000000000006 implements MigrationInterface {
name = 'AddProfileAvatarMetadata1000000000006';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarHash" TEXT`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarMime" TEXT`);
await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarUpdatedAt" INTEGER`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarHash" TEXT`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarMime" TEXT`);
await queryRunner.query(`ALTER TABLE "room_members" ADD COLUMN "avatarUpdatedAt" INTEGER`);
}
public async down(): Promise<void> {
// SQLite column removal requires table rebuilds. Keep rollback no-op.
}
}

View File

@@ -6,6 +6,7 @@ const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monit
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed'; const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received'; const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed'; const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
const IDLE_STATE_CHANGED_CHANNEL = 'idle-state-changed';
export interface LinuxScreenShareAudioRoutingInfo { export interface LinuxScreenShareAudioRoutingInfo {
available: boolean; available: boolean;
@@ -124,6 +125,22 @@ function readLinuxDisplayServer(): string {
} }
} }
export interface ContextMenuParams {
posX: number;
posY: number;
isEditable: boolean;
selectionText: string;
linkURL: string;
mediaType: string;
srcURL: string;
editFlags: {
canCut: boolean;
canCopy: boolean;
canPaste: boolean;
canSelectAll: boolean;
};
}
export interface ElectronAPI { export interface ElectronAPI {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -194,6 +211,13 @@ export interface ElectronAPI {
deleteFile: (filePath: string) => Promise<boolean>; deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
contextMenuCommand: (command: string) => Promise<void>;
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
getIdleState: () => Promise<'active' | 'idle'>;
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
command: <T = unknown>(command: Command) => Promise<T>; command: <T = unknown>(command: Command) => Promise<T>;
query: <T = unknown>(query: Query) => Promise<T>; query: <T = unknown>(query: Query) => Promise<T>;
} }
@@ -299,6 +323,33 @@ const electronAPI: ElectronAPI = {
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath), deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath), ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
onContextMenu: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, params: ContextMenuParams) => {
listener(params);
};
ipcRenderer.on('show-context-menu', wrappedListener);
return () => {
ipcRenderer.removeListener('show-context-menu', wrappedListener);
};
},
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
getIdleState: () => ipcRenderer.invoke('get-idle-state'),
onIdleStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: 'active' | 'idle') => {
listener(state);
};
ipcRenderer.on(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(IDLE_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
command: (command) => ipcRenderer.invoke('cqrs:command', command), command: (command) => ipcRenderer.invoke('cqrs:command', command),
query: (query) => ipcRenderer.invoke('cqrs:query', query) query: (query) => ipcRenderer.invoke('cqrs:query', query)
}; };

View File

@@ -210,6 +210,40 @@ export async function createWindow(): Promise<void> {
); );
} }
if (process.platform === 'win32') {
session.defaultSession.setDisplayMediaRequestHandler(
async (request, respond) => {
// On Windows the system picker (useSystemPicker: true) is preferred.
// This handler is only reached when the system picker is unavailable.
// Include loopback audio when the renderer requested it so that
// getDisplayMedia receives an audio track and the renderer-side
// restrictOwnAudio constraint can keep the app's own voice playback
// out of the captured stream.
try {
const sources = await desktopCapturer.getSources({
types: ['window', 'screen'],
thumbnailSize: { width: 150, height: 150 }
});
const firstSource = sources[0];
if (firstSource) {
respond({
video: firstSource,
...(request.audioRequested ? { audio: 'loopback' } : {})
});
return;
}
} catch {
// desktopCapturer also unavailable
}
respond({});
},
{ useSystemPicker: true }
);
}
if (process.env['NODE_ENV'] === 'development') { if (process.env['NODE_ENV'] === 'development') {
const devUrl = process.env['SSL'] === 'true' const devUrl = process.env['SSL'] === 'true'
? 'https://localhost:4200' ? 'https://localhost:4200'
@@ -264,6 +298,24 @@ export async function createWindow(): Promise<void> {
emitWindowState(); emitWindowState();
mainWindow.webContents.on('context-menu', (_event, params) => {
mainWindow?.webContents.send('show-context-menu', {
posX: params.x,
posY: params.y,
isEditable: params.isEditable,
selectionText: params.selectionText,
linkURL: params.linkURL,
mediaType: params.mediaType,
srcURL: params.srcURL,
editFlags: {
canCut: params.editFlags.canCut,
canCopy: params.editFlags.canCopy,
canPaste: params.editFlags.canPaste,
canSelectAll: params.editFlags.canSelectAll
}
});
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => { mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url); shell.openExternal(url);
return { action: 'deny' }; return { action: 'deny' };

View File

@@ -123,7 +123,7 @@ module.exports = tseslint.config(
'complexity': ['warn',{ max:20 }], 'complexity': ['warn',{ max:20 }],
'curly': 'off', 'curly': 'off',
'eol-last': 'error', 'eol-last': 'error',
'id-denylist': ['warn','e','cb','i','x','c','y','any','string','String','Undefined','undefined','callback'], 'id-denylist': ['warn','e','cb','i','c','any','string','String','Undefined','undefined','callback'],
'max-len': ['error',{ code:150, ignoreComments:true }], 'max-len': ['error',{ code:150, ignoreComments:true }],
'new-parens': 'error', 'new-parens': 'error',
'newline-per-chained-call': 'error', 'newline-per-chained-call': 'error',
@@ -172,7 +172,7 @@ module.exports = tseslint.config(
// Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); } // Ensure only one statement per line to prevent patterns like: if (cond) { doThing(); }
'max-statements-per-line': ['error', { max: 1 }], 'max-statements-per-line': ['error', { max: 1 }],
// Prevent single-character identifiers for variables/params; do not check object property names // Prevent single-character identifiers for variables/params; do not check object property names
'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_'] }], 'id-length': ['error', { min: 2, properties: 'never', exceptions: ['_', 'x', 'y'] }],
// Require blank lines around block-like statements (if, function, class, switch, try, etc.) // Require blank lines around block-like statements (if, function, class, switch, try, etc.)
'padding-line-between-statements': [ 'padding-line-between-statements': [
'error', 'error',

437
package-lock.json generated
View File

@@ -56,9 +56,11 @@
"@angular/cli": "^21.0.4", "@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0", "@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@playwright/test": "^1.59.1",
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5", "@types/auto-launch": "^5.0.5",
"@types/mocha": "^10.0.10",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0", "angular-eslint": "21.2.0",
@@ -78,6 +80,7 @@
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "8.50.1", "typescript-eslint": "8.50.1",
"vitest": "^4.1.4",
"wait-on": "^7.2.0" "wait-on": "^7.2.0"
} }
}, },
@@ -9337,6 +9340,22 @@
"url": "https://opencollective.com/pkgr" "url": "https://opencollective.com/pkgr"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.47", "version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
@@ -11008,6 +11027,17 @@
"@types/responselike": "^1.0.0" "@types/responselike": "^1.0.0"
} }
}, },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/connect": { "node_modules/@types/connect": {
"version": "3.4.38", "version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
@@ -11289,6 +11319,13 @@
"@types/ms": "*" "@types/ms": "*"
} }
}, },
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/eslint": { "node_modules/@types/eslint": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
@@ -11448,6 +11485,13 @@
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/mocha": {
"version": "10.0.10",
"resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz",
"integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": { "node_modules/@types/ms": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -12253,6 +12297,146 @@
"vite": "^6.0.0 || ^7.0.0" "vite": "^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@vitest/expect": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz",
"integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz",
"integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.4",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz",
"integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz",
"integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.4",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz",
"integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"@vitest/utils": "4.1.4",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz",
"integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz",
"integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.4",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils/node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true,
"license": "MIT"
},
"node_modules/@webassemblyjs/ast": { "node_modules/@webassemblyjs/ast": {
"version": "1.14.1", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -13091,6 +13275,16 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/astral-regex": { "node_modules/astral-regex": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
@@ -13987,6 +14181,16 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": { "node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -17466,6 +17670,16 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -17545,6 +17759,16 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/exponential-backoff": { "node_modules/exponential-backoff": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz",
@@ -23521,6 +23745,17 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/on-finished": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -24652,6 +24887,53 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/plist": { "node_modules/plist": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
@@ -27315,6 +27597,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -27709,6 +27998,13 @@
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
} }
}, },
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/stackframe": { "node_modules/stackframe": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz",
@@ -27734,6 +28030,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/stdin-discarder": { "node_modules/stdin-discarder": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
@@ -28607,6 +28910,13 @@
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -28632,6 +28942,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "url": "https://github.com/sponsors/SuperchupuDev"
} }
}, },
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.2.5", "version": "0.2.5",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
@@ -30503,6 +30823,106 @@
"@esbuild/win32-x64": "0.25.12" "@esbuild/win32-x64": "0.25.12"
} }
}, },
"node_modules/vitest": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz",
"integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.4",
"@vitest/mocker": "4.1.4",
"@vitest/pretty-format": "4.1.4",
"@vitest/runner": "4.1.4",
"@vitest/snapshot": "4.1.4",
"@vitest/spy": "4.1.4",
"@vitest/utils": "4.1.4",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.4",
"@vitest/browser-preview": "4.1.4",
"@vitest/browser-webdriverio": "4.1.4",
"@vitest/coverage-istanbul": "4.1.4",
"@vitest/coverage-v8": "4.1.4",
"@vitest/ui": "4.1.4",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/vscode-jsonrpc": { "node_modules/vscode-jsonrpc": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
@@ -31375,6 +31795,23 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wildcard": { "node_modules/wildcard": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz",

View File

@@ -17,7 +17,7 @@
"build:all": "npm run build && npm run build:electron && cd server && npm run build", "build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'", "build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
"watch": "cd \"toju-app\" && ng build --watch --configuration development", "watch": "cd \"toju-app\" && ng build --watch --configuration development",
"test": "cd \"toju-app\" && ng test", "test": "cd \"toju-app\" && vitest run",
"server:build": "cd server && npm run build", "server:build": "cd server && npm run build",
"server:start": "cd server && npm start", "server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev", "server:dev": "cd server && npm run dev",
@@ -49,7 +49,11 @@
"release:version": "node tools/resolve-release-version.js", "release:version": "node tools/resolve-release-version.js",
"server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64", "server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64",
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe", "server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
"sort:props": "node tools/sort-template-properties.js" "sort:props": "node tools/sort-template-properties.js",
"test:e2e": "cd e2e && npx playwright test",
"test:e2e:ui": "cd e2e && npx playwright test --ui",
"test:e2e:debug": "cd e2e && npx playwright test --debug",
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report"
}, },
"private": true, "private": true,
"packageManager": "npm@10.9.2", "packageManager": "npm@10.9.2",
@@ -102,9 +106,11 @@
"@angular/cli": "^21.0.4", "@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0", "@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3", "@eslint/js": "^9.39.3",
"@playwright/test": "^1.59.1",
"@stylistic/eslint-plugin-js": "^4.4.1", "@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1", "@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5", "@types/auto-launch": "^5.0.5",
"@types/mocha": "^10.0.10",
"@types/simple-peer": "^9.11.9", "@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0", "angular-eslint": "21.2.0",
@@ -124,6 +130,7 @@
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "~5.9.2", "typescript": "~5.9.2",
"typescript-eslint": "8.50.1", "typescript-eslint": "8.50.1",
"vitest": "^4.1.4",
"wait-on": "^7.2.0" "wait-on": "^7.2.0"
}, },
"build": { "build": {
@@ -141,9 +148,13 @@
"output": "dist-electron" "output": "dist-electron"
}, },
"files": [ "files": [
"!node_modules",
"dist/client/**/*", "dist/client/**/*",
"dist/electron/**/*", "dist/electron/**/*",
"node_modules/**/*", "node_modules/{ansi-regex,ansi-styles,ansis,app-root-path,applescript,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,call-bind,call-bind-apply-helpers,call-bound,cliui,concat-map,cross-spawn,dayjs,debug,dedent,define-data-property,dotenv,dunder-proto,electron-updater,emoji-regex,es-define-property,es-errors,es-object-atoms,escalade,for-each,foreground-child,fs-extra,function-bind,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,hasown,ieee754,inherits,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,math-intrinsics,minimatch,minimist,minipass,mkdirp,ms,package-json-from-dist,path-is-absolute,path-key,path-scurry,possible-typed-array-names,reflect-metadata,safe-buffer,sax,semver,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,sql-highlight,sql.js,string-width,string-width-cjs,strip-ansi,strip-ansi-cjs,tiny-typed-emitter,to-buffer,tslib,typed-array-buffer,typeorm,universalify,untildify,uuid,which,which-typed-array,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
"node_modules/@isaacs/cliui/**/*",
"node_modules/@pkgjs/parseargs/**/*",
"node_modules/@sqltools/formatter/**/*",
"!node_modules/**/test/**/*", "!node_modules/**/test/**/*",
"!node_modules/**/tests/**/*", "!node_modules/**/tests/**/*",
"!node_modules/**/*.d.ts", "!node_modules/**/*.d.ts",

View File

@@ -4,18 +4,28 @@ import { resolveRuntimePath } from '../runtime-paths';
export type ServerHttpProtocol = 'http' | 'https'; export type ServerHttpProtocol = 'http' | 'https';
export interface LinkPreviewConfig {
enabled: boolean;
cacheTtlMinutes: number;
maxCacheSizeMb: number;
}
export interface ServerVariablesConfig { export interface ServerVariablesConfig {
klipyApiKey: string; klipyApiKey: string;
releaseManifestUrl: string; releaseManifestUrl: string;
serverPort: number; serverPort: number;
serverProtocol: ServerHttpProtocol; serverProtocol: ServerHttpProtocol;
serverHost: string; serverHost: string;
linkPreview: LinkPreviewConfig;
} }
const DATA_DIR = resolveRuntimePath('data'); const DATA_DIR = resolveRuntimePath('data');
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json'); const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
const DEFAULT_SERVER_PORT = 3001; const DEFAULT_SERVER_PORT = 3001;
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http'; const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
const DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES = 7200;
const DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB = 50;
const HARD_MAX_CACHE_SIZE_MB = 50;
function normalizeKlipyApiKey(value: unknown): string { function normalizeKlipyApiKey(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : '';
@@ -66,6 +76,27 @@ function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): nu
: fallback; : fallback;
} }
function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
const raw = (value && typeof value === 'object' && !Array.isArray(value))
? value as Record<string, unknown>
: {};
const enabled = typeof raw.enabled === 'boolean'
? raw.enabled
: true;
const cacheTtl = typeof raw.cacheTtlMinutes === 'number'
&& Number.isFinite(raw.cacheTtlMinutes)
&& raw.cacheTtlMinutes >= 0
? raw.cacheTtlMinutes
: DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES;
const maxSize = typeof raw.maxCacheSizeMb === 'number'
&& Number.isFinite(raw.maxCacheSizeMb)
&& raw.maxCacheSizeMb >= 0
? Math.min(raw.maxCacheSizeMb, HARD_MAX_CACHE_SIZE_MB)
: DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB;
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
}
function hasEnvironmentOverride(value: string | undefined): value is string { function hasEnvironmentOverride(value: string | undefined): value is string {
return typeof value === 'string' && value.trim().length > 0; return typeof value === 'string' && value.trim().length > 0;
} }
@@ -111,7 +142,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl), releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
serverPort: normalizeServerPort(remainingParsed.serverPort), serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol), serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress) serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
}; };
const nextContents = JSON.stringify(normalized, null, 2) + '\n'; const nextContents = JSON.stringify(normalized, null, 2) + '\n';
@@ -124,7 +156,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
releaseManifestUrl: normalized.releaseManifestUrl, releaseManifestUrl: normalized.releaseManifestUrl,
serverPort: normalized.serverPort, serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol, serverProtocol: normalized.serverProtocol,
serverHost: normalized.serverHost serverHost: normalized.serverHost,
linkPreview: normalized.linkPreview
}; };
} }
@@ -169,3 +202,7 @@ export function getServerHost(): string | undefined {
export function isHttpsServerEnabled(): boolean { export function isHttpsServerEnabled(): boolean {
return getServerProtocol() === 'https'; return getServerProtocol() === 'https';
} }
export function getLinkPreviewConfig(): LinkPreviewConfig {
return getVariablesConfig().linkPreview;
}

View File

@@ -17,11 +17,77 @@ import {
import { serverMigrations } from '../migrations'; import { serverMigrations } from '../migrations';
import { findExistingPath, resolveRuntimePath } from '../runtime-paths'; import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
const DATA_DIR = resolveRuntimePath('data'); function resolveDbFile(): string {
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite'); const envPath = process.env.DB_PATH;
if (envPath) {
return path.resolve(envPath);
}
return path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
}
const DB_FILE = resolveDbFile();
const DB_BACKUP = DB_FILE + '.bak';
const DATA_DIR = path.dirname(DB_FILE);
// SQLite files start with this 16-byte header string.
const SQLITE_MAGIC = 'SQLite format 3\0';
let applicationDataSource: DataSource | undefined; let applicationDataSource: DataSource | undefined;
/**
* Returns true when `data` looks like a valid SQLite file
* (correct header magic and at least one complete page).
*/
function isValidSqlite(data: Uint8Array): boolean {
if (data.length < 100)
return false;
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
return header === SQLITE_MAGIC;
}
/**
* Back up the current DB file so there is always a recovery point.
* If the main file is corrupted/empty but a valid backup exists,
* restore the backup before the server loads the database.
*/
function safeguardDbFile(): Uint8Array | undefined {
if (!fs.existsSync(DB_FILE))
return undefined;
const data = new Uint8Array(fs.readFileSync(DB_FILE));
if (isValidSqlite(data)) {
// Good file - rotate it into the backup slot.
fs.copyFileSync(DB_FILE, DB_BACKUP);
console.log('[DB] Backed up database to', DB_BACKUP);
return data;
}
// The main file is corrupt or empty.
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
if (fs.existsSync(DB_BACKUP)) {
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
if (isValidSqlite(backup)) {
fs.copyFileSync(DB_BACKUP, DB_FILE);
console.warn('[DB] Restored database from backup', DB_BACKUP);
return backup;
}
console.error('[DB] Backup is also invalid - starting with a fresh database');
} else {
console.error('[DB] No backup available - starting with a fresh database');
}
return undefined;
}
function resolveSqlJsConfig(): { locateFile: (file: string) => string } { function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
return { return {
locateFile: (file) => { locateFile: (file) => {
@@ -47,10 +113,7 @@ export async function initDatabase(): Promise<void> {
if (!fs.existsSync(DATA_DIR)) if (!fs.existsSync(DATA_DIR))
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
let database: Uint8Array | undefined; const database = safeguardDbFile();
if (fs.existsSync(DB_FILE))
database = fs.readFileSync(DB_FILE);
try { try {
applicationDataSource = new DataSource({ applicationDataSource = new DataSource({
@@ -70,7 +133,7 @@ export async function initDatabase(): Promise<void> {
ServerBanEntity ServerBanEntity
], ],
migrations: serverMigrations, migrations: serverMigrations,
synchronize: false, synchronize: process.env.DB_SYNCHRONIZE === 'true',
logging: false, logging: false,
autoSave: true, autoSave: true,
location: DB_FILE, location: DB_FILE,
@@ -90,8 +153,12 @@ export async function initDatabase(): Promise<void> {
console.log('[DB] Connection initialised at:', DB_FILE); console.log('[DB] Connection initialised at:', DB_FILE);
await applicationDataSource.runMigrations(); if (process.env.DB_SYNCHRONIZE !== 'true') {
console.log('[DB] Migrations executed'); await applicationDataSource.runMigrations();
console.log('[DB] Migrations executed');
} else {
console.log('[DB] Synchronize mode - migrations skipped');
}
} }
export async function destroyDatabase(): Promise<void> { export async function destroyDatabase(): Promise<void> {

View File

@@ -9,7 +9,7 @@ import { resolveCertificateDirectory, resolveEnvFilePath } from './runtime-paths
// Load .env from project root (one level up from server/) // Load .env from project root (one level up from server/)
dotenv.config({ path: resolveEnvFilePath() }); dotenv.config({ path: resolveEnvFilePath() });
import { initDatabase } from './db/database'; import { initDatabase, destroyDatabase } from './db/database';
import { deleteStaleJoinRequests } from './cqrs'; import { deleteStaleJoinRequests } from './cqrs';
import { createApp } from './app'; import { createApp } from './app';
import { import {
@@ -59,6 +59,9 @@ function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHt
return createHttpServer(app); return createHttpServer(app);
} }
let listeningServer: ReturnType<typeof buildServer> | null = null;
let staleJoinRequestInterval: ReturnType<typeof setInterval> | null = null;
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const variablesConfig = ensureVariablesConfig(); const variablesConfig = ensureVariablesConfig();
const serverProtocol = getServerProtocol(); const serverProtocol = getServerProtocol();
@@ -86,10 +89,12 @@ async function bootstrap(): Promise<void> {
const app = createApp(); const app = createApp();
const server = buildServer(app, serverProtocol); const server = buildServer(app, serverProtocol);
listeningServer = server;
setupWebSocket(server); setupWebSocket(server);
// Periodically clean up stale join requests (older than 24 h) // Periodically clean up stale join requests (older than 24 h)
setInterval(() => { staleJoinRequestInterval = setInterval(() => {
deleteStaleJoinRequests(24 * 60 * 60 * 1000) deleteStaleJoinRequests(24 * 60 * 60 * 1000)
.catch(err => console.error('Failed to clean up stale join requests:', err)); .catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000); }, 60 * 1000);
@@ -119,6 +124,45 @@ async function bootstrap(): Promise<void> {
} }
} }
let shuttingDown = false;
async function gracefulShutdown(signal: string): Promise<void> {
if (shuttingDown)
return;
shuttingDown = true;
if (staleJoinRequestInterval) {
clearInterval(staleJoinRequestInterval);
staleJoinRequestInterval = null;
}
console.log(`\n[Shutdown] ${signal} received - closing database…`);
if (listeningServer?.listening) {
try {
await new Promise<void>((resolve) => {
listeningServer?.close(() => resolve());
});
} catch (err) {
console.error('[Shutdown] Error closing server:', err);
}
}
listeningServer = null;
try {
await destroyDatabase();
} catch (err) {
console.error('[Shutdown] Error closing database:', err);
}
process.exit(0);
}
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
bootstrap().catch((err) => { bootstrap().catch((err) => {
console.error('Failed to start server:', err); console.error('Failed to start server:', err);
process.exit(1); process.exit(1);

View File

@@ -4,7 +4,11 @@ export class ServerChannels1000000000002 implements MigrationInterface {
name = 'ServerChannels1000000000002'; name = 'ServerChannels1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`); const columns: { name: string }[] = await queryRunner.query(`PRAGMA table_info("servers")`);
const hasChannels = columns.some(c => c.name === 'channels');
if (!hasChannels) {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "channels" TEXT NOT NULL DEFAULT '[]'`);
}
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {

View File

@@ -1,10 +1,15 @@
import { Router } from 'express'; import { Router } from 'express';
import { randomUUID } from 'crypto';
import { getAllPublicServers } from '../cqrs'; import { getAllPublicServers } from '../cqrs';
import { getReleaseManifestUrl } from '../config/variables'; import { getReleaseManifestUrl } from '../config/variables';
import { SERVER_BUILD_VERSION } from '../generated/build-version'; import { SERVER_BUILD_VERSION } from '../generated/build-version';
import { connectedUsers } from '../websocket/state'; import { connectedUsers } from '../websocket/state';
const router = Router(); const router = Router();
const SERVER_INSTANCE_ID = typeof process.env.METOYOU_SERVER_INSTANCE_ID === 'string'
&& process.env.METOYOU_SERVER_INSTANCE_ID.trim().length > 0
? process.env.METOYOU_SERVER_INSTANCE_ID.trim()
: randomUUID();
function getServerProjectVersion(): string { function getServerProjectVersion(): string {
return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0 return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0
@@ -20,6 +25,7 @@ router.get('/health', async (_req, res) => {
timestamp: Date.now(), timestamp: Date.now(),
serverCount: servers.length, serverCount: servers.length,
connectedUsers: connectedUsers.size, connectedUsers: connectedUsers.size,
serverInstanceId: SERVER_INSTANCE_ID,
serverVersion: getServerProjectVersion(), serverVersion: getServerProjectVersion(),
releaseManifestUrl: getReleaseManifestUrl() releaseManifestUrl: getReleaseManifestUrl()
}); });

View File

@@ -1,6 +1,7 @@
import { Express } from 'express'; import { Express } from 'express';
import healthRouter from './health'; import healthRouter from './health';
import klipyRouter from './klipy'; import klipyRouter from './klipy';
import linkMetadataRouter from './link-metadata';
import proxyRouter from './proxy'; import proxyRouter from './proxy';
import usersRouter from './users'; import usersRouter from './users';
import serversRouter from './servers'; import serversRouter from './servers';
@@ -10,6 +11,7 @@ import { invitesApiRouter, invitePageRouter } from './invites';
export function registerRoutes(app: Express): void { export function registerRoutes(app: Express): void {
app.use('/api', healthRouter); app.use('/api', healthRouter);
app.use('/api', klipyRouter); app.use('/api', klipyRouter);
app.use('/api', linkMetadataRouter);
app.use('/api', proxyRouter); app.use('/api', proxyRouter);
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/servers', serversRouter); app.use('/api/servers', serversRouter);

View File

@@ -1,4 +1,3 @@
/* eslint-disable complexity */
import { Router } from 'express'; import { Router } from 'express';
import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables'; import { getKlipyApiKey, hasKlipyApiKey } from '../config/variables';
@@ -47,6 +46,11 @@ interface KlipyApiResponse {
}; };
} }
interface ResolvedGifMedia {
previewMeta: NormalizedMediaMeta | null;
sourceMeta: NormalizedMediaMeta;
}
function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined { function pickFirst<T>(...values: (T | null | undefined)[]): T | undefined {
for (const value of values) { for (const value of values) {
if (value != null) if (value != null)
@@ -130,33 +134,49 @@ function extractKlipyResponseData(payload: unknown): { items: unknown[]; hasNext
}; };
} }
function resolveGifMedia(file?: KlipyGifVariants): ResolvedGifMedia | null {
const previewVariant = pickFirst(file?.md, file?.sm, file?.xs, file?.hd);
const sourceVariant = pickFirst(file?.hd, file?.md, file?.sm, file?.xs);
const previewMeta = pickGifMeta(previewVariant);
const sourceMeta = pickGifMeta(sourceVariant) ?? previewMeta;
if (!sourceMeta?.url)
return null;
return {
previewMeta,
sourceMeta
};
}
function resolveGifSlug(gifItem: KlipyGifItem): string | undefined {
return sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
}
function normalizeGifItem(item: unknown): NormalizedKlipyGif | null { function normalizeGifItem(item: unknown): NormalizedKlipyGif | null {
if (!item || typeof item !== 'object') if (!item || typeof item !== 'object')
return null; return null;
const gifItem = item as KlipyGifItem; const gifItem = item as KlipyGifItem;
const resolvedMedia = resolveGifMedia(gifItem.file);
const slug = resolveGifSlug(gifItem);
if (gifItem.type === 'ad') if (gifItem.type === 'ad')
return null; return null;
const lowVariant = pickFirst(gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs, gifItem.file?.hd); if (!slug || !resolvedMedia)
const highVariant = pickFirst(gifItem.file?.hd, gifItem.file?.md, gifItem.file?.sm, gifItem.file?.xs);
const lowMeta = pickGifMeta(lowVariant);
const highMeta = pickGifMeta(highVariant);
const selectedMeta = highMeta ?? lowMeta;
const slug = sanitizeString(gifItem.slug) ?? sanitizeString(gifItem.id);
if (!slug || !selectedMeta?.url)
return null; return null;
const { previewMeta, sourceMeta } = resolvedMedia;
return { return {
id: slug, id: slug,
slug, slug,
title: sanitizeString(gifItem.title), title: sanitizeString(gifItem.title),
url: selectedMeta.url, url: sourceMeta.url,
previewUrl: lowMeta?.url ?? selectedMeta.url, previewUrl: previewMeta?.url ?? sourceMeta.url,
width: selectedMeta.width ?? lowMeta?.width ?? 0, width: sourceMeta.width ?? previewMeta?.width ?? 0,
height: selectedMeta.height ?? lowMeta?.height ?? 0 height: sourceMeta.height ?? previewMeta?.height ?? 0
}; };
} }

View File

@@ -0,0 +1,292 @@
import { Router } from 'express';
import { getLinkPreviewConfig } from '../config/variables';
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
const router = Router();
const REQUEST_TIMEOUT_MS = 8000;
const MAX_HTML_BYTES = 512 * 1024;
const BYTES_PER_MB = 1024 * 1024;
const MAX_FIELD_LENGTH = 512;
interface CachedMetadata {
title?: string;
description?: string;
imageUrl?: string;
siteName?: string;
failed?: boolean;
cachedAt: number;
}
const metadataCache = new Map<string, CachedMetadata>();
let cacheByteEstimate = 0;
function estimateEntryBytes(key: string, entry: CachedMetadata): number {
let bytes = key.length * 2;
if (entry.title)
bytes += entry.title.length * 2;
if (entry.description)
bytes += entry.description.length * 2;
if (entry.imageUrl)
bytes += entry.imageUrl.length * 2;
if (entry.siteName)
bytes += entry.siteName.length * 2;
return bytes + 64;
}
function cacheSet(key: string, entry: CachedMetadata): void {
const config = getLinkPreviewConfig();
const maxBytes = config.maxCacheSizeMb * BYTES_PER_MB;
if (metadataCache.has(key)) {
const existing = metadataCache.get(key) as CachedMetadata;
cacheByteEstimate -= estimateEntryBytes(key, existing);
}
const entryBytes = estimateEntryBytes(key, entry);
while (cacheByteEstimate + entryBytes > maxBytes && metadataCache.size > 0) {
const oldest = metadataCache.keys().next().value as string;
const oldestEntry = metadataCache.get(oldest) as CachedMetadata;
cacheByteEstimate -= estimateEntryBytes(oldest, oldestEntry);
metadataCache.delete(oldest);
}
metadataCache.set(key, entry);
cacheByteEstimate += entryBytes;
}
function truncateField(value: string | undefined): string | undefined {
if (!value)
return value;
if (value.length <= MAX_FIELD_LENGTH)
return value;
return value.slice(0, MAX_FIELD_LENGTH);
}
function sanitizeImageUrl(rawUrl: string | undefined, baseUrl: string): string | undefined {
if (!rawUrl)
return undefined;
try {
const resolved = new URL(rawUrl, baseUrl);
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:')
return undefined;
return resolved.href;
} catch {
return undefined;
}
}
function getMetaContent(html: string, patterns: RegExp[]): string | undefined {
for (const pattern of patterns) {
const match = pattern.exec(html);
if (match?.[1])
return decodeHtmlEntities(match[1].trim());
}
return undefined;
}
function decodeHtmlEntities(text: string): string {
return text
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, '/');
}
function parseMetadata(html: string, url: string): CachedMetadata {
const title = getMetaContent(html, [
/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:title["']/i,
/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:title["']/i,
/<title[^>]*>([^<]+)<\/title>/i
]);
const description = getMetaContent(html, [
/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:description["']/i,
/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:description["']/i,
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']description["']/i
]);
const rawImageUrl = getMetaContent(html, [
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i,
/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i
]);
const siteNamePatterns = [
// eslint-disable-next-line @stylistic/js/array-element-newline
/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i,
/<meta[^>]+content=["']([^"']+)["'][^>]+property=["']og:site_name["']/i
];
const siteName = getMetaContent(html, siteNamePatterns);
const imageUrl = sanitizeImageUrl(rawImageUrl, url);
return {
title: truncateField(title),
description: truncateField(description),
imageUrl,
siteName: truncateField(siteName),
cachedAt: Date.now()
};
}
function evictExpired(): void {
const config = getLinkPreviewConfig();
if (config.cacheTtlMinutes === 0) {
cacheByteEstimate = 0;
metadataCache.clear();
return;
}
const ttlMs = config.cacheTtlMinutes * 60 * 1000;
const now = Date.now();
for (const [key, entry] of metadataCache) {
if (now - entry.cachedAt > ttlMs) {
cacheByteEstimate -= estimateEntryBytes(key, entry);
metadataCache.delete(key);
}
}
}
router.get('/link-metadata', async (req, res) => {
try {
const config = getLinkPreviewConfig();
if (!config.enabled) {
return res.status(403).json({ error: 'Link previews are disabled' });
}
const url = String(req.query.url || '');
if (!/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'Invalid URL' });
}
const hostAllowed = await resolveAndValidateHost(url);
if (!hostAllowed) {
return res.status(400).json({ error: 'URL resolves to a blocked address' });
}
evictExpired();
const cached = metadataCache.get(url);
if (cached) {
const { cachedAt, ...metadata } = cached;
console.log(`[Link Metadata] Cache hit for ${url} (cached at ${new Date(cachedAt).toISOString()})`);
return res.json(metadata);
}
console.log(`[Link Metadata] Cache miss for ${url}. Fetching...`);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const response = await safeFetch(url, {
signal: controller.signal,
headers: {
'Accept': 'text/html',
'User-Agent': 'MetoYou-LinkPreview/1.0'
}
});
clearTimeout(timeout);
if (!response || !response.ok) {
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
cacheSet(url, failed);
return res.json({ failed: true });
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html')) {
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
cacheSet(url, failed);
return res.json({ failed: true });
}
const reader = response.body?.getReader();
if (!reader) {
const failed: CachedMetadata = { failed: true, cachedAt: Date.now() };
cacheSet(url, failed);
return res.json({ failed: true });
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let done = false;
while (!done) {
const result = await reader.read();
done = result.done;
if (result.value) {
chunks.push(result.value);
totalBytes += result.value.length;
if (totalBytes > MAX_HTML_BYTES) {
reader.cancel();
break;
}
}
}
const html = Buffer.concat(chunks).toString('utf-8');
const metadata = parseMetadata(html, url);
cacheSet(url, metadata);
const { cachedAt, ...result } = metadata;
res.json(result);
} catch (err) {
const url = String(req.query.url || '');
if (url) {
cacheSet(url, { failed: true, cachedAt: Date.now() });
}
if ((err as { name?: string })?.name === 'AbortError') {
return res.json({ failed: true });
}
console.error('Link metadata error:', err);
res.json({ failed: true });
}
});
export default router;

View File

@@ -1,4 +1,5 @@
import { Router } from 'express'; import { Router } from 'express';
import { resolveAndValidateHost, safeFetch } from './ssrf-guard';
const router = Router(); const router = Router();
@@ -10,14 +11,20 @@ router.get('/image-proxy', async (req, res) => {
return res.status(400).json({ error: 'Invalid URL' }); return res.status(400).json({ error: 'Invalid URL' });
} }
const hostAllowed = await resolveAndValidateHost(url);
if (!hostAllowed) {
return res.status(400).json({ error: 'URL resolves to a blocked address' });
}
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000); const timeout = setTimeout(() => controller.abort(), 8000);
const response = await fetch(url, { redirect: 'follow', signal: controller.signal }); const response = await safeFetch(url, { signal: controller.signal });
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) { if (!response || !response.ok) {
return res.status(response.status).end(); return res.status(response?.status ?? 502).end();
} }
const contentType = response.headers.get('content-type') || ''; const contentType = response.headers.get('content-type') || '';

View File

@@ -0,0 +1,119 @@
import { lookup } from 'dns/promises';
const MAX_REDIRECTS = 5;
function isPrivateIp(ip: string): boolean {
if (
ip === '127.0.0.1' ||
ip === '::1' ||
ip === '0.0.0.0' ||
ip === '::'
)
return true;
// 10.x.x.x
if (ip.startsWith('10.'))
return true;
// 172.16.0.0 - 172.31.255.255
if (ip.startsWith('172.')) {
const second = parseInt(ip.split('.')[1], 10);
if (second >= 16 && second <= 31)
return true;
}
// 192.168.x.x
if (ip.startsWith('192.168.'))
return true;
// 169.254.x.x (link-local, AWS metadata)
if (ip.startsWith('169.254.'))
return true;
// IPv6 private ranges (fc00::/7, fe80::/10)
const lower = ip.toLowerCase();
if (lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80'))
return true;
return false;
}
export async function resolveAndValidateHost(url: string): Promise<boolean> {
let hostname: string;
try {
hostname = new URL(url).hostname;
} catch {
return false;
}
// Block obvious private hostnames
if (hostname === 'localhost' || hostname === 'metadata.google.internal')
return false;
// If hostname is already an IP literal, check it directly
if (/^[\d.]+$/.test(hostname) || hostname.startsWith('['))
return !isPrivateIp(hostname.replace(/[[\]]/g, ''));
try {
const { address } = await lookup(hostname);
return !isPrivateIp(address);
} catch {
return false;
}
}
export interface SafeFetchOptions {
signal?: AbortSignal;
headers?: Record<string, string>;
}
/**
* Fetches a URL while following redirects safely, validating each
* hop against SSRF (private/reserved IPs, blocked hostnames).
*
* The caller must validate the initial URL with `resolveAndValidateHost`
* before calling this function.
*/
export async function safeFetch(url: string, options: SafeFetchOptions = {}): Promise<Response | undefined> {
let currentUrl = url;
let response: Response | undefined;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects++) {
response = await fetch(currentUrl, {
redirect: 'manual',
signal: options.signal,
headers: options.headers
});
const location = response.headers.get('location');
if (response.status >= 300 && response.status < 400 && location) {
let nextUrl: string;
try {
nextUrl = new URL(location, currentUrl).href;
} catch {
break;
}
if (!/^https?:\/\//i.test(nextUrl))
break;
const redirectAllowed = await resolveAndValidateHost(nextUrl);
if (!redirectAllowed)
break;
currentUrl = nextUrl;
continue;
}
break;
}
return response;
}

View File

@@ -1,4 +1,6 @@
import { WebSocket } from 'ws';
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { ConnectedUser } from './types';
interface WsMessage { interface WsMessage {
[key: string]: unknown; [key: string]: unknown;
@@ -8,8 +10,14 @@ interface WsMessage {
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void { export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type); console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
// Deduplicate by oderId so users with multiple connections (e.g. from
// different signal URLs routing to the same server) receive the
// broadcast only once.
const sentToOderIds = new Set<string>();
connectedUsers.forEach((user) => { connectedUsers.forEach((user) => {
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId) { if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) {
sentToOderIds.add(user.oderId);
console.log(` -> Sending to ${user.displayName} (${user.oderId})`); console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
user.ws.send(JSON.stringify(message)); user.ws.send(JSON.stringify(message));
} }
@@ -24,6 +32,43 @@ export function notifyServerOwner(ownerId: string, message: WsMessage): void {
} }
} }
export function getUniqueUsersInServer(serverId: string, excludeOderId?: string): ConnectedUser[] {
const usersByOderId = new Map<string, ConnectedUser>();
connectedUsers.forEach((user) => {
if (user.oderId === excludeOderId || !user.serverIds.has(serverId) || user.ws.readyState !== WebSocket.OPEN) {
return;
}
usersByOderId.set(user.oderId, user);
});
return Array.from(usersByOderId.values());
}
export function isOderIdConnectedToServer(oderId: string, serverId: string, excludeConnectionId?: string): boolean {
return Array.from(connectedUsers.entries()).some(([connectionId, user]) =>
connectionId !== excludeConnectionId
&& user.oderId === oderId
&& user.serverIds.has(serverId)
&& user.ws.readyState === WebSocket.OPEN
);
}
export function getServerIdsForOderId(oderId: string, excludeConnectionId?: string): string[] {
const serverIds = new Set<string>();
connectedUsers.forEach((user, connectionId) => {
if (connectionId === excludeConnectionId || user.oderId !== oderId || user.ws.readyState !== WebSocket.OPEN) {
return;
}
user.serverIds.forEach((serverId) => serverIds.add(serverId));
});
return Array.from(serverIds);
}
export function notifyUser(oderId: string, message: WsMessage): void { export function notifyUser(oderId: string, message: WsMessage): void {
const user = findUserByOderId(oderId); const user = findUserByOderId(oderId);
@@ -33,5 +78,13 @@ export function notifyUser(oderId: string, message: WsMessage): void {
} }
export function findUserByOderId(oderId: string) { export function findUserByOderId(oderId: string) {
return Array.from(connectedUsers.values()).find(user => user.oderId === oderId); let match: ConnectedUser | undefined;
connectedUsers.forEach((user) => {
if (user.oderId === oderId && user.ws.readyState === WebSocket.OPEN) {
match = user;
}
});
return match;
} }

View File

@@ -0,0 +1,199 @@
import {
describe,
it,
expect,
beforeEach
} from 'vitest';
import { connectedUsers } from './state';
import { handleWebSocketMessage } from './handler';
import { ConnectedUser } from './types';
import { WebSocket } from 'ws';
/**
* Minimal mock WebSocket that records sent messages.
*/
function createMockWs(): WebSocket & { sentMessages: string[] } {
const sent: string[] = [];
const ws = {
readyState: WebSocket.OPEN,
send: (data: string) => { sent.push(data); },
close: () => {},
sentMessages: sent
} as unknown as WebSocket & { sentMessages: string[] };
return ws;
}
function createConnectedUser(
connectionId: string,
oderId: string,
overrides: Partial<ConnectedUser> = {}
): ConnectedUser {
const ws = createMockWs();
const user: ConnectedUser = {
oderId,
ws,
serverIds: new Set(),
displayName: 'Test User',
lastPong: Date.now(),
...overrides
};
connectedUsers.set(connectionId, user);
return user;
}
function getRequiredConnectedUser(connectionId: string): ConnectedUser {
const connectedUser = connectedUsers.get(connectionId);
if (!connectedUser)
throw new Error(`Expected connected user for ${connectionId}`);
return connectedUser;
}
function getSentMessagesStore(user: ConnectedUser): { sentMessages: string[] } {
return user.ws as unknown as { sentMessages: string[] };
}
describe('server websocket handler - status_update', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('updates user status on valid status_update message', async () => {
const user = createConnectedUser('conn-1', 'user-1');
user.serverIds.add('server-1');
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
expect(connectedUsers.get('conn-1')?.status).toBe('away');
});
it('broadcasts status_update to other users in the same server', async () => {
const user1 = createConnectedUser('conn-1', 'user-1');
const user2 = createConnectedUser('conn-2', 'user-2');
user1.serverIds.add('server-1');
user2.serverIds.add('server-1');
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'busy' });
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const statusMsg = messages.find((message: { type: string }) => message.type === 'status_update');
expect(statusMsg).toBeDefined();
expect(statusMsg?.oderId).toBe('user-1');
expect(statusMsg?.status).toBe('busy');
});
it('does not broadcast to users in different servers', async () => {
createConnectedUser('conn-1', 'user-1');
const user2 = createConnectedUser('conn-2', 'user-2');
getRequiredConnectedUser('conn-1').serverIds.add('server-1');
user2.serverIds.add('server-2');
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
expect(getSentMessagesStore(user2).sentMessages.length).toBe(0);
});
it('ignores invalid status values', async () => {
createConnectedUser('conn-1', 'user-1');
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'invalid_status' });
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
});
it('ignores missing status field', async () => {
createConnectedUser('conn-1', 'user-1');
await handleWebSocketMessage('conn-1', { type: 'status_update' });
expect(connectedUsers.get('conn-1')?.status).toBeUndefined();
});
it('accepts all valid status values', async () => {
for (const status of [
'online',
'away',
'busy',
'offline'
]) {
connectedUsers.clear();
createConnectedUser('conn-1', 'user-1');
await handleWebSocketMessage('conn-1', { type: 'status_update', status });
expect(connectedUsers.get('conn-1')?.status).toBe(status);
}
});
it('includes status in server_users response after status change', async () => {
const user1 = createConnectedUser('conn-1', 'user-1');
const user2 = createConnectedUser('conn-2', 'user-2');
user1.serverIds.add('server-1');
user2.serverIds.add('server-1');
// Set user-1 to away
await handleWebSocketMessage('conn-1', { type: 'status_update', status: 'away' });
// Clear sent messages
getSentMessagesStore(user2).sentMessages.length = 0;
// 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
getSentMessagesStore(user2).sentMessages.length = 0;
await handleWebSocketMessage('conn-2', { type: 'join_server', serverId: 'server-1' });
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const serverUsersMsg = messages.find((message: { type: string }) => message.type === 'server_users');
expect(serverUsersMsg).toBeDefined();
const user1InList = serverUsersMsg?.users?.find((userEntry: { oderId: string }) => userEntry.oderId === 'user-1');
expect(user1InList?.status).toBe('away');
});
});
describe('server websocket handler - user_joined includes status', () => {
beforeEach(() => {
connectedUsers.clear();
});
it('includes status in user_joined broadcast', async () => {
const user1 = createConnectedUser('conn-1', 'user-1');
const user2 = createConnectedUser('conn-2', 'user-2');
user1.serverIds.add('server-1');
user2.serverIds.add('server-1');
// Set user-1's status to busy before joining
getRequiredConnectedUser('conn-1').status = 'busy';
// Identify user-1
await handleWebSocketMessage('conn-1', { type: 'identify', oderId: 'user-1', displayName: 'User 1' });
getSentMessagesStore(user2).sentMessages.length = 0;
// user-1 joins server-1
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
const messages = getSentMessagesStore(user2).sentMessages.map((messageText: string) => JSON.parse(messageText));
const joinMsg = messages.find((message: { type: string }) => message.type === 'user_joined');
// user_joined may or may not appear depending on whether it's a new identity membership
// Since both are already in the server, it may not broadcast. Either way, verify no crash.
if (joinMsg) {
expect(joinMsg.status).toBe('busy');
}
});
});

View File

@@ -1,6 +1,12 @@
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { ConnectedUser } from './types'; import { ConnectedUser } from './types';
import { broadcastToServer, findUserByOderId } from './broadcast'; import {
broadcastToServer,
findUserByOderId,
getServerIdsForOderId,
getUniqueUsersInServer,
isOderIdConnectedToServer
} from './broadcast';
import { authorizeWebSocketJoin } from '../services/server-access.service'; import { authorizeWebSocketJoin } from '../services/server-access.service';
interface WsMessage { interface WsMessage {
@@ -14,24 +20,60 @@ function normalizeDisplayName(value: unknown, fallback = 'User'): string {
return normalized || fallback; return normalized || fallback;
} }
function readMessageId(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
if (!normalized || normalized === 'undefined' || normalized === 'null') {
return undefined;
}
return normalized;
}
/** Sends the current user list for a given server to a single connected user. */ /** Sends the current user list for a given server to a single connected user. */
function sendServerUsers(user: ConnectedUser, serverId: string): void { function sendServerUsers(user: ConnectedUser, serverId: string): void {
const users = Array.from(connectedUsers.values()) const users = getUniqueUsersInServer(serverId, user.oderId)
.filter(cu => cu.serverIds.has(serverId) && cu.oderId !== user.oderId) .map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName), status: cu.status ?? 'online' }));
.map(cu => ({ oderId: cu.oderId, displayName: normalizeDisplayName(cu.displayName) }));
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users })); user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
} }
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
user.oderId = String(message['oderId'] || connectionId); const newOderId = readMessageId(message['oderId']) ?? connectionId;
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
// Close stale connections from the same identity AND the same connection
// scope so offer routing always targets the freshest socket (e.g. after
// page refresh). Connections with a *different* scope (= a different
// signal URL that happens to route to this server) are left untouched so
// multi-signal-URL setups don't trigger an eviction loop.
connectedUsers.forEach((existing, existingId) => {
if (existingId !== connectionId
&& existing.oderId === newOderId
&& existing.connectionScope === newScope) {
console.log(`Closing stale connection for ${newOderId} (old=${existingId}, new=${connectionId}, scope=${newScope ?? 'none'})`);
try {
existing.ws.close();
} catch { /* already closing */ }
connectedUsers.delete(existingId);
}
});
user.oderId = newOderId;
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName)); user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
user.connectionScope = newScope;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User identified: ${user.displayName} (${user.oderId})`); console.log(`User identified: ${user.displayName} (${user.oderId})`);
} }
async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> { async function handleJoinServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
const sid = String(message['serverId']); const sid = readMessageId(message['serverId']);
if (!sid) if (!sid)
return; return;
@@ -48,27 +90,35 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
return; return;
} }
const isNew = !user.serverIds.has(sid); const isNewConnectionMembership = !user.serverIds.has(sid);
const isNewIdentityMembership = isNewConnectionMembership && !isOderIdConnectedToServer(user.oderId, sid, connectionId);
user.serverIds.add(sid); user.serverIds.add(sid);
user.viewedServerId = sid; user.viewedServerId = sid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} (new=${isNew})`); console.log(
`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) joined server ${sid} `
+ `(newConnection=${isNewConnectionMembership}, newIdentity=${isNewIdentityMembership})`
);
sendServerUsers(user, sid); sendServerUsers(user, sid);
if (isNew) { if (isNewIdentityMembership) {
broadcastToServer(sid, { broadcastToServer(sid, {
type: 'user_joined', type: 'user_joined',
oderId: user.oderId, oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName), displayName: normalizeDisplayName(user.displayName),
status: user.status ?? 'online',
serverId: sid serverId: sid
}, user.oderId); }, user.oderId);
} }
} }
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const viewSid = String(message['serverId']); const viewSid = readMessageId(message['serverId']);
if (!viewSid)
return;
user.viewedServerId = viewSid; user.viewedServerId = viewSid;
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
@@ -78,7 +128,7 @@ function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId:
} }
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void { function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const leaveSid = (message['serverId'] as string | undefined) ?? user.viewedServerId; const leaveSid = readMessageId(message['serverId']) ?? user.viewedServerId;
if (!leaveSid) if (!leaveSid)
return; return;
@@ -90,17 +140,23 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
connectedUsers.set(connectionId, user); connectedUsers.set(connectionId, user);
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
if (remainingServerIds.includes(leaveSid)) {
return;
}
broadcastToServer(leaveSid, { broadcastToServer(leaveSid, {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: normalizeDisplayName(user.displayName), displayName: normalizeDisplayName(user.displayName),
serverId: leaveSid, serverId: leaveSid,
serverIds: Array.from(user.serverIds) serverIds: remainingServerIds
}, user.oderId); }, user.oderId);
} }
function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void { function forwardRtcMessage(user: ConnectedUser, message: WsMessage): void {
const targetUserId = String(message['targetUserId'] || ''); const targetUserId = readMessageId(message['targetUserId']) ?? '';
console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`); console.log(`Forwarding ${message.type} from ${user.oderId} to ${targetUserId}`);
@@ -149,6 +205,32 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
} }
} }
const VALID_STATUSES = new Set([
'online',
'away',
'busy',
'offline'
]);
function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionId: string): void {
const status = typeof message['status'] === 'string' ? message['status'] : undefined;
if (!status || !VALID_STATUSES.has(status))
return;
user.status = status as ConnectedUser['status'];
connectedUsers.set(connectionId, user);
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) status → ${status}`);
for (const serverId of user.serverIds) {
broadcastToServer(serverId, {
type: 'status_update',
oderId: user.oderId,
status
}, user.oderId);
}
}
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> { export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
const user = connectedUsers.get(connectionId); const user = connectedUsers.get(connectionId);
@@ -186,6 +268,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
handleTyping(user, message); handleTyping(user, message);
break; break;
case 'status_update':
handleStatusUpdate(user, message, connectionId);
break;
default: default:
console.log('Unknown message type:', message.type); console.log('Unknown message type:', message.type);
} }

View File

@@ -6,7 +6,11 @@ import {
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { connectedUsers } from './state'; import { connectedUsers } from './state';
import { broadcastToServer } from './broadcast'; import {
broadcastToServer,
getServerIdsForOderId,
isOderIdConnectedToServer
} from './broadcast';
import { handleWebSocketMessage } from './handler'; import { handleWebSocketMessage } from './handler';
/** How often to ping all connected clients (ms). */ /** How often to ping all connected clients (ms). */
@@ -20,13 +24,19 @@ function removeDeadConnection(connectionId: string): void {
if (user) { if (user) {
console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`); console.log(`Removing dead connection: ${user.displayName ?? 'Unknown'} (${user.oderId})`);
const remainingServerIds = getServerIdsForOderId(user.oderId, connectionId);
user.serverIds.forEach((sid) => { user.serverIds.forEach((sid) => {
if (isOderIdConnectedToServer(user.oderId, sid, connectionId)) {
return;
}
broadcastToServer(sid, { broadcastToServer(sid, {
type: 'user_left', type: 'user_left',
oderId: user.oderId, oderId: user.oderId,
displayName: user.displayName, displayName: user.displayName,
serverId: sid, serverId: sid,
serverIds: [] serverIds: remainingServerIds
}, user.oderId); }, user.oderId);
}); });

View File

@@ -6,6 +6,15 @@ export interface ConnectedUser {
serverIds: Set<string>; serverIds: Set<string>;
viewedServerId?: string; viewedServerId?: string;
displayName?: string; displayName?: string;
/**
* Opaque scope string sent by the client (typically the signal URL it
* connected through). Stale-connection eviction only targets connections
* that share the same (oderId, connectionScope) pair, so multiple signal
* URLs routing to the same server coexist without an eviction loop.
*/
connectionScope?: string;
/** User availability status (online, away, busy, offline). */
status?: 'online' | 'away' | 'busy' | 'offline';
/** Timestamp of the last pong received (used to detect dead connections). */ /** Timestamp of the last pong received (used to detect dead connections). */
lastPong: number; lastPong: number;
} }

View File

@@ -17,5 +17,5 @@
"sourceMap": true "sourceMap": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "src/**/*.spec.ts"]
} }

10
skills-lock.json Normal file
View File

@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"caveman": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"computedHash": "4d486dd6f9fbb27ce1c51c972c9a5eb25a53236ae05eabf4d076ac1e293f4b7a"
}
}
}

View File

@@ -97,7 +97,7 @@
{ {
"type": "initial", "type": "initial",
"maximumWarning": "2.2MB", "maximumWarning": "2.2MB",
"maximumError": "2.3MB" "maximumError": "2.32MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",

View File

@@ -16,9 +16,12 @@ import { roomsReducer } from './store/rooms/rooms.reducer';
import { NotificationsEffects } from './domains/notifications'; import { NotificationsEffects } from './domains/notifications';
import { MessagesEffects } from './store/messages/messages.effects'; import { MessagesEffects } from './store/messages/messages.effects';
import { MessagesSyncEffects } from './store/messages/messages-sync.effects'; import { MessagesSyncEffects } from './store/messages/messages-sync.effects';
import { UserAvatarEffects } from './store/users/user-avatar.effects';
import { UsersEffects } from './store/users/users.effects'; import { UsersEffects } from './store/users/users.effects';
import { RoomsEffects } from './store/rooms/rooms.effects'; import { RoomsEffects } from './store/rooms/rooms.effects';
import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects'; import { RoomMembersSyncEffects } from './store/rooms/room-members-sync.effects';
import { RoomStateSyncEffects } from './store/rooms/room-state-sync.effects';
import { RoomSettingsEffects } from './store/rooms/room-settings.effects';
import { STORE_DEVTOOLS_MAX_AGE } from './core/constants'; import { STORE_DEVTOOLS_MAX_AGE } from './core/constants';
/** Root application configuration providing routing, HTTP, NgRx store, and devtools. */ /** Root application configuration providing routing, HTTP, NgRx store, and devtools. */
@@ -36,9 +39,12 @@ export const appConfig: ApplicationConfig = {
NotificationsEffects, NotificationsEffects,
MessagesEffects, MessagesEffects,
MessagesSyncEffects, MessagesSyncEffects,
UserAvatarEffects,
UsersEffects, UsersEffects,
RoomsEffects, RoomsEffects,
RoomMembersSyncEffects RoomMembersSyncEffects,
RoomStateSyncEffects,
RoomSettingsEffects
]), ]),
provideStoreDevtools({ provideStoreDevtools({
maxAge: STORE_DEVTOOLS_MAX_AGE, maxAge: STORE_DEVTOOLS_MAX_AGE,

View File

@@ -150,6 +150,7 @@
} }
<app-settings-modal /> <app-settings-modal />
<app-screen-share-source-picker /> <app-screen-share-source-picker />
<app-native-context-menu />
<app-debug-console [showLauncher]="false" /> <app-debug-console [showLauncher]="false" />
<app-theme-picker-overlay /> <app-theme-picker-overlay />
</div> </div>

View File

@@ -10,12 +10,12 @@ export const routes: Routes = [
{ {
path: 'login', path: 'login',
loadComponent: () => loadComponent: () =>
import('./domains/auth/feature/login/login.component').then((module) => module.LoginComponent) import('./domains/authentication/feature/login/login.component').then((module) => module.LoginComponent)
}, },
{ {
path: 'register', path: 'register',
loadComponent: () => loadComponent: () =>
import('./domains/auth/feature/register/register.component').then((module) => module.RegisterComponent) import('./domains/authentication/feature/register/register.component').then((module) => module.RegisterComponent)
}, },
{ {
path: 'invite/:inviteId', path: 'invite/:inviteId',

View File

@@ -33,12 +33,14 @@ import { VoiceSessionFacade } from './domains/voice-session';
import { ExternalLinkService } from './core/platform'; import { ExternalLinkService } from './core/platform';
import { SettingsModalService } from './core/services/settings-modal.service'; import { SettingsModalService } from './core/services/settings-modal.service';
import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from './core/platform/electron/electron-bridge.service';
import { ServersRailComponent } from './features/servers/servers-rail.component'; import { UserStatusService } from './core/services/user-status.service';
import { TitleBarComponent } from './features/shell/title-bar.component'; import { ServersRailComponent } from './features/servers/servers-rail/servers-rail.component';
import { TitleBarComponent } from './features/shell/title-bar/title-bar.component';
import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component'; import { FloatingVoiceControlsComponent } from './domains/voice-session/feature/floating-voice-controls/floating-voice-controls.component';
import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component'; import { SettingsModalComponent } from './features/settings/settings-modal/settings-modal.component';
import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component'; import { DebugConsoleComponent } from './shared/components/debug-console/debug-console.component';
import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component'; import { ScreenShareSourcePickerComponent } from './shared/components/screen-share-source-picker/screen-share-source-picker.component';
import { NativeContextMenuComponent } from './features/shell/native-context-menu/native-context-menu.component';
import { UsersActions } from './store/users/users.actions'; import { UsersActions } from './store/users/users.actions';
import { RoomsActions } from './store/rooms/rooms.actions'; import { RoomsActions } from './store/rooms/rooms.actions';
import { selectCurrentRoom } from './store/rooms/rooms.selectors'; import { selectCurrentRoom } from './store/rooms/rooms.selectors';
@@ -61,6 +63,7 @@ import {
SettingsModalComponent, SettingsModalComponent,
DebugConsoleComponent, DebugConsoleComponent,
ScreenShareSourcePickerComponent, ScreenShareSourcePickerComponent,
NativeContextMenuComponent,
ThemeNodeDirective, ThemeNodeDirective,
ThemePickerOverlayComponent ThemePickerOverlayComponent
], ],
@@ -90,6 +93,7 @@ export class App implements OnInit, OnDestroy {
readonly voiceSession = inject(VoiceSessionFacade); readonly voiceSession = inject(VoiceSessionFacade);
readonly externalLinks = inject(ExternalLinkService); readonly externalLinks = inject(ExternalLinkService);
readonly electronBridge = inject(ElectronBridgeService); readonly electronBridge = inject(ElectronBridgeService);
readonly userStatus = inject(UserStatusService);
readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null); readonly dismissedDesktopUpdateNoticeKey = signal<string | null>(null);
readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null); readonly themeStudioFullscreenComponent = signal<Type<unknown> | null>(null);
readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null); readonly themeStudioControlsPosition = signal<{ x: number; y: number } | null>(null);
@@ -157,7 +161,7 @@ export class App implements OnInit, OnDestroy {
return; return;
} }
void import('./domains/theme/feature/settings/theme-settings.component') void import('./domains/theme/feature/settings/theme-settings/theme-settings.component')
.then((module) => { .then((module) => {
this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent); this.themeStudioFullscreenComponent.set(module.ThemeSettingsComponent);
}); });
@@ -229,6 +233,8 @@ export class App implements OnInit, OnDestroy {
this.store.dispatch(UsersActions.loadCurrentUser()); this.store.dispatch(UsersActions.loadCurrentUser());
this.userStatus.start();
this.store.dispatch(RoomsActions.loadRooms()); this.store.dispatch(RoomsActions.loadRooms());
const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID); const currentUserId = localStorage.getItem(STORAGE_KEY_CURRENT_USER_ID);

View File

@@ -10,8 +10,9 @@ export const STORAGE_KEY_THEME_DRAFT = 'metoyou_theme_draft';
export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes'; export const STORAGE_KEY_USER_VOLUMES = 'metoyou_user_volumes';
export const ROOM_URL_PATTERN = /\/room\/([^/]+)/; export const ROOM_URL_PATTERN = /\/room\/([^/]+)/;
export const STORE_DEVTOOLS_MAX_AGE = 25; export const STORE_DEVTOOLS_MAX_AGE = 25;
export const DEBUG_LOG_MAX_ENTRIES = 500; export const DEBUG_LOG_MAX_ENTRIES = 5000;
export const DEFAULT_MAX_USERS = 50; export const DEFAULT_MAX_USERS = 50;
export const DEFAULT_AUDIO_BITRATE_KBPS = 96; export const DEFAULT_AUDIO_BITRATE_KBPS = 96;
export const DEFAULT_VOLUME = 100; export const DEFAULT_VOLUME = 100;
export const SEARCH_DEBOUNCE_MS = 300; export const SEARCH_DEBOUNCE_MS = 300;
export const RECONNECT_SOUND_GRACE_MS = 15_000;

View File

@@ -134,6 +134,22 @@ export interface ElectronQuery {
payload: unknown; payload: unknown;
} }
export interface ContextMenuParams {
posX: number;
posY: number;
isEditable: boolean;
selectionText: string;
linkURL: string;
mediaType: string;
srcURL: string;
editFlags: {
canCut: boolean;
canCopy: boolean;
canPaste: boolean;
canSelectAll: boolean;
};
}
export interface ElectronApi { export interface ElectronApi {
linuxDisplayServer: string; linuxDisplayServer: string;
minimizeWindow: () => void; minimizeWindow: () => void;
@@ -176,6 +192,9 @@ export interface ElectronApi {
fileExists: (filePath: string) => Promise<boolean>; fileExists: (filePath: string) => Promise<boolean>;
deleteFile: (filePath: string) => Promise<boolean>; deleteFile: (filePath: string) => Promise<boolean>;
ensureDir: (dirPath: string) => Promise<boolean>; ensureDir: (dirPath: string) => Promise<boolean>;
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
contextMenuCommand: (command: string) => Promise<void>;
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
command: <T = unknown>(command: ElectronCommand) => Promise<T>; command: <T = unknown>(command: ElectronCommand) => Promise<T>;
query: <T = unknown>(query: ElectronQuery) => Promise<T>; query: <T = unknown>(query: ElectronQuery) => Promise<T>;
} }

View File

@@ -2,3 +2,4 @@ export * from './notification-audio.service';
export * from '../models/debugging.models'; export * from '../models/debugging.models';
export * from './debugging/debugging.service'; export * from './debugging/debugging.service';
export * from './settings-modal.service'; export * from './settings-modal.service';
export * from './user-status.service';

View File

@@ -41,6 +41,9 @@ export class NotificationAudioService {
/** Reactive notification volume (0 - 1), persisted to localStorage. */ /** Reactive notification volume (0 - 1), persisted to localStorage. */
readonly notificationVolume = signal(this.loadVolume()); readonly notificationVolume = signal(this.loadVolume());
/** When true, all sound playback is suppressed (Do Not Disturb). */
readonly dndMuted = signal(false);
constructor() { constructor() {
this.preload(); this.preload();
} }
@@ -106,6 +109,9 @@ export class NotificationAudioService {
* the persisted {@link notificationVolume} is used. * the persisted {@link notificationVolume} is used.
*/ */
play(sound: AppSound, volumeOverride?: number): void { play(sound: AppSound, volumeOverride?: number): void {
if (this.dndMuted())
return;
const cached = this.cache.get(sound); const cached = this.cache.get(sound);
const src = this.sources.get(sound); const src = this.sources.get(sound);

View File

@@ -0,0 +1,181 @@
import {
Injectable,
OnDestroy,
NgZone,
inject
} from '@angular/core';
import { Store } from '@ngrx/store';
import { UsersActions } from '../../store/users/users.actions';
import { selectManualStatus, selectCurrentUser } from '../../store/users/users.selectors';
import { RealtimeSessionFacade } from '../realtime';
import { NotificationAudioService } from './notification-audio.service';
import { UserStatus } from '../../shared-kernel';
const BROWSER_IDLE_POLL_MS = 10_000;
const BROWSER_IDLE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
interface ElectronIdleApi {
onIdleStateChanged: (listener: (state: 'active' | 'idle') => void) => () => void;
getIdleState: () => Promise<'active' | 'idle'>;
}
type IdleAwareWindow = Window & {
electronAPI?: ElectronIdleApi;
};
/**
* Orchestrates user status based on idle detection (Electron powerMonitor
* or browser-fallback) and manual overrides (e.g. Do Not Disturb).
*
* Manual status always takes priority over automatic idle detection.
* When manual status is cleared, the service falls back to automatic.
*/
@Injectable({ providedIn: 'root' })
export class UserStatusService implements OnDestroy {
private store = inject(Store);
private zone = inject(NgZone);
private webrtc = inject(RealtimeSessionFacade);
private audio = inject(NotificationAudioService);
private readonly manualStatus = this.store.selectSignal(selectManualStatus);
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
private electronCleanup: (() => void) | null = null;
private browserPollTimer: ReturnType<typeof setInterval> | null = null;
private lastActivityTimestamp = Date.now();
private browserActivityListeners: (() => void)[] = [];
private currentAutoStatus: UserStatus = 'online';
private started = false;
start(): void {
if (this.started)
return;
this.started = true;
if (this.getElectronIdleApi()?.onIdleStateChanged) {
this.startElectronIdleDetection();
} else {
this.startBrowserIdleDetection();
}
}
/** Set a manual status override (e.g. DND = 'busy'). Pass `null` to clear. */
setManualStatus(status: UserStatus | null): void {
this.store.dispatch(UsersActions.setManualStatus({ status }));
this.audio.dndMuted.set(status === 'busy');
this.broadcastStatus(this.resolveEffectiveStatus(status));
}
ngOnDestroy(): void {
this.cleanup();
}
private cleanup(): void {
this.electronCleanup?.();
this.electronCleanup = null;
if (this.browserPollTimer) {
clearInterval(this.browserPollTimer);
this.browserPollTimer = null;
}
for (const remove of this.browserActivityListeners) {
remove();
}
this.browserActivityListeners = [];
this.started = false;
}
private startElectronIdleDetection(): void {
const api = this.getElectronIdleApi();
if (!api)
return;
this.electronCleanup = api.onIdleStateChanged((idleState: 'active' | 'idle') => {
this.zone.run(() => {
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
this.applyAutoStatusIfAllowed();
});
});
// Check initial state
api.getIdleState().then((idleState: 'active' | 'idle') => {
this.zone.run(() => {
this.currentAutoStatus = idleState === 'idle' ? 'away' : 'online';
this.applyAutoStatusIfAllowed();
});
});
}
private startBrowserIdleDetection(): void {
this.lastActivityTimestamp = Date.now();
const onActivity = () => {
this.lastActivityTimestamp = Date.now();
const wasAway = this.currentAutoStatus === 'away';
if (wasAway) {
this.currentAutoStatus = 'online';
this.zone.run(() => this.applyAutoStatusIfAllowed());
}
};
const events = [
'mousemove',
'keydown',
'mousedown',
'touchstart',
'scroll'
] as const;
for (const evt of events) {
document.addEventListener(evt, onActivity, { passive: true });
this.browserActivityListeners.push(() =>
document.removeEventListener(evt, onActivity)
);
}
this.zone.runOutsideAngular(() => {
this.browserPollTimer = setInterval(() => {
const idle = Date.now() - this.lastActivityTimestamp >= BROWSER_IDLE_THRESHOLD_MS;
if (idle && this.currentAutoStatus !== 'away') {
this.currentAutoStatus = 'away';
this.zone.run(() => this.applyAutoStatusIfAllowed());
}
}, BROWSER_IDLE_POLL_MS);
});
}
private applyAutoStatusIfAllowed(): void {
const manualStatus = this.manualStatus();
// Manual status overrides automatic
if (manualStatus)
return;
const currentUser = this.currentUser();
if (currentUser?.status !== this.currentAutoStatus) {
this.store.dispatch(UsersActions.setManualStatus({ status: null }));
this.store.dispatch(UsersActions.updateCurrentUser({ updates: { status: this.currentAutoStatus } }));
this.broadcastStatus(this.currentAutoStatus);
}
}
private resolveEffectiveStatus(manualStatus: UserStatus | null): UserStatus {
return manualStatus ?? this.currentAutoStatus;
}
private broadcastStatus(status: UserStatus): void {
this.webrtc.sendRawMessage({
type: 'status_update',
status
});
}
private getElectronIdleApi(): ElectronIdleApi | undefined {
return (window as IdleAwareWindow).electronAPI;
}
}

View File

@@ -9,10 +9,11 @@ infrastructure adapters and UI.
| Domain | Purpose | Public entry point | | Domain | Purpose | Public entry point |
| -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------- |
| **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` | | **attachment** | File upload/download, chunk transfer, persistence | `AttachmentFacade` |
| **access-control** | Role, permission, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()` | | **access-control** | Role, permission, ban matching, moderation, and room access rules | `normalizeRoomAccessControl()`, `resolveRoomPermission()`, `hasRoomBanForUser()` |
| **auth** | Login / register HTTP orchestration, user-bar UI | `AuthService` | | **authentication** | Login / register HTTP orchestration, user-bar UI | `AuthenticationService` |
| **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` | | **chat** | Messaging rules, sync logic, GIF/Klipy integration, chat UI | `KlipyService`, `canEditMessage()`, `ChatMessagesComponent` |
| **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` | | **notifications** | Notification preferences, unread tracking, desktop alert orchestration | `NotificationsFacade` |
| **profile-avatar** | Profile picture upload, crop/zoom editing, processing, local persistence, and P2P avatar sync | `ProfileAvatarFacade` |
| **screen-share** | Source picker, quality presets | `ScreenShareFacade` | | **screen-share** | Source picker, quality presets | `ScreenShareFacade` |
| **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` | | **server-directory** | Multi-server endpoint management, health checks, invites, server search UI | `ServerDirectoryFacade` |
| **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` | | **theme** | JSON-driven theming, element registry, layout syncing, picker tooling, and Electron saved-theme library management | `ThemeService` |
@@ -25,9 +26,10 @@ The larger domains also keep longer design notes in their own folders:
- [attachment/README.md](attachment/README.md) - [attachment/README.md](attachment/README.md)
- [access-control/README.md](access-control/README.md) - [access-control/README.md](access-control/README.md)
- [auth/README.md](auth/README.md) - [authentication/README.md](authentication/README.md)
- [chat/README.md](chat/README.md) - [chat/README.md](chat/README.md)
- [notifications/README.md](notifications/README.md) - [notifications/README.md](notifications/README.md)
- [profile-avatar/README.md](profile-avatar/README.md)
- [screen-share/README.md](screen-share/README.md) - [screen-share/README.md](screen-share/README.md)
- [server-directory/README.md](server-directory/README.md) - [server-directory/README.md](server-directory/README.md)
- [voice-connection/README.md](voice-connection/README.md) - [voice-connection/README.md](voice-connection/README.md)

View File

@@ -7,13 +7,18 @@ Role and permission rules for servers, including default system roles, role assi
``` ```
access-control/ access-control/
├── domain/ ├── domain/
│ ├── access-control.models.ts MemberIdentity and RoomPermissionDefinition domain types │ ├── models/
── access-control.constants.ts SYSTEM_ROLE_IDS and permission metadata │ └── access-control.model.ts MemberIdentity and RoomPermissionDefinition domain types
│ ├── role.rules.ts Role defaults, normalization, ordering, create/update helpers │ ├── constants/
├── role-assignment.rules.ts Assignment normalization and member-role lookups │ └── access-control.constants.ts SYSTEM_ROLE_IDS and permission metadata
│ ├── permission.rules.ts Permission resolution and moderation hierarchy checks │ ├── util/
├── room.rules.ts Legacy compatibility, room hydration, room-level normalization │ └── access-control.util.ts Internal helpers (normalization, identity matching, sorting)
│ └── access-control.logic.ts Public barrel for domain rules │ └── rules/
│ ├── role.rules.ts Role defaults, normalization, ordering, create/update helpers
│ ├── role-assignment.rules.ts Assignment normalization and member-role lookups
│ ├── permission.rules.ts Permission resolution and moderation hierarchy checks
│ ├── room.rules.ts Legacy compatibility, room hydration, room-level normalization
│ └── ban.rules.ts Ban matching and user-ban resolution
└── index.ts Domain barrel used by other layers └── index.ts Domain barrel used by other layers
``` ```
@@ -29,6 +34,8 @@ access-control/
| `canManageMember(...)` | Applies both permission checks and role hierarchy checks | | `canManageMember(...)` | Applies both permission checks and role hierarchy checks |
| `canManageRole(...)` | Prevents editing roles at or above the actor's highest role | | `canManageRole(...)` | Prevents editing roles at or above the actor's highest role |
| `normalizeRoomAccessControl(room)` | Produces a fully hydrated room with normalized roles, assignments, overrides, and legacy compatibility fields | | `normalizeRoomAccessControl(room)` | Produces a fully hydrated room with normalized roles, assignments, overrides, and legacy compatibility fields |
| `hasRoomBanForUser(bans, user, persistedUserId?)` | Returns true when any active ban entry targets the provided user |
| `isRoomBanMatch(ban, user, persistedUserId?)` | Returns true when a single ban entry targets the provided user |
## Layering ## Layering

View File

@@ -1,6 +0,0 @@
export * from './access-control.models';
export * from './access-control.constants';
export * from './role.rules';
export * from './role-assignment.rules';
export * from './permission.rules';
export * from './room.rules';

View File

@@ -1,4 +1,4 @@
import type { RoomPermissionDefinition } from './access-control.models'; import type { RoomPermissionDefinition } from '../models/access-control.model';
export const SYSTEM_ROLE_IDS = { export const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone', everyone: 'system-everyone',

View File

@@ -2,7 +2,7 @@ import type {
RoomMember, RoomMember,
RoomPermissionKey, RoomPermissionKey,
User User
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
export interface RoomPermissionDefinition { export interface RoomPermissionDefinition {
key: RoomPermissionKey; key: RoomPermissionKey;

View File

@@ -1,4 +1,4 @@
import { BanEntry, User } from '../models/index'; import { BanEntry, User } from '../../../../shared-kernel';
type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined; type BanAwareUser = Pick<User, 'id' | 'oderId'> | null | undefined;

View File

@@ -4,9 +4,9 @@ import {
Room, Room,
RoomPermissionKey, RoomPermissionKey,
RoomRole RoomRole
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants'; import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import type { MemberIdentity } from './access-control.models'; import type { MemberIdentity } from '../models/access-control.model';
import { import {
buildRoleLookup, buildRoleLookup,
getRolePermissionState, getRolePermissionState,
@@ -14,7 +14,7 @@ import {
normalizePermissionState, normalizePermissionState,
roleSortAscending, roleSortAscending,
compareText compareText
} from './access-control.internal'; } from '../util/access-control.util';
import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules'; import { getAssignedRoleIds, getHighestAssignedRole } from './role-assignment.rules';
import { getRoomRoleById, normalizeRoomRoles } from './role.rules'; import { getRoomRoleById, normalizeRoomRoles } from './role.rules';

View File

@@ -3,9 +3,9 @@ import {
RoomMember, RoomMember,
RoomRole, RoomRole,
RoomRoleAssignment RoomRoleAssignment
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants'; import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import type { MemberIdentity } from './access-control.models'; import type { MemberIdentity } from '../models/access-control.model';
import { import {
buildRoleLookup, buildRoleLookup,
compareText, compareText,
@@ -13,7 +13,7 @@ import {
matchesIdentity, matchesIdentity,
roleSortDescending, roleSortDescending,
uniqueStrings uniqueStrings
} from './access-control.internal'; } from '../util/access-control.util';
import { getRoomRoleById, normalizeRoomRoles } from './role.rules'; import { getRoomRoleById, normalizeRoomRoles } from './role.rules';
function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] { function sortAssignments(assignments: readonly RoomRoleAssignment[]): RoomRoleAssignment[] {

View File

@@ -2,8 +2,8 @@ import {
RoomPermissionMatrix, RoomPermissionMatrix,
RoomPermissions, RoomPermissions,
RoomRole RoomRole
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants'; import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import { import {
buildRoleLookup, buildRoleLookup,
buildSystemRole, buildSystemRole,
@@ -12,7 +12,7 @@ import {
normalizePermissionMatrix, normalizePermissionMatrix,
roleSortAscending, roleSortAscending,
roleSortDescending roleSortDescending
} from './access-control.internal'; } from '../util/access-control.util';
const ROLE_COLORS = { const ROLE_COLORS = {
everyone: '#6b7280', everyone: '#6b7280',

View File

@@ -7,14 +7,14 @@ import {
RoomRole, RoomRole,
RoomRoleAssignment, RoomRoleAssignment,
UserRole UserRole
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import { SYSTEM_ROLE_IDS } from './access-control.constants'; import { SYSTEM_ROLE_IDS } from '../constants/access-control.constants';
import { import {
getRolePermissionState, getRolePermissionState,
permissionStateToBoolean, permissionStateToBoolean,
resolveLegacyAllowState resolveLegacyAllowState
} from './access-control.internal'; } from '../util/access-control.util';
import type { MemberIdentity } from './access-control.models'; import type { MemberIdentity } from '../models/access-control.model';
import { import {
getAssignedRoleIds, getAssignedRoleIds,
normalizeRoomRoleAssignments, normalizeRoomRoleAssignments,

View File

@@ -5,8 +5,8 @@ import {
RoomRole, RoomRole,
RoomRoleAssignment, RoomRoleAssignment,
ROOM_PERMISSION_KEYS ROOM_PERMISSION_KEYS
} from '../../../shared-kernel'; } from '../../../../shared-kernel';
import type { MemberIdentity } from './access-control.models'; import type { MemberIdentity } from '../models/access-control.model';
export function normalizeName(name: string): string { export function normalizeName(name: string): string {
return name.trim().replace(/\s+/g, ' '); return name.trim().replace(/\s+/g, ' ');

View File

@@ -1,6 +1,7 @@
export * from './domain/access-control.models'; export * from './domain/models/access-control.model';
export * from './domain/access-control.constants'; export * from './domain/constants/access-control.constants';
export * from './domain/role.rules'; export * from './domain/rules/role.rules';
export * from './domain/role-assignment.rules'; export * from './domain/rules/role-assignment.rules';
export * from './domain/permission.rules'; export * from './domain/rules/permission.rules';
export * from './domain/room.rules'; export * from './domain/rules/room.rules';
export * from './domain/rules/ban.rules';

View File

@@ -7,25 +7,32 @@ Handles file sharing between peers over WebRTC data channels. Files are announce
``` ```
attachment/ attachment/
├── application/ ├── application/
│ ├── attachment.facade.ts Thin entry point, delegates to manager │ ├── facades/
── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners │ └── attachment.facade.ts Thin entry point, delegates to manager
── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel) ── services/
│ ├── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming ├── attachment-manager.service.ts Orchestrates lifecycle, auto-download, peer listeners
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage ├── attachment-transfer.service.ts P2P file transfer protocol (announce/request/chunk/cancel)
── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending) ── attachment-transfer-transport.service.ts Base64 encode/decode, chunked streaming
│ ├── attachment-persistence.service.ts DB + filesystem persistence, migration from localStorage
│ └── attachment-runtime.store.ts In-memory signal-based state (Maps for attachments, chunks, pending)
├── domain/ ├── domain/
│ ├── attachment.models.ts Attachment type extending AttachmentMeta with runtime state │ ├── logic/
── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment │ └── attachment.logic.ts isAttachmentMedia, shouldAutoRequestWhenWatched, shouldPersistDownloadedAttachment
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB │ ├── models/
│ ├── attachment-transfer.models.ts Protocol event types (file-announce, file-chunk, file-request, ...) │ ├── attachment.model.ts Attachment type extending AttachmentMeta with runtime state
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages │ └── attachment-transfer.model.ts Protocol event types (file-announce, file-chunk, file-request, ...)
│ └── constants/
│ ├── attachment.constants.ts MAX_AUTO_SAVE_SIZE_BYTES = 10 MB
│ └── attachment-transfer.constants.ts FILE_CHUNK_SIZE_BYTES = 64 KB, EWMA weights, error messages
├── infrastructure/ ├── infrastructure/
│ ├── attachment-storage.service.ts Electron filesystem access (save / read / delete) │ ├── services/
│ └── attachment-storage.helpers.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket │ └── attachment-storage.service.ts Electron filesystem access (save / read / delete)
│ └── util/
│ └── attachment-storage.util.ts sanitizeAttachmentRoomName, resolveAttachmentStorageBucket
└── index.ts Barrel exports └── index.ts Barrel exports
``` ```
## Service composition ## Service composition
@@ -52,17 +59,17 @@ graph TD
Transfer --> Store Transfer --> Store
Persistence --> Storage Persistence --> Storage
Persistence --> Store Persistence --> Store
Storage --> Helpers[attachment-storage.helpers] Storage --> Helpers[attachment-storage.util]
click Facade "application/attachment.facade.ts" "Thin entry point" _blank click Facade "application/facades/attachment.facade.ts" "Thin entry point" _blank
click Manager "application/attachment-manager.service.ts" "Orchestrates lifecycle" _blank click Manager "application/services/attachment-manager.service.ts" "Orchestrates lifecycle" _blank
click Transfer "application/attachment-transfer.service.ts" "P2P file transfer protocol" _blank click Transfer "application/services/attachment-transfer.service.ts" "P2P file transfer protocol" _blank
click Transport "application/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank click Transport "application/services/attachment-transfer-transport.service.ts" "Base64 encode/decode, chunked streaming" _blank
click Persistence "application/attachment-persistence.service.ts" "DB + filesystem persistence" _blank click Persistence "application/services/attachment-persistence.service.ts" "DB + filesystem persistence" _blank
click Store "application/attachment-runtime.store.ts" "In-memory signal-based state" _blank click Store "application/services/attachment-runtime.store.ts" "In-memory signal-based state" _blank
click Storage "infrastructure/attachment-storage.service.ts" "Electron filesystem access" _blank click Storage "infrastructure/services/attachment-storage.service.ts" "Electron filesystem access" _blank
click Helpers "infrastructure/attachment-storage.helpers.ts" "Path helpers" _blank click Helpers "infrastructure/util/attachment-storage.util.ts" "Path helpers" _blank
click Logic "domain/attachment.logic.ts" "Pure decision functions" _blank click Logic "domain/logic/attachment.logic.ts" "Pure decision functions" _blank
``` ```
## File transfer protocol ## File transfer protocol

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { AttachmentManagerService } from './attachment-manager.service'; import { AttachmentManagerService } from '../services/attachment-manager.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentFacade { export class AttachmentFacade {

View File

@@ -4,18 +4,18 @@ import {
inject inject
} from '@angular/core'; } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router'; import { NavigationEnd, Router } from '@angular/router';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { ROOM_URL_PATTERN } from '../../../core/constants'; import { ROOM_URL_PATTERN } from '../../../../core/constants';
import { shouldAutoRequestWhenWatched } from '../domain/attachment.logic'; import { shouldAutoRequestWhenWatched } from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import type { import type {
FileAnnouncePayload, FileAnnouncePayload,
FileCancelPayload, FileCancelPayload,
FileChunkPayload, FileChunkPayload,
FileNotFoundPayload, FileNotFoundPayload,
FileRequestPayload FileRequestPayload
} from '../domain/attachment-transfer.models'; } from '../../domain/models/attachment-transfer.model';
import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferService } from './attachment-transfer.service'; import { AttachmentTransferService } from './attachment-transfer.service';

View File

@@ -1,12 +1,12 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { take } from 'rxjs'; import { take } from 'rxjs';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { selectCurrentRoomName } from '../../../store/rooms/rooms.selectors'; import { selectCurrentRoomName } from '../../../../store/rooms/rooms.selectors';
import { DatabaseService } from '../../../infrastructure/persistence'; import { DatabaseService } from '../../../../infrastructure/persistence';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../domain/attachment-transfer.constants'; import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -1,5 +1,5 @@
import { Injectable, signal } from '@angular/core'; import { Injectable, signal } from '@angular/core';
import type { Attachment } from '../domain/attachment.models'; import type { Attachment } from '../../domain/models/attachment.model';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentRuntimeStore { export class AttachmentRuntimeStore {

View File

@@ -1,8 +1,13 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { FILE_CHUNK_SIZE_BYTES } from '../domain/attachment-transfer.constants'; import { FILE_CHUNK_SIZE_BYTES } from '../../domain/constants/attachment-transfer.constants';
import { FileChunkEvent } from '../domain/attachment-transfer.models'; import { FileChunkEvent } from '../../domain/models/attachment-transfer.model';
import {
arrayBufferToBase64,
decodeBase64,
iterateBlobChunks
} from '../../../../shared-kernel';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentTransferTransportService { export class AttachmentTransferTransportService {
@@ -10,14 +15,7 @@ export class AttachmentTransferTransportService {
private readonly attachmentStorage = inject(AttachmentStorageService); private readonly attachmentStorage = inject(AttachmentStorageService);
decodeBase64(base64: string): Uint8Array { decodeBase64(base64: string): Uint8Array {
const binary = atob(base64); return decodeBase64(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
} }
async streamFileToPeer( async streamFileToPeer(
@@ -27,31 +25,20 @@ export class AttachmentTransferTransportService {
file: File, file: File,
isCancelled: () => boolean isCancelled: () => boolean
): Promise<void> { ): Promise<void> {
const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE_BYTES); for await (const chunk of iterateBlobChunks(file, FILE_CHUNK_SIZE_BYTES)) {
let offset = 0;
let chunkIndex = 0;
while (offset < file.size) {
if (isCancelled()) if (isCancelled())
break; break;
const slice = file.slice(offset, offset + FILE_CHUNK_SIZE_BYTES);
const arrayBuffer = await slice.arrayBuffer();
const base64 = this.arrayBufferToBase64(arrayBuffer);
const fileChunkEvent: FileChunkEvent = { const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
fileId, fileId,
index: chunkIndex, index: chunk.index,
total: totalChunks, total: chunk.total,
data: base64 data: chunk.base64
}; };
await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent); await this.webrtc.sendToPeerBuffered(targetPeerId, fileChunkEvent);
offset += FILE_CHUNK_SIZE_BYTES;
chunkIndex++;
} }
} }
@@ -67,7 +54,7 @@ export class AttachmentTransferTransportService {
if (!base64Full) if (!base64Full)
return; return;
const fileBytes = this.decodeBase64(base64Full); const fileBytes = decodeBase64(base64Full);
const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES); const totalChunks = Math.ceil(fileBytes.byteLength / FILE_CHUNK_SIZE_BYTES);
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
@@ -81,7 +68,7 @@ export class AttachmentTransferTransportService {
slice.byteOffset, slice.byteOffset,
slice.byteOffset + slice.byteLength slice.byteOffset + slice.byteLength
); );
const base64Chunk = this.arrayBufferToBase64(sliceBuffer); const base64Chunk = arrayBufferToBase64(sliceBuffer);
const fileChunkEvent: FileChunkEvent = { const fileChunkEvent: FileChunkEvent = {
type: 'file-chunk', type: 'file-chunk',
messageId, messageId,
@@ -94,16 +81,4 @@ export class AttachmentTransferTransportService {
this.webrtc.sendToPeer(targetPeerId, fileChunkEvent); 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);
}
} }

View File

@@ -1,17 +1,17 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { recordDebugNetworkFileChunk } from '../../../infrastructure/realtime/logging/debug-network-metrics'; import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime/logging/debug-network-metrics';
import { RealtimeSessionFacade } from '../../../core/realtime'; import { RealtimeSessionFacade } from '../../../../core/realtime';
import { AttachmentStorageService } from '../infrastructure/attachment-storage.service'; import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../domain/attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
import { shouldPersistDownloadedAttachment } from '../domain/attachment.logic'; import { shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
import type { Attachment, AttachmentMeta } from '../domain/attachment.models'; import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
import { import {
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT, ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT, ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
DEFAULT_ATTACHMENT_MIME_TYPE, DEFAULT_ATTACHMENT_MIME_TYPE,
FILE_NOT_FOUND_REQUEST_ERROR, FILE_NOT_FOUND_REQUEST_ERROR,
NO_CONNECTED_PEERS_REQUEST_ERROR NO_CONNECTED_PEERS_REQUEST_ERROR
} from '../domain/attachment-transfer.constants'; } from '../../domain/constants/attachment-transfer.constants';
import { import {
type FileAnnounceEvent, type FileAnnounceEvent,
type FileAnnouncePayload, type FileAnnouncePayload,
@@ -23,7 +23,7 @@ import {
type FileRequestEvent, type FileRequestEvent,
type FileRequestPayload, type FileRequestPayload,
type LocalFileWithPath type LocalFileWithPath
} from '../domain/attachment-transfer.models'; } from '../../domain/models/attachment-transfer.model';
import { AttachmentPersistenceService } from './attachment-persistence.service'; import { AttachmentPersistenceService } from './attachment-persistence.service';
import { AttachmentRuntimeStore } from './attachment-runtime.store'; import { AttachmentRuntimeStore } from './attachment-runtime.store';
import { AttachmentTransferTransportService } from './attachment-transfer-transport.service'; import { AttachmentTransferTransportService } from './attachment-transfer-transport.service';

View File

@@ -1,5 +1,4 @@
/** Size (bytes) of each chunk when streaming a file over RTCDataChannel. */ export { P2P_BASE64_CHUNK_SIZE_BYTES as FILE_CHUNK_SIZE_BYTES } from '../../../../shared-kernel/p2p-transfer.constants';
export const FILE_CHUNK_SIZE_BYTES = 64 * 1024; // 64 KB
/** /**
* EWMA smoothing weight for the previous speed estimate. * EWMA smoothing weight for the previous speed estimate.

View File

@@ -1,5 +1,5 @@
import { MAX_AUTO_SAVE_SIZE_BYTES } from './attachment.constants'; import { MAX_AUTO_SAVE_SIZE_BYTES } from '../constants/attachment.constants';
import type { Attachment } from './attachment.models'; import type { Attachment } from '../models/attachment.model';
export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean { export function isAttachmentMedia(attachment: Pick<Attachment, 'mime'>): boolean {
return attachment.mime.startsWith('image/') || return attachment.mime.startsWith('image/') ||

View File

@@ -1,5 +1,5 @@
import type { ChatEvent } from '../../../shared-kernel'; import type { ChatEvent } from '../../../../shared-kernel';
import type { ChatAttachmentAnnouncement } from '../../../shared-kernel'; import type { ChatAttachmentAnnouncement } from '../../../../shared-kernel';
export type FileAnnounceEvent = ChatEvent & { export type FileAnnounceEvent = ChatEvent & {
type: 'file-announce'; type: 'file-announce';

View File

@@ -1,4 +1,4 @@
import type { ChatAttachmentMeta } from '../../../shared-kernel'; import type { ChatAttachmentMeta } from '../../../../shared-kernel';
export type AttachmentMeta = ChatAttachmentMeta; export type AttachmentMeta = ChatAttachmentMeta;

View File

@@ -1,3 +1,3 @@
export * from './application/attachment.facade'; export * from './application/facades/attachment.facade';
export * from './domain/attachment.constants'; export * from './domain/constants/attachment.constants';
export * from './domain/attachment.models'; export * from './domain/models/attachment.model';

View File

@@ -1,11 +1,11 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { ElectronBridgeService } from '../../../core/platform/electron/electron-bridge.service'; import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
import type { Attachment } from '../domain/attachment.models'; import type { Attachment } from '../../domain/models/attachment.model';
import { import {
resolveAttachmentStorageBucket, resolveAttachmentStorageBucket,
resolveAttachmentStoredFilename, resolveAttachmentStoredFilename,
sanitizeAttachmentRoomName sanitizeAttachmentRoomName
} from './attachment-storage.helpers'; } from '../util/attachment-storage.util';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AttachmentStorageService { export class AttachmentStorageService {

View File

@@ -1,35 +0,0 @@
<div class="h-10 border-b border-border bg-card flex items-center justify-end px-3 gap-2">
<div class="flex-1"></div>
@if (user()) {
<div class="flex items-center gap-2 text-sm">
<ng-icon
name="lucideUser"
class="w-4 h-4 text-muted-foreground"
/>
<span class="text-foreground">{{ user()?.displayName }}</span>
</div>
} @else {
<button
type="button"
(click)="goto('login')"
class="px-2 py-1 text-sm rounded bg-secondary hover:bg-secondary/80 flex items-center gap-1"
>
<ng-icon
name="lucideLogIn"
class="w-4 h-4"
/>
Login
</button>
<button
type="button"
(click)="goto('register')"
class="px-2 py-1 text-sm rounded bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-1"
>
<ng-icon
name="lucideUserPlus"
class="w-4 h-4"
/>
Register
</button>
}
</div>

View File

@@ -1 +0,0 @@
export * from './application/auth.service';

Some files were not shown because too many files have changed in this diff Show More