62 Commits

Author SHA1 Message Date
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
Myx
ae0ee8fac7 Fix lint, make design more consistent, add license texts,
All checks were successful
Queue Release Build / prepare (push) Successful in 11s
Deploy Web Apps / deploy (push) Successful in 14m0s
Queue Release Build / build-linux (push) Successful in 35m41s
Queue Release Build / build-windows (push) Successful in 28m53s
Queue Release Build / finalize (push) Successful in 2m6s
2026-04-02 04:08:53 +02:00
Myx
37cac95b38 Add access control rework 2026-04-02 03:18:37 +02:00
Myx
314a26325f Database changes to make it better practise 2026-04-02 01:32:08 +02:00
Myx
5d7e045764 feat: Add chat seperator and restore last viewed chat on restart 2026-04-02 00:47:44 +02:00
Myx
bbb6deb0a2 feat: Theme engine
big changes
2026-04-02 00:08:38 +02:00
Myx
65b9419869 Rework design part 1 2026-04-01 19:31:00 +02:00
Myx
fed270d28d Fix issues with server navigation 2026-04-01 18:18:31 +02:00
Myx
8b6578da3c fix: Notification audio
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 11m55s
Queue Release Build / build-linux (push) Successful in 30m56s
Queue Release Build / build-windows (push) Successful in 27m50s
Queue Release Build / finalize (push) Successful in 2m0s
2026-03-30 21:14:26 +02:00
Myx
851d6ae759 fix: Prefer cached channels before loaded 2026-03-30 20:37:24 +02:00
1e833ec7f2 Merge pull request 'Restructure' (#9) from maybe-ddd into main
All checks were successful
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 16m15s
Queue Release Build / build-linux (push) Successful in 37m23s
Queue Release Build / build-windows (push) Successful in 28m39s
Queue Release Build / finalize (push) Successful in 2m7s
Reviewed-on: #9
2026-03-30 02:56:34 +00:00
Myx
64e34ad586 feat: basic selected server indicator 2026-03-30 04:54:02 +02:00
Myx
e3b23247a9 feat: Close to tray 2026-03-30 04:48:34 +02:00
Myx
42ac712571 feat: Add notifications 2026-03-30 04:41:58 +02:00
Myx
b7d4bf20e3 feat: Add webcam basic support 2026-03-30 03:10:44 +02:00
Myx
727059fb52 Add seperation of voice channels, creation of new ones, and move around users 2026-03-30 02:11:39 +02:00
Myx
83694570e3 feat: Allow admin to create new text channels 2026-03-30 01:25:56 +02:00
Myx
109402cdd6 perf: Health snapshot changes 2026-03-30 00:28:45 +02:00
Myx
eb23fd71ec perf: Optimizing the image loading
Does no longer load all klipy images through image proxy from signal server. Improves loading performance.
2026-03-30 00:26:28 +02:00
Myx
11917f3412 fix: Make attachments unique when downloaded
Fixes the issue with attachments replacing each other locally so files with same filename appears as the same file
2026-03-30 00:08:53 +02:00
Myx
8162e0444a Move toju-app into own its folder 2026-03-29 23:55:24 +02:00
Myx
0467a7b612 documentation improvement 2026-03-23 01:34:18 +01:00
Myx
971a5afb8b ddd test 2 2026-03-23 00:42:08 +01:00
Myx
fe9c1dd1c0 ddd test 2026-03-20 03:05:29 +01:00
Myx
429bb9d8ff Chat message placeholder adjustment 2026-03-19 21:39:47 +01:00
Myx
b5d676fb78 New attempt to fix windows screenshare 2026-03-19 21:39:20 +01:00
Myx
aa595c45d8 Fix autostart on linux 2026-03-19 21:38:47 +01:00
Myx
1c7e535057 Possibly screensharing fix for windows where they get deafened when screensharing with audio
All checks were successful
Queue Release Build / prepare (push) Successful in 14s
Deploy Web Apps / deploy (push) Successful in 9m24s
Queue Release Build / build-linux (push) Successful in 24m57s
Queue Release Build / build-windows (push) Successful in 25m21s
Queue Release Build / finalize (push) Successful in 1m43s
2026-03-19 04:15:59 +01:00
Myx
8f960be1e9 Resync username instead of using Anonymous 2026-03-19 03:57:51 +01:00
Myx
9a173792a4 Fix users list to only show server users 2026-03-19 03:48:41 +01:00
Myx
cb2c0495b9 hotfix handshake issue
All checks were successful
Queue Release Build / prepare (push) Successful in 16s
Deploy Web Apps / deploy (push) Successful in 10m15s
Queue Release Build / build-linux (push) Successful in 26m14s
Queue Release Build / build-windows (push) Successful in 25m41s
Queue Release Build / finalize (push) Successful in 1m51s
2026-03-19 03:34:26 +01:00
Myx
c3ef8e8800 Allow multiple signal servers (might need rollback)
All checks were successful
Queue Release Build / prepare (push) Successful in 17s
Deploy Web Apps / deploy (push) Successful in 9m58s
Queue Release Build / build-linux (push) Successful in 26m26s
Queue Release Build / build-windows (push) Successful in 25m3s
Queue Release Build / finalize (push) Successful in 1m43s
2026-03-19 02:11:15 +01:00
Myx
c862c2fe03 Auto start with system
Some checks failed
Queue Release Build / prepare (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
Deploy Web Apps / deploy (push) Successful in 6m2s
2026-03-18 23:46:16 +01:00
Myx
4faa62864d Fix syncing issues
Some checks failed
Deploy Web Apps / deploy (push) Has been cancelled
Queue Release Build / prepare (push) Has been cancelled
Queue Release Build / build-linux (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
Queue Release Build / finalize (push) Has been cancelled
2026-03-18 23:11:48 +01:00
Myx
1cdd1c5d2b fix typing indicator on wrong server
Some checks failed
Queue Release Build / build-linux (push) Blocked by required conditions
Queue Release Build / prepare (push) Successful in 15s
Deploy Web Apps / deploy (push) Successful in 16m15s
Queue Release Build / finalize (push) Has been cancelled
Queue Release Build / build-windows (push) Has been cancelled
2026-03-18 22:10:11 +01:00
Myx
141de64767 Reconnection when signal server is not active and minor changes 2026-03-18 20:45:31 +01:00
Myx
eb987ac672 Private servers with password and invite links (Experimental) 2026-03-18 20:42:40 +01:00
Myx
f8fd78d21a Add server variables
All checks were successful
Queue Release Build / prepare (push) Successful in 21s
Deploy Web Apps / deploy (push) Successful in 15m14s
Queue Release Build / build-linux (push) Successful in 22m12s
Queue Release Build / build-windows (push) Successful in 23m20s
Queue Release Build / finalize (push) Successful in 2m12s
2026-03-15 16:12:21 +01:00
Myx
150c45c31a Use wayland if possible on linux
All checks were successful
Queue Release Build / prepare (push) Successful in 58s
Deploy Web Apps / deploy (push) Successful in 22m58s
Queue Release Build / build-linux (push) Successful in 1h10m11s
Queue Release Build / build-windows (push) Successful in 32m8s
Queue Release Build / finalize (push) Successful in 3m11s
2026-03-13 20:14:19 +01:00
494 changed files with 41458 additions and 9756 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

@@ -1,4 +1,5 @@
# Toggle SSL for local development (true/false)
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
# When false: plain HTTP everywhere (only works on localhost)
# Overrides server/data/variables.json for local development only
SSL=true

View File

@@ -17,6 +17,13 @@ jobs:
- name: Checkout
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
env:
NODE_ENV: development

View File

@@ -48,18 +48,30 @@ jobs:
- name: Checkout
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
env:
NODE_ENV: development
run: |
apt-get update && apt-get install -y --no-install-recommends zip
npm ci
cd server && npm ci
- name: Install zip utility
run: |
apt-get update
apt-get install -y zip
- name: Set CI release version
run: >
node tools/set-release-version.js
@@ -67,8 +79,10 @@ jobs:
- name: Build application
run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
cd toju-app
npx ng build --configuration production --base-href='./'
cd ..
npx --package typescript tsc -p tsconfig.electron.json
cd server
node ../tools/sync-server-build-version.js
@@ -106,6 +120,22 @@ jobs:
- name: Checkout
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
env:
NODE_ENV: development
@@ -120,8 +150,10 @@ jobs:
- name: Build application
run: |
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js
npx esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js
Push-Location "toju-app"
npx ng build --configuration production --base-href='./'
Pop-Location
npx --package typescript tsc -p tsconfig.electron.json
Push-Location server
node ../tools/sync-server-build-version.js
@@ -213,9 +245,6 @@ jobs:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Install dependencies
run: npm ci --omit=dev
- name: Download previous manifest
env:
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}

11
.gitignore vendored
View File

@@ -6,7 +6,9 @@
/tmp
/out-tsc
/bazel-out
*.sqlite
*/architecture.md
/docs
# Node
/node_modules
npm-debug.log
@@ -42,6 +44,10 @@ testem.log
/typings
__screenshots__/
# Playwright
test-results/
e2e/playwright-report/
# System files
.DS_Store
Thumbs.db
@@ -51,3 +57,6 @@ Thumbs.db
.certs/
/server/data/variables.json
dist-server/*
AGENTS.md
doc/**

View File

@@ -1,10 +1,14 @@
<img src="./images/icon.png" width="100" height="100">
# Toju / Zoracord
Desktop chat app with three parts:
Desktop chat app with four parts:
- `src/` Angular client
- `electron/` desktop shell, IPC, and local database
- `server/` directory server, join request API, and websocket events
- `website/` Toju website served at toju.app
## Install
@@ -17,7 +21,7 @@ Desktop chat app with three parts:
Root `.env`:
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
- `PORT=3001` changes the server port
- `PORT=3001` changes the server port in local development and overrides the server app setting
If `SSL=true`, run `./generate-cert.sh` once.
@@ -25,6 +29,10 @@ Server files:
- `server/data/variables.json` holds `klipyApiKey`
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
- `server/data/variables.json` can now also hold optional `serverHost` (an IP address or hostname to bind to)
- `server/data/variables.json` can now also hold `serverProtocol` (`http` or `https`)
- `server/data/variables.json` can now also hold `serverPort` (1-65535)
- When `serverProtocol` is `https`, the certificate must match the configured `serverHost` or IP
## Main commands
@@ -48,3 +56,64 @@ Inside `server/`:
- `npm run dev` starts the server with reload
- `npm run build` compiles to `dist/`
- `npm run start` runs the compiled server
# Images
<img src="./website/src/images/screenshots/gif.png" width="700" height="400">
<img src="./website/src/images/screenshots/screenshare_gaming.png" width="700" height="400">
## Main Toju app Structure
| Path | Description |
|------|-------------|
| `src/app/` | Main application root |
| `src/app/core/` | Core utilities, services, models |
| `src/app/domains/` | Domain-driven modules |
| `src/app/features/` | UI feature modules |
| `src/app/infrastructure/` | Low-level infrastructure (DB, realtime, etc.) |
| `src/app/shared/` | Shared UI components |
| `src/app/shared-kernel/` | Shared domain contracts & models |
| `src/app/store/` | Global state management |
| `src/assets/` | Static assets |
| `src/environments/` | Environment configs |
---
### Domains
| Path | Link |
|------|------|
| Attachment | [app/domains/attachment/README.md](src/app/domains/attachment/README.md) |
| Auth | [app/domains/auth/README.md](src/app/domains/auth/README.md) |
| Chat | [app/domains/chat/README.md](src/app/domains/chat/README.md) |
| Screen Share | [app/domains/screen-share/README.md](src/app/domains/screen-share/README.md) |
| Server Directory | [app/domains/server-directory/README.md](src/app/domains/server-directory/README.md) |
| Voice Connection | [app/domains/voice-connection/README.md](src/app/domains/voice-connection/README.md) |
| Voice Session | [app/domains/voice-session/README.md](src/app/domains/voice-session/README.md) |
| Domains Root | [app/domains/README.md](src/app/domains/README.md) |
---
### Infrastructure
| Path | Link |
|------|------|
| Persistence | [src/app/infrastructure/persistence/README.md](src/app/infrastructure/persistence/README.md) |
| Realtime | [src/app/infrastructure/realtime/README.md](src/app/infrastructure/realtime/README.md) |
---
### Shared Kernel
| Path | Link |
|------|------|
| Shared Kernel | [src/app/shared-kernel/README.md](src/app/shared-kernel/README.md) |
---
### Entry Points
| File | Link |
|------|------|
| Main | [main.ts](src/main.ts) |
| Index HTML | [index.html](src/index.html) |
| App Root | [app/app.ts](src/app/app.ts) |

6
dev.sh
View File

@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
"$DIR/generate-cert.sh"
fi
NG_SERVE="ng serve --host=0.0.0.0 --ssl --ssl-cert=.certs/localhost.crt --ssl-key=.certs/localhost.key"
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0 --ssl --ssl-cert=../.certs/localhost.crt --ssl-key=../.certs/localhost.key"
WAIT_URL="https://localhost:4200"
HEALTH_URL="https://localhost:3001/api/health"
export NODE_TLS_REJECT_UNAUTHORIZED=0
else
NG_SERVE="ng serve --host=0.0.0.0"
NG_SERVE="cd toju-app && npx ng serve --host=0.0.0.0"
WAIT_URL="http://localhost:4200"
HEALTH_URL="http://localhost:3001/api/health"
fi
@@ -33,4 +33,4 @@ fi
exec npx concurrently --kill-others \
"cd server && npm run dev" \
"$NG_SERVE" \
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL electron . --no-sandbox --disable-dev-shm-usage"
"wait-on $WAIT_URL $HEALTH_URL && cross-env NODE_ENV=development SSL=$SSL node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage"

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,77 @@
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';
type 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');
// ── 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(
'npx',
['ts-node', '--project', SERVER_TSCONFIG, SERVER_ENTRY],
{
cwd: tmpDir,
env: {
...process.env,
PORT: TEST_PORT,
SSL: 'false',
NODE_ENV: 'test',
DB_SYNCHRONIZE: 'true',
},
stdio: 'inherit',
shell: true,
}
);
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,717 @@
/* 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[] = [];
(window as any).__rtcConnections = connections;
(window as any).__rtcRemoteTracks = [] as { kind: string; id: string; readyState: string }[];
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 oscillator = audioCtx.createOscillator();
oscillator.frequency.value = 440;
const dest = audioCtx.createMediaStreamDestination();
oscillator.connect(dest);
oscillator.start();
const synthAudioTrack = dest.stream.getAudioTracks()[0];
const resultStream = new MediaStream();
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();
}
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();
const audioTrack = dest.stream.getAudioTracks()[0];
// Combine video + audio into one stream
const resultStream = new MediaStream([videoTrack, audioTrack]);
// 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,143 @@
import {
expect,
type Locator,
type Page
} from '@playwright/test';
export type 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,45 @@
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; click through.
const registerLink = this.page.getByRole('link', { name: 'Register' })
.or(this.page.getByText('Register'));
await registerLink.first().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();
}
}

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

@@ -0,0 +1,39 @@
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',
port: 4200,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View File

@@ -0,0 +1,295 @@
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');
});
});
});
type 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,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);
}

129
electron/app/auto-start.ts Normal file
View File

@@ -0,0 +1,129 @@
import { app } from 'electron';
import AutoLaunch from 'auto-launch';
import * as fsp from 'fs/promises';
import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings';
let autoLauncher: AutoLaunch | null = null;
let autoLaunchPath = '';
const LINUX_AUTO_START_ARGUMENTS = ['--no-sandbox', '%U'];
function resolveLaunchPath(): string {
// AppImage runs from a temporary mount; APPIMAGE points to the real file path.
const appImagePath = process.platform === 'linux'
? String(process.env['APPIMAGE'] || '').trim()
: '';
return appImagePath || process.execPath;
}
function escapeDesktopEntryExecArgument(argument: string): string {
const escapedArgument = argument.replace(/(["\\$`])/g, '\\$1');
return /[\s"]/u.test(argument)
? `"${escapedArgument}"`
: escapedArgument;
}
function getLinuxAutoStartDesktopEntryPath(launchPath: string): string {
return path.join(app.getPath('home'), '.config', 'autostart', `${path.basename(launchPath)}.desktop`);
}
function buildLinuxAutoStartExecLine(launchPath: string): string {
return `Exec=${[escapeDesktopEntryExecArgument(launchPath), ...LINUX_AUTO_START_ARGUMENTS].join(' ')}`;
}
function buildLinuxAutoStartDesktopEntry(launchPath: string): string {
const appName = path.basename(launchPath);
return [
'[Desktop Entry]',
'Type=Application',
'Version=1.0',
`Name=${appName}`,
`Comment=${appName}startup script`,
buildLinuxAutoStartExecLine(launchPath),
'StartupNotify=false',
'Terminal=false'
].join('\n');
}
async function synchronizeLinuxAutoStartDesktopEntry(launchPath: string): Promise<void> {
if (process.platform !== 'linux') {
return;
}
const desktopEntryPath = getLinuxAutoStartDesktopEntryPath(launchPath);
const execLine = buildLinuxAutoStartExecLine(launchPath);
let currentDesktopEntry = '';
try {
currentDesktopEntry = await fsp.readFile(desktopEntryPath, 'utf8');
} catch {
// Create the desktop entry if auto-launch did not leave one behind.
}
const nextDesktopEntry = currentDesktopEntry
? /^Exec=.*$/m.test(currentDesktopEntry)
? currentDesktopEntry.replace(/^Exec=.*$/m, execLine)
: `${currentDesktopEntry.trimEnd()}\n${execLine}\n`
: buildLinuxAutoStartDesktopEntry(launchPath);
if (nextDesktopEntry === currentDesktopEntry) {
return;
}
await fsp.mkdir(path.dirname(desktopEntryPath), { recursive: true });
await fsp.writeFile(desktopEntryPath, nextDesktopEntry, 'utf8');
}
function getAutoLauncher(): AutoLaunch | null {
if (!app.isPackaged) {
return null;
}
if (!autoLauncher) {
autoLaunchPath = resolveLaunchPath();
autoLauncher = new AutoLaunch({
name: app.getName(),
path: autoLaunchPath
});
}
return autoLauncher;
}
async function setAutoStartEnabled(enabled: boolean): Promise<void> {
const launcher = getAutoLauncher();
if (!launcher) {
return;
}
const currentlyEnabled = await launcher.isEnabled();
if (!enabled && currentlyEnabled === enabled) {
return;
}
if (enabled) {
if (!currentlyEnabled) {
await launcher.enable();
}
await synchronizeLinuxAutoStartDesktopEntry(autoLaunchPath);
return;
}
await launcher.disable();
}
export async function synchronizeAutoStartSetting(enabled = readDesktopSettings().autoStart): Promise<void> {
try {
await setAutoStartEnabled(enabled);
} catch {
// Auto-launch integration should never block app startup or settings saves.
}
}

121
electron/app/deep-links.ts Normal file
View File

@@ -0,0 +1,121 @@
import { app } from 'electron';
import * as path from 'path';
import { createWindow, getMainWindow } from '../window/create-window';
const CUSTOM_PROTOCOL = 'toju';
const DEEP_LINK_PREFIX = `${CUSTOM_PROTOCOL}://`;
const DEV_SINGLE_INSTANCE_EXIT_CODE_ENV = 'METOYOU_SINGLE_INSTANCE_EXIT_CODE';
let pendingDeepLink: string | null = null;
function resolveDevSingleInstanceExitCode(): number | null {
const rawValue = process.env[DEV_SINGLE_INSTANCE_EXIT_CODE_ENV];
if (!rawValue) {
return null;
}
const parsedValue = Number.parseInt(rawValue, 10);
return Number.isInteger(parsedValue) && parsedValue > 0
? parsedValue
: null;
}
function extractDeepLink(argv: string[]): string | null {
return argv.find((argument) => typeof argument === 'string' && argument.startsWith(DEEP_LINK_PREFIX)) || null;
}
function focusMainWindow(): void {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.show();
mainWindow.focus();
}
function forwardDeepLink(url: string): void {
const mainWindow = getMainWindow();
if (!mainWindow || mainWindow.isDestroyed() || mainWindow.webContents.isLoadingMainFrame()) {
pendingDeepLink = url;
if (app.isReady() && (!mainWindow || mainWindow.isDestroyed())) {
void createWindow();
}
return;
}
focusMainWindow();
mainWindow.webContents.send('deep-link-received', url);
}
function registerProtocolClient(): void {
if (process.defaultApp) {
const appEntrypoint = process.argv[1];
if (appEntrypoint) {
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL, process.execPath, [path.resolve(appEntrypoint)]);
return;
}
}
app.setAsDefaultProtocolClient(CUSTOM_PROTOCOL);
}
export function initializeDeepLinkHandling(): boolean {
const hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!hasSingleInstanceLock) {
const devExitCode = resolveDevSingleInstanceExitCode();
if (devExitCode != null) {
app.exit(devExitCode);
} else {
app.quit();
}
return false;
}
registerProtocolClient();
const initialDeepLink = extractDeepLink(process.argv);
if (initialDeepLink) {
pendingDeepLink = initialDeepLink;
}
app.on('second-instance', (_event, argv) => {
focusMainWindow();
const deepLink = extractDeepLink(argv);
if (deepLink) {
forwardDeepLink(deepLink);
}
});
app.on('open-url', (event, url) => {
event.preventDefault();
forwardDeepLink(url);
});
return true;
}
export function consumePendingDeepLink(): string | null {
const deepLink = pendingDeepLink;
pendingDeepLink = null;
return deepLink;
}

View File

@@ -45,9 +45,9 @@ function linuxSpecificFlags(): void {
app.commandLine.appendSwitch('no-sandbox');
app.commandLine.appendSwitch('disable-dev-shm-usage');
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
// works for screen capture on Wayland compositors
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
// Chromium chooses the Linux Ozone platform before Electron runs this file.
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
// sessions so the browser process selects the correct backend early enough.
}
function networkFlags(): void {

View File

@@ -1,12 +1,19 @@
import { app, BrowserWindow } from 'electron';
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
import { synchronizeAutoStartSetting } from './auto-start';
import {
initializeDatabase,
destroyDatabase,
getDataSource
} from '../db/database';
import { createWindow, getDockIconPath } from '../window/create-window';
import {
createWindow,
getDockIconPath,
getMainWindow,
prepareWindowForAppQuit,
showMainWindow
} from '../window/create-window';
import {
setupCqrsHandlers,
setupSystemHandlers,
@@ -24,12 +31,18 @@ export function registerAppLifecycle(): void {
setupCqrsHandlers();
setupWindowControlHandlers();
setupSystemHandlers();
await synchronizeAutoStartSetting();
initializeDesktopUpdater();
await createWindow();
app.on('activate', () => {
if (getMainWindow()) {
void showMainWindow();
return;
}
if (BrowserWindow.getAllWindows().length === 0)
createWindow();
void createWindow();
});
});
@@ -39,6 +52,8 @@ export function registerAppLifecycle(): void {
});
app.on('before-quit', async (event) => {
prepareWindowForAppQuit();
if (getDataSource()?.isInitialized) {
event.preventDefault();
shutdownDesktopUpdater();

View File

@@ -3,6 +3,11 @@ import {
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
@@ -13,6 +18,11 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
await dataSource.getRepository(MessageEntity).clear();
await dataSource.getRepository(UserEntity).clear();
await dataSource.getRepository(RoomEntity).clear();
await dataSource.getRepository(RoomChannelEntity).clear();
await dataSource.getRepository(RoomMemberEntity).clear();
await dataSource.getRepository(RoomRoleEntity).clear();
await dataSource.getRepository(RoomUserRoleEntity).clear();
await dataSource.getRepository(RoomChannelPermissionEntity).clear();
await dataSource.getRepository(ReactionEntity).clear();
await dataSource.getRepository(BanEntity).clear();
await dataSource.getRepository(AttachmentEntity).clear();

View File

@@ -1,10 +1,25 @@
import { DataSource } from 'typeorm';
import { RoomEntity, MessageEntity } from '../../../entities';
import {
RoomChannelPermissionEntity,
RoomChannelEntity,
RoomEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
MessageEntity
} from '../../../entities';
import { DeleteRoomCommand } from '../../types';
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId } = command.payload;
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
await dataSource.getRepository(MessageEntity).delete({ roomId });
await dataSource.transaction(async (manager) => {
await manager.getRepository(RoomChannelPermissionEntity).delete({ roomId });
await manager.getRepository(RoomChannelEntity).delete({ roomId });
await manager.getRepository(RoomMemberEntity).delete({ roomId });
await manager.getRepository(RoomRoleEntity).delete({ roomId });
await manager.getRepository(RoomUserRoleEntity).delete({ roomId });
await manager.getRepository(RoomEntity).delete({ id: roomId });
await manager.getRepository(MessageEntity).delete({ roomId });
});
}

View File

@@ -1,23 +1,28 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { SaveMessageCommand } from '../../types';
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { message } = command.payload;
const entity = repo.create({
id: message.id,
roomId: message.roomId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
reactions: JSON.stringify(message.reactions ?? []),
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null
});
await repo.save(entity);
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity);
const entity = repo.create({
id: message.id,
roomId: message.roomId,
channelId: message.channelId ?? null,
senderId: message.senderId,
senderName: message.senderName,
content: message.content,
timestamp: message.timestamp,
editedAt: message.editedAt ?? null,
isDeleted: message.isDeleted ? 1 : 0,
replyToId: message.replyToId ?? null,
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
});
await repo.save(entity);
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
});
}

View File

@@ -1,27 +1,55 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { SaveRoomCommand } from '../../types';
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { room } = command.payload;
const entity = repo.create({
id: room.id,
name: room.name,
description: room.description ?? null,
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
channels: room.channels != null ? JSON.stringify(room.channels) : null,
members: room.members != null ? JSON.stringify(room.members) : null
});
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
return room.slowModeInterval;
}
await repo.save(entity);
const permissions = room.permissions && typeof room.permissions === 'object'
? room.permissions as { slowModeInterval?: unknown }
: null;
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: 0;
}
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
const { room } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity);
const entity = repo.create({
id: room.id,
name: room.name,
description: room.description ?? null,
topic: room.topic ?? null,
hostId: room.hostId,
password: room.password ?? null,
hasPassword: room.hasPassword ? 1 : 0,
isPrivate: room.isPrivate ? 1 : 0,
createdAt: room.createdAt,
userCount: room.userCount ?? 0,
maxUsers: room.maxUsers ?? null,
icon: room.icon ?? null,
iconUpdatedAt: room.iconUpdatedAt ?? null,
slowModeInterval: extractSlowModeInterval(room),
sourceId: room.sourceId ?? null,
sourceName: room.sourceName ?? null,
sourceUrl: room.sourceUrl ?? null
});
await repo.save(entity);
await replaceRoomRelations(manager, room.id, {
channels: room.channels ?? [],
members: room.members ?? [],
roles: room.roles ?? [],
roleAssignments: room.roleAssignments ?? [],
channelPermissions: room.channelPermissions ?? [],
permissions: room.permissions
});
});
}

View File

@@ -1,41 +1,52 @@
import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { replaceMessageReactions } from '../../relations';
import { UpdateMessageCommand } from '../../types';
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(MessageEntity);
const { messageId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: messageId } });
if (!existing)
return;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(MessageEntity);
const existing = await repo.findOne({ where: { id: messageId } });
if (updates.channelId !== undefined)
existing.channelId = updates.channelId ?? null;
if (!existing)
return;
if (updates.senderId !== undefined)
existing.senderId = updates.senderId;
const directFields = [
'senderId',
'senderName',
'content',
'timestamp'
] as const;
const entity = existing as unknown as Record<string, unknown>;
if (updates.senderName !== undefined)
existing.senderName = updates.senderName;
for (const field of directFields) {
if (updates[field] !== undefined)
entity[field] = updates[field];
}
if (updates.content !== undefined)
existing.content = updates.content;
const nullableFields = [
'channelId',
'editedAt',
'replyToId'
] as const;
if (updates.timestamp !== undefined)
existing.timestamp = updates.timestamp;
for (const field of nullableFields) {
if (updates[field] !== undefined)
entity[field] = updates[field] ?? null;
}
if (updates.editedAt !== undefined)
existing.editedAt = updates.editedAt ?? null;
if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0;
if (updates.reactions !== undefined)
existing.reactions = JSON.stringify(updates.reactions ?? []);
if (updates.linkMetadata !== undefined)
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
if (updates.isDeleted !== undefined)
existing.isDeleted = updates.isDeleted ? 1 : 0;
await repo.save(existing);
if (updates.replyToId !== undefined)
existing.replyToId = updates.replyToId ?? null;
await repo.save(existing);
if (updates.reactions !== undefined) {
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
}
});
}

View File

@@ -1,30 +1,68 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { replaceRoomRelations } from '../../relations';
import { UpdateRoomCommand } from '../../types';
import {
applyUpdates,
boolToInt,
jsonOrNull,
TransformMap
} from './utils/applyUpdates';
const ROOM_TRANSFORMS: TransformMap = {
hasPassword: boolToInt,
isPrivate: boolToInt,
userCount: (val) => (val ?? 0),
permissions: jsonOrNull,
channels: jsonOrNull,
members: jsonOrNull
userCount: (val) => (val ?? 0)
};
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(RoomEntity);
const { roomId, updates } = command.payload;
const existing = await repo.findOne({ where: { id: roomId } });
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
return updates.slowModeInterval;
}
if (!existing)
return;
const permissions = updates.permissions && typeof updates.permissions === 'object'
? updates.permissions as { slowModeInterval?: unknown }
: null;
applyUpdates(existing, updates, ROOM_TRANSFORMS);
await repo.save(existing);
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
? permissions.slowModeInterval
: undefined;
}
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
const { roomId, updates } = command.payload;
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(RoomEntity);
const existing = await repo.findOne({ where: { id: roomId } });
if (!existing)
return;
const {
channels,
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions,
...entityUpdates
} = updates;
const slowModeInterval = extractSlowModeInterval(updates);
if (slowModeInterval !== undefined) {
entityUpdates.slowModeInterval = slowModeInterval;
}
applyUpdates(existing, entityUpdates, ROOM_TRANSFORMS);
await repo.save(existing);
await replaceRoomRelations(manager, roomId, {
channels,
members,
roles,
roleAssignments,
channelPermissions,
permissions: rawPermissions
});
});
}

View File

@@ -9,10 +9,19 @@ import { RoomEntity } from '../entities/RoomEntity';
import { ReactionEntity } from '../entities/ReactionEntity';
import { BanEntity } from '../entities/BanEntity';
import { AttachmentEntity } from '../entities/AttachmentEntity';
import { ReactionPayload } from './types';
import {
relationRecordToRoomPayload,
RoomChannelPermissionRecord,
RoomChannelRecord,
RoomMemberRecord,
RoomRoleAssignmentRecord,
RoomRoleRecord
} from './relations';
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
export function rowToMessage(row: MessageEntity) {
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
const isDeleted = !!row.isDeleted;
return {
@@ -24,9 +33,10 @@ export function rowToMessage(row: MessageEntity) {
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
timestamp: row.timestamp,
editedAt: row.editedAt ?? undefined,
reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
reactions: isDeleted ? [] : reactions,
isDeleted,
replyToId: row.replyToId ?? undefined
replyToId: row.replyToId ?? undefined,
linkMetadata: row.linkMetadata ? JSON.parse(row.linkMetadata) : undefined
};
}
@@ -49,7 +59,30 @@ export function rowToUser(row: UserEntity) {
};
}
export function rowToRoom(row: RoomEntity) {
export function rowToRoom(
row: RoomEntity,
relations: {
channels?: RoomChannelRecord[];
members?: RoomMemberRecord[];
roles?: RoomRoleRecord[];
roleAssignments?: RoomRoleAssignmentRecord[];
channelPermissions?: RoomChannelPermissionRecord[];
} = {
channels: [],
members: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
) {
const relationPayload = relationRecordToRoomPayload({ slowModeInterval: row.slowModeInterval }, {
channels: relations.channels ?? [],
members: relations.members ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return {
id: row.id,
name: row.name,
@@ -57,15 +90,23 @@ export function rowToRoom(row: RoomEntity) {
topic: row.topic ?? undefined,
hostId: row.hostId,
password: row.password ?? undefined,
hasPassword: !!row.hasPassword,
isPrivate: !!row.isPrivate,
createdAt: row.createdAt,
userCount: row.userCount,
maxUsers: row.maxUsers ?? undefined,
icon: row.icon ?? undefined,
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
channels: row.channels ? JSON.parse(row.channels) : undefined,
members: row.members ? JSON.parse(row.members) : undefined
slowModeInterval: row.slowModeInterval,
permissions: relationPayload.permissions,
channels: relationPayload.channels,
members: relationPayload.members,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
sourceId: row.sourceId ?? undefined,
sourceName: row.sourceName ?? undefined,
sourceUrl: row.sourceUrl ?? undefined
};
}

View File

@@ -1,10 +1,12 @@
import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { rowToRoom } from '../../mappers';
import { loadRoomRelationsMap } from '../../relations';
export async function handleGetAllRooms(dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const rows = await repo.find();
const relationsByRoomId = await loadRoomRelationsMap(dataSource, rows.map((row) => row.id));
return rows.map(rowToRoom);
return rows.map((row) => rowToRoom(row, relationsByRoomId.get(row.id)));
}

View File

@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessageByIdQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const row = await repo.findOne({ where: { id: query.payload.messageId } });
return row ? rowToMessage(row) : null;
if (!row) {
return null;
}
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, [row.id]);
return rowToMessage(row, reactionsByMessageId.get(row.id) ?? []);
}

View File

@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
@@ -12,6 +13,7 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
take: limit,
skip: offset
});
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
return rows.map(rowToMessage);
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
}

View File

@@ -0,0 +1,20 @@
import { DataSource, MoreThan } from 'typeorm';
import { MessageEntity } from '../../../entities';
import { GetMessagesSinceQuery } from '../../types';
import { rowToMessage } from '../../mappers';
import { loadMessageReactionsMap } from '../../relations';
export async function handleGetMessagesSince(query: GetMessagesSinceQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(MessageEntity);
const { roomId, sinceTimestamp } = query.payload;
const rows = await repo.find({
where: {
roomId,
timestamp: MoreThan(sinceTimestamp)
},
order: { timestamp: 'ASC' }
});
const reactionsByMessageId = await loadMessageReactionsMap(dataSource, rows.map((row) => row.id));
return rows.map((row) => rowToMessage(row, reactionsByMessageId.get(row.id) ?? []));
}

View File

@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
import { RoomEntity } from '../../../entities';
import { GetRoomQuery } from '../../types';
import { rowToRoom } from '../../mappers';
import { loadRoomRelationsMap } from '../../relations';
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(RoomEntity);
const row = await repo.findOne({ where: { id: query.payload.roomId } });
return row ? rowToRoom(row) : null;
if (!row) {
return null;
}
const relationsByRoomId = await loadRoomRelationsMap(dataSource, [row.id]);
return rowToRoom(row, relationsByRoomId.get(row.id));
}

View File

@@ -4,6 +4,7 @@ import {
QueryTypeKey,
Query,
GetMessagesQuery,
GetMessagesSinceQuery,
GetMessageByIdQuery,
GetReactionsForMessageQuery,
GetUserQuery,
@@ -13,6 +14,7 @@ import {
GetAttachmentsForMessageQuery
} from '../types';
import { handleGetMessages } from './handlers/getMessages';
import { handleGetMessagesSince } from './handlers/getMessagesSince';
import { handleGetMessageById } from './handlers/getMessageById';
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
import { handleGetUser } from './handlers/getUser';
@@ -27,6 +29,7 @@ import { handleGetAllAttachments } from './handlers/getAllAttachments';
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
[QueryType.GetMessagesSince]: (query) => handleGetMessagesSince(query as GetMessagesSinceQuery, dataSource),
[QueryType.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),

1002
electron/cqrs/relations.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
export const QueryType = {
GetMessages: 'get-messages',
GetMessagesSince: 'get-messages-since',
GetMessageById: 'get-message-by-id',
GetReactionsForMessage: 'get-reactions-for-message',
GetUser: 'get-user',
@@ -49,6 +50,7 @@ export interface MessagePayload {
reactions?: ReactionPayload[];
isDeleted?: boolean;
replyToId?: string;
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
}
export interface ReactionPayload {
@@ -60,6 +62,44 @@ export interface ReactionPayload {
timestamp: number;
}
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
export type RoomPermissionKeyPayload =
| 'manageServer'
| 'manageRoles'
| 'manageChannels'
| 'manageIcon'
| 'kickMembers'
| 'banMembers'
| 'manageBans'
| 'deleteMessages'
| 'joinVoice'
| 'shareScreen'
| 'uploadFiles';
export interface AccessRolePayload {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: Partial<Record<RoomPermissionKeyPayload, PermissionStatePayload>>;
}
export interface RoleAssignmentPayload {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionPayload {
channelId: string;
targetType: 'role' | 'user';
targetId: string;
permission: RoomPermissionKeyPayload;
value: PermissionStatePayload;
}
export interface UserPayload {
id: string;
oderId?: string;
@@ -84,15 +124,23 @@ export interface RoomPayload {
topic?: string;
hostId: string;
password?: string;
hasPassword?: boolean;
isPrivate?: boolean;
createdAt: number;
userCount?: number;
maxUsers?: number;
icon?: string;
iconUpdatedAt?: number;
slowModeInterval?: number;
permissions?: unknown;
channels?: unknown[];
members?: unknown[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
sourceId?: string;
sourceName?: string;
sourceUrl?: string;
}
export interface BanPayload {
@@ -156,6 +204,7 @@ export type Command =
| ClearAllDataCommand;
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
export interface GetMessagesSinceQuery { type: typeof QueryType.GetMessagesSince; payload: { roomId: string; sinceTimestamp: number } }
export interface GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: string } }
@@ -170,6 +219,7 @@ export interface GetAllAttachmentsQuery { type: typeof QueryType.GetAllAttachmen
export type Query =
| GetMessagesQuery
| GetMessagesSinceQuery
| GetMessageByIdQuery
| GetReactionsForMessageQuery
| GetUserQuery

View File

@@ -17,6 +17,11 @@ import {
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
@@ -38,6 +43,11 @@ export const AppDataSource = new DataSource({
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,

View File

@@ -7,6 +7,11 @@ import {
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,
@@ -40,6 +45,11 @@ export async function initializeDatabase(): Promise<void> {
MessageEntity,
UserEntity,
RoomEntity,
RoomChannelEntity,
RoomMemberEntity,
RoomRoleEntity,
RoomUserRoleEntity,
RoomChannelPermissionEntity,
ReactionEntity,
BanEntity,
AttachmentEntity,

View File

@@ -6,6 +6,8 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
export interface DesktopSettings {
autoUpdateMode: AutoUpdateMode;
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
@@ -19,6 +21,8 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
autoUpdateMode: 'auto',
autoStart: true,
closeToTray: true,
hardwareAcceleration: true,
manifestUrls: [],
preferredVersion: null,
@@ -81,6 +85,12 @@ export function readDesktopSettings(): DesktopSettings {
return {
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
autoStart: typeof parsed.autoStart === 'boolean'
? parsed.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof parsed.closeToTray === 'boolean'
? parsed.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
? parsed.vaapiVideoEncode
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
@@ -102,6 +112,12 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
};
const nextSettings: DesktopSettings = {
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
autoStart: typeof mergedSettings.autoStart === 'boolean'
? mergedSettings.autoStart
: DEFAULT_DESKTOP_SETTINGS.autoStart,
closeToTray: typeof mergedSettings.closeToTray === 'boolean'
? mergedSettings.closeToTray
: DEFAULT_DESKTOP_SETTINGS.closeToTray,
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
? mergedSettings.hardwareAcceleration
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,

View File

@@ -30,12 +30,12 @@ export class MessageEntity {
@Column('integer', { nullable: true })
editedAt!: number | null;
@Column('text', { default: '[]' })
reactions!: string;
@Column('integer', { default: 0 })
isDeleted!: number;
@Column('text', { nullable: true })
replyToId!: string | null;
@Column('text', { nullable: true })
linkMetadata!: string | null;
}

View File

@@ -0,0 +1,23 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('room_channels')
export class RoomChannelEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
channelId!: string;
@Column('text')
name!: string;
@Column('text')
type!: 'text' | 'voice';
@Column('integer')
position!: number;
}

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_channel_permissions')
export class RoomChannelPermissionEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
channelId!: string;
@PrimaryColumn('text')
targetType!: 'role' | 'user';
@PrimaryColumn('text')
targetId!: string;
@PrimaryColumn('text')
permission!: string;
@Column('text')
value!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -24,6 +24,9 @@ export class RoomEntity {
@Column('text', { nullable: true })
password!: string | null;
@Column('integer', { default: 0 })
hasPassword!: number;
@Column('integer', { default: 0 })
isPrivate!: number;
@@ -42,12 +45,15 @@ export class RoomEntity {
@Column('integer', { nullable: true })
iconUpdatedAt!: number | null;
@Column('text', { nullable: true })
permissions!: string | null;
@Column('integer', { default: 0 })
slowModeInterval!: number;
@Column('text', { nullable: true })
channels!: string | null;
sourceId!: string | null;
@Column('text', { nullable: true })
members!: string | null;
sourceName!: string | null;
@Column('text', { nullable: true })
sourceUrl!: string | null;
}

View File

@@ -0,0 +1,38 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('room_members')
export class RoomMemberEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
memberKey!: string;
@Column('text')
id!: string;
@Column('text', { nullable: true })
oderId!: string | null;
@Column('text')
username!: string;
@Column('text')
displayName!: string;
@Column('text', { nullable: true })
avatarUrl!: string | null;
@Column('text')
role!: 'host' | 'admin' | 'moderator' | 'member';
@Column('integer')
joinedAt!: number;
@Column('integer')
lastSeenAt!: number;
}

View File

@@ -0,0 +1,59 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_roles')
export class RoomRoleEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
color!: string | null;
@Column('integer')
position!: number;
@Column('integer', { default: 0 })
isSystem!: number;
@Column('text', { default: 'inherit' })
manageServer!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageRoles!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageChannels!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageIcon!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
kickMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
banMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageBans!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
deleteMessages!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
joinVoice!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
shareScreen!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
uploadFiles!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -0,0 +1,23 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('room_user_roles')
export class RoomUserRoleEntity {
@PrimaryColumn('text')
roomId!: string;
@PrimaryColumn('text')
userKey!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
userId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
}

View File

@@ -1,6 +1,11 @@
export { MessageEntity } from './MessageEntity';
export { UserEntity } from './UserEntity';
export { RoomEntity } from './RoomEntity';
export { RoomChannelEntity } from './RoomChannelEntity';
export { RoomMemberEntity } from './RoomMemberEntity';
export { RoomRoleEntity } from './RoomRoleEntity';
export { RoomUserRoleEntity } from './RoomUserRoleEntity';
export { RoomChannelPermissionEntity } from './RoomChannelPermissionEntity';
export { ReactionEntity } from './ReactionEntity';
export { BanEntity } from './BanEntity';
export { AttachmentEntity } from './AttachmentEntity';

View File

@@ -4,6 +4,9 @@ import {
desktopCapturer,
dialog,
ipcMain,
nativeImage,
net,
Notification,
shell
} from 'electron';
import * as fs from 'fs';
@@ -28,8 +31,23 @@ import {
getDesktopUpdateState,
handleDesktopSettingsChanged,
restartToApplyUpdate,
readDesktopUpdateServerHealth,
type DesktopUpdateServerContext
} from '../update/desktop-updater';
import { consumePendingDeepLink } from '../app/deep-links';
import { synchronizeAutoStartSetting } from '../app/auto-start';
import {
getMainWindow,
getWindowIconPath,
updateCloseToTraySetting
} from '../window/create-window';
import {
deleteSavedTheme,
getSavedThemesPath,
listSavedThemes,
readSavedTheme,
writeSavedTheme
} from '../theme-library';
const DEFAULT_MIME_TYPE = 'application/octet-stream';
const FILE_CLIPBOARD_FORMATS = [
@@ -83,6 +101,63 @@ interface ClipboardFilePayload {
path?: string;
}
interface DesktopNotificationPayload {
body: string;
requestAttention?: boolean;
title: string;
}
function resolveLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
}
const ozonePlatform = app.commandLine.getSwitchValue('ozone-platform')
.trim()
.toLowerCase();
if (ozonePlatform === 'wayland') {
return 'Wayland';
}
if (ozonePlatform === 'x11') {
return 'X11';
}
const ozonePlatformHint = app.commandLine.getSwitchValue('ozone-platform-hint')
.trim()
.toLowerCase();
if (ozonePlatformHint === 'wayland') {
return 'Wayland';
}
if (ozonePlatformHint === 'x11') {
return 'X11';
}
const sessionType = String(process.env['XDG_SESSION_TYPE'] || '').trim()
.toLowerCase();
if (sessionType === 'wayland') {
return 'Wayland';
}
if (sessionType === 'x11') {
return 'X11';
}
if (String(process.env['WAYLAND_DISPLAY'] || '').trim().length > 0) {
return 'Wayland';
}
if (String(process.env['DISPLAY'] || '').trim().length > 0) {
return 'X11';
}
return 'Unknown (Linux)';
}
function isSupportedClipboardFileFormat(format: string): boolean {
return FILE_CLIPBOARD_FORMATS.some(
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
@@ -194,6 +269,10 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
}
export function setupSystemHandlers(): void {
ipcMain.on('get-linux-display-server', (event) => {
event.returnValue = resolveLinuxDisplayServer();
});
ipcMain.handle('open-external', async (_event, url: string) => {
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
await shell.openExternal(url);
@@ -203,6 +282,8 @@ export function setupSystemHandlers(): void {
return false;
});
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
ipcMain.handle('get-sources', async () => {
try {
const thumbnailSize = { width: 240, height: 150 };
@@ -253,11 +334,91 @@ export function setupSystemHandlers(): void {
});
ipcMain.handle('get-app-data-path', () => app.getPath('userData'));
ipcMain.handle('get-saved-themes-path', async () => await getSavedThemesPath());
ipcMain.handle('list-saved-themes', async () => await listSavedThemes());
ipcMain.handle('read-saved-theme', async (_event, fileName: string) => await readSavedTheme(fileName));
ipcMain.handle('write-saved-theme', async (_event, fileName: string, text: string) => {
return await writeSavedTheme(fileName, text);
});
ipcMain.handle('delete-saved-theme', async (_event, fileName: string) => {
return await deleteSavedTheme(fileName);
});
ipcMain.handle('get-desktop-settings', () => getDesktopSettingsSnapshot());
ipcMain.handle('show-desktop-notification', async (_event, payload: DesktopNotificationPayload) => {
const title = typeof payload?.title === 'string' ? payload.title.trim() : '';
const body = typeof payload?.body === 'string' ? payload.body : '';
const mainWindow = getMainWindow();
const suppressSystemNotification = mainWindow?.isVisible() === true
&& !mainWindow.isMinimized()
&& mainWindow.isMaximized();
if (!title) {
return false;
}
if (!suppressSystemNotification && Notification.isSupported()) {
try {
const notification = new Notification({
title,
body,
icon: getWindowIconPath(),
silent: true
});
notification.on('click', () => {
if (!mainWindow) {
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
});
notification.show();
} catch {
// Ignore notification center failures and still attempt taskbar attention.
}
}
if (payload?.requestAttention && mainWindow && (mainWindow.isMinimized() || !mainWindow.isFocused())) {
mainWindow.flashFrame(true);
}
return true;
});
ipcMain.handle('request-window-attention', () => {
const mainWindow = getMainWindow();
if (!mainWindow || (!mainWindow.isMinimized() && mainWindow.isFocused())) {
return false;
}
mainWindow.flashFrame(true);
return true;
});
ipcMain.handle('clear-window-attention', () => {
getMainWindow()?.flashFrame(false);
return true;
});
ipcMain.handle('get-auto-update-state', () => getDesktopUpdateState());
ipcMain.handle('get-auto-update-server-health', async (_event, serverUrl: string) => {
return await readDesktopUpdateServerHealth(serverUrl);
});
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
return await configureDesktopUpdaterContext(context);
});
@@ -271,6 +432,8 @@ export function setupSystemHandlers(): void {
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
const snapshot = updateDesktopSettings(patch);
await synchronizeAutoStartSetting(snapshot.autoStart);
updateCloseToTraySetting(snapshot.closeToTray);
await handleDesktopSettingsChanged();
return snapshot;
});
@@ -342,4 +505,56 @@ export function setupSystemHandlers(): void {
await fsp.mkdir(dirPath, { recursive: 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;
}
});
}

View File

@@ -1,6 +1,10 @@
import 'reflect-metadata';
import { initializeDeepLinkHandling } from './app/deep-links';
import { configureAppFlags } from './app/flags';
import { registerAppLifecycle } from './app/lifecycle';
configureAppFlags();
registerAppLifecycle();
if (initializeDeepLinkHandling()) {
registerAppLifecycle();
}

View File

@@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRoomSourceAndPasswordState1000000000002 implements MigrationInterface {
name = 'AddRoomSourceAndPasswordState1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "hasPassword" INTEGER NOT NULL DEFAULT 0`);
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceId" TEXT`);
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceName" TEXT`);
await queryRunner.query(`ALTER TABLE "rooms" ADD COLUMN "sourceUrl" TEXT`);
await queryRunner.query(`
UPDATE "rooms"
SET "hasPassword" = CASE
WHEN "password" IS NOT NULL AND TRIM("password") <> '' THEN 1
ELSE 0
END
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceUrl"`);
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceName"`);
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "sourceId"`);
await queryRunner.query(`ALTER TABLE "rooms" DROP COLUMN "hasPassword"`);
}
}

View File

@@ -0,0 +1,396 @@
import { randomUUID } from 'crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyMessageRow = {
id: string;
reactions: string | null;
};
type LegacyRoomRow = {
id: string;
channels: string | null;
members: string | null;
};
type ChannelType = 'text' | 'voice';
type RoomMemberRole = 'host' | 'admin' | 'moderator' | 'member';
type LegacyReaction = {
id?: unknown;
oderId?: unknown;
userId?: unknown;
emoji?: unknown;
timestamp?: unknown;
};
type LegacyRoomChannel = {
id?: unknown;
name?: unknown;
type?: unknown;
position?: unknown;
};
type LegacyRoomMember = {
id?: unknown;
oderId?: unknown;
username?: unknown;
displayName?: unknown;
avatarUrl?: unknown;
role?: unknown;
joinedAt?: unknown;
lastSeenAt?: unknown;
};
function parseArray<T>(raw: string | null): T[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed as T[] : [];
} catch {
return [];
}
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: ChannelType, name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function memberKey(member: { id?: string; oderId?: string }): string {
return member.oderId?.trim() || member.id?.trim() || '';
}
function fallbackDisplayName(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
return member.displayName || member.username || member.oderId || member.id || 'User';
}
function fallbackUsername(member: Partial<{ displayName: string; username: string; oderId: string; id: string }>): string {
const base = fallbackDisplayName(member)
.trim()
.toLowerCase()
.replace(/\s+/g, '_');
return base || member.oderId || member.id || 'user';
}
function normalizeRoomMemberRole(value: unknown): RoomMemberRole {
return value === 'host' || value === 'admin' || value === 'moderator' || value === 'member'
? value
: 'member';
}
function mergeRoomMemberRole(
existingRole: RoomMemberRole,
incomingRole: RoomMemberRole,
preferIncoming: boolean
): RoomMemberRole {
if (existingRole === incomingRole) {
return existingRole;
}
if (incomingRole === 'member' && existingRole !== 'member') {
return existingRole;
}
if (existingRole === 'member' && incomingRole !== 'member') {
return incomingRole;
}
return preferIncoming ? incomingRole : existingRole;
}
function compareRoomMembers(
firstMember: {
id: string;
oderId?: string;
displayName: string;
},
secondMember: {
id: string;
oderId?: string;
displayName: string;
}
): number {
const displayNameCompare = firstMember.displayName.localeCompare(secondMember.displayName, undefined, { sensitivity: 'base' });
if (displayNameCompare !== 0) {
return displayNameCompare;
}
return memberKey(firstMember).localeCompare(memberKey(secondMember));
}
function normalizeMessageReactions(messageId: string, raw: string | null) {
const reactions = parseArray<LegacyReaction>(raw);
const seen = new Set<string>();
return reactions.flatMap((reaction) => {
const emoji = typeof reaction.emoji === 'string' ? reaction.emoji : '';
const userId = typeof reaction.userId === 'string' ? reaction.userId : '';
const dedupeKey = `${userId}:${emoji}`;
if (!emoji || seen.has(dedupeKey)) {
return [];
}
seen.add(dedupeKey);
return [{
id: typeof reaction.id === 'string' && reaction.id.trim() ? reaction.id : randomUUID(),
messageId,
oderId: typeof reaction.oderId === 'string' ? reaction.oderId : null,
userId: userId || null,
emoji,
timestamp: isFiniteNumber(reaction.timestamp) ? reaction.timestamp : 0
}];
});
}
function normalizeRoomChannels(raw: string | null) {
const channels = parseArray<LegacyRoomChannel>(raw);
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return channels.flatMap((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = isFiniteNumber(channel.position) ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return [];
}
seenIds.add(id);
seenNames.add(nameKey);
return [{
channelId: id,
name,
type,
position
}];
});
}
function normalizeRoomMembers(raw: string | null, now = Date.now()) {
const members = parseArray<LegacyRoomMember>(raw);
const membersByKey = new Map<string, {
id: string;
oderId?: string;
username: string;
displayName: string;
avatarUrl?: string;
role: RoomMemberRole;
joinedAt: number;
lastSeenAt: number;
}>();
for (const rawMember of members) {
const normalizedId = typeof rawMember.id === 'string' ? rawMember.id.trim() : '';
const normalizedOderId = typeof rawMember.oderId === 'string' ? rawMember.oderId.trim() : '';
const key = normalizedOderId || normalizedId;
if (!key) {
continue;
}
const lastSeenAt = isFiniteNumber(rawMember.lastSeenAt)
? rawMember.lastSeenAt
: isFiniteNumber(rawMember.joinedAt)
? rawMember.joinedAt
: now;
const joinedAt = isFiniteNumber(rawMember.joinedAt) ? rawMember.joinedAt : lastSeenAt;
const username = typeof rawMember.username === 'string' ? rawMember.username.trim() : '';
const displayName = typeof rawMember.displayName === 'string' ? rawMember.displayName.trim() : '';
const avatarUrl = typeof rawMember.avatarUrl === 'string' ? rawMember.avatarUrl.trim() : '';
const nextMember = {
id: normalizedId || key,
oderId: normalizedOderId || undefined,
username: username || fallbackUsername({ id: normalizedId || key, oderId: normalizedOderId || undefined, displayName }),
displayName: displayName || fallbackDisplayName({ id: normalizedId || key, oderId: normalizedOderId || undefined, username }),
avatarUrl: avatarUrl || undefined,
role: normalizeRoomMemberRole(rawMember.role),
joinedAt,
lastSeenAt
};
const existingMember = membersByKey.get(key);
if (!existingMember) {
membersByKey.set(key, nextMember);
continue;
}
const preferIncoming = nextMember.lastSeenAt >= existingMember.lastSeenAt;
membersByKey.set(key, {
id: existingMember.id || nextMember.id,
oderId: nextMember.oderId || existingMember.oderId,
username: preferIncoming
? (nextMember.username || existingMember.username)
: (existingMember.username || nextMember.username),
displayName: preferIncoming
? (nextMember.displayName || existingMember.displayName)
: (existingMember.displayName || nextMember.displayName),
avatarUrl: preferIncoming
? (nextMember.avatarUrl || existingMember.avatarUrl)
: (existingMember.avatarUrl || nextMember.avatarUrl),
role: mergeRoomMemberRole(existingMember.role, nextMember.role, preferIncoming),
joinedAt: Math.min(existingMember.joinedAt, nextMember.joinedAt),
lastSeenAt: Math.max(existingMember.lastSeenAt, nextMember.lastSeenAt)
});
}
return Array.from(membersByKey.values()).sort(compareRoomMembers);
}
export class NormalizeArrayColumns1000000000003 implements MigrationInterface {
name = 'NormalizeArrayColumns1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_channels" (
"roomId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"position" INTEGER NOT NULL,
PRIMARY KEY ("roomId", "channelId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channels_roomId" ON "room_channels" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_members" (
"roomId" TEXT NOT NULL,
"memberKey" TEXT NOT NULL,
"id" TEXT NOT NULL,
"oderId" TEXT,
"username" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"role" TEXT NOT NULL,
"joinedAt" INTEGER NOT NULL,
"lastSeenAt" INTEGER NOT NULL,
PRIMARY KEY ("roomId", "memberKey")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_members_roomId" ON "room_members" ("roomId")`);
const messageRows = await queryRunner.query(`SELECT "id", "reactions" FROM "messages"`) as LegacyMessageRow[];
for (const row of messageRows) {
const reactions = normalizeMessageReactions(row.id, row.reactions);
for (const reaction of reactions) {
const existing = await queryRunner.query(
`SELECT 1 FROM "reactions" WHERE "messageId" = ? AND "userId" IS ? AND "emoji" = ? LIMIT 1`,
[reaction.messageId, reaction.userId, reaction.emoji]
) as Array<{ 1: number }>;
if (existing.length > 0) {
continue;
}
await queryRunner.query(
`INSERT INTO "reactions" ("id", "messageId", "oderId", "userId", "emoji", "timestamp") VALUES (?, ?, ?, ?, ?, ?)`,
[reaction.id, reaction.messageId, reaction.oderId, reaction.userId, reaction.emoji, reaction.timestamp]
);
}
}
const roomRows = await queryRunner.query(`SELECT "id", "channels", "members" FROM "rooms"`) as LegacyRoomRow[];
for (const row of roomRows) {
for (const channel of normalizeRoomChannels(row.channels)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_channels" ("roomId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
[row.id, channel.channelId, channel.name, channel.type, channel.position]
);
}
for (const member of normalizeRoomMembers(row.members)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_members" ("roomId", "memberKey", "id", "oderId", "username", "displayName", "avatarUrl", "role", "joinedAt", "lastSeenAt") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
row.id,
memberKey(member),
member.id,
member.oderId ?? null,
member.username,
member.displayName,
member.avatarUrl ?? null,
member.role,
member.joinedAt,
member.lastSeenAt
]
);
}
}
await queryRunner.query(`
CREATE TABLE "messages_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"roomId" TEXT NOT NULL,
"channelId" TEXT,
"senderId" TEXT NOT NULL,
"senderName" TEXT NOT NULL,
"content" TEXT NOT NULL,
"timestamp" INTEGER NOT NULL,
"editedAt" INTEGER,
"isDeleted" INTEGER NOT NULL DEFAULT 0,
"replyToId" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "messages_next" ("id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId")
SELECT "id", "roomId", "channelId", "senderId", "senderName", "content", "timestamp", "editedAt", "isDeleted", "replyToId"
FROM "messages"
`);
await queryRunner.query(`DROP TABLE "messages"`);
await queryRunner.query(`ALTER TABLE "messages_next" RENAME TO "messages"`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_messages_roomId" ON "messages" ("roomId")`);
await queryRunner.query(`
CREATE TABLE "rooms_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"permissions" TEXT,
"hasPassword" INTEGER NOT NULL DEFAULT 0,
"sourceId" TEXT,
"sourceName" TEXT,
"sourceUrl" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl")
SELECT "id", "name", "description", "topic", "hostId", "password", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "hasPassword", "sourceId", "sourceName", "sourceUrl"
FROM "rooms"
`);
await queryRunner.query(`DROP TABLE "rooms"`);
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "room_members"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_channels"`);
}
}

View File

@@ -0,0 +1,310 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyRoomRow = {
id: string;
name: string;
description: string | null;
topic: string | null;
hostId: string;
password: string | null;
hasPassword: number;
isPrivate: number;
createdAt: number;
userCount: number;
maxUsers: number | null;
icon: string | null;
iconUpdatedAt: number | null;
permissions: string | null;
sourceId: string | null;
sourceName: string | null;
sourceUrl: string | null;
};
type RoomMemberRow = {
roomId: string;
memberKey: string;
id: string;
oderId: string | null;
role: string;
};
type LegacyRoomPermissions = {
adminsManageRooms?: boolean;
moderatorsManageRooms?: boolean;
adminsManageIcon?: boolean;
moderatorsManageIcon?: boolean;
allowVoice?: boolean;
allowScreenShare?: boolean;
allowFileUploads?: boolean;
slowModeInterval?: number;
};
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
function parseLegacyPermissions(rawPermissions: string | null): LegacyRoomPermissions {
try {
const parsed = JSON.parse(rawPermissions || '{}') as Record<string, unknown>;
return {
adminsManageRooms: parsed['adminsManageRooms'] === true,
moderatorsManageRooms: parsed['moderatorsManageRooms'] === true,
adminsManageIcon: parsed['adminsManageIcon'] === true,
moderatorsManageIcon: parsed['moderatorsManageIcon'] === true,
allowVoice: parsed['allowVoice'] !== false,
allowScreenShare: parsed['allowScreenShare'] !== false,
allowFileUploads: parsed['allowFileUploads'] !== false,
slowModeInterval: typeof parsed['slowModeInterval'] === 'number' && Number.isFinite(parsed['slowModeInterval'])
? parsed['slowModeInterval']
: 0
};
} catch {
return {
allowVoice: true,
allowScreenShare: true,
allowFileUploads: true,
slowModeInterval: 0
};
}
}
function buildDefaultRoomRoles(legacyPermissions: LegacyRoomPermissions) {
return [
{
roleId: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'inherit',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'inherit',
joinVoice: legacyPermissions.allowVoice === false ? 'deny' : 'allow',
shareScreen: legacyPermissions.allowScreenShare === false ? 'deny' : 'allow',
uploadFiles: legacyPermissions.allowFileUploads === false ? 'deny' : 'allow'
},
{
roleId: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: legacyPermissions.moderatorsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.moderatorsManageIcon ? 'allow' : 'inherit',
kickMembers: 'allow',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
},
{
roleId: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: legacyPermissions.adminsManageRooms ? 'allow' : 'inherit',
manageIcon: legacyPermissions.adminsManageIcon ? 'allow' : 'inherit',
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
}
];
}
function roleIdsForMemberRole(role: string): string[] {
if (role === 'admin') {
return [SYSTEM_ROLE_IDS.admin];
}
if (role === 'moderator') {
return [SYSTEM_ROLE_IDS.moderator];
}
return [];
}
export class NormalizeRoomAccessControl1000000000004 implements MigrationInterface {
name = 'NormalizeRoomAccessControl1000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_roles" (
"roomId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"position" INTEGER NOT NULL,
"isSystem" INTEGER NOT NULL DEFAULT 0,
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
PRIMARY KEY ("roomId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_roles_roomId" ON "room_roles" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_user_roles" (
"roomId" TEXT NOT NULL,
"userKey" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"oderId" TEXT,
PRIMARY KEY ("roomId", "userKey", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_user_roles_roomId" ON "room_user_roles" ("roomId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "room_channel_permissions" (
"roomId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("roomId", "channelId", "targetType", "targetId", "permission")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_room_channel_permissions_roomId" ON "room_channel_permissions" ("roomId")`);
const rooms = await queryRunner.query(`
SELECT "id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "permissions", "sourceId", "sourceName", "sourceUrl"
FROM "rooms"
`) as LegacyRoomRow[];
const members = await queryRunner.query(`
SELECT "roomId", "memberKey", "id", "oderId", "role"
FROM "room_members"
`) as RoomMemberRow[];
for (const room of rooms) {
const legacyPermissions = parseLegacyPermissions(room.permissions);
const roles = buildDefaultRoomRoles(legacyPermissions);
for (const role of roles) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_roles" ("roomId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id,
role.roleId,
role.name,
role.color,
role.position,
role.isSystem,
role.manageServer,
role.manageRoles,
role.manageChannels,
role.manageIcon,
role.kickMembers,
role.banMembers,
role.manageBans,
role.deleteMessages,
role.joinVoice,
role.shareScreen,
role.uploadFiles
]
);
}
for (const member of members.filter((candidateMember) => candidateMember.roomId === room.id)) {
for (const roleId of roleIdsForMemberRole(member.role)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "room_user_roles" ("roomId", "userKey", "roleId", "userId", "oderId") VALUES (?, ?, ?, ?, ?)`,
[
room.id,
member.memberKey,
roleId,
member.id,
member.oderId
]
);
}
}
}
await queryRunner.query(`
CREATE TABLE "rooms_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"topic" TEXT,
"hostId" TEXT NOT NULL,
"password" TEXT,
"hasPassword" INTEGER NOT NULL DEFAULT 0,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"userCount" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER,
"icon" TEXT,
"iconUpdatedAt" INTEGER,
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
"sourceId" TEXT,
"sourceName" TEXT,
"sourceUrl" TEXT
)
`);
for (const room of rooms) {
const legacyPermissions = parseLegacyPermissions(room.permissions);
await queryRunner.query(
`INSERT INTO "rooms_next" ("id", "name", "description", "topic", "hostId", "password", "hasPassword", "isPrivate", "createdAt", "userCount", "maxUsers", "icon", "iconUpdatedAt", "slowModeInterval", "sourceId", "sourceName", "sourceUrl") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
room.id,
room.name,
room.description,
room.topic,
room.hostId,
room.password,
room.hasPassword,
room.isPrivate,
room.createdAt,
room.userCount,
room.maxUsers,
room.icon,
room.iconUpdatedAt,
legacyPermissions.slowModeInterval ?? 0,
room.sourceId,
room.sourceName,
room.sourceUrl
]
);
}
await queryRunner.query(`DROP TABLE "rooms"`);
await queryRunner.query(`ALTER TABLE "rooms_next" RENAME TO "rooms"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "room_channel_permissions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_user_roles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "room_roles"`);
}
}

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

@@ -4,6 +4,8 @@ import { Command, Query } from './cqrs/types';
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const DEEP_LINK_RECEIVED_CHANNEL = 'deep-link-received';
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
export interface LinuxScreenShareAudioRoutingInfo {
available: boolean;
@@ -49,6 +51,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: 'auto' | 'off' | 'version';
availableVersions: string[];
@@ -83,7 +91,57 @@ export interface DesktopUpdateState {
targetVersion: string | null;
}
export interface DesktopNotificationPayload {
body: string;
requestAttention: boolean;
title: string;
}
export interface WindowStateSnapshot {
isFocused: boolean;
isMinimized: boolean;
}
export interface SavedThemeFileDescriptor {
fileName: string;
modifiedAt: number;
path: string;
}
function readLinuxDisplayServer(): string {
if (process.platform !== 'linux') {
return 'N/A';
}
try {
const displayServer = ipcRenderer.sendSync('get-linux-display-server');
return typeof displayServer === 'string' && displayServer.trim().length > 0
? displayServer
: 'Unknown (Linux)';
} catch {
return 'Unknown (Linux)';
}
}
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 {
linuxDisplayServer: string;
minimizeWindow: () => void;
maximizeWindow: () => void;
closeWindow: () => void;
@@ -98,27 +156,44 @@ export interface ElectronAPI {
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
getAppDataPath: () => Promise<string>;
getSavedThemesPath: () => Promise<string>;
listSavedThemes: () => Promise<SavedThemeFileDescriptor[]>;
readSavedTheme: (fileName: string) => Promise<string>;
writeSavedTheme: (fileName: string, text: string) => Promise<boolean>;
deleteSavedTheme: (fileName: string) => Promise<boolean>;
consumePendingDeepLink: () => Promise<string | null>;
getDesktopSettings: () => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
runtimeHardwareAcceleration: boolean;
restartRequired: boolean;
}>;
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
requestWindowAttention: () => Promise<boolean>;
clearWindowAttention: () => Promise<boolean>;
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
getAutoUpdateState: () => Promise<DesktopUpdateState>;
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
checkForAppUpdates: () => Promise<DesktopUpdateState>;
restartToApplyUpdate: () => Promise<boolean>;
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
setDesktopSettings: (patch: {
autoUpdateMode?: 'auto' | 'off' | 'version';
autoStart?: boolean;
closeToTray?: boolean;
hardwareAcceleration?: boolean;
manifestUrls?: string[];
preferredVersion?: string | null;
vaapiVideoEncode?: boolean;
}) => Promise<{
autoUpdateMode: 'auto' | 'off' | 'version';
autoStart: boolean;
closeToTray: boolean;
hardwareAcceleration: boolean;
manifestUrls: string[];
preferredVersion: string | null;
@@ -126,6 +201,7 @@ export interface ElectronAPI {
restartRequired: boolean;
}>;
relaunchApp: () => Promise<boolean>;
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
readFile: (filePath: string) => Promise<string>;
writeFile: (filePath: string, data: string) => Promise<boolean>;
@@ -134,11 +210,16 @@ export interface ElectronAPI {
deleteFile: (filePath: 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: Command) => Promise<T>;
query: <T = unknown>(query: Query) => Promise<T>;
}
const electronAPI: ElectronAPI = {
linuxDisplayServer: readLinuxDisplayServer(),
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
@@ -180,8 +261,29 @@ const electronAPI: ElectronAPI = {
};
},
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
getSavedThemesPath: () => ipcRenderer.invoke('get-saved-themes-path'),
listSavedThemes: () => ipcRenderer.invoke('list-saved-themes'),
readSavedTheme: (fileName) => ipcRenderer.invoke('read-saved-theme', fileName),
writeSavedTheme: (fileName, text) => ipcRenderer.invoke('write-saved-theme', fileName, text),
deleteSavedTheme: (fileName) => ipcRenderer.invoke('delete-saved-theme', fileName),
consumePendingDeepLink: () => ipcRenderer.invoke('consume-pending-deep-link'),
getDesktopSettings: () => ipcRenderer.invoke('get-desktop-settings'),
showDesktopNotification: (payload) => ipcRenderer.invoke('show-desktop-notification', payload),
requestWindowAttention: () => ipcRenderer.invoke('request-window-attention'),
clearWindowAttention: () => ipcRenderer.invoke('clear-window-attention'),
onWindowStateChanged: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, state: WindowStateSnapshot) => {
listener(state);
};
ipcRenderer.on(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(WINDOW_STATE_CHANGED_CHANNEL, wrappedListener);
};
},
getAutoUpdateState: () => ipcRenderer.invoke('get-auto-update-state'),
getAutoUpdateServerHealth: (serverUrl) => ipcRenderer.invoke('get-auto-update-server-health', serverUrl),
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
@@ -198,6 +300,17 @@ const electronAPI: ElectronAPI = {
},
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
onDeepLinkReceived: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, url: string) => {
listener(url);
};
ipcRenderer.on(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
return () => {
ipcRenderer.removeListener(DEEP_LINK_RECEIVED_CHANNEL, wrappedListener);
};
},
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
@@ -206,6 +319,20 @@ const electronAPI: ElectronAPI = {
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
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),
command: (command) => ipcRenderer.invoke('cqrs:command', command),
query: (query) => ipcRenderer.invoke('cqrs:query', query)
};

91
electron/theme-library.ts Normal file
View File

@@ -0,0 +1,91 @@
import { app } from 'electron';
import * as fsp from 'fs/promises';
import * as path from 'path';
export interface SavedThemeFileDescriptor {
fileName: string;
modifiedAt: number;
path: string;
}
const SAVED_THEME_FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.json$/;
function resolveSavedThemesPath(): string {
return path.join(app.getPath('userData'), 'themes');
}
async function ensureSavedThemesPath(): Promise<string> {
const themesPath = resolveSavedThemesPath();
await fsp.mkdir(themesPath, { recursive: true });
return themesPath;
}
function assertSavedThemeFileName(fileName: string): string {
const normalized = typeof fileName === 'string'
? fileName.trim()
: '';
if (!SAVED_THEME_FILE_NAME_PATTERN.test(normalized) || normalized.includes('..')) {
throw new Error('Invalid saved theme file name.');
}
return normalized;
}
async function resolveSavedThemeFilePath(fileName: string): Promise<string> {
const themesPath = await ensureSavedThemesPath();
return path.join(themesPath, assertSavedThemeFileName(fileName));
}
export async function getSavedThemesPath(): Promise<string> {
return await ensureSavedThemesPath();
}
export async function listSavedThemes(): Promise<SavedThemeFileDescriptor[]> {
const themesPath = await ensureSavedThemesPath();
const entries = await fsp.readdir(themesPath, { withFileTypes: true });
const files = entries.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.json'));
const descriptors = await Promise.all(files.map(async (entry) => {
const filePath = path.join(themesPath, entry.name);
const stats = await fsp.stat(filePath);
return {
fileName: entry.name,
modifiedAt: Math.round(stats.mtimeMs),
path: filePath
} satisfies SavedThemeFileDescriptor;
}));
return descriptors.sort((left, right) => right.modifiedAt - left.modifiedAt || left.fileName.localeCompare(right.fileName));
}
export async function readSavedTheme(fileName: string): Promise<string> {
const filePath = await resolveSavedThemeFilePath(fileName);
return await fsp.readFile(filePath, 'utf8');
}
export async function writeSavedTheme(fileName: string, text: string): Promise<boolean> {
const filePath = await resolveSavedThemeFilePath(fileName);
await fsp.writeFile(filePath, text, 'utf8');
return true;
}
export async function deleteSavedTheme(fileName: string): Promise<boolean> {
const filePath = await resolveSavedThemeFilePath(fileName);
try {
await fsp.unlink(filePath);
return true;
} catch (error) {
if ((error as { code?: string }).code === 'ENOENT') {
return true;
}
throw error;
}
}

View File

@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
version: string;
}
interface ServerHealthResponse {
releaseManifestUrl?: string;
serverVersion?: string;
}
interface UpdateVersionInfo {
version: string;
}
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateServerHealthSnapshot {
manifestUrl: string | null;
serverVersion: string | null;
serverVersionStatus: DesktopUpdateServerVersionStatus;
}
export interface DesktopUpdateState {
autoUpdateMode: AutoUpdateMode;
availableVersions: string[];
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
const SERVER_HEALTH_TIMEOUT_MS = 5_000;
let currentCheckPromise: Promise<void> | null = null;
let currentContext: DesktopUpdateServerContext = {
manifestUrls: [],
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
return parseReleaseManifest(payload);
}
function createUnavailableServerHealthSnapshot(): DesktopUpdateServerHealthSnapshot {
return {
manifestUrl: null,
serverVersion: null,
serverVersionStatus: 'unavailable'
};
}
async function loadServerHealth(serverUrl: string): Promise<DesktopUpdateServerHealthSnapshot> {
const sanitizedServerUrl = sanitizeHttpUrl(serverUrl);
if (!sanitizedServerUrl) {
return createUnavailableServerHealthSnapshot();
}
try {
const response = await net.fetch(`${sanitizedServerUrl}/api/health`, {
method: 'GET',
headers: {
accept: 'application/json'
},
signal: AbortSignal.timeout(SERVER_HEALTH_TIMEOUT_MS)
});
if (!response.ok) {
return createUnavailableServerHealthSnapshot();
}
const payload = await response.json() as ServerHealthResponse;
const serverVersion = normalizeSemanticVersion(payload.serverVersion);
return {
manifestUrl: sanitizeHttpUrl(payload.releaseManifestUrl),
serverVersion,
serverVersionStatus: serverVersion ? 'reported' : 'missing'
};
} catch {
return createUnavailableServerHealthSnapshot();
}
}
function formatManifestLoadErrors(errors: string[]): string {
if (errors.length === 0) {
return 'No valid release manifest could be loaded.';
@@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
return desktopUpdateState;
}
export async function readDesktopUpdateServerHealth(
serverUrl: string
): Promise<DesktopUpdateServerHealthSnapshot> {
return await loadServerHealth(serverUrl);
}
export function restartToApplyUpdate(): boolean {
if (!desktopUpdateState.restartRequired) {
return false;

View File

@@ -2,13 +2,21 @@ import {
app,
BrowserWindow,
desktopCapturer,
Menu,
session,
shell
shell,
Tray
} from 'electron';
import * as fs from 'fs';
import * as path from 'path';
import { readDesktopSettings } from '../desktop-settings';
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let closeToTrayEnabled = true;
let appQuitting = false;
const WINDOW_STATE_CHANGED_CHANNEL = 'window-state-changed';
function getAssetPath(...segments: string[]): string {
const basePath = app.isPackaged
@@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined {
return getExistingAssetPath('macos', '1024x1024.png');
}
function getTrayIconPath(): string | undefined {
if (process.platform === 'win32')
return getExistingAssetPath('windows', 'icon.ico');
return getExistingAssetPath('icon.png');
}
export { getWindowIconPath };
export function getMainWindow(): BrowserWindow | null {
return mainWindow;
}
function destroyTray(): void {
if (!tray) {
return;
}
tray.destroy();
tray = null;
}
function requestAppQuit(): void {
prepareWindowForAppQuit();
app.quit();
}
function ensureTray(): void {
if (tray) {
return;
}
const trayIconPath = getTrayIconPath();
if (!trayIconPath) {
return;
}
tray = new Tray(trayIconPath);
tray.setToolTip('MetoYou');
tray.setContextMenu(
Menu.buildFromTemplate([
{
label: 'Open MetoYou',
click: () => {
void showMainWindow();
}
},
{
type: 'separator'
},
{
label: 'Close MetoYou',
click: () => {
requestAppQuit();
}
}
])
);
tray.on('click', () => {
void showMainWindow();
});
}
function hideWindowToTray(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.hide();
emitWindowState();
}
export function updateCloseToTraySetting(enabled: boolean): void {
closeToTrayEnabled = enabled;
}
export function prepareWindowForAppQuit(): void {
appQuitting = true;
destroyTray();
}
export async function showMainWindow(): Promise<void> {
if (!mainWindow || mainWindow.isDestroyed()) {
await createWindow();
return;
}
if (mainWindow.isMinimized()) {
mainWindow.restore();
}
if (!mainWindow.isVisible()) {
mainWindow.show();
}
mainWindow.focus();
emitWindowState();
}
function emitWindowState(): void {
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.webContents.send(WINDOW_STATE_CHANGED_CHANNEL, {
isFocused: mainWindow.isFocused(),
isMinimized: mainWindow.isMinimized()
});
}
export async function createWindow(): Promise<void> {
const windowIconPath = getWindowIconPath();
closeToTrayEnabled = readDesktopSettings().closeToTray;
ensureTray();
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
@@ -105,10 +224,64 @@ export async function createWindow(): Promise<void> {
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
}
mainWindow.on('close', (event) => {
if (appQuitting || !closeToTrayEnabled) {
return;
}
event.preventDefault();
hideWindowToTray();
});
mainWindow.on('closed', () => {
mainWindow = null;
});
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false);
emitWindowState();
});
mainWindow.on('blur', () => {
emitWindowState();
});
mainWindow.on('minimize', () => {
emitWindowState();
});
mainWindow.on('restore', () => {
emitWindowState();
});
mainWindow.on('show', () => {
emitWindowState();
});
mainWindow.on('hide', () => {
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 }) => {
shell.openExternal(url);
return { action: 'deny' };

View File

@@ -199,7 +199,7 @@ module.exports = tseslint.config(
},
// HTML template formatting rules (external Angular templates only)
{
files: ['src/app/**/*.html'],
files: ['toju-app/src/app/**/*.html'],
plugins: { 'no-dashes': noDashPlugin },
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
rules: {

298
package-lock.json generated
View File

@@ -14,6 +14,12 @@
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@ng-icons/core": "^33.0.0",
"@ng-icons/lucide": "^33.0.0",
"@ngrx/effects": "^21.0.1",
@@ -24,8 +30,10 @@
"@spartan-ng/cli": "^0.0.1-alpha.589",
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
"@timephy/rnnoise-wasm": "^1.0.0",
"auto-launch": "^5.0.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"cytoscape": "^3.33.1",
"electron-updater": "^6.6.2",
"mermaid": "^11.12.3",
@@ -45,11 +53,13 @@
},
"devDependencies": {
"@angular/build": "^21.0.4",
"@angular/cli": "^21.2.1",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3",
"@playwright/test": "^1.59.1",
"@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5",
"@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0",
@@ -2695,6 +2705,109 @@
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
"license": "Apache-2.0"
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.1",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.1.tgz",
"integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.5",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.5.tgz",
"integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.35.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/theme-one-dark": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.41.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.41.0.tgz",
"integrity": "sha512-6H/qadXsVuDY219Yljhohglve8xf4B8xJkVOEWfA5uiYKiTFppjqsvsfR5iPA0RbvRBoOyTZpbLIxe9+0UR8xA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
@@ -5670,6 +5783,41 @@
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
"license": "MIT"
},
"node_modules/@lezer/common": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.1.tgz",
"integrity": "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.8.tgz",
"integrity": "sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@listr2/prompt-adapter-inquirer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
@@ -5863,6 +6011,12 @@
"node": ">= 10.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@mermaid-js/parser": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
@@ -9184,6 +9338,22 @@
"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": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
@@ -10816,6 +10986,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/auto-launch": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/@types/auto-launch/-/auto-launch-5.0.5.tgz",
"integrity": "sha512-/nGvQZSzM/pvCMCh4Gt2kIeiUmOP/cKGJbjlInI+A+5MoV/7XmT56DJ6EU8bqc3+ItxEe4UC2GVspmPzcCc8cg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
@@ -12875,6 +13052,11 @@
"node": ">= 6.0.0"
}
},
"node_modules/applescript": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/applescript/-/applescript-1.0.0.tgz",
"integrity": "sha512-yvtNHdWvtbYEiIazXAdp/NY+BBb65/DAseqlNiJQjOx9DynuzOYDbVLBJvuc0ve0VL9x6B3OHF6eH52y9hCBtQ=="
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
@@ -12968,6 +13150,22 @@
"node": ">= 4.0.0"
}
},
"node_modules/auto-launch": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/auto-launch/-/auto-launch-5.0.6.tgz",
"integrity": "sha512-OgxiAm4q9EBf9EeXdPBiVNENaWE3jUZofwrhAkWjHDYGezu1k3FRZHU8V2FBxGuSJOHzKmTJEd0G7L7/0xDGFA==",
"license": "MIT",
"dependencies": {
"applescript": "^1.0.0",
"mkdirp": "^0.5.1",
"path-is-absolute": "^1.0.0",
"untildify": "^3.0.2",
"winreg": "1.2.4"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/autoprefixer": {
"version": "10.4.23",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
@@ -14108,6 +14306,21 @@
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
"license": "MIT"
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -14736,6 +14949,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cron-parser": {
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
@@ -22285,9 +22504,7 @@
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -23745,7 +23962,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -24453,6 +24669,53 @@
"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": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
@@ -27755,6 +28018,12 @@
"webpack": "^5.0.0"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/stylehacks": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
@@ -29571,6 +29840,15 @@
"node": ">= 0.8"
}
},
"node_modules/untildify": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/untildify/-/untildify-3.0.3.tgz",
"integrity": "sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/upath": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
@@ -30338,6 +30616,12 @@
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wait-on": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
@@ -31161,6 +31445,12 @@
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
"license": "MIT"
},
"node_modules/winreg": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/winreg/-/winreg-1.2.4.tgz",
"integrity": "sha512-IHpzORub7kYlb8A43Iig3reOvlcBJGX9gZ0WycHhghHtA65X0LYnMRuJs+aH1abVnMJztQkvQNlltnbPi5aGIA==",
"license": "BSD-2-Clause"
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View File

@@ -7,24 +7,24 @@
"homepage": "https://git.azaaxin.com/myxelium/Toju",
"main": "dist/electron/main.js",
"scripts": {
"ng": "ng",
"ng": "cd \"toju-app\" && ng",
"prebuild": "npm run bundle:rnnoise",
"prestart": "npm run bundle:rnnoise",
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=public/rnnoise-worklet.js",
"start": "ng serve",
"build": "ng build",
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
"start": "cd \"toju-app\" && ng serve",
"build": "cd \"toju-app\" && ng build",
"build:electron": "tsc -p tsconfig.electron.json",
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
"build:prod": "ng build --configuration production --base-href='./'",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
"test": "cd \"toju-app\" && ng test",
"server:build": "cd server && npm run build",
"server:start": "cd server && npm start",
"server:dev": "cd server && npm run dev",
"electron": "ng build && npm run build:electron && electron . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --no-sandbox --disable-dev-shm-usage\"",
"electron": "npm run build && npm run build:electron && node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage",
"electron:dev": "concurrently \"npm run start\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"electron:full": "./dev.sh",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production electron . --no-sandbox --disable-dev-shm-usage\"",
"electron:full:build": "npm run build:all && concurrently --kill-others \"cd server && npm start\" \"cross-env NODE_ENV=production node tools/launch-electron.js . --no-sandbox --disable-dev-shm-usage\"",
"migration:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
"migration:create": "typeorm migration:create electron/migrations/New",
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
@@ -40,8 +40,8 @@
"dev:app": "npm run electron:dev",
"lint": "eslint .",
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
"format": "prettier --write \"src/app/**/*.html\"",
"format:check": "prettier --check \"src/app/**/*.html\"",
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
"format:check": "prettier --check \"toju-app/src/app/**/*.html\"",
"release:build:linux": "npm run build:prod:all && electron-builder --linux && npm run server:bundle:linux",
"release:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
"release:manifest": "node tools/generate-release-manifest.js",
@@ -49,7 +49,11 @@
"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: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,
"packageManager": "npm@10.9.2",
@@ -60,6 +64,12 @@
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
"@ng-icons/core": "^33.0.0",
"@ng-icons/lucide": "^33.0.0",
"@ngrx/effects": "^21.0.1",
@@ -70,8 +80,10 @@
"@spartan-ng/cli": "^0.0.1-alpha.589",
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
"@timephy/rnnoise-wasm": "^1.0.0",
"auto-launch": "^5.0.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"codemirror": "^6.0.2",
"cytoscape": "^3.33.1",
"electron-updater": "^6.6.2",
"mermaid": "^11.12.3",
@@ -94,8 +106,10 @@
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@eslint/js": "^9.39.3",
"@playwright/test": "^1.59.1",
"@stylistic/eslint-plugin-js": "^4.4.1",
"@stylistic/eslint-plugin-ts": "^4.4.1",
"@types/auto-launch": "^5.0.5",
"@types/simple-peer": "^9.11.9",
"@types/uuid": "^10.0.0",
"angular-eslint": "21.2.0",
@@ -120,13 +134,25 @@
"build": {
"appId": "com.metoyou.app",
"productName": "MetoYou",
"protocols": [
{
"name": "Toju Invite Links",
"schemes": [
"toju"
]
}
],
"directories": {
"output": "dist-electron"
},
"files": [
"!node_modules",
"dist/client/**/*",
"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/**/tests/**/*",
"!node_modules/**/*.d.ts",

Binary file not shown.

View File

@@ -5,6 +5,7 @@ import { registerRoutes } from './routes';
export function createApp(): express.Express {
const app = express();
app.set('trust proxy', true);
app.use(cors());
app.use(express.json());

View File

@@ -2,13 +2,30 @@ import fs from 'fs';
import path from 'path';
import { resolveRuntimePath } from '../runtime-paths';
export type ServerHttpProtocol = 'http' | 'https';
export interface LinkPreviewConfig {
enabled: boolean;
cacheTtlMinutes: number;
maxCacheSizeMb: number;
}
export interface ServerVariablesConfig {
klipyApiKey: string;
releaseManifestUrl: string;
serverPort: number;
serverProtocol: ServerHttpProtocol;
serverHost: string;
linkPreview: LinkPreviewConfig;
}
const DATA_DIR = resolveRuntimePath('data');
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
const DEFAULT_SERVER_PORT = 3001;
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 {
return typeof value === 'string' ? value.trim() : '';
@@ -18,6 +35,72 @@ function normalizeReleaseManifestUrl(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeServerHost(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeServerProtocol(
value: unknown,
fallback: ServerHttpProtocol = DEFAULT_SERVER_PROTOCOL
): ServerHttpProtocol {
if (typeof value === 'boolean') {
return value ? 'https' : 'http';
}
if (typeof value !== 'string') {
return fallback;
}
const normalized = value.trim().toLowerCase();
if (normalized === 'https' || normalized === 'true') {
return 'https';
}
if (normalized === 'http' || normalized === 'false') {
return 'http';
}
return fallback;
}
function normalizeServerPort(value: unknown, fallback = DEFAULT_SERVER_PORT): number {
const parsed = typeof value === 'number'
? value
: typeof value === 'string'
? Number.parseInt(value.trim(), 10)
: Number.NaN;
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
? parsed
: 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 {
return typeof value === 'string' && value.trim().length > 0;
}
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
if (!fs.existsSync(VARIABLES_FILE)) {
return { rawContents: '', parsed: {} };
@@ -52,10 +135,15 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
}
const { rawContents, parsed } = readRawVariables();
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
const normalized = {
...parsed,
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
...remainingParsed,
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
releaseManifestUrl: normalizeReleaseManifestUrl(remainingParsed.releaseManifestUrl),
serverPort: normalizeServerPort(remainingParsed.serverPort),
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
};
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
@@ -65,7 +153,11 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
return {
klipyApiKey: normalized.klipyApiKey,
releaseManifestUrl: normalized.releaseManifestUrl
releaseManifestUrl: normalized.releaseManifestUrl,
serverPort: normalized.serverPort,
serverProtocol: normalized.serverProtocol,
serverHost: normalized.serverHost,
linkPreview: normalized.linkPreview
};
}
@@ -84,3 +176,33 @@ export function hasKlipyApiKey(): boolean {
export function getReleaseManifestUrl(): string {
return getVariablesConfig().releaseManifestUrl;
}
export function getServerProtocol(): ServerHttpProtocol {
if (hasEnvironmentOverride(process.env.SSL)) {
return normalizeServerProtocol(process.env.SSL);
}
return getVariablesConfig().serverProtocol;
}
export function getServerPort(): number {
if (hasEnvironmentOverride(process.env.PORT)) {
return normalizeServerPort(process.env.PORT);
}
return getVariablesConfig().serverPort;
}
export function getServerHost(): string | undefined {
const serverHost = getVariablesConfig().serverHost;
return serverHost || undefined;
}
export function isHttpsServerEnabled(): boolean {
return getServerProtocol() === 'https';
}
export function getLinkPreviewConfig(): LinkPreviewConfig {
return getVariablesConfig().linkPreview;
}

View File

@@ -1,10 +1,31 @@
import { DataSource } from 'typeorm';
import { ServerEntity, JoinRequestEntity } from '../../../entities';
import {
ServerChannelPermissionEntity,
ServerChannelEntity,
ServerEntity,
ServerRoleEntity,
ServerTagEntity,
ServerUserRoleEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
} from '../../../entities';
import { DeleteServerCommand } from '../../types';
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
const { serverId } = command.payload;
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
await dataSource.getRepository(ServerEntity).delete(serverId);
await dataSource.transaction(async (manager) => {
await manager.getRepository(ServerTagEntity).delete({ serverId });
await manager.getRepository(ServerChannelEntity).delete({ serverId });
await manager.getRepository(ServerRoleEntity).delete({ serverId });
await manager.getRepository(ServerUserRoleEntity).delete({ serverId });
await manager.getRepository(ServerChannelPermissionEntity).delete({ serverId });
await manager.getRepository(JoinRequestEntity).delete({ serverId });
await manager.getRepository(ServerMembershipEntity).delete({ serverId });
await manager.getRepository(ServerInviteEntity).delete({ serverId });
await manager.getRepository(ServerBanEntity).delete({ serverId });
await manager.getRepository(ServerEntity).delete(serverId);
});
}

View File

@@ -1,23 +1,35 @@
import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities';
import { replaceServerRelations } from '../../relations';
import { UpsertServerCommand } from '../../types';
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
const repo = dataSource.getRepository(ServerEntity);
const { server } = command.payload;
const entity = repo.create({
id: server.id,
name: server.name,
description: server.description ?? null,
ownerId: server.ownerId,
ownerPublicKey: server.ownerPublicKey,
isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
tags: JSON.stringify(server.tags),
createdAt: server.createdAt,
lastSeen: server.lastSeen
});
await repo.save(entity);
await dataSource.transaction(async (manager) => {
const repo = manager.getRepository(ServerEntity);
const entity = repo.create({
id: server.id,
name: server.name,
description: server.description ?? null,
ownerId: server.ownerId,
ownerPublicKey: server.ownerPublicKey,
passwordHash: server.passwordHash ?? null,
isPrivate: server.isPrivate ? 1 : 0,
maxUsers: server.maxUsers,
currentUsers: server.currentUsers,
slowModeInterval: server.slowModeInterval ?? 0,
createdAt: server.createdAt,
lastSeen: server.lastSeen
});
await repo.save(entity);
await replaceServerRelations(manager, server.id, {
tags: server.tags,
channels: server.channels ?? [],
roles: server.roles ?? [],
roleAssignments: server.roleAssignments ?? [],
channelPermissions: server.channelPermissions ?? []
});
});
}

View File

@@ -6,6 +6,7 @@ import {
ServerPayload,
JoinRequestPayload
} from './types';
import { relationRecordToServerPayload } from './relations';
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
return {
@@ -17,17 +18,41 @@ export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
};
}
export function rowToServer(row: ServerEntity): ServerPayload {
export function rowToServer(
row: ServerEntity,
relations: Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions'> = {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
}
): ServerPayload {
const relationPayload = relationRecordToServerPayload({ slowModeInterval: row.slowModeInterval }, {
tags: relations.tags ?? [],
channels: relations.channels ?? [],
roles: relations.roles ?? [],
roleAssignments: relations.roleAssignments ?? [],
channelPermissions: relations.channelPermissions ?? []
});
return {
id: row.id,
name: row.name,
description: row.description ?? undefined,
ownerId: row.ownerId,
ownerPublicKey: row.ownerPublicKey,
hasPassword: !!row.passwordHash,
passwordHash: row.passwordHash ?? undefined,
isPrivate: !!row.isPrivate,
maxUsers: row.maxUsers,
currentUsers: row.currentUsers,
tags: JSON.parse(row.tags || '[]'),
slowModeInterval: relationPayload.slowModeInterval,
tags: relationPayload.tags,
channels: relationPayload.channels,
roles: relationPayload.roles,
roleAssignments: relationPayload.roleAssignments,
channelPermissions: relationPayload.channelPermissions,
createdAt: row.createdAt,
lastSeen: row.lastSeen
};

View File

@@ -1,10 +1,12 @@
import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities';
import { rowToServer } from '../../mappers';
import { loadServerRelationsMap } from '../../relations';
export async function handleGetAllPublicServers(dataSource: DataSource) {
const repo = dataSource.getRepository(ServerEntity);
const rows = await repo.find({ where: { isPrivate: 0 } });
const relationsByServerId = await loadServerRelationsMap(dataSource, rows.map((row) => row.id));
return rows.map(rowToServer);
return rows.map((row) => rowToServer(row, relationsByServerId.get(row.id)));
}

View File

@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
import { ServerEntity } from '../../../entities';
import { GetServerByIdQuery } from '../../types';
import { rowToServer } from '../../mappers';
import { loadServerRelationsMap } from '../../relations';
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
const repo = dataSource.getRepository(ServerEntity);
const row = await repo.findOne({ where: { id: query.payload.serverId } });
return row ? rowToServer(row) : null;
if (!row) {
return null;
}
const relationsByServerId = await loadServerRelationsMap(dataSource, [row.id]);
return rowToServer(row, relationsByServerId.get(row.id));
}

View File

@@ -0,0 +1,603 @@
import {
DataSource,
EntityManager,
In
} from 'typeorm';
import {
ServerChannelEntity,
ServerTagEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity
} from '../entities';
import {
AccessRolePayload,
ChannelPermissionPayload,
RoleAssignmentPayload,
ServerChannelPayload,
ServerPayload,
ServerPermissionKeyPayload,
PermissionStatePayload
} from './types';
const SERVER_PERMISSION_KEYS: ServerPermissionKeyPayload[] = [
'manageServer',
'manageRoles',
'manageChannels',
'manageIcon',
'kickMembers',
'banMembers',
'manageBans',
'deleteMessages',
'joinVoice',
'shareScreen',
'uploadFiles'
];
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
interface ServerRelationRecord {
tags: string[];
channels: ServerChannelPayload[];
roles: AccessRolePayload[];
roleAssignments: RoleAssignmentPayload[];
channelPermissions: ChannelPermissionPayload[];
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: ServerChannelPayload['type'], name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function compareText(firstValue: string, secondValue: string): number {
return firstValue.localeCompare(secondValue, undefined, { sensitivity: 'base' });
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function uniqueStrings(values: readonly string[] | undefined): string[] {
return Array.from(new Set((values ?? [])
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.map((value) => value.trim())));
}
function normalizePermissionState(value: unknown): PermissionStatePayload {
return value === 'allow' || value === 'deny' || value === 'inherit'
? value
: 'inherit';
}
function normalizePermissionMatrix(rawMatrix: unknown): Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> {
const matrix = rawMatrix && typeof rawMatrix === 'object'
? rawMatrix as Record<string, unknown>
: {};
const normalized: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>> = {};
for (const key of SERVER_PERMISSION_KEYS) {
const value = normalizePermissionState(matrix[key]);
if (value !== 'inherit') {
normalized[key] = value;
}
}
return normalized;
}
function buildDefaultServerRoles(): AccessRolePayload[] {
return [
{
id: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: true,
permissions: {
joinVoice: 'allow',
shareScreen: 'allow',
uploadFiles: 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: true,
permissions: {
kickMembers: 'allow',
deleteMessages: 'allow'
}
},
{
id: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: true,
permissions: {
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
manageChannels: 'allow',
manageIcon: 'allow'
}
}
];
}
function normalizeServerRole(rawRole: Partial<AccessRolePayload>, fallbackRole?: AccessRolePayload): AccessRolePayload | null {
const id = typeof rawRole.id === 'string' ? rawRole.id.trim() : fallbackRole?.id ?? '';
const name = typeof rawRole.name === 'string' ? rawRole.name.trim().replace(/\s+/g, ' ') : fallbackRole?.name ?? '';
if (!id || !name) {
return null;
}
return {
id,
name,
color: typeof rawRole.color === 'string' && rawRole.color.trim() ? rawRole.color.trim() : fallbackRole?.color,
position: isFiniteNumber(rawRole.position) ? rawRole.position : fallbackRole?.position ?? 0,
isSystem: typeof rawRole.isSystem === 'boolean' ? rawRole.isSystem : fallbackRole?.isSystem,
permissions: normalizePermissionMatrix(rawRole.permissions ?? fallbackRole?.permissions)
};
}
function compareRoles(firstRole: AccessRolePayload, secondRole: AccessRolePayload): number {
if (firstRole.position !== secondRole.position) {
return firstRole.position - secondRole.position;
}
return compareText(firstRole.name, secondRole.name);
}
function compareAssignments(firstAssignment: RoleAssignmentPayload, secondAssignment: RoleAssignmentPayload): number {
return compareText(firstAssignment.oderId || firstAssignment.userId, secondAssignment.oderId || secondAssignment.userId);
}
export function normalizeServerTags(rawTags: unknown): string[] {
if (!Array.isArray(rawTags)) {
return [];
}
return rawTags
.filter((tag): tag is string => typeof tag === 'string')
.map((tag) => tag.trim())
.filter(Boolean);
}
export function normalizeServerChannels(rawChannels: unknown): ServerChannelPayload[] {
if (!Array.isArray(rawChannels)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
const channels: ServerChannelPayload[] = [];
for (const [index, rawChannel] of rawChannels.entries()) {
if (!rawChannel || typeof rawChannel !== 'object') {
continue;
}
const channel = rawChannel as Record<string, unknown>;
const id = typeof channel['id'] === 'string' ? channel['id'].trim() : '';
const name = typeof channel['name'] === 'string' ? normalizeChannelName(channel['name']) : '';
const type = channel['type'] === 'text' || channel['type'] === 'voice' ? channel['type'] : null;
const position = isFiniteNumber(channel['position']) ? channel['position'] : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
continue;
}
seenIds.add(id);
seenNames.add(nameKey);
channels.push({
id,
name,
type,
position
});
}
return channels;
}
export function normalizeServerRoles(rawRoles: unknown): AccessRolePayload[] {
const rolesById = new Map<string, AccessRolePayload>();
if (Array.isArray(rawRoles)) {
for (const rawRole of rawRoles) {
if (!rawRole || typeof rawRole !== 'object') {
continue;
}
const normalizedRole = normalizeServerRole(rawRole as Record<string, unknown>);
if (normalizedRole) {
rolesById.set(normalizedRole.id, normalizedRole);
}
}
}
for (const defaultRole of buildDefaultServerRoles()) {
const mergedRole = normalizeServerRole(rolesById.get(defaultRole.id) ?? defaultRole, defaultRole) ?? defaultRole;
rolesById.set(defaultRole.id, mergedRole);
}
return Array.from(rolesById.values()).sort(compareRoles);
}
export function normalizeServerRoleAssignments(rawAssignments: unknown, roles: readonly AccessRolePayload[]): RoleAssignmentPayload[] {
const validRoleIds = new Set(roles.map((role) => role.id).filter((roleId) => roleId !== SYSTEM_ROLE_IDS.everyone));
const assignmentsByKey = new Map<string, RoleAssignmentPayload>();
if (!Array.isArray(rawAssignments)) {
return [];
}
for (const rawAssignment of rawAssignments) {
if (!rawAssignment || typeof rawAssignment !== 'object') {
continue;
}
const assignment = rawAssignment as Record<string, unknown>;
const userId = typeof assignment['userId'] === 'string' ? assignment['userId'].trim() : '';
const oderId = typeof assignment['oderId'] === 'string' ? assignment['oderId'].trim() : undefined;
const key = oderId || userId;
if (!key) {
continue;
}
const roleIds = uniqueStrings(Array.isArray(assignment['roleIds']) ? assignment['roleIds'] as string[] : undefined)
.filter((roleId) => validRoleIds.has(roleId));
if (roleIds.length === 0) {
continue;
}
assignmentsByKey.set(key, {
userId: userId || key,
oderId,
roleIds
});
}
return Array.from(assignmentsByKey.values()).sort(compareAssignments);
}
export function normalizeServerChannelPermissions(
rawChannelPermissions: unknown,
roles: readonly AccessRolePayload[]
): ChannelPermissionPayload[] {
if (!Array.isArray(rawChannelPermissions)) {
return [];
}
const validRoleIds = new Set(roles.map((role) => role.id));
const overridesByKey = new Map<string, ChannelPermissionPayload>();
for (const rawOverride of rawChannelPermissions) {
if (!rawOverride || typeof rawOverride !== 'object') {
continue;
}
const override = rawOverride as Record<string, unknown>;
const channelId = typeof override['channelId'] === 'string' ? override['channelId'].trim() : '';
const targetType = override['targetType'] === 'role' || override['targetType'] === 'user' ? override['targetType'] : null;
const targetId = typeof override['targetId'] === 'string' ? override['targetId'].trim() : '';
const permission = SERVER_PERMISSION_KEYS.find((key) => key === override['permission']);
const value = normalizePermissionState(override['value']);
if (!channelId || !targetType || !targetId || !permission || value === 'inherit') {
continue;
}
if (targetType === 'role' && !validRoleIds.has(targetId)) {
continue;
}
const key = `${channelId}:${targetType}:${targetId}:${permission}`;
overridesByKey.set(key, {
channelId,
targetType,
targetId,
permission,
value
});
}
return Array.from(overridesByKey.values()).sort((firstOverride, secondOverride) => {
const channelCompare = compareText(firstOverride.channelId, secondOverride.channelId);
if (channelCompare !== 0) {
return channelCompare;
}
if (firstOverride.targetType !== secondOverride.targetType) {
return compareText(firstOverride.targetType, secondOverride.targetType);
}
const targetCompare = compareText(firstOverride.targetId, secondOverride.targetId);
if (targetCompare !== 0) {
return targetCompare;
}
return compareText(firstOverride.permission, secondOverride.permission);
});
}
export async function replaceServerRelations(
manager: EntityManager,
serverId: string,
options: {
tags: unknown;
channels: unknown;
roles?: unknown;
roleAssignments?: unknown;
channelPermissions?: unknown;
}
): Promise<void> {
const tagRepo = manager.getRepository(ServerTagEntity);
const channelRepo = manager.getRepository(ServerChannelEntity);
const roleRepo = manager.getRepository(ServerRoleEntity);
const userRoleRepo = manager.getRepository(ServerUserRoleEntity);
const channelPermissionRepo = manager.getRepository(ServerChannelPermissionEntity);
const tags = normalizeServerTags(options.tags);
const channels = normalizeServerChannels(options.channels);
const roles = options.roles !== undefined ? normalizeServerRoles(options.roles) : [];
await tagRepo.delete({ serverId });
await channelRepo.delete({ serverId });
if (options.roles !== undefined) {
await roleRepo.delete({ serverId });
}
if (options.roleAssignments !== undefined) {
await userRoleRepo.delete({ serverId });
}
if (options.channelPermissions !== undefined) {
await channelPermissionRepo.delete({ serverId });
}
if (tags.length > 0) {
await tagRepo.insert(
tags.map((tag, position) => ({
serverId,
position,
value: tag
}))
);
}
if (channels.length > 0) {
await channelRepo.insert(
channels.map((channel) => ({
serverId,
channelId: channel.id,
name: channel.name,
type: channel.type,
position: channel.position
}))
);
}
if (options.roles !== undefined && roles.length > 0) {
await roleRepo.insert(
roles.map((role) => ({
serverId,
roleId: role.id,
name: role.name,
color: role.color ?? null,
position: role.position,
isSystem: role.isSystem ? 1 : 0,
manageServer: normalizePermissionState(role.permissions?.manageServer),
manageRoles: normalizePermissionState(role.permissions?.manageRoles),
manageChannels: normalizePermissionState(role.permissions?.manageChannels),
manageIcon: normalizePermissionState(role.permissions?.manageIcon),
kickMembers: normalizePermissionState(role.permissions?.kickMembers),
banMembers: normalizePermissionState(role.permissions?.banMembers),
manageBans: normalizePermissionState(role.permissions?.manageBans),
deleteMessages: normalizePermissionState(role.permissions?.deleteMessages),
joinVoice: normalizePermissionState(role.permissions?.joinVoice),
shareScreen: normalizePermissionState(role.permissions?.shareScreen),
uploadFiles: normalizePermissionState(role.permissions?.uploadFiles)
}))
);
}
if (options.roleAssignments !== undefined) {
const roleAssignments = normalizeServerRoleAssignments(options.roleAssignments, roles.length > 0 ? roles : normalizeServerRoles([]));
const rows = roleAssignments.flatMap((assignment) =>
assignment.roleIds.map((roleId) => ({
serverId,
userId: assignment.userId,
roleId,
oderId: assignment.oderId ?? null
}))
);
if (rows.length > 0) {
await userRoleRepo.insert(rows);
}
}
if (options.channelPermissions !== undefined) {
const channelPermissions = normalizeServerChannelPermissions(
options.channelPermissions,
roles.length > 0 ? roles : normalizeServerRoles([])
);
if (channelPermissions.length > 0) {
await channelPermissionRepo.insert(
channelPermissions.map((channelPermission) => ({
serverId,
channelId: channelPermission.channelId,
targetType: channelPermission.targetType,
targetId: channelPermission.targetId,
permission: channelPermission.permission,
value: channelPermission.value
}))
);
}
}
}
export async function loadServerRelationsMap(
dataSource: DataSource,
serverIds: readonly string[]
): Promise<Map<string, ServerRelationRecord>> {
const groupedRelations = new Map<string, ServerRelationRecord>();
if (serverIds.length === 0) {
return groupedRelations;
}
const [
tagRows,
channelRows,
roleRows,
userRoleRows,
channelPermissionRows
] = await Promise.all([
dataSource.getRepository(ServerTagEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerRoleEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerUserRoleEntity).find({
where: { serverId: In([...serverIds]) }
}),
dataSource.getRepository(ServerChannelPermissionEntity).find({
where: { serverId: In([...serverIds]) }
})
]);
for (const serverId of serverIds) {
groupedRelations.set(serverId, {
tags: [],
channels: [],
roles: [],
roleAssignments: [],
channelPermissions: []
});
}
for (const row of tagRows) {
groupedRelations.get(row.serverId)?.tags.push(row.value);
}
for (const row of channelRows) {
groupedRelations.get(row.serverId)?.channels.push({
id: row.channelId,
name: row.name,
type: row.type,
position: row.position
});
}
for (const row of roleRows) {
groupedRelations.get(row.serverId)?.roles.push({
id: row.roleId,
name: row.name,
color: row.color ?? undefined,
position: row.position,
isSystem: !!row.isSystem,
permissions: normalizePermissionMatrix({
manageServer: row.manageServer,
manageRoles: row.manageRoles,
manageChannels: row.manageChannels,
manageIcon: row.manageIcon,
kickMembers: row.kickMembers,
banMembers: row.banMembers,
manageBans: row.manageBans,
deleteMessages: row.deleteMessages,
joinVoice: row.joinVoice,
shareScreen: row.shareScreen,
uploadFiles: row.uploadFiles
})
});
}
for (const row of userRoleRows) {
const relation = groupedRelations.get(row.serverId);
if (!relation) {
continue;
}
const existing = relation.roleAssignments.find((assignment) => assignment.userId === row.userId || assignment.oderId === row.oderId);
if (existing) {
existing.roleIds = uniqueStrings([...existing.roleIds, row.roleId]);
continue;
}
relation.roleAssignments.push({
userId: row.userId,
oderId: row.oderId ?? undefined,
roleIds: [row.roleId]
});
}
for (const row of channelPermissionRows) {
groupedRelations.get(row.serverId)?.channelPermissions.push({
channelId: row.channelId,
targetType: row.targetType,
targetId: row.targetId,
permission: row.permission as ServerPermissionKeyPayload,
value: normalizePermissionState(row.value)
});
}
for (const [serverId, relation] of groupedRelations) {
relation.tags = tagRows
.filter((row) => row.serverId === serverId)
.sort((firstTag, secondTag) => firstTag.position - secondTag.position)
.map((row) => row.value);
relation.channels.sort(
(firstChannel, secondChannel) => firstChannel.position - secondChannel.position || compareText(firstChannel.name, secondChannel.name)
);
relation.roles.sort(compareRoles);
relation.roleAssignments.sort(compareAssignments);
}
return groupedRelations;
}
export function relationRecordToServerPayload(
row: Pick<ServerPayload, 'slowModeInterval'>,
relations: ServerRelationRecord
): Pick<ServerPayload, 'tags' | 'channels' | 'roles' | 'roleAssignments' | 'channelPermissions' | 'slowModeInterval'> {
return {
tags: relations.tags,
channels: relations.channels,
roles: relations.roles,
roleAssignments: relations.roleAssignments,
channelPermissions: relations.channelPermissions,
slowModeInterval: row.slowModeInterval ?? 0
};
}

View File

@@ -28,16 +28,70 @@ export interface AuthUserPayload {
createdAt: number;
}
export type ServerChannelType = 'text' | 'voice';
export interface ServerChannelPayload {
id: string;
name: string;
type: ServerChannelType;
position: number;
}
export type PermissionStatePayload = 'allow' | 'deny' | 'inherit';
export type ServerPermissionKeyPayload =
| 'manageServer'
| 'manageRoles'
| 'manageChannels'
| 'manageIcon'
| 'kickMembers'
| 'banMembers'
| 'manageBans'
| 'deleteMessages'
| 'joinVoice'
| 'shareScreen'
| 'uploadFiles';
export interface AccessRolePayload {
id: string;
name: string;
color?: string;
position: number;
isSystem?: boolean;
permissions?: Partial<Record<ServerPermissionKeyPayload, PermissionStatePayload>>;
}
export interface RoleAssignmentPayload {
userId: string;
oderId?: string;
roleIds: string[];
}
export interface ChannelPermissionPayload {
channelId: string;
targetType: 'role' | 'user';
targetId: string;
permission: ServerPermissionKeyPayload;
value: PermissionStatePayload;
}
export interface ServerPayload {
id: string;
name: string;
description?: string;
ownerId: string;
ownerPublicKey: string;
hasPassword?: boolean;
passwordHash?: string | null;
isPrivate: boolean;
maxUsers: number;
currentUsers: number;
slowModeInterval?: number;
tags: string[];
channels: ServerChannelPayload[];
roles?: AccessRolePayload[];
roleAssignments?: RoleAssignmentPayload[];
channelPermissions?: ChannelPermissionPayload[];
createdAt: number;
lastSeen: number;
}

View File

@@ -4,7 +4,15 @@ import { DataSource } from 'typeorm';
import {
AuthUserEntity,
ServerEntity,
JoinRequestEntity
ServerTagEntity,
ServerChannelEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
} from '../entities';
import { serverMigrations } from '../migrations';
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
@@ -51,10 +59,18 @@ export async function initDatabase(): Promise<void> {
entities: [
AuthUserEntity,
ServerEntity,
JoinRequestEntity
ServerTagEntity,
ServerChannelEntity,
ServerRoleEntity,
ServerUserRoleEntity,
ServerChannelPermissionEntity,
JoinRequestEntity,
ServerMembershipEntity,
ServerInviteEntity,
ServerBanEntity
],
migrations: serverMigrations,
synchronize: false,
synchronize: process.env.DB_SYNCHRONIZE === 'true',
logging: false,
autoSave: true,
location: DB_FILE,
@@ -74,8 +90,12 @@ export async function initDatabase(): Promise<void> {
console.log('[DB] Connection initialised at:', DB_FILE);
await applicationDataSource.runMigrations();
console.log('[DB] Migrations executed');
if (process.env.DB_SYNCHRONIZE !== 'true') {
await applicationDataSource.runMigrations();
console.log('[DB] Migrations executed');
} else {
console.log('[DB] Synchronize mode — migrations skipped');
}
}
export async function destroyDatabase(): Promise<void> {

View File

@@ -0,0 +1,35 @@
import {
Entity,
PrimaryColumn,
Column,
Index
} from 'typeorm';
@Entity('server_bans')
export class ServerBanEntity {
@PrimaryColumn('text')
id!: string;
@Index()
@Column('text')
serverId!: string;
@Index()
@Column('text')
userId!: string;
@Column('text')
bannedBy!: string;
@Column('text', { nullable: true })
displayName!: string | null;
@Column('text', { nullable: true })
reason!: string | null;
@Column('integer', { nullable: true })
expiresAt!: number | null;
@Column('integer')
createdAt!: number;
}

View File

@@ -0,0 +1,23 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('server_channels')
export class ServerChannelEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
channelId!: string;
@Column('text')
name!: string;
@Column('text')
type!: 'text' | 'voice';
@Column('integer')
position!: number;
}

View File

@@ -0,0 +1,26 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_channel_permissions')
export class ServerChannelPermissionEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
channelId!: string;
@PrimaryColumn('text')
targetType!: 'role' | 'user';
@PrimaryColumn('text')
targetId!: string;
@PrimaryColumn('text')
permission!: string;
@Column('text')
value!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -21,6 +21,9 @@ export class ServerEntity {
@Column('text')
ownerPublicKey!: string;
@Column('text', { nullable: true })
passwordHash!: string | null;
@Column('integer', { default: 0 })
isPrivate!: number;
@@ -30,8 +33,8 @@ export class ServerEntity {
@Column('integer', { default: 0 })
currentUsers!: number;
@Column('text', { default: '[]' })
tags!: string;
@Column('integer', { default: 0 })
slowModeInterval!: number;
@Column('integer')
createdAt!: number;

View File

@@ -0,0 +1,29 @@
import {
Entity,
PrimaryColumn,
Column,
Index
} from 'typeorm';
@Entity('server_invites')
export class ServerInviteEntity {
@PrimaryColumn('text')
id!: string;
@Index()
@Column('text')
serverId!: string;
@Column('text')
createdBy!: string;
@Column('text', { nullable: true })
createdByDisplayName!: string | null;
@Column('integer')
createdAt!: number;
@Index()
@Column('integer')
expiresAt!: number;
}

View File

@@ -0,0 +1,26 @@
import {
Entity,
PrimaryColumn,
Column,
Index
} from 'typeorm';
@Entity('server_memberships')
export class ServerMembershipEntity {
@PrimaryColumn('text')
id!: string;
@Index()
@Column('text')
serverId!: string;
@Index()
@Column('text')
userId!: string;
@Column('integer')
joinedAt!: number;
@Column('integer')
lastAccessAt!: number;
}

View File

@@ -0,0 +1,59 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_roles')
export class ServerRoleEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text')
name!: string;
@Column('text', { nullable: true })
color!: string | null;
@Column('integer')
position!: number;
@Column('integer', { default: 0 })
isSystem!: number;
@Column('text', { default: 'inherit' })
manageServer!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageRoles!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageChannels!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageIcon!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
kickMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
banMembers!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
manageBans!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
deleteMessages!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
joinVoice!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
shareScreen!: 'allow' | 'deny' | 'inherit';
@Column('text', { default: 'inherit' })
uploadFiles!: 'allow' | 'deny' | 'inherit';
}

View File

@@ -0,0 +1,17 @@
import {
Entity,
PrimaryColumn,
Column
} from 'typeorm';
@Entity('server_tags')
export class ServerTagEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('integer')
position!: number;
@Column('text')
value!: string;
}

View File

@@ -0,0 +1,20 @@
import {
Column,
Entity,
PrimaryColumn
} from 'typeorm';
@Entity('server_user_roles')
export class ServerUserRoleEntity {
@PrimaryColumn('text')
serverId!: string;
@PrimaryColumn('text')
userId!: string;
@PrimaryColumn('text')
roleId!: string;
@Column('text', { nullable: true })
oderId!: string | null;
}

View File

@@ -1,3 +1,11 @@
export { AuthUserEntity } from './AuthUserEntity';
export { ServerEntity } from './ServerEntity';
export { ServerTagEntity } from './ServerTagEntity';
export { ServerChannelEntity } from './ServerChannelEntity';
export { ServerRoleEntity } from './ServerRoleEntity';
export { ServerUserRoleEntity } from './ServerUserRoleEntity';
export { ServerChannelPermissionEntity } from './ServerChannelPermissionEntity';
export { JoinRequestEntity } from './JoinRequestEntity';
export { ServerMembershipEntity } from './ServerMembershipEntity';
export { ServerInviteEntity } from './ServerInviteEntity';
export { ServerBanEntity } from './ServerBanEntity';

View File

@@ -14,23 +14,39 @@ import { deleteStaleJoinRequests } from './cqrs';
import { createApp } from './app';
import {
ensureVariablesConfig,
getServerHost,
getVariablesConfigPath,
hasKlipyApiKey
getServerPort,
getServerProtocol,
ServerHttpProtocol
} from './config/variables';
import { setupWebSocket } from './websocket';
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
const PORT = process.env.PORT || 3001;
function formatHostForUrl(host: string): string {
if (host.startsWith('[') || !host.includes(':')) {
return host;
}
function buildServer(app: ReturnType<typeof createApp>) {
if (USE_SSL) {
return `[${host}]`;
}
function getDisplayHost(serverHost: string | undefined): string {
if (!serverHost || serverHost === '0.0.0.0' || serverHost === '::') {
return 'localhost';
}
return serverHost;
}
function buildServer(app: ReturnType<typeof createApp>, serverProtocol: ServerHttpProtocol) {
if (serverProtocol === 'https') {
const certDir = resolveCertificateDirectory();
const certFile = path.join(certDir, 'localhost.crt');
const keyFile = path.join(certDir, 'localhost.key');
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
console.error(`SSL=true but certs not found in ${certDir}`);
console.error('Run ./generate-cert.sh first.');
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
process.exit(1);
}
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
}
async function bootstrap(): Promise<void> {
ensureVariablesConfig();
const variablesConfig = ensureVariablesConfig();
const serverProtocol = getServerProtocol();
const serverPort = getServerPort();
const serverHost = getServerHost();
const bindHostLabel = serverHost || 'default interface';
console.log('[Config] Variables loaded from:', getVariablesConfigPath());
if (!hasKlipyApiKey()) {
if (
variablesConfig.serverProtocol !== serverProtocol
|| variablesConfig.serverPort !== serverPort
) {
console.log(`[Config] Server runtime override active: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
} else {
console.log(`[Config] Server runtime config: protocol=${serverProtocol}, host=${bindHostLabel}, port=${serverPort}`);
}
if (!variablesConfig.klipyApiKey) {
console.log('[KLIPY] API key not configured. GIF search is disabled.');
}
await initDatabase();
const app = createApp();
const server = buildServer(app);
const server = buildServer(app, serverProtocol);
setupWebSocket(server);
@@ -64,14 +94,29 @@ async function bootstrap(): Promise<void> {
.catch(err => console.error('Failed to clean up stale join requests:', err));
}, 60 * 1000);
server.listen(PORT, () => {
const proto = USE_SSL ? 'https' : 'http';
const wsProto = USE_SSL ? 'wss' : 'ws';
const onListening = () => {
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
const wsProto = serverProtocol === 'https' ? 'wss' : 'ws';
const localHostNames = [
'localhost',
'127.0.0.1',
'::1'
];
console.log(`MetoYou signaling server running on port ${PORT} (SSL=${USE_SSL})`);
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
});
console.log(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
console.log(` WebSocket: ${wsProto}://${displayHost}:${serverPort}`);
if (serverProtocol === 'https' && serverHost && !localHostNames.includes(serverHost)) {
console.warn('[Config] HTTPS certificates must match the configured serverHost/server IP.');
}
};
if (serverHost) {
server.listen(serverPort, serverHost, onListening);
} else {
server.listen(serverPort, onListening);
}
}
bootstrap().catch((err) => {

View File

@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"tags" TEXT NOT NULL DEFAULT '[]',
"channels" TEXT NOT NULL DEFAULT '[]',
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL
)

View File

@@ -0,0 +1,56 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerAccessControl1000000000001 implements MigrationInterface {
name = 'ServerAccessControl1000000000001';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "servers" ADD COLUMN "passwordHash" TEXT`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_memberships" (
"id" TEXT PRIMARY KEY NOT NULL,
"serverId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"joinedAt" INTEGER NOT NULL,
"lastAccessAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_serverId" ON "server_memberships" ("serverId")`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_memberships_userId" ON "server_memberships" ("userId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_invites" (
"id" TEXT PRIMARY KEY NOT NULL,
"serverId" TEXT NOT NULL,
"createdBy" TEXT NOT NULL,
"createdByDisplayName" TEXT,
"createdAt" INTEGER NOT NULL,
"expiresAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_serverId" ON "server_invites" ("serverId")`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_invites_expiresAt" ON "server_invites" ("expiresAt")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_bans" (
"id" TEXT PRIMARY KEY NOT NULL,
"serverId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"bannedBy" TEXT NOT NULL,
"displayName" TEXT,
"reason" TEXT,
"expiresAt" INTEGER,
"createdAt" INTEGER NOT NULL
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_serverId" ON "server_bans" ("serverId")`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_bans_userId" ON "server_bans" ("userId")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "server_bans"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_invites"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_memberships"`);
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "passwordHash"`);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ServerChannels1000000000002 implements MigrationInterface {
name = 'ServerChannels1000000000002';
public async up(queryRunner: QueryRunner): Promise<void> {
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> {
await queryRunner.query(`ALTER TABLE "servers" DROP COLUMN "channels"`);
}
}

View File

@@ -0,0 +1,119 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
interface LegacyServerRow {
id: string;
channels: string | null;
}
interface LegacyServerChannel {
id: string;
name: string;
type: 'text' | 'voice';
position: number;
}
function normalizeLegacyChannels(raw: string | null): LegacyServerChannel[] {
try {
const parsed = JSON.parse(raw || '[]');
if (!Array.isArray(parsed)) {
return [];
}
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return parsed
.filter((channel): channel is Record<string, unknown> => !!channel && typeof channel === 'object')
.map((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? channel.name.trim().replace(/\s+/g, ' ') : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = typeof channel.position === 'number' ? channel.position : index;
const nameKey = type ? `${type}:${name.toLocaleLowerCase()}` : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return null;
}
seenIds.add(id);
seenNames.add(nameKey);
return {
id,
name,
type,
position
} satisfies LegacyServerChannel;
})
.filter((channel): channel is LegacyServerChannel => !!channel);
} catch {
return [];
}
}
function shouldRestoreLegacyVoiceGeneral(channels: LegacyServerChannel[]): boolean {
const hasTextGeneral = channels.some(
(channel) => channel.type === 'text' && (channel.id === 'general' || channel.name.toLocaleLowerCase() === 'general')
);
const hasVoiceAfk = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-afk' || channel.name.toLocaleLowerCase() === 'afk')
);
const hasVoiceGeneral = channels.some(
(channel) => channel.type === 'voice' && (channel.id === 'vc-general' || channel.name.toLocaleLowerCase() === 'general')
);
return hasTextGeneral && hasVoiceAfk && !hasVoiceGeneral;
}
function repairLegacyVoiceChannels(channels: LegacyServerChannel[]): LegacyServerChannel[] {
if (!shouldRestoreLegacyVoiceGeneral(channels)) {
return channels;
}
const textChannels = channels.filter((channel) => channel.type === 'text');
const voiceChannels = channels.filter((channel) => channel.type === 'voice');
const repairedVoiceChannels = [
{
id: 'vc-general',
name: 'General',
type: 'voice' as const,
position: 0
},
...voiceChannels
].map((channel, index) => ({
...channel,
position: index
}));
return [
...textChannels,
...repairedVoiceChannels
];
}
export class RepairLegacyVoiceChannels1000000000003 implements MigrationInterface {
name = 'RepairLegacyVoiceChannels1000000000003';
public async up(queryRunner: QueryRunner): Promise<void> {
const rows = await queryRunner.query(`SELECT "id", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
const channels = normalizeLegacyChannels(row.channels);
const repaired = repairLegacyVoiceChannels(channels);
if (JSON.stringify(repaired) === JSON.stringify(channels)) {
continue;
}
await queryRunner.query(
`UPDATE "servers" SET "channels" = ? WHERE "id" = ?`,
[JSON.stringify(repaired), row.id]
);
}
}
public async down(_queryRunner: QueryRunner): Promise<void> {
// Forward-only data repair migration.
}
}

View File

@@ -0,0 +1,142 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyServerRow = {
id: string;
tags: string | null;
channels: string | null;
};
type LegacyServerChannel = {
id?: unknown;
name?: unknown;
type?: unknown;
position?: unknown;
};
function parseArray<T>(raw: string | null): T[] {
try {
const parsed = JSON.parse(raw || '[]');
return Array.isArray(parsed) ? parsed as T[] : [];
} catch {
return [];
}
}
function normalizeChannelName(name: string): string {
return name.trim().replace(/\s+/g, ' ');
}
function channelNameKey(type: 'text' | 'voice', name: string): string {
return `${type}:${normalizeChannelName(name).toLocaleLowerCase()}`;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function normalizeServerTags(raw: string | null): string[] {
return parseArray<unknown>(raw).filter((tag): tag is string => typeof tag === 'string');
}
function normalizeServerChannels(raw: string | null) {
const channels = parseArray<LegacyServerChannel>(raw);
const seenIds = new Set<string>();
const seenNames = new Set<string>();
return channels.flatMap((channel, index) => {
const id = typeof channel.id === 'string' ? channel.id.trim() : '';
const name = typeof channel.name === 'string' ? normalizeChannelName(channel.name) : '';
const type = channel.type === 'text' || channel.type === 'voice' ? channel.type : null;
const position = isFiniteNumber(channel.position) ? channel.position : index;
const nameKey = type ? channelNameKey(type, name) : '';
if (!id || !name || !type || seenIds.has(id) || seenNames.has(nameKey)) {
return [];
}
seenIds.add(id);
seenNames.add(nameKey);
return [{
channelId: id,
name,
type,
position
}];
});
}
export class NormalizeServerArrays1000000000004 implements MigrationInterface {
name = 'NormalizeServerArrays1000000000004';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_tags" (
"serverId" TEXT NOT NULL,
"position" INTEGER NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("serverId", "position")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_tags_serverId" ON "server_tags" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_channels" (
"serverId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"position" INTEGER NOT NULL,
PRIMARY KEY ("serverId", "channelId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channels_serverId" ON "server_channels" ("serverId")`);
const rows = await queryRunner.query(`SELECT "id", "tags", "channels" FROM "servers"`) as LegacyServerRow[];
for (const row of rows) {
for (const [position, tag] of normalizeServerTags(row.tags).entries()) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_tags" ("serverId", "position", "value") VALUES (?, ?, ?)`,
[row.id, position, tag]
);
}
for (const channel of normalizeServerChannels(row.channels)) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_channels" ("serverId", "channelId", "name", "type", "position") VALUES (?, ?, ?, ?, ?)`,
[row.id, channel.channelId, channel.name, channel.type, channel.position]
);
}
}
await queryRunner.query(`
CREATE TABLE "servers_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"ownerId" TEXT NOT NULL,
"ownerPublicKey" TEXT NOT NULL,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL,
"passwordHash" TEXT
)
`);
await queryRunner.query(`
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash")
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen", "passwordHash"
FROM "servers"
`);
await queryRunner.query(`DROP TABLE "servers"`);
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "server_channels"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_tags"`);
}
}

View File

@@ -0,0 +1,196 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type LegacyServerRow = {
id: string;
name: string;
description: string | null;
ownerId: string;
ownerPublicKey: string;
passwordHash: string | null;
isPrivate: number;
maxUsers: number;
currentUsers: number;
createdAt: number;
lastSeen: number;
};
const SYSTEM_ROLE_IDS = {
everyone: 'system-everyone',
moderator: 'system-moderator',
admin: 'system-admin'
} as const;
function buildDefaultServerRoles() {
return [
{
roleId: SYSTEM_ROLE_IDS.everyone,
name: '@everyone',
color: '#6b7280',
position: 0,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'inherit',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'inherit',
joinVoice: 'allow',
shareScreen: 'allow',
uploadFiles: 'allow'
},
{
roleId: SYSTEM_ROLE_IDS.moderator,
name: 'Moderator',
color: '#10b981',
position: 200,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'inherit',
manageIcon: 'inherit',
kickMembers: 'allow',
banMembers: 'inherit',
manageBans: 'inherit',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
},
{
roleId: SYSTEM_ROLE_IDS.admin,
name: 'Admin',
color: '#60a5fa',
position: 300,
isSystem: 1,
manageServer: 'inherit',
manageRoles: 'inherit',
manageChannels: 'allow',
manageIcon: 'allow',
kickMembers: 'allow',
banMembers: 'allow',
manageBans: 'allow',
deleteMessages: 'allow',
joinVoice: 'inherit',
shareScreen: 'inherit',
uploadFiles: 'inherit'
}
];
}
export class ServerRoleAccessControl1000000000005 implements MigrationInterface {
name = 'ServerRoleAccessControl1000000000005';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_roles" (
"serverId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"position" INTEGER NOT NULL,
"isSystem" INTEGER NOT NULL DEFAULT 0,
"manageServer" TEXT NOT NULL DEFAULT 'inherit',
"manageRoles" TEXT NOT NULL DEFAULT 'inherit',
"manageChannels" TEXT NOT NULL DEFAULT 'inherit',
"manageIcon" TEXT NOT NULL DEFAULT 'inherit',
"kickMembers" TEXT NOT NULL DEFAULT 'inherit',
"banMembers" TEXT NOT NULL DEFAULT 'inherit',
"manageBans" TEXT NOT NULL DEFAULT 'inherit',
"deleteMessages" TEXT NOT NULL DEFAULT 'inherit',
"joinVoice" TEXT NOT NULL DEFAULT 'inherit',
"shareScreen" TEXT NOT NULL DEFAULT 'inherit',
"uploadFiles" TEXT NOT NULL DEFAULT 'inherit',
PRIMARY KEY ("serverId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_roles_serverId" ON "server_roles" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_user_roles" (
"serverId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"oderId" TEXT,
PRIMARY KEY ("serverId", "userId", "roleId")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_user_roles_serverId" ON "server_user_roles" ("serverId")`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "server_channel_permissions" (
"serverId" TEXT NOT NULL,
"channelId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"permission" TEXT NOT NULL,
"value" TEXT NOT NULL,
PRIMARY KEY ("serverId", "channelId", "targetType", "targetId", "permission")
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "idx_server_channel_permissions_serverId" ON "server_channel_permissions" ("serverId")`);
const servers = await queryRunner.query(`
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "createdAt", "lastSeen"
FROM "servers"
`) as LegacyServerRow[];
for (const server of servers) {
for (const role of buildDefaultServerRoles()) {
await queryRunner.query(
`INSERT OR REPLACE INTO "server_roles" ("serverId", "roleId", "name", "color", "position", "isSystem", "manageServer", "manageRoles", "manageChannels", "manageIcon", "kickMembers", "banMembers", "manageBans", "deleteMessages", "joinVoice", "shareScreen", "uploadFiles") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
server.id,
role.roleId,
role.name,
role.color,
role.position,
role.isSystem,
role.manageServer,
role.manageRoles,
role.manageChannels,
role.manageIcon,
role.kickMembers,
role.banMembers,
role.manageBans,
role.deleteMessages,
role.joinVoice,
role.shareScreen,
role.uploadFiles
]
);
}
}
await queryRunner.query(`
CREATE TABLE "servers_next" (
"id" TEXT PRIMARY KEY NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"ownerId" TEXT NOT NULL,
"ownerPublicKey" TEXT NOT NULL,
"passwordHash" TEXT,
"isPrivate" INTEGER NOT NULL DEFAULT 0,
"maxUsers" INTEGER NOT NULL DEFAULT 0,
"currentUsers" INTEGER NOT NULL DEFAULT 0,
"slowModeInterval" INTEGER NOT NULL DEFAULT 0,
"createdAt" INTEGER NOT NULL,
"lastSeen" INTEGER NOT NULL
)
`);
await queryRunner.query(`
INSERT INTO "servers_next" ("id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", "slowModeInterval", "createdAt", "lastSeen")
SELECT "id", "name", "description", "ownerId", "ownerPublicKey", "passwordHash", "isPrivate", "maxUsers", "currentUsers", 0, "createdAt", "lastSeen"
FROM "servers"
`);
await queryRunner.query(`DROP TABLE "servers"`);
await queryRunner.query(`ALTER TABLE "servers_next" RENAME TO "servers"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "server_channel_permissions"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_user_roles"`);
await queryRunner.query(`DROP TABLE IF EXISTS "server_roles"`);
}
}

View File

@@ -1,3 +1,15 @@
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
import { ServerAccessControl1000000000001 } from './1000000000001-ServerAccessControl';
import { ServerChannels1000000000002 } from './1000000000002-ServerChannels';
import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLegacyVoiceChannels';
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
export const serverMigrations = [InitialSchema1000000000000];
export const serverMigrations = [
InitialSchema1000000000000,
ServerAccessControl1000000000001,
ServerChannels1000000000002,
RepairLegacyVoiceChannels1000000000003,
NormalizeServerArrays1000000000004,
ServerRoleAccessControl1000000000005
];

View File

@@ -1,10 +1,15 @@
import { Router } from 'express';
import { randomUUID } from 'crypto';
import { getAllPublicServers } from '../cqrs';
import { getReleaseManifestUrl } from '../config/variables';
import { SERVER_BUILD_VERSION } from '../generated/build-version';
import { connectedUsers } from '../websocket/state';
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 {
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(),
serverCount: servers.length,
connectedUsers: connectedUsers.size,
serverInstanceId: SERVER_INSTANCE_ID,
serverVersion: getServerProjectVersion(),
releaseManifestUrl: getReleaseManifestUrl()
});

View File

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

View File

@@ -0,0 +1,57 @@
import { Request } from 'express';
function buildOrigin(protocol: string, host: string): string {
return `${protocol}://${host}`.replace(/\/+$/, '');
}
function originFromUrl(url: URL): string {
return buildOrigin(url.protocol.replace(':', ''), url.host);
}
export function getRequestOrigin(request: Request): string {
const forwardedProtoHeader = request.get('x-forwarded-proto');
const forwardedHostHeader = request.get('x-forwarded-host');
const protocol = forwardedProtoHeader?.split(',')[0]?.trim() || request.protocol;
const host = forwardedHostHeader?.split(',')[0]?.trim() || request.get('host') || 'localhost';
return buildOrigin(protocol, host);
}
export function deriveWebAppOrigin(signalOrigin: string): string {
const url = new URL(signalOrigin);
if (url.hostname === 'signal.toju.app' && !url.port) {
return 'https://web.toju.app';
}
if (url.hostname.startsWith('signal.')) {
url.hostname = url.hostname.replace(/^signal\./, 'web.');
if (url.port === '3001') {
url.port = '4200';
}
return originFromUrl(url);
}
if (url.port === '3001') {
url.port = '4200';
return originFromUrl(url);
}
return 'https://web.toju.app';
}
export function buildInviteUrl(signalOrigin: string, inviteId: string): string {
return `${signalOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}`;
}
export function buildBrowserInviteUrl(signalOrigin: string, inviteId: string): string {
const browserOrigin = deriveWebAppOrigin(signalOrigin);
return `${browserOrigin.replace(/\/+$/, '')}/invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
}
export function buildAppInviteUrl(signalOrigin: string, inviteId: string): string {
return `toju://invite/${encodeURIComponent(inviteId)}?server=${encodeURIComponent(signalOrigin)}`;
}

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