Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3b56fb1cc | |||
| 315820d487 | |||
| 878fd1c766 | |||
| 391d9235f1 | |||
| f33440a827 | |||
| 52912327ae | |||
| 7a0664b3c4 | |||
| cea3dccef1 | |||
| c8bb82feb5 | |||
| 3fb5515c3a | |||
| a6bdac1a25 | |||
| db7e683504 | |||
| 98ed8eeb68 | |||
| 39b85e2e3a | |||
| 58e338246f | |||
| 0b9a9f311e | |||
| 6800c73292 | |||
| ef1182d46f | |||
| 0865c2fe33 | |||
| 4a41de79d6 | |||
| 84fa45985a | |||
| 35352923a5 | |||
| b9df9c92f2 | |||
| 8674579b19 | |||
| de2d3300d4 | |||
| ae0ee8fac7 | |||
| 37cac95b38 | |||
| 314a26325f | |||
| 5d7e045764 | |||
| bbb6deb0a2 | |||
| 65b9419869 | |||
| fed270d28d | |||
| 8b6578da3c | |||
| 851d6ae759 | |||
| 1e833ec7f2 | |||
| 64e34ad586 | |||
| e3b23247a9 | |||
| 42ac712571 | |||
| b7d4bf20e3 | |||
| 727059fb52 | |||
| 83694570e3 | |||
| 109402cdd6 | |||
| eb23fd71ec | |||
| 11917f3412 | |||
| 8162e0444a | |||
| 0467a7b612 | |||
| 971a5afb8b | |||
| fe9c1dd1c0 | |||
| 429bb9d8ff | |||
| b5d676fb78 | |||
| aa595c45d8 | |||
| 1c7e535057 | |||
| 8f960be1e9 | |||
| 9a173792a4 | |||
| cb2c0495b9 | |||
| c3ef8e8800 | |||
| c862c2fe03 | |||
| 4faa62864d | |||
| 1cdd1c5d2b | |||
| 141de64767 | |||
| eb987ac672 | |||
| f8fd78d21a | |||
| 150c45c31a | |||
| 00adf39121 | |||
| 2b6e477c9a | |||
| 22d355a522 | |||
| 15c5952e29 | |||
| 781c05294f | |||
| 778e75bef5 |
63
.agents/skills/caveman/SKILL.md
Normal file
63
.agents/skills/caveman/SKILL.md
Normal 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.
|
||||||
283
.agents/skills/playwright-e2e/SKILL.md
Normal file
283
.agents/skills/playwright-e2e/SKILL.md
Normal 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 (0–200% 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 |
|
||||||
536
.agents/skills/playwright-e2e/reference/multi-client-webrtc.md
Normal file
536
.agents/skills/playwright-e2e/reference/multi-client-webrtc.md
Normal 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 (0–200%). 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 | 3–5s 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.
|
||||||
175
.agents/skills/playwright-e2e/reference/project-setup.md
Normal file
175
.agents/skills/playwright-e2e/reference/project-setup.md
Normal 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.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
# Toggle SSL for local development (true/false)
|
# Toggle SSL for local development (true/false)
|
||||||
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
# When true: ng serve uses --ssl, Express API uses HTTPS, Electron loads https://
|
||||||
# When false: plain HTTP everywhere (only works on localhost)
|
# When false: plain HTTP everywhere (only works on localhost)
|
||||||
|
# Overrides server/data/variables.json for local development only
|
||||||
SSL=true
|
SSL=true
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/AppData/Local/npm-cache
|
||||||
|
key: npm-windows-${{ hashFiles('package-lock.json', 'website/package-lock.json') }}
|
||||||
|
restore-keys: npm-windows-
|
||||||
|
|
||||||
- name: Install root dependencies
|
- name: Install root dependencies
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
|
|||||||
@@ -48,18 +48,30 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /root/.npm
|
||||||
|
key: npm-linux-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
|
||||||
|
restore-keys: npm-linux-
|
||||||
|
|
||||||
|
- name: Restore Electron cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
/root/.cache/electron
|
||||||
|
/root/.cache/electron-builder
|
||||||
|
key: electron-linux-${{ hashFiles('package.json') }}
|
||||||
|
restore-keys: electron-linux-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
run: |
|
run: |
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends zip
|
||||||
npm ci
|
npm ci
|
||||||
cd server && npm ci
|
cd server && npm ci
|
||||||
|
|
||||||
- name: Install zip utility
|
|
||||||
run: |
|
|
||||||
apt-get update
|
|
||||||
apt-get install -y zip
|
|
||||||
|
|
||||||
- name: Set CI release version
|
- name: Set CI release version
|
||||||
run: >
|
run: >
|
||||||
node tools/set-release-version.js
|
node tools/set-release-version.js
|
||||||
@@ -67,8 +79,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: |
|
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='./'
|
npx ng build --configuration production --base-href='./'
|
||||||
|
cd ..
|
||||||
npx --package typescript tsc -p tsconfig.electron.json
|
npx --package typescript tsc -p tsconfig.electron.json
|
||||||
cd server
|
cd server
|
||||||
node ../tools/sync-server-build-version.js
|
node ../tools/sync-server-build-version.js
|
||||||
@@ -106,6 +120,22 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Restore npm cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/AppData/Local/npm-cache
|
||||||
|
key: npm-windows-${{ hashFiles('package-lock.json', 'server/package-lock.json') }}
|
||||||
|
restore-keys: npm-windows-
|
||||||
|
|
||||||
|
- name: Restore Electron cache
|
||||||
|
uses: https://github.com/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/AppData/Local/electron/Cache
|
||||||
|
~/AppData/Local/electron-builder/Cache
|
||||||
|
key: electron-windows-${{ hashFiles('package.json') }}
|
||||||
|
restore-keys: electron-windows-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
env:
|
env:
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
@@ -120,8 +150,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Build application
|
- name: Build application
|
||||||
run: |
|
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='./'
|
npx ng build --configuration production --base-href='./'
|
||||||
|
Pop-Location
|
||||||
npx --package typescript tsc -p tsconfig.electron.json
|
npx --package typescript tsc -p tsconfig.electron.json
|
||||||
Push-Location server
|
Push-Location server
|
||||||
node ../tools/sync-server-build-version.js
|
node ../tools/sync-server-build-version.js
|
||||||
@@ -213,9 +245,6 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: https://github.com/actions/checkout@v4
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci --omit=dev
|
|
||||||
|
|
||||||
- name: Download previous manifest
|
- name: Download previous manifest
|
||||||
env:
|
env:
|
||||||
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITEA_RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -6,7 +6,9 @@
|
|||||||
/tmp
|
/tmp
|
||||||
/out-tsc
|
/out-tsc
|
||||||
/bazel-out
|
/bazel-out
|
||||||
|
*.sqlite
|
||||||
|
*/architecture.md
|
||||||
|
/docs
|
||||||
# Node
|
# Node
|
||||||
/node_modules
|
/node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
@@ -42,6 +44,10 @@ testem.log
|
|||||||
/typings
|
/typings
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
test-results/
|
||||||
|
e2e/playwright-report/
|
||||||
|
|
||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
@@ -51,3 +57,6 @@ Thumbs.db
|
|||||||
.certs/
|
.certs/
|
||||||
/server/data/variables.json
|
/server/data/variables.json
|
||||||
dist-server/*
|
dist-server/*
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
|
doc/**
|
||||||
|
|||||||
73
README.md
73
README.md
@@ -1,10 +1,14 @@
|
|||||||
|
<img src="./images/icon.png" width="100" height="100">
|
||||||
|
|
||||||
|
|
||||||
# Toju / Zoracord
|
# Toju / Zoracord
|
||||||
|
|
||||||
Desktop chat app with three parts:
|
Desktop chat app with four parts:
|
||||||
|
|
||||||
- `src/` Angular client
|
- `src/` Angular client
|
||||||
- `electron/` desktop shell, IPC, and local database
|
- `electron/` desktop shell, IPC, and local database
|
||||||
- `server/` directory server, join request API, and websocket events
|
- `server/` directory server, join request API, and websocket events
|
||||||
|
- `website/` Toju website served at toju.app
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
@@ -17,7 +21,7 @@ Desktop chat app with three parts:
|
|||||||
Root `.env`:
|
Root `.env`:
|
||||||
|
|
||||||
- `SSL=true` uses HTTPS for Angular, the server, and Electron dev mode
|
- `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.
|
If `SSL=true`, run `./generate-cert.sh` once.
|
||||||
|
|
||||||
@@ -25,6 +29,10 @@ Server files:
|
|||||||
|
|
||||||
- `server/data/variables.json` holds `klipyApiKey`
|
- `server/data/variables.json` holds `klipyApiKey`
|
||||||
- `server/data/variables.json` also holds `releaseManifestUrl` for desktop auto updates
|
- `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
|
## Main commands
|
||||||
|
|
||||||
@@ -48,3 +56,64 @@ Inside `server/`:
|
|||||||
- `npm run dev` starts the server with reload
|
- `npm run dev` starts the server with reload
|
||||||
- `npm run build` compiles to `dist/`
|
- `npm run build` compiles to `dist/`
|
||||||
- `npm run start` runs the compiled server
|
- `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
6
dev.sh
@@ -20,12 +20,12 @@ if [ "$SSL" = "true" ]; then
|
|||||||
"$DIR/generate-cert.sh"
|
"$DIR/generate-cert.sh"
|
||||||
fi
|
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"
|
WAIT_URL="https://localhost:4200"
|
||||||
HEALTH_URL="https://localhost:3001/api/health"
|
HEALTH_URL="https://localhost:3001/api/health"
|
||||||
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
export NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||||
else
|
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"
|
WAIT_URL="http://localhost:4200"
|
||||||
HEALTH_URL="http://localhost:3001/api/health"
|
HEALTH_URL="http://localhost:3001/api/health"
|
||||||
fi
|
fi
|
||||||
@@ -33,4 +33,4 @@ fi
|
|||||||
exec npx concurrently --kill-others \
|
exec npx concurrently --kill-others \
|
||||||
"cd server && npm run dev" \
|
"cd server && npm run dev" \
|
||||||
"$NG_SERVE" \
|
"$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
4
e2e/fixtures/base.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
export const test = base;
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
202
e2e/fixtures/multi-client.ts
Normal file
202
e2e/fixtures/multi-client.ts
Normal 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
BIN
e2e/fixtures/test-tone.wav
Normal file
Binary file not shown.
77
e2e/helpers/seed-test-endpoint.ts
Normal file
77
e2e/helpers/seed-test-endpoint.ts
Normal 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);
|
||||||
|
}
|
||||||
107
e2e/helpers/start-test-server.js
Normal file
107
e2e/helpers/start-test-server.js
Normal 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);
|
||||||
717
e2e/helpers/webrtc-helpers.ts
Normal file
717
e2e/helpers/webrtc-helpers.ts
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
143
e2e/pages/chat-messages.page.ts
Normal file
143
e2e/pages/chat-messages.page.ts
Normal 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
390
e2e/pages/chat-room.page.ts
Normal 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
29
e2e/pages/login.page.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
e2e/pages/register.page.ts
Normal file
45
e2e/pages/register.page.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
e2e/pages/server-search.page.ts
Normal file
65
e2e/pages/server-search.page.ts
Normal 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
39
e2e/playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
295
e2e/tests/chat/chat-message-features.spec.ts
Normal file
295
e2e/tests/chat/chat-message-features.spec.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
396
e2e/tests/screen-share/screen-share.spec.ts
Normal file
396
e2e/tests/screen-share/screen-share.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
260
e2e/tests/voice/voice-full-journey.spec.ts
Normal file
260
e2e/tests/voice/voice-full-journey.spec.ts
Normal 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
129
electron/app/auto-start.ts
Normal 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
121
electron/app/deep-links.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -45,9 +45,9 @@ function linuxSpecificFlags(): void {
|
|||||||
app.commandLine.appendSwitch('no-sandbox');
|
app.commandLine.appendSwitch('no-sandbox');
|
||||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||||
|
|
||||||
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
|
// Chromium chooses the Linux Ozone platform before Electron runs this file.
|
||||||
// works for screen capture on Wayland compositors
|
// The launch scripts pass `--ozone-platform=wayland` up front for Wayland
|
||||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
// sessions so the browser process selects the correct backend early enough.
|
||||||
}
|
}
|
||||||
|
|
||||||
function networkFlags(): void {
|
function networkFlags(): void {
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
import { cleanupLinuxScreenShareAudioRouting } from '../audio/linux-screen-share-routing';
|
||||||
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
import { initializeDesktopUpdater, shutdownDesktopUpdater } from '../update/desktop-updater';
|
||||||
|
import { synchronizeAutoStartSetting } from './auto-start';
|
||||||
import {
|
import {
|
||||||
initializeDatabase,
|
initializeDatabase,
|
||||||
destroyDatabase,
|
destroyDatabase,
|
||||||
getDataSource
|
getDataSource
|
||||||
} from '../db/database';
|
} from '../db/database';
|
||||||
import { createWindow, getDockIconPath } from '../window/create-window';
|
import {
|
||||||
|
createWindow,
|
||||||
|
getDockIconPath,
|
||||||
|
getMainWindow,
|
||||||
|
prepareWindowForAppQuit,
|
||||||
|
showMainWindow
|
||||||
|
} from '../window/create-window';
|
||||||
import {
|
import {
|
||||||
setupCqrsHandlers,
|
setupCqrsHandlers,
|
||||||
setupSystemHandlers,
|
setupSystemHandlers,
|
||||||
@@ -24,12 +31,18 @@ export function registerAppLifecycle(): void {
|
|||||||
setupCqrsHandlers();
|
setupCqrsHandlers();
|
||||||
setupWindowControlHandlers();
|
setupWindowControlHandlers();
|
||||||
setupSystemHandlers();
|
setupSystemHandlers();
|
||||||
|
await synchronizeAutoStartSetting();
|
||||||
initializeDesktopUpdater();
|
initializeDesktopUpdater();
|
||||||
await createWindow();
|
await createWindow();
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on('activate', () => {
|
||||||
|
if (getMainWindow()) {
|
||||||
|
void showMainWindow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0)
|
if (BrowserWindow.getAllWindows().length === 0)
|
||||||
createWindow();
|
void createWindow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,6 +52,8 @@ export function registerAppLifecycle(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', async (event) => {
|
app.on('before-quit', async (event) => {
|
||||||
|
prepareWindowForAppQuit();
|
||||||
|
|
||||||
if (getDataSource()?.isInitialized) {
|
if (getDataSource()?.isInitialized) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
shutdownDesktopUpdater();
|
shutdownDesktopUpdater();
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ interface SinkInputDetails extends ShortSinkInputEntry {
|
|||||||
properties: Record<string, string>;
|
properties: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DescendantProcessInfo {
|
||||||
|
ids: ReadonlySet<string>;
|
||||||
|
binaryNames: ReadonlySet<string>;
|
||||||
|
}
|
||||||
|
|
||||||
interface PactlJsonSinkInputEntry {
|
interface PactlJsonSinkInputEntry {
|
||||||
index?: number | string;
|
index?: number | string;
|
||||||
properties?: Record<string, unknown>;
|
properties?: Record<string, unknown>;
|
||||||
@@ -44,6 +49,7 @@ interface LinuxScreenShareAudioRoutingState {
|
|||||||
screenShareLoopbackModuleId: string | null;
|
screenShareLoopbackModuleId: string | null;
|
||||||
voiceLoopbackModuleId: string | null;
|
voiceLoopbackModuleId: string | null;
|
||||||
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
rerouteIntervalId: ReturnType<typeof setInterval> | null;
|
||||||
|
subscribeProcess: ChildProcess | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinuxScreenShareMonitorCaptureState {
|
interface LinuxScreenShareMonitorCaptureState {
|
||||||
@@ -77,7 +83,8 @@ const routingState: LinuxScreenShareAudioRoutingState = {
|
|||||||
restoreSinkName: null,
|
restoreSinkName: null,
|
||||||
screenShareLoopbackModuleId: null,
|
screenShareLoopbackModuleId: null,
|
||||||
voiceLoopbackModuleId: null,
|
voiceLoopbackModuleId: null,
|
||||||
rerouteIntervalId: null
|
rerouteIntervalId: null,
|
||||||
|
subscribeProcess: null
|
||||||
};
|
};
|
||||||
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
const monitorCaptureState: LinuxScreenShareMonitorCaptureState = {
|
||||||
captureId: null,
|
captureId: null,
|
||||||
@@ -126,12 +133,21 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
|||||||
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
routingState.screenShareLoopbackModuleId = await loadLoopbackModule(SCREEN_SHARE_MONITOR_SOURCE_NAME, restoreSinkName);
|
||||||
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
routingState.voiceLoopbackModuleId = await loadLoopbackModule(`${VOICE_SINK_NAME}.monitor`, restoreSinkName);
|
||||||
|
|
||||||
await setDefaultSink(SCREEN_SHARE_SINK_NAME);
|
// Set the default sink to the voice sink so that new app audio
|
||||||
await moveSinkInputs(SCREEN_SHARE_SINK_NAME, (sinkName) => !!sinkName && sinkName !== SCREEN_SHARE_SINK_NAME && sinkName !== VOICE_SINK_NAME);
|
// streams (received WebRTC voice) never land on the screenshare
|
||||||
|
// capture sink. This prevents the feedback loop where remote
|
||||||
|
// voice audio was picked up by parec before the reroute interval
|
||||||
|
// could move the stream away.
|
||||||
|
await setDefaultSink(VOICE_SINK_NAME);
|
||||||
|
|
||||||
routingState.active = true;
|
routingState.active = true;
|
||||||
await rerouteAppSinkInputsToVoiceSink();
|
|
||||||
|
// Let the combined reroute decide placement for every existing
|
||||||
|
// stream. This avoids briefly shoving the app's own playback to the
|
||||||
|
// screenshare sink before ownership detection can move it back.
|
||||||
|
await rerouteSinkInputs();
|
||||||
startSinkInputRerouteLoop();
|
startSinkInputRerouteLoop();
|
||||||
|
startSubscribeWatcher();
|
||||||
|
|
||||||
return buildRoutingInfo(true, true);
|
return buildRoutingInfo(true, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -148,6 +164,7 @@ export async function activateLinuxScreenShareAudioRouting(): Promise<LinuxScree
|
|||||||
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean> {
|
||||||
const restoreSinkName = routingState.restoreSinkName;
|
const restoreSinkName = routingState.restoreSinkName;
|
||||||
|
|
||||||
|
stopSubscribeWatcher();
|
||||||
stopSinkInputRerouteLoop();
|
stopSinkInputRerouteLoop();
|
||||||
await stopLinuxScreenShareMonitorCapture();
|
await stopLinuxScreenShareMonitorCapture();
|
||||||
|
|
||||||
@@ -166,6 +183,7 @@ export async function deactivateLinuxScreenShareAudioRouting(): Promise<boolean>
|
|||||||
routingState.restoreSinkName = null;
|
routingState.restoreSinkName = null;
|
||||||
routingState.screenShareLoopbackModuleId = null;
|
routingState.screenShareLoopbackModuleId = null;
|
||||||
routingState.voiceLoopbackModuleId = null;
|
routingState.voiceLoopbackModuleId = null;
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -425,35 +443,53 @@ async function setDefaultSink(sinkName: string): Promise<void> {
|
|||||||
await runPactl('set-default-sink', sinkName);
|
await runPactl('set-default-sink', sinkName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rerouteAppSinkInputsToVoiceSink(): Promise<void> {
|
/**
|
||||||
|
* Combined reroute that enforces sink placement in both directions:
|
||||||
|
* - App-owned sink inputs that are NOT on the voice sink are moved there.
|
||||||
|
* - Non-app sink inputs that ARE on the voice sink are moved to the
|
||||||
|
* screenshare sink so they are captured by parec.
|
||||||
|
*
|
||||||
|
* This two-way approach, combined with the voice sink being the PulseAudio
|
||||||
|
* default, ensures that received WebRTC voice audio can never leak into the
|
||||||
|
* screenshare monitor source.
|
||||||
|
*/
|
||||||
|
async function rerouteSinkInputs(): Promise<void> {
|
||||||
const [
|
const [
|
||||||
sinks,
|
sinks,
|
||||||
sinkInputs,
|
sinkInputs,
|
||||||
descendantProcessIds
|
descendantProcessInfo
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
listSinks(),
|
listSinks(),
|
||||||
listSinkInputDetails(),
|
listSinkInputDetails(),
|
||||||
collectDescendantProcessIds(process.pid)
|
collectDescendantProcessInfo(process.pid)
|
||||||
]);
|
]);
|
||||||
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
const sinkNamesByIndex = new Map(sinks.map((sink) => [sink.index, sink.name]));
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
sinkInputs.map(async (sinkInput) => {
|
sinkInputs.map(async (sinkInput) => {
|
||||||
if (!isAppOwnedSinkInput(sinkInput, descendantProcessIds)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
const sinkName = sinkNamesByIndex.get(sinkInput.sinkIndex) ?? null;
|
||||||
|
const appOwned = isAppOwnedSinkInput(sinkInput, descendantProcessInfo);
|
||||||
|
|
||||||
if (sinkName === VOICE_SINK_NAME) {
|
// App-owned streams must stay on the voice sink.
|
||||||
return;
|
if (appOwned && sinkName !== VOICE_SINK_NAME) {
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
await runPactl('move-sink-input', sinkInput.index, VOICE_SINK_NAME);
|
||||||
} catch {
|
} catch {
|
||||||
// Streams can disappear or be recreated while rerouting.
|
// Streams can disappear or be recreated while rerouting.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-app streams sitting on the voice sink should be moved to the
|
||||||
|
// screenshare sink for desktop-audio capture.
|
||||||
|
if (!appOwned && sinkName === VOICE_SINK_NAME) {
|
||||||
|
try {
|
||||||
|
await runPactl('move-sink-input', sinkInput.index, SCREEN_SHARE_SINK_NAME);
|
||||||
|
} catch {
|
||||||
|
// Streams can disappear or be recreated while rerouting.
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -515,7 +551,7 @@ function startSinkInputRerouteLoop(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
routingState.rerouteIntervalId = setInterval(() => {
|
routingState.rerouteIntervalId = setInterval(() => {
|
||||||
void rerouteAppSinkInputsToVoiceSink();
|
void rerouteSinkInputs();
|
||||||
}, REROUTE_INTERVAL_MS);
|
}, REROUTE_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,13 +564,108 @@ function stopSinkInputRerouteLoop(): void {
|
|||||||
routingState.rerouteIntervalId = null;
|
routingState.rerouteIntervalId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawns `pactl subscribe` to receive PulseAudio events in real time.
|
||||||
|
* When a new or changed sink-input is detected, a reroute is triggered
|
||||||
|
* immediately instead of waiting for the next interval tick. This
|
||||||
|
* drastically reduces the time non-app desktop audio spends on the
|
||||||
|
* voice sink before being moved to the screenshare sink.
|
||||||
|
*/
|
||||||
|
function startSubscribeWatcher(): void {
|
||||||
|
if (routingState.subscribeProcess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let proc: ChildProcess;
|
||||||
|
|
||||||
|
try {
|
||||||
|
proc = spawn('pactl', ['subscribe'], {
|
||||||
|
env: process.env,
|
||||||
|
stdio: [
|
||||||
|
'ignore',
|
||||||
|
'pipe',
|
||||||
|
'ignore'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// If pactl subscribe fails to spawn, the interval loop still covers us.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routingState.subscribeProcess = proc;
|
||||||
|
|
||||||
|
let pending = false;
|
||||||
|
|
||||||
|
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||||
|
if (!routingState.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = chunk.toString();
|
||||||
|
|
||||||
|
if (/Event '(?:new|change)' on sink-input/.test(text)) {
|
||||||
|
if (!pending) {
|
||||||
|
pending = true;
|
||||||
|
|
||||||
|
// Batch rapid-fire events with a short delay.
|
||||||
|
setTimeout(() => {
|
||||||
|
pending = false;
|
||||||
|
void rerouteSinkInputs();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('close', () => {
|
||||||
|
if (routingState.subscribeProcess === proc) {
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on('error', () => {
|
||||||
|
if (routingState.subscribeProcess === proc) {
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscribeWatcher(): void {
|
||||||
|
const proc = routingState.subscribeProcess;
|
||||||
|
|
||||||
|
if (!proc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
routingState.subscribeProcess = null;
|
||||||
|
|
||||||
|
if (!proc.killed) {
|
||||||
|
proc.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isAppOwnedSinkInput(
|
function isAppOwnedSinkInput(
|
||||||
sinkInput: SinkInputDetails,
|
sinkInput: SinkInputDetails,
|
||||||
descendantProcessIds: ReadonlySet<string>
|
descendantProcessInfo: DescendantProcessInfo
|
||||||
): boolean {
|
): boolean {
|
||||||
const processId = sinkInput.properties['application.process.id'];
|
const processId = sinkInput.properties['application.process.id'];
|
||||||
|
|
||||||
return typeof processId === 'string' && descendantProcessIds.has(processId);
|
if (typeof processId === 'string' && descendantProcessInfo.ids.has(processId)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processBinary = normalizeProcessBinary(sinkInput.properties['application.process.binary']);
|
||||||
|
|
||||||
|
if (processBinary && descendantProcessInfo.binaryNames.has(processBinary)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicationName = normalizeProcessBinary(sinkInput.properties['application.name']);
|
||||||
|
|
||||||
|
if (applicationName && descendantProcessInfo.binaryNames.has(applicationName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moveSinkInputs(
|
async function moveSinkInputs(
|
||||||
@@ -697,31 +828,45 @@ async function listSinkInputDetails(): Promise<SinkInputDetails[]> {
|
|||||||
return entries.filter((entry) => !!entry.sinkIndex);
|
return entries.filter((entry) => !!entry.sinkIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<string>> {
|
async function collectDescendantProcessInfo(rootProcessId: number): Promise<DescendantProcessInfo> {
|
||||||
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid='], {
|
const { stdout } = await execFileAsync('ps', ['-eo', 'pid=,ppid=,comm='], {
|
||||||
env: process.env
|
env: process.env
|
||||||
});
|
});
|
||||||
const childrenByParentId = new Map<string, string[]>();
|
const childrenByParentId = new Map<string, string[]>();
|
||||||
|
const binaryNameByProcessId = new Map<string, string>();
|
||||||
|
|
||||||
stdout
|
stdout
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.forEach((line) => {
|
.forEach((line) => {
|
||||||
const [pid, ppid] = line.split(/\s+/);
|
const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||||
|
|
||||||
if (!pid || !ppid) {
|
if (!match) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
,
|
||||||
|
pid,
|
||||||
|
ppid,
|
||||||
|
command
|
||||||
|
] = match;
|
||||||
const siblings = childrenByParentId.get(ppid) ?? [];
|
const siblings = childrenByParentId.get(ppid) ?? [];
|
||||||
|
|
||||||
siblings.push(pid);
|
siblings.push(pid);
|
||||||
childrenByParentId.set(ppid, siblings);
|
childrenByParentId.set(ppid, siblings);
|
||||||
|
|
||||||
|
const normalizedBinaryName = normalizeProcessBinary(command);
|
||||||
|
|
||||||
|
if (normalizedBinaryName) {
|
||||||
|
binaryNameByProcessId.set(pid, normalizedBinaryName);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const rootId = `${rootProcessId}`;
|
const rootId = `${rootProcessId}`;
|
||||||
const descendantIds = new Set<string>([rootId]);
|
const descendantIds = new Set<string>([rootId]);
|
||||||
|
const descendantBinaryNames = new Set<string>();
|
||||||
const queue = [rootId];
|
const queue = [rootId];
|
||||||
|
|
||||||
while (queue.length > 0) {
|
while (queue.length > 0) {
|
||||||
@@ -731,6 +876,12 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const binaryName = binaryNameByProcessId.get(currentId);
|
||||||
|
|
||||||
|
if (binaryName) {
|
||||||
|
descendantBinaryNames.add(binaryName);
|
||||||
|
}
|
||||||
|
|
||||||
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
for (const childId of childrenByParentId.get(currentId) ?? []) {
|
||||||
if (descendantIds.has(childId)) {
|
if (descendantIds.has(childId)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -741,7 +892,30 @@ async function collectDescendantProcessIds(rootProcessId: number): Promise<Set<s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return descendantIds;
|
return {
|
||||||
|
ids: descendantIds,
|
||||||
|
binaryNames: descendantBinaryNames
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProcessBinary(value: string | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basename = trimmed
|
||||||
|
.split(/[\\/]/)
|
||||||
|
.pop()
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
return basename || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripSurroundingQuotes(value: string): string {
|
function stripSurroundingQuotes(value: string): string {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -13,6 +18,11 @@ export async function handleClearAllData(dataSource: DataSource): Promise<void>
|
|||||||
await dataSource.getRepository(MessageEntity).clear();
|
await dataSource.getRepository(MessageEntity).clear();
|
||||||
await dataSource.getRepository(UserEntity).clear();
|
await dataSource.getRepository(UserEntity).clear();
|
||||||
await dataSource.getRepository(RoomEntity).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(ReactionEntity).clear();
|
||||||
await dataSource.getRepository(BanEntity).clear();
|
await dataSource.getRepository(BanEntity).clear();
|
||||||
await dataSource.getRepository(AttachmentEntity).clear();
|
await dataSource.getRepository(AttachmentEntity).clear();
|
||||||
|
|||||||
@@ -1,10 +1,25 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity, MessageEntity } from '../../../entities';
|
import {
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
MessageEntity
|
||||||
|
} from '../../../entities';
|
||||||
import { DeleteRoomCommand } from '../../types';
|
import { DeleteRoomCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteRoom(command: DeleteRoomCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { roomId } = command.payload;
|
const { roomId } = command.payload;
|
||||||
|
|
||||||
await dataSource.getRepository(RoomEntity).delete({ id: roomId });
|
await dataSource.transaction(async (manager) => {
|
||||||
await dataSource.getRepository(MessageEntity).delete({ roomId });
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
|
import { replaceMessageReactions } from '../../relations';
|
||||||
import { SaveMessageCommand } from '../../types';
|
import { SaveMessageCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
export async function handleSaveMessage(command: SaveMessageCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
|
||||||
const { message } = command.payload;
|
const { message } = command.payload;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(MessageEntity);
|
||||||
const entity = repo.create({
|
const entity = repo.create({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
roomId: message.roomId,
|
roomId: message.roomId,
|
||||||
@@ -14,10 +17,12 @@ export async function handleSaveMessage(command: SaveMessageCommand, dataSource:
|
|||||||
content: message.content,
|
content: message.content,
|
||||||
timestamp: message.timestamp,
|
timestamp: message.timestamp,
|
||||||
editedAt: message.editedAt ?? null,
|
editedAt: message.editedAt ?? null,
|
||||||
reactions: JSON.stringify(message.reactions ?? []),
|
|
||||||
isDeleted: message.isDeleted ? 1 : 0,
|
isDeleted: message.isDeleted ? 1 : 0,
|
||||||
replyToId: message.replyToId ?? null
|
replyToId: message.replyToId ?? null,
|
||||||
|
linkMetadata: message.linkMetadata ? JSON.stringify(message.linkMetadata) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
await replaceMessageReactions(manager, message.id, message.reactions ?? []);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity } from '../../../entities';
|
||||||
|
import { replaceRoomRelations } from '../../relations';
|
||||||
import { SaveRoomCommand } from '../../types';
|
import { SaveRoomCommand } from '../../types';
|
||||||
|
|
||||||
|
function extractSlowModeInterval(room: SaveRoomCommand['payload']['room']): number {
|
||||||
|
if (typeof room.slowModeInterval === 'number' && Number.isFinite(room.slowModeInterval)) {
|
||||||
|
return room.slowModeInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
|
||||||
const { room } = command.payload;
|
const { room } = command.payload;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(RoomEntity);
|
||||||
const entity = repo.create({
|
const entity = repo.create({
|
||||||
id: room.id,
|
id: room.id,
|
||||||
name: room.name,
|
name: room.name,
|
||||||
@@ -12,16 +29,27 @@ export async function handleSaveRoom(command: SaveRoomCommand, dataSource: DataS
|
|||||||
topic: room.topic ?? null,
|
topic: room.topic ?? null,
|
||||||
hostId: room.hostId,
|
hostId: room.hostId,
|
||||||
password: room.password ?? null,
|
password: room.password ?? null,
|
||||||
|
hasPassword: room.hasPassword ? 1 : 0,
|
||||||
isPrivate: room.isPrivate ? 1 : 0,
|
isPrivate: room.isPrivate ? 1 : 0,
|
||||||
createdAt: room.createdAt,
|
createdAt: room.createdAt,
|
||||||
userCount: room.userCount ?? 0,
|
userCount: room.userCount ?? 0,
|
||||||
maxUsers: room.maxUsers ?? null,
|
maxUsers: room.maxUsers ?? null,
|
||||||
icon: room.icon ?? null,
|
icon: room.icon ?? null,
|
||||||
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
iconUpdatedAt: room.iconUpdatedAt ?? null,
|
||||||
permissions: room.permissions != null ? JSON.stringify(room.permissions) : null,
|
slowModeInterval: extractSlowModeInterval(room),
|
||||||
channels: room.channels != null ? JSON.stringify(room.channels) : null,
|
sourceId: room.sourceId ?? null,
|
||||||
members: room.members != null ? JSON.stringify(room.members) : null
|
sourceName: room.sourceName ?? null,
|
||||||
|
sourceUrl: room.sourceUrl ?? null
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,52 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
|
import { replaceMessageReactions } from '../../relations';
|
||||||
import { UpdateMessageCommand } from '../../types';
|
import { UpdateMessageCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
export async function handleUpdateMessage(command: UpdateMessageCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
|
||||||
const { messageId, updates } = command.payload;
|
const { messageId, updates } = command.payload;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(MessageEntity);
|
||||||
const existing = await repo.findOne({ where: { id: messageId } });
|
const existing = await repo.findOne({ where: { id: messageId } });
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (updates.channelId !== undefined)
|
const directFields = [
|
||||||
existing.channelId = updates.channelId ?? null;
|
'senderId',
|
||||||
|
'senderName',
|
||||||
|
'content',
|
||||||
|
'timestamp'
|
||||||
|
] as const;
|
||||||
|
const entity = existing as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
if (updates.senderId !== undefined)
|
for (const field of directFields) {
|
||||||
existing.senderId = updates.senderId;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field];
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.senderName !== undefined)
|
const nullableFields = [
|
||||||
existing.senderName = updates.senderName;
|
'channelId',
|
||||||
|
'editedAt',
|
||||||
|
'replyToId'
|
||||||
|
] as const;
|
||||||
|
|
||||||
if (updates.content !== undefined)
|
for (const field of nullableFields) {
|
||||||
existing.content = updates.content;
|
if (updates[field] !== undefined)
|
||||||
|
entity[field] = updates[field] ?? null;
|
||||||
if (updates.timestamp !== undefined)
|
}
|
||||||
existing.timestamp = updates.timestamp;
|
|
||||||
|
|
||||||
if (updates.editedAt !== undefined)
|
|
||||||
existing.editedAt = updates.editedAt ?? null;
|
|
||||||
|
|
||||||
if (updates.reactions !== undefined)
|
|
||||||
existing.reactions = JSON.stringify(updates.reactions ?? []);
|
|
||||||
|
|
||||||
if (updates.isDeleted !== undefined)
|
if (updates.isDeleted !== undefined)
|
||||||
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
existing.isDeleted = updates.isDeleted ? 1 : 0;
|
||||||
|
|
||||||
if (updates.replyToId !== undefined)
|
if (updates.linkMetadata !== undefined)
|
||||||
existing.replyToId = updates.replyToId ?? null;
|
existing.linkMetadata = updates.linkMetadata ? JSON.stringify(updates.linkMetadata) : null;
|
||||||
|
|
||||||
await repo.save(existing);
|
await repo.save(existing);
|
||||||
|
|
||||||
|
if (updates.reactions !== undefined) {
|
||||||
|
await replaceMessageReactions(manager, messageId, updates.reactions ?? []);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,68 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity } from '../../../entities';
|
||||||
|
import { replaceRoomRelations } from '../../relations';
|
||||||
import { UpdateRoomCommand } from '../../types';
|
import { UpdateRoomCommand } from '../../types';
|
||||||
import {
|
import {
|
||||||
applyUpdates,
|
applyUpdates,
|
||||||
boolToInt,
|
boolToInt,
|
||||||
jsonOrNull,
|
|
||||||
TransformMap
|
TransformMap
|
||||||
} from './utils/applyUpdates';
|
} from './utils/applyUpdates';
|
||||||
|
|
||||||
const ROOM_TRANSFORMS: TransformMap = {
|
const ROOM_TRANSFORMS: TransformMap = {
|
||||||
|
hasPassword: boolToInt,
|
||||||
isPrivate: boolToInt,
|
isPrivate: boolToInt,
|
||||||
userCount: (val) => (val ?? 0),
|
userCount: (val) => (val ?? 0)
|
||||||
permissions: jsonOrNull,
|
|
||||||
channels: jsonOrNull,
|
|
||||||
members: jsonOrNull
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function extractSlowModeInterval(updates: UpdateRoomCommand['payload']['updates']): number | undefined {
|
||||||
|
if (typeof updates.slowModeInterval === 'number' && Number.isFinite(updates.slowModeInterval)) {
|
||||||
|
return updates.slowModeInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = updates.permissions && typeof updates.permissions === 'object'
|
||||||
|
? updates.permissions as { slowModeInterval?: unknown }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return typeof permissions?.slowModeInterval === 'number' && Number.isFinite(permissions.slowModeInterval)
|
||||||
|
? permissions.slowModeInterval
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
export async function handleUpdateRoom(command: UpdateRoomCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
|
||||||
const { roomId, updates } = command.payload;
|
const { roomId, updates } = command.payload;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(RoomEntity);
|
||||||
const existing = await repo.findOne({ where: { id: roomId } });
|
const existing = await repo.findOne({ where: { id: roomId } });
|
||||||
|
|
||||||
if (!existing)
|
if (!existing)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
applyUpdates(existing, updates, ROOM_TRANSFORMS);
|
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 repo.save(existing);
|
||||||
|
await replaceRoomRelations(manager, roomId, {
|
||||||
|
channels,
|
||||||
|
members,
|
||||||
|
roles,
|
||||||
|
roleAssignments,
|
||||||
|
channelPermissions,
|
||||||
|
permissions: rawPermissions
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,19 @@ import { RoomEntity } from '../entities/RoomEntity';
|
|||||||
import { ReactionEntity } from '../entities/ReactionEntity';
|
import { ReactionEntity } from '../entities/ReactionEntity';
|
||||||
import { BanEntity } from '../entities/BanEntity';
|
import { BanEntity } from '../entities/BanEntity';
|
||||||
import { AttachmentEntity } from '../entities/AttachmentEntity';
|
import { AttachmentEntity } from '../entities/AttachmentEntity';
|
||||||
|
import { ReactionPayload } from './types';
|
||||||
|
import {
|
||||||
|
relationRecordToRoomPayload,
|
||||||
|
RoomChannelPermissionRecord,
|
||||||
|
RoomChannelRecord,
|
||||||
|
RoomMemberRecord,
|
||||||
|
RoomRoleAssignmentRecord,
|
||||||
|
RoomRoleRecord
|
||||||
|
} from './relations';
|
||||||
|
|
||||||
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
const DELETED_MESSAGE_CONTENT = '[Message deleted]';
|
||||||
|
|
||||||
export function rowToMessage(row: MessageEntity) {
|
export function rowToMessage(row: MessageEntity, reactions: ReactionPayload[] = []) {
|
||||||
const isDeleted = !!row.isDeleted;
|
const isDeleted = !!row.isDeleted;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -24,9 +33,10 @@ export function rowToMessage(row: MessageEntity) {
|
|||||||
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
content: isDeleted ? DELETED_MESSAGE_CONTENT : row.content,
|
||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
editedAt: row.editedAt ?? undefined,
|
editedAt: row.editedAt ?? undefined,
|
||||||
reactions: isDeleted ? [] : JSON.parse(row.reactions || '[]') as unknown[],
|
reactions: isDeleted ? [] : reactions,
|
||||||
isDeleted,
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -57,15 +90,23 @@ export function rowToRoom(row: RoomEntity) {
|
|||||||
topic: row.topic ?? undefined,
|
topic: row.topic ?? undefined,
|
||||||
hostId: row.hostId,
|
hostId: row.hostId,
|
||||||
password: row.password ?? undefined,
|
password: row.password ?? undefined,
|
||||||
|
hasPassword: !!row.hasPassword,
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
userCount: row.userCount,
|
userCount: row.userCount,
|
||||||
maxUsers: row.maxUsers ?? undefined,
|
maxUsers: row.maxUsers ?? undefined,
|
||||||
icon: row.icon ?? undefined,
|
icon: row.icon ?? undefined,
|
||||||
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
iconUpdatedAt: row.iconUpdatedAt ?? undefined,
|
||||||
permissions: row.permissions ? JSON.parse(row.permissions) : undefined,
|
slowModeInterval: row.slowModeInterval,
|
||||||
channels: row.channels ? JSON.parse(row.channels) : undefined,
|
permissions: relationPayload.permissions,
|
||||||
members: row.members ? JSON.parse(row.members) : undefined
|
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity } from '../../../entities';
|
||||||
import { rowToRoom } from '../../mappers';
|
import { rowToRoom } from '../../mappers';
|
||||||
|
import { loadRoomRelationsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetAllRooms(dataSource: DataSource) {
|
export async function handleGetAllRooms(dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
const repo = dataSource.getRepository(RoomEntity);
|
||||||
const rows = await repo.find();
|
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)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
|||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
import { GetMessageByIdQuery } from '../../types';
|
import { GetMessageByIdQuery } from '../../types';
|
||||||
import { rowToMessage } from '../../mappers';
|
import { rowToMessage } from '../../mappers';
|
||||||
|
import { loadMessageReactionsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
export async function handleGetMessageById(query: GetMessageByIdQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
const repo = dataSource.getRepository(MessageEntity);
|
||||||
const row = await repo.findOne({ where: { id: query.payload.messageId } });
|
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) ?? []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { DataSource } from 'typeorm';
|
|||||||
import { MessageEntity } from '../../../entities';
|
import { MessageEntity } from '../../../entities';
|
||||||
import { GetMessagesQuery } from '../../types';
|
import { GetMessagesQuery } from '../../types';
|
||||||
import { rowToMessage } from '../../mappers';
|
import { rowToMessage } from '../../mappers';
|
||||||
|
import { loadMessageReactionsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
export async function handleGetMessages(query: GetMessagesQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(MessageEntity);
|
const repo = dataSource.getRepository(MessageEntity);
|
||||||
@@ -12,6 +13,7 @@ export async function handleGetMessages(query: GetMessagesQuery, dataSource: Dat
|
|||||||
take: limit,
|
take: limit,
|
||||||
skip: offset
|
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) ?? []));
|
||||||
}
|
}
|
||||||
|
|||||||
20
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal file
20
electron/cqrs/queries/handlers/getMessagesSince.ts
Normal 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) ?? []));
|
||||||
|
}
|
||||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
|||||||
import { RoomEntity } from '../../../entities';
|
import { RoomEntity } from '../../../entities';
|
||||||
import { GetRoomQuery } from '../../types';
|
import { GetRoomQuery } from '../../types';
|
||||||
import { rowToRoom } from '../../mappers';
|
import { rowToRoom } from '../../mappers';
|
||||||
|
import { loadRoomRelationsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
export async function handleGetRoom(query: GetRoomQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(RoomEntity);
|
const repo = dataSource.getRepository(RoomEntity);
|
||||||
const row = await repo.findOne({ where: { id: query.payload.roomId } });
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
QueryTypeKey,
|
QueryTypeKey,
|
||||||
Query,
|
Query,
|
||||||
GetMessagesQuery,
|
GetMessagesQuery,
|
||||||
|
GetMessagesSinceQuery,
|
||||||
GetMessageByIdQuery,
|
GetMessageByIdQuery,
|
||||||
GetReactionsForMessageQuery,
|
GetReactionsForMessageQuery,
|
||||||
GetUserQuery,
|
GetUserQuery,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
GetAttachmentsForMessageQuery
|
GetAttachmentsForMessageQuery
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { handleGetMessages } from './handlers/getMessages';
|
import { handleGetMessages } from './handlers/getMessages';
|
||||||
|
import { handleGetMessagesSince } from './handlers/getMessagesSince';
|
||||||
import { handleGetMessageById } from './handlers/getMessageById';
|
import { handleGetMessageById } from './handlers/getMessageById';
|
||||||
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
import { handleGetReactionsForMessage } from './handlers/getReactionsForMessage';
|
||||||
import { handleGetUser } from './handlers/getUser';
|
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>> => ({
|
export const buildQueryHandlers = (dataSource: DataSource): Record<QueryTypeKey, (query: Query) => Promise<unknown>> => ({
|
||||||
[QueryType.GetMessages]: (query) => handleGetMessages(query as GetMessagesQuery, dataSource),
|
[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.GetMessageById]: (query) => handleGetMessageById(query as GetMessageByIdQuery, dataSource),
|
||||||
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
[QueryType.GetReactionsForMessage]: (query) => handleGetReactionsForMessage(query as GetReactionsForMessageQuery, dataSource),
|
||||||
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
[QueryType.GetUser]: (query) => handleGetUser(query as GetUserQuery, dataSource),
|
||||||
|
|||||||
1002
electron/cqrs/relations.ts
Normal file
1002
electron/cqrs/relations.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ export type CommandTypeKey = typeof CommandType[keyof typeof CommandType];
|
|||||||
|
|
||||||
export const QueryType = {
|
export const QueryType = {
|
||||||
GetMessages: 'get-messages',
|
GetMessages: 'get-messages',
|
||||||
|
GetMessagesSince: 'get-messages-since',
|
||||||
GetMessageById: 'get-message-by-id',
|
GetMessageById: 'get-message-by-id',
|
||||||
GetReactionsForMessage: 'get-reactions-for-message',
|
GetReactionsForMessage: 'get-reactions-for-message',
|
||||||
GetUser: 'get-user',
|
GetUser: 'get-user',
|
||||||
@@ -49,6 +50,7 @@ export interface MessagePayload {
|
|||||||
reactions?: ReactionPayload[];
|
reactions?: ReactionPayload[];
|
||||||
isDeleted?: boolean;
|
isDeleted?: boolean;
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
linkMetadata?: { url: string; title?: string; description?: string; imageUrl?: string; siteName?: string; failed?: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReactionPayload {
|
export interface ReactionPayload {
|
||||||
@@ -60,6 +62,44 @@ export interface ReactionPayload {
|
|||||||
timestamp: number;
|
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 {
|
export interface UserPayload {
|
||||||
id: string;
|
id: string;
|
||||||
oderId?: string;
|
oderId?: string;
|
||||||
@@ -84,15 +124,23 @@ export interface RoomPayload {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
hostId: string;
|
hostId: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
isPrivate?: boolean;
|
isPrivate?: boolean;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
userCount?: number;
|
userCount?: number;
|
||||||
maxUsers?: number;
|
maxUsers?: number;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
iconUpdatedAt?: number;
|
iconUpdatedAt?: number;
|
||||||
|
slowModeInterval?: number;
|
||||||
permissions?: unknown;
|
permissions?: unknown;
|
||||||
channels?: unknown[];
|
channels?: unknown[];
|
||||||
members?: unknown[];
|
members?: unknown[];
|
||||||
|
roles?: AccessRolePayload[];
|
||||||
|
roleAssignments?: RoleAssignmentPayload[];
|
||||||
|
channelPermissions?: ChannelPermissionPayload[];
|
||||||
|
sourceId?: string;
|
||||||
|
sourceName?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BanPayload {
|
export interface BanPayload {
|
||||||
@@ -156,6 +204,7 @@ export type Command =
|
|||||||
| ClearAllDataCommand;
|
| ClearAllDataCommand;
|
||||||
|
|
||||||
export interface GetMessagesQuery { type: typeof QueryType.GetMessages; payload: { roomId: string; limit?: number; offset?: number } }
|
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 GetMessageByIdQuery { type: typeof QueryType.GetMessageById; payload: { messageId: string } }
|
||||||
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
export interface GetReactionsForMessageQuery { type: typeof QueryType.GetReactionsForMessage; payload: { messageId: string } }
|
||||||
export interface GetUserQuery { type: typeof QueryType.GetUser; payload: { userId: 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 =
|
export type Query =
|
||||||
| GetMessagesQuery
|
| GetMessagesQuery
|
||||||
|
| GetMessagesSinceQuery
|
||||||
| GetMessageByIdQuery
|
| GetMessageByIdQuery
|
||||||
| GetReactionsForMessageQuery
|
| GetReactionsForMessageQuery
|
||||||
| GetUserQuery
|
| GetUserQuery
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -38,6 +43,11 @@ export const AppDataSource = new DataSource({
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
@@ -40,6 +45,11 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
MessageEntity,
|
MessageEntity,
|
||||||
UserEntity,
|
UserEntity,
|
||||||
RoomEntity,
|
RoomEntity,
|
||||||
|
RoomChannelEntity,
|
||||||
|
RoomMemberEntity,
|
||||||
|
RoomRoleEntity,
|
||||||
|
RoomUserRoleEntity,
|
||||||
|
RoomChannelPermissionEntity,
|
||||||
ReactionEntity,
|
ReactionEntity,
|
||||||
BanEntity,
|
BanEntity,
|
||||||
AttachmentEntity,
|
AttachmentEntity,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type AutoUpdateMode = 'auto' | 'off' | 'version';
|
|||||||
|
|
||||||
export interface DesktopSettings {
|
export interface DesktopSettings {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -19,6 +21,8 @@ export interface DesktopSettingsSnapshot extends DesktopSettings {
|
|||||||
|
|
||||||
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
|
||||||
autoUpdateMode: 'auto',
|
autoUpdateMode: 'auto',
|
||||||
|
autoStart: true,
|
||||||
|
closeToTray: true,
|
||||||
hardwareAcceleration: true,
|
hardwareAcceleration: true,
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
preferredVersion: null,
|
preferredVersion: null,
|
||||||
@@ -81,6 +85,12 @@ export function readDesktopSettings(): DesktopSettings {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(parsed.autoUpdateMode),
|
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'
|
vaapiVideoEncode: typeof parsed.vaapiVideoEncode === 'boolean'
|
||||||
? parsed.vaapiVideoEncode
|
? parsed.vaapiVideoEncode
|
||||||
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
: DEFAULT_DESKTOP_SETTINGS.vaapiVideoEncode,
|
||||||
@@ -102,6 +112,12 @@ export function updateDesktopSettings(patch: Partial<DesktopSettings>): DesktopS
|
|||||||
};
|
};
|
||||||
const nextSettings: DesktopSettings = {
|
const nextSettings: DesktopSettings = {
|
||||||
autoUpdateMode: normalizeAutoUpdateMode(mergedSettings.autoUpdateMode),
|
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'
|
hardwareAcceleration: typeof mergedSettings.hardwareAcceleration === 'boolean'
|
||||||
? mergedSettings.hardwareAcceleration
|
? mergedSettings.hardwareAcceleration
|
||||||
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
: DEFAULT_DESKTOP_SETTINGS.hardwareAcceleration,
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ export class MessageEntity {
|
|||||||
@Column('integer', { nullable: true })
|
@Column('integer', { nullable: true })
|
||||||
editedAt!: number | null;
|
editedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { default: '[]' })
|
|
||||||
reactions!: string;
|
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isDeleted!: number;
|
isDeleted!: number;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
replyToId!: string | null;
|
replyToId!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
linkMetadata!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
23
electron/entities/RoomChannelEntity.ts
Normal file
23
electron/entities/RoomChannelEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
26
electron/entities/RoomChannelPermissionEntity.ts
Normal file
26
electron/entities/RoomChannelPermissionEntity.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ export class RoomEntity {
|
|||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
password!: string | null;
|
password!: string | null;
|
||||||
|
|
||||||
|
@Column('integer', { default: 0 })
|
||||||
|
hasPassword!: number;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
@@ -42,12 +45,15 @@ export class RoomEntity {
|
|||||||
@Column('integer', { nullable: true })
|
@Column('integer', { nullable: true })
|
||||||
iconUpdatedAt!: number | null;
|
iconUpdatedAt!: number | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('integer', { default: 0 })
|
||||||
permissions!: string | null;
|
slowModeInterval!: number;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
channels!: string | null;
|
sourceId!: string | null;
|
||||||
|
|
||||||
@Column('text', { nullable: true })
|
@Column('text', { nullable: true })
|
||||||
members!: string | null;
|
sourceName!: string | null;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
sourceUrl!: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
38
electron/entities/RoomMemberEntity.ts
Normal file
38
electron/entities/RoomMemberEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
59
electron/entities/RoomRoleEntity.ts
Normal file
59
electron/entities/RoomRoleEntity.ts
Normal 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';
|
||||||
|
}
|
||||||
23
electron/entities/RoomUserRoleEntity.ts
Normal file
23
electron/entities/RoomUserRoleEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
export { MessageEntity } from './MessageEntity';
|
export { MessageEntity } from './MessageEntity';
|
||||||
export { UserEntity } from './UserEntity';
|
export { UserEntity } from './UserEntity';
|
||||||
export { RoomEntity } from './RoomEntity';
|
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 { ReactionEntity } from './ReactionEntity';
|
||||||
export { BanEntity } from './BanEntity';
|
export { BanEntity } from './BanEntity';
|
||||||
export { AttachmentEntity } from './AttachmentEntity';
|
export { AttachmentEntity } from './AttachmentEntity';
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import {
|
|||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
dialog,
|
dialog,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
|
nativeImage,
|
||||||
|
net,
|
||||||
|
Notification,
|
||||||
shell
|
shell
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -28,8 +31,23 @@ import {
|
|||||||
getDesktopUpdateState,
|
getDesktopUpdateState,
|
||||||
handleDesktopSettingsChanged,
|
handleDesktopSettingsChanged,
|
||||||
restartToApplyUpdate,
|
restartToApplyUpdate,
|
||||||
|
readDesktopUpdateServerHealth,
|
||||||
type DesktopUpdateServerContext
|
type DesktopUpdateServerContext
|
||||||
} from '../update/desktop-updater';
|
} 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 DEFAULT_MIME_TYPE = 'application/octet-stream';
|
||||||
const FILE_CLIPBOARD_FORMATS = [
|
const FILE_CLIPBOARD_FORMATS = [
|
||||||
@@ -83,6 +101,63 @@ interface ClipboardFilePayload {
|
|||||||
path?: string;
|
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 {
|
function isSupportedClipboardFileFormat(format: string): boolean {
|
||||||
return FILE_CLIPBOARD_FORMATS.some(
|
return FILE_CLIPBOARD_FORMATS.some(
|
||||||
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
(supportedFormat) => supportedFormat.toLowerCase() === format.toLowerCase()
|
||||||
@@ -194,6 +269,10 @@ async function readClipboardFiles(): Promise<ClipboardFilePayload[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setupSystemHandlers(): void {
|
export function setupSystemHandlers(): void {
|
||||||
|
ipcMain.on('get-linux-display-server', (event) => {
|
||||||
|
event.returnValue = resolveLinuxDisplayServer();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('open-external', async (_event, url: string) => {
|
ipcMain.handle('open-external', async (_event, url: string) => {
|
||||||
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
if (typeof url === 'string' && (url.startsWith('http://') || url.startsWith('https://'))) {
|
||||||
await shell.openExternal(url);
|
await shell.openExternal(url);
|
||||||
@@ -203,6 +282,8 @@ export function setupSystemHandlers(): void {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('consume-pending-deep-link', () => consumePendingDeepLink());
|
||||||
|
|
||||||
ipcMain.handle('get-sources', async () => {
|
ipcMain.handle('get-sources', async () => {
|
||||||
try {
|
try {
|
||||||
const thumbnailSize = { width: 240, height: 150 };
|
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-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('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-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>) => {
|
ipcMain.handle('configure-auto-update-context', async (_event, context: Partial<DesktopUpdateServerContext>) => {
|
||||||
return await configureDesktopUpdaterContext(context);
|
return await configureDesktopUpdaterContext(context);
|
||||||
});
|
});
|
||||||
@@ -271,6 +432,8 @@ export function setupSystemHandlers(): void {
|
|||||||
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
ipcMain.handle('set-desktop-settings', async (_event, patch: Partial<DesktopSettings>) => {
|
||||||
const snapshot = updateDesktopSettings(patch);
|
const snapshot = updateDesktopSettings(patch);
|
||||||
|
|
||||||
|
await synchronizeAutoStartSetting(snapshot.autoStart);
|
||||||
|
updateCloseToTraySetting(snapshot.closeToTray);
|
||||||
await handleDesktopSettingsChanged();
|
await handleDesktopSettingsChanged();
|
||||||
return snapshot;
|
return snapshot;
|
||||||
});
|
});
|
||||||
@@ -342,4 +505,56 @@ export function setupSystemHandlers(): void {
|
|||||||
await fsp.mkdir(dirPath, { recursive: true });
|
await fsp.mkdir(dirPath, { recursive: true });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('copy-image-to-clipboard', (_event, srcURL: string) => {
|
||||||
|
if (typeof srcURL !== 'string' || !srcURL) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const request = net.request(srcURL);
|
||||||
|
|
||||||
|
request.on('response', (response) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
const image = nativeImage.createFromBuffer(Buffer.concat(chunks));
|
||||||
|
|
||||||
|
if (!image.isEmpty()) {
|
||||||
|
clipboard.writeImage(image);
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
response.on('error', () => resolve(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
request.on('error', () => resolve(false));
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('context-menu-command', (_event, command: string) => {
|
||||||
|
const allowedCommands = ['cut', 'copy', 'paste', 'selectAll'] as const;
|
||||||
|
|
||||||
|
if (!allowedCommands.includes(command as typeof allowedCommands[number])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainWindow = getMainWindow();
|
||||||
|
const webContents = mainWindow?.webContents;
|
||||||
|
|
||||||
|
if (!webContents) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case 'cut': webContents.cut(); break;
|
||||||
|
case 'copy': webContents.copy(); break;
|
||||||
|
case 'paste': webContents.paste(); break;
|
||||||
|
case 'selectAll': webContents.selectAll(); break;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
import { initializeDeepLinkHandling } from './app/deep-links';
|
||||||
import { configureAppFlags } from './app/flags';
|
import { configureAppFlags } from './app/flags';
|
||||||
import { registerAppLifecycle } from './app/lifecycle';
|
import { registerAppLifecycle } from './app/lifecycle';
|
||||||
|
|
||||||
configureAppFlags();
|
configureAppFlags();
|
||||||
registerAppLifecycle();
|
|
||||||
|
if (initializeDeepLinkHandling()) {
|
||||||
|
registerAppLifecycle();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal file
396
electron/migrations/1000000000003-NormalizeArrayColumns.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal file
310
electron/migrations/1000000000004-NormalizeRoomAccessControl.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal file
11
electron/migrations/1000000000005-AddLinkMetadata.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_CHUNK_CHANNEL = 'linux-screen-share-monitor-audio-chunk';
|
||||||
const LINUX_SCREEN_SHARE_MONITOR_AUDIO_ENDED_CHANNEL = 'linux-screen-share-monitor-audio-ended';
|
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 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 {
|
export interface LinuxScreenShareAudioRoutingInfo {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -49,6 +51,12 @@ export interface DesktopUpdateServerContext {
|
|||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateServerHealthSnapshot {
|
||||||
|
manifestUrl: string | null;
|
||||||
|
serverVersion: string | null;
|
||||||
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopUpdateState {
|
export interface DesktopUpdateState {
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
availableVersions: string[];
|
availableVersions: string[];
|
||||||
@@ -83,7 +91,57 @@ export interface DesktopUpdateState {
|
|||||||
targetVersion: string | null;
|
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 {
|
export interface ElectronAPI {
|
||||||
|
linuxDisplayServer: string;
|
||||||
minimizeWindow: () => void;
|
minimizeWindow: () => void;
|
||||||
maximizeWindow: () => void;
|
maximizeWindow: () => void;
|
||||||
closeWindow: () => void;
|
closeWindow: () => void;
|
||||||
@@ -98,27 +156,44 @@ export interface ElectronAPI {
|
|||||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||||
getAppDataPath: () => Promise<string>;
|
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<{
|
getDesktopSettings: () => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
runtimeHardwareAcceleration: boolean;
|
runtimeHardwareAcceleration: boolean;
|
||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
showDesktopNotification: (payload: DesktopNotificationPayload) => Promise<boolean>;
|
||||||
|
requestWindowAttention: () => Promise<boolean>;
|
||||||
|
clearWindowAttention: () => Promise<boolean>;
|
||||||
|
onWindowStateChanged: (listener: (state: WindowStateSnapshot) => void) => () => void;
|
||||||
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
getAutoUpdateState: () => Promise<DesktopUpdateState>;
|
||||||
|
getAutoUpdateServerHealth: (serverUrl: string) => Promise<DesktopUpdateServerHealthSnapshot>;
|
||||||
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
configureAutoUpdateContext: (context: Partial<DesktopUpdateServerContext>) => Promise<DesktopUpdateState>;
|
||||||
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
checkForAppUpdates: () => Promise<DesktopUpdateState>;
|
||||||
restartToApplyUpdate: () => Promise<boolean>;
|
restartToApplyUpdate: () => Promise<boolean>;
|
||||||
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
onAutoUpdateStateChanged: (listener: (state: DesktopUpdateState) => void) => () => void;
|
||||||
setDesktopSettings: (patch: {
|
setDesktopSettings: (patch: {
|
||||||
autoUpdateMode?: 'auto' | 'off' | 'version';
|
autoUpdateMode?: 'auto' | 'off' | 'version';
|
||||||
|
autoStart?: boolean;
|
||||||
|
closeToTray?: boolean;
|
||||||
hardwareAcceleration?: boolean;
|
hardwareAcceleration?: boolean;
|
||||||
manifestUrls?: string[];
|
manifestUrls?: string[];
|
||||||
preferredVersion?: string | null;
|
preferredVersion?: string | null;
|
||||||
vaapiVideoEncode?: boolean;
|
vaapiVideoEncode?: boolean;
|
||||||
}) => Promise<{
|
}) => Promise<{
|
||||||
autoUpdateMode: 'auto' | 'off' | 'version';
|
autoUpdateMode: 'auto' | 'off' | 'version';
|
||||||
|
autoStart: boolean;
|
||||||
|
closeToTray: boolean;
|
||||||
hardwareAcceleration: boolean;
|
hardwareAcceleration: boolean;
|
||||||
manifestUrls: string[];
|
manifestUrls: string[];
|
||||||
preferredVersion: string | null;
|
preferredVersion: string | null;
|
||||||
@@ -126,6 +201,7 @@ export interface ElectronAPI {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}>;
|
}>;
|
||||||
relaunchApp: () => Promise<boolean>;
|
relaunchApp: () => Promise<boolean>;
|
||||||
|
onDeepLinkReceived: (listener: (url: string) => void) => () => void;
|
||||||
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
readClipboardFiles: () => Promise<ClipboardFilePayload[]>;
|
||||||
readFile: (filePath: string) => Promise<string>;
|
readFile: (filePath: string) => Promise<string>;
|
||||||
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
writeFile: (filePath: string, data: string) => Promise<boolean>;
|
||||||
@@ -134,11 +210,16 @@ export interface ElectronAPI {
|
|||||||
deleteFile: (filePath: string) => Promise<boolean>;
|
deleteFile: (filePath: string) => Promise<boolean>;
|
||||||
ensureDir: (dirPath: string) => Promise<boolean>;
|
ensureDir: (dirPath: string) => Promise<boolean>;
|
||||||
|
|
||||||
|
onContextMenu: (listener: (params: ContextMenuParams) => void) => () => void;
|
||||||
|
contextMenuCommand: (command: string) => Promise<void>;
|
||||||
|
copyImageToClipboard: (srcURL: string) => Promise<boolean>;
|
||||||
|
|
||||||
command: <T = unknown>(command: Command) => Promise<T>;
|
command: <T = unknown>(command: Command) => Promise<T>;
|
||||||
query: <T = unknown>(query: Query) => Promise<T>;
|
query: <T = unknown>(query: Query) => Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const electronAPI: ElectronAPI = {
|
const electronAPI: ElectronAPI = {
|
||||||
|
linuxDisplayServer: readLinuxDisplayServer(),
|
||||||
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
minimizeWindow: () => ipcRenderer.send('window-minimize'),
|
||||||
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
maximizeWindow: () => ipcRenderer.send('window-maximize'),
|
||||||
closeWindow: () => ipcRenderer.send('window-close'),
|
closeWindow: () => ipcRenderer.send('window-close'),
|
||||||
@@ -180,8 +261,29 @@ const electronAPI: ElectronAPI = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
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'),
|
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'),
|
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),
|
configureAutoUpdateContext: (context) => ipcRenderer.invoke('configure-auto-update-context', context),
|
||||||
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
checkForAppUpdates: () => ipcRenderer.invoke('check-for-app-updates'),
|
||||||
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
restartToApplyUpdate: () => ipcRenderer.invoke('restart-to-apply-update'),
|
||||||
@@ -198,6 +300,17 @@ const electronAPI: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
setDesktopSettings: (patch) => ipcRenderer.invoke('set-desktop-settings', patch),
|
||||||
relaunchApp: () => ipcRenderer.invoke('relaunch-app'),
|
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'),
|
readClipboardFiles: () => ipcRenderer.invoke('read-clipboard-files'),
|
||||||
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
|
||||||
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
writeFile: (filePath, data) => ipcRenderer.invoke('write-file', filePath, data),
|
||||||
@@ -206,6 +319,20 @@ const electronAPI: ElectronAPI = {
|
|||||||
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
deleteFile: (filePath) => ipcRenderer.invoke('delete-file', filePath),
|
||||||
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
ensureDir: (dirPath) => ipcRenderer.invoke('ensure-dir', dirPath),
|
||||||
|
|
||||||
|
onContextMenu: (listener) => {
|
||||||
|
const wrappedListener = (_event: Electron.IpcRendererEvent, params: ContextMenuParams) => {
|
||||||
|
listener(params);
|
||||||
|
};
|
||||||
|
|
||||||
|
ipcRenderer.on('show-context-menu', wrappedListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ipcRenderer.removeListener('show-context-menu', wrappedListener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextMenuCommand: (command) => ipcRenderer.invoke('context-menu-command', command),
|
||||||
|
copyImageToClipboard: (srcURL) => ipcRenderer.invoke('copy-image-to-clipboard', srcURL),
|
||||||
|
|
||||||
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
command: (command) => ipcRenderer.invoke('cqrs:command', command),
|
||||||
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
query: (query) => ipcRenderer.invoke('cqrs:query', query)
|
||||||
};
|
};
|
||||||
|
|||||||
91
electron/theme-library.ts
Normal file
91
electron/theme-library.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ interface ReleaseManifestEntry {
|
|||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ServerHealthResponse {
|
||||||
|
releaseManifestUrl?: string;
|
||||||
|
serverVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface UpdateVersionInfo {
|
interface UpdateVersionInfo {
|
||||||
version: string;
|
version: string;
|
||||||
}
|
}
|
||||||
@@ -53,6 +58,12 @@ export interface DesktopUpdateServerContext {
|
|||||||
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DesktopUpdateServerHealthSnapshot {
|
||||||
|
manifestUrl: string | null;
|
||||||
|
serverVersion: string | null;
|
||||||
|
serverVersionStatus: DesktopUpdateServerVersionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DesktopUpdateState {
|
export interface DesktopUpdateState {
|
||||||
autoUpdateMode: AutoUpdateMode;
|
autoUpdateMode: AutoUpdateMode;
|
||||||
availableVersions: string[];
|
availableVersions: string[];
|
||||||
@@ -78,6 +89,8 @@ export interface DesktopUpdateState {
|
|||||||
|
|
||||||
export const AUTO_UPDATE_STATE_CHANGED_CHANNEL = 'auto-update-state-changed';
|
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 currentCheckPromise: Promise<void> | null = null;
|
||||||
let currentContext: DesktopUpdateServerContext = {
|
let currentContext: DesktopUpdateServerContext = {
|
||||||
manifestUrls: [],
|
manifestUrls: [],
|
||||||
@@ -388,6 +401,47 @@ async function loadReleaseManifest(manifestUrl: string): Promise<ReleaseManifest
|
|||||||
return parseReleaseManifest(payload);
|
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 {
|
function formatManifestLoadErrors(errors: string[]): string {
|
||||||
if (errors.length === 0) {
|
if (errors.length === 0) {
|
||||||
return 'No valid release manifest could be loaded.';
|
return 'No valid release manifest could be loaded.';
|
||||||
@@ -724,6 +778,12 @@ export async function checkForDesktopUpdates(): Promise<DesktopUpdateState> {
|
|||||||
return desktopUpdateState;
|
return desktopUpdateState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readDesktopUpdateServerHealth(
|
||||||
|
serverUrl: string
|
||||||
|
): Promise<DesktopUpdateServerHealthSnapshot> {
|
||||||
|
return await loadServerHealth(serverUrl);
|
||||||
|
}
|
||||||
|
|
||||||
export function restartToApplyUpdate(): boolean {
|
export function restartToApplyUpdate(): boolean {
|
||||||
if (!desktopUpdateState.restartRequired) {
|
if (!desktopUpdateState.restartRequired) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -2,13 +2,21 @@ import {
|
|||||||
app,
|
app,
|
||||||
BrowserWindow,
|
BrowserWindow,
|
||||||
desktopCapturer,
|
desktopCapturer,
|
||||||
|
Menu,
|
||||||
session,
|
session,
|
||||||
shell
|
shell,
|
||||||
|
Tray
|
||||||
} from 'electron';
|
} from 'electron';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { readDesktopSettings } from '../desktop-settings';
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
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 {
|
function getAssetPath(...segments: string[]): string {
|
||||||
const basePath = app.isPackaged
|
const basePath = app.isPackaged
|
||||||
@@ -38,13 +46,124 @@ export function getDockIconPath(): string | undefined {
|
|||||||
return getExistingAssetPath('macos', '1024x1024.png');
|
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 {
|
export function getMainWindow(): BrowserWindow | null {
|
||||||
return mainWindow;
|
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> {
|
export async function createWindow(): Promise<void> {
|
||||||
const windowIconPath = getWindowIconPath();
|
const windowIconPath = getWindowIconPath();
|
||||||
|
|
||||||
|
closeToTrayEnabled = readDesktopSettings().closeToTray;
|
||||||
|
ensureTray();
|
||||||
|
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1400,
|
width: 1400,
|
||||||
height: 900,
|
height: 900,
|
||||||
@@ -105,10 +224,64 @@ export async function createWindow(): Promise<void> {
|
|||||||
await mainWindow.loadFile(path.join(__dirname, '..', '..', 'client', 'browser', 'index.html'));
|
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.on('closed', () => {
|
||||||
mainWindow = null;
|
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 }) => {
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
shell.openExternal(url);
|
shell.openExternal(url);
|
||||||
return { action: 'deny' };
|
return { action: 'deny' };
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ module.exports = tseslint.config(
|
|||||||
},
|
},
|
||||||
// HTML template formatting rules (external Angular templates only)
|
// HTML template formatting rules (external Angular templates only)
|
||||||
{
|
{
|
||||||
files: ['src/app/**/*.html'],
|
files: ['toju-app/src/app/**/*.html'],
|
||||||
plugins: { 'no-dashes': noDashPlugin },
|
plugins: { 'no-dashes': noDashPlugin },
|
||||||
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
extends: [...angular.configs.templateRecommended, ...angular.configs.templateAccessibility],
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
298
package-lock.json
generated
298
package-lock.json
generated
@@ -14,6 +14,12 @@
|
|||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.0.0",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^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/core": "^33.0.0",
|
||||||
"@ng-icons/lucide": "^33.0.0",
|
"@ng-icons/lucide": "^33.0.0",
|
||||||
"@ngrx/effects": "^21.0.1",
|
"@ngrx/effects": "^21.0.1",
|
||||||
@@ -24,8 +30,10 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
@@ -45,11 +53,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/build": "^21.0.4",
|
"@angular/build": "^21.0.4",
|
||||||
"@angular/cli": "^21.2.1",
|
"@angular/cli": "^21.0.4",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -2695,6 +2705,109 @@
|
|||||||
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/@develar/schema-utils": {
|
||||||
"version": "2.6.5",
|
"version": "2.6.5",
|
||||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||||
@@ -5670,6 +5783,41 @@
|
|||||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@listr2/prompt-adapter-inquirer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz",
|
"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": ">= 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": {
|
"node_modules/@mermaid-js/parser": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz",
|
||||||
@@ -9184,6 +9338,22 @@
|
|||||||
"url": "https://opencollective.com/pkgr"
|
"url": "https://opencollective.com/pkgr"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-beta.47",
|
"version": "1.0.0-beta.47",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
|
||||||
@@ -10816,6 +10986,13 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.6",
|
"version": "1.19.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
@@ -12875,6 +13052,11 @@
|
|||||||
"node": ">= 6.0.0"
|
"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": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -12968,6 +13150,22 @@
|
|||||||
"node": ">= 4.0.0"
|
"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": {
|
"node_modules/autoprefixer": {
|
||||||
"version": "10.4.23",
|
"version": "10.4.23",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz",
|
||||||
@@ -14108,6 +14306,21 @@
|
|||||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -14736,6 +14949,12 @@
|
|||||||
"ieee754": "^1.1.13"
|
"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": {
|
"node_modules/cron-parser": {
|
||||||
"version": "4.9.0",
|
"version": "4.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||||
@@ -22285,9 +22504,7 @@
|
|||||||
"version": "0.5.6",
|
"version": "0.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.6"
|
"minimist": "^1.2.6"
|
||||||
},
|
},
|
||||||
@@ -23745,7 +23962,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -24453,6 +24669,53 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.59.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.59.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||||
|
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/plist": {
|
"node_modules/plist": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||||
@@ -27755,6 +28018,12 @@
|
|||||||
"webpack": "^5.0.0"
|
"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": {
|
"node_modules/stylehacks": {
|
||||||
"version": "6.1.1",
|
"version": "6.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-6.1.1.tgz",
|
||||||
@@ -29571,6 +29840,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/upath": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/upath/-/upath-2.0.1.tgz",
|
||||||
@@ -30338,6 +30616,12 @@
|
|||||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/wait-on": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz",
|
||||||
@@ -31161,6 +31445,12 @@
|
|||||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
54
package.json
54
package.json
@@ -7,24 +7,24 @@
|
|||||||
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
"homepage": "https://git.azaaxin.com/myxelium/Toju",
|
||||||
"main": "dist/electron/main.js",
|
"main": "dist/electron/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "cd \"toju-app\" && ng",
|
||||||
"prebuild": "npm run bundle:rnnoise",
|
"prebuild": "npm run bundle:rnnoise",
|
||||||
"prestart": "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",
|
"bundle:rnnoise": "esbuild node_modules/@timephy/rnnoise-wasm/dist/NoiseSuppressorWorklet.js --bundle --format=esm --outfile=toju-app/public/rnnoise-worklet.js",
|
||||||
"start": "ng serve",
|
"start": "cd \"toju-app\" && ng serve",
|
||||||
"build": "ng build",
|
"build": "cd \"toju-app\" && ng build",
|
||||||
"build:electron": "tsc -p tsconfig.electron.json",
|
"build:electron": "tsc -p tsconfig.electron.json",
|
||||||
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
"build:all": "npm run build && npm run build:electron && cd server && npm run build",
|
||||||
"build:prod": "ng build --configuration production --base-href='./'",
|
"build:prod": "cd \"toju-app\" && ng build --configuration production --base-href='./'",
|
||||||
"watch": "ng build --watch --configuration development",
|
"watch": "cd \"toju-app\" && ng build --watch --configuration development",
|
||||||
"test": "ng test",
|
"test": "cd \"toju-app\" && ng test",
|
||||||
"server:build": "cd server && npm run build",
|
"server:build": "cd server && npm run build",
|
||||||
"server:start": "cd server && npm start",
|
"server:start": "cd server && npm start",
|
||||||
"server:dev": "cd server && npm run dev",
|
"server:dev": "cd server && npm run dev",
|
||||||
"electron": "ng build && npm run build:electron && 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 \"ng serve\" \"wait-on http://localhost:4200 && npm run build:electron && cross-env NODE_ENV=development electron . --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": "./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:generate": "typeorm migration:generate electron/migrations/Auto -d dist/electron/data-source.js",
|
||||||
"migration:create": "typeorm migration:create electron/migrations/New",
|
"migration:create": "typeorm migration:create electron/migrations/New",
|
||||||
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
"migration:run": "typeorm migration:run -d dist/electron/data-source.js",
|
||||||
@@ -40,8 +40,8 @@
|
|||||||
"dev:app": "npm run electron:dev",
|
"dev:app": "npm run electron:dev",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||||
"format": "prettier --write \"src/app/**/*.html\"",
|
"format": "prettier --write \"toju-app/src/app/**/*.html\"",
|
||||||
"format:check": "prettier --check \"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: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:build:win": "npm run build:prod:all && electron-builder --win && npm run server:bundle:win",
|
||||||
"release:manifest": "node tools/generate-release-manifest.js",
|
"release:manifest": "node tools/generate-release-manifest.js",
|
||||||
@@ -49,7 +49,11 @@
|
|||||||
"release:version": "node tools/resolve-release-version.js",
|
"release:version": "node tools/resolve-release-version.js",
|
||||||
"server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64",
|
"server:bundle:linux": "node tools/package-server-executable.js --target node18-linux-x64 --output metoyou-server-linux-x64",
|
||||||
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
|
"server:bundle:win": "node tools/package-server-executable.js --target node18-win-x64 --output metoyou-server-win-x64.exe",
|
||||||
"sort:props": "node tools/sort-template-properties.js"
|
"sort:props": "node tools/sort-template-properties.js",
|
||||||
|
"test:e2e": "cd e2e && npx playwright test",
|
||||||
|
"test:e2e:ui": "cd e2e && npx playwright test --ui",
|
||||||
|
"test:e2e:debug": "cd e2e && npx playwright test --debug",
|
||||||
|
"test:e2e:report": "cd e2e && npx playwright show-report ../test-results/html-report"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "npm@10.9.2",
|
"packageManager": "npm@10.9.2",
|
||||||
@@ -60,6 +64,12 @@
|
|||||||
"@angular/forms": "^21.0.0",
|
"@angular/forms": "^21.0.0",
|
||||||
"@angular/platform-browser": "^21.0.0",
|
"@angular/platform-browser": "^21.0.0",
|
||||||
"@angular/router": "^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/core": "^33.0.0",
|
||||||
"@ng-icons/lucide": "^33.0.0",
|
"@ng-icons/lucide": "^33.0.0",
|
||||||
"@ngrx/effects": "^21.0.1",
|
"@ngrx/effects": "^21.0.1",
|
||||||
@@ -70,8 +80,10 @@
|
|||||||
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
"@spartan-ng/cli": "^0.0.1-alpha.589",
|
||||||
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
"@spartan-ng/ui-core": "^0.0.1-alpha.380",
|
||||||
"@timephy/rnnoise-wasm": "^1.0.0",
|
"@timephy/rnnoise-wasm": "^1.0.0",
|
||||||
|
"auto-launch": "^5.0.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"cytoscape": "^3.33.1",
|
"cytoscape": "^3.33.1",
|
||||||
"electron-updater": "^6.6.2",
|
"electron-updater": "^6.6.2",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.12.3",
|
||||||
@@ -94,8 +106,10 @@
|
|||||||
"@angular/cli": "^21.0.4",
|
"@angular/cli": "^21.0.4",
|
||||||
"@angular/compiler-cli": "^21.0.0",
|
"@angular/compiler-cli": "^21.0.0",
|
||||||
"@eslint/js": "^9.39.3",
|
"@eslint/js": "^9.39.3",
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
"@stylistic/eslint-plugin-js": "^4.4.1",
|
"@stylistic/eslint-plugin-js": "^4.4.1",
|
||||||
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
||||||
|
"@types/auto-launch": "^5.0.5",
|
||||||
"@types/simple-peer": "^9.11.9",
|
"@types/simple-peer": "^9.11.9",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"angular-eslint": "21.2.0",
|
"angular-eslint": "21.2.0",
|
||||||
@@ -120,13 +134,25 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"appId": "com.metoyou.app",
|
"appId": "com.metoyou.app",
|
||||||
"productName": "MetoYou",
|
"productName": "MetoYou",
|
||||||
|
"protocols": [
|
||||||
|
{
|
||||||
|
"name": "Toju Invite Links",
|
||||||
|
"schemes": [
|
||||||
|
"toju"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist-electron"
|
"output": "dist-electron"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
|
"!node_modules",
|
||||||
"dist/client/**/*",
|
"dist/client/**/*",
|
||||||
"dist/electron/**/*",
|
"dist/electron/**/*",
|
||||||
"node_modules/**/*",
|
"node_modules/{ansi-regex,ansi-styles,ansis,app-root-path,applescript,argparse,auto-launch,available-typed-arrays,balanced-match,base64-js,brace-expansion,buffer,builder-util-runtime,call-bind,call-bind-apply-helpers,call-bound,cliui,concat-map,cross-spawn,dayjs,debug,dedent,define-data-property,dotenv,dunder-proto,electron-updater,emoji-regex,es-define-property,es-errors,es-object-atoms,escalade,for-each,foreground-child,fs-extra,function-bind,get-caller-file,get-east-asian-width,get-intrinsic,get-proto,glob,gopd,graceful-fs,has-property-descriptors,has-symbols,has-tostringtag,hasown,ieee754,inherits,is-callable,is-fullwidth-code-point,is-typed-array,isarray,isexe,jackspeak,js-yaml,jsonfile,lazy-val,lodash.escaperegexp,lodash.isequal,lru-cache,math-intrinsics,minimatch,minimist,minipass,mkdirp,ms,package-json-from-dist,path-is-absolute,path-key,path-scurry,possible-typed-array-names,reflect-metadata,safe-buffer,sax,semver,set-function-length,sha.js,shebang-command,shebang-regex,signal-exit,sql-highlight,sql.js,string-width,string-width-cjs,strip-ansi,strip-ansi-cjs,tiny-typed-emitter,to-buffer,tslib,typed-array-buffer,typeorm,universalify,untildify,uuid,which,which-typed-array,winreg,wrap-ansi,wrap-ansi-cjs,y18n,yallist,yargs,yargs-parser}/**/*",
|
||||||
|
"node_modules/@isaacs/cliui/**/*",
|
||||||
|
"node_modules/@pkgjs/parseargs/**/*",
|
||||||
|
"node_modules/@sqltools/formatter/**/*",
|
||||||
"!node_modules/**/test/**/*",
|
"!node_modules/**/test/**/*",
|
||||||
"!node_modules/**/tests/**/*",
|
"!node_modules/**/tests/**/*",
|
||||||
"!node_modules/**/*.d.ts",
|
"!node_modules/**/*.d.ts",
|
||||||
|
|||||||
Binary file not shown.
@@ -5,6 +5,7 @@ import { registerRoutes } from './routes';
|
|||||||
export function createApp(): express.Express {
|
export function createApp(): express.Express {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.set('trust proxy', true);
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,30 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { resolveRuntimePath } from '../runtime-paths';
|
import { resolveRuntimePath } from '../runtime-paths';
|
||||||
|
|
||||||
|
export type ServerHttpProtocol = 'http' | 'https';
|
||||||
|
|
||||||
|
export interface LinkPreviewConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
cacheTtlMinutes: number;
|
||||||
|
maxCacheSizeMb: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerVariablesConfig {
|
export interface ServerVariablesConfig {
|
||||||
klipyApiKey: string;
|
klipyApiKey: string;
|
||||||
releaseManifestUrl: string;
|
releaseManifestUrl: string;
|
||||||
|
serverPort: number;
|
||||||
|
serverProtocol: ServerHttpProtocol;
|
||||||
|
serverHost: string;
|
||||||
|
linkPreview: LinkPreviewConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
const DATA_DIR = resolveRuntimePath('data');
|
||||||
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
const VARIABLES_FILE = path.join(DATA_DIR, 'variables.json');
|
||||||
|
const DEFAULT_SERVER_PORT = 3001;
|
||||||
|
const DEFAULT_SERVER_PROTOCOL: ServerHttpProtocol = 'http';
|
||||||
|
const DEFAULT_LINK_PREVIEW_CACHE_TTL_MINUTES = 7200;
|
||||||
|
const DEFAULT_LINK_PREVIEW_MAX_CACHE_SIZE_MB = 50;
|
||||||
|
const HARD_MAX_CACHE_SIZE_MB = 50;
|
||||||
|
|
||||||
function normalizeKlipyApiKey(value: unknown): string {
|
function normalizeKlipyApiKey(value: unknown): string {
|
||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
@@ -18,6 +35,72 @@ function normalizeReleaseManifestUrl(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
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> } {
|
function readRawVariables(): { rawContents: string; parsed: Record<string, unknown> } {
|
||||||
if (!fs.existsSync(VARIABLES_FILE)) {
|
if (!fs.existsSync(VARIABLES_FILE)) {
|
||||||
return { rawContents: '', parsed: {} };
|
return { rawContents: '', parsed: {} };
|
||||||
@@ -52,10 +135,15 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rawContents, parsed } = readRawVariables();
|
const { rawContents, parsed } = readRawVariables();
|
||||||
|
const { serverIpAddress: legacyServerIpAddress, ...remainingParsed } = parsed;
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...parsed,
|
...remainingParsed,
|
||||||
klipyApiKey: normalizeKlipyApiKey(parsed.klipyApiKey),
|
klipyApiKey: normalizeKlipyApiKey(remainingParsed.klipyApiKey),
|
||||||
releaseManifestUrl: normalizeReleaseManifestUrl(parsed.releaseManifestUrl)
|
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';
|
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||||
|
|
||||||
@@ -65,7 +153,11 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
klipyApiKey: normalized.klipyApiKey,
|
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 {
|
export function getReleaseManifestUrl(): string {
|
||||||
return getVariablesConfig().releaseManifestUrl;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,31 @@
|
|||||||
import { DataSource } from 'typeorm';
|
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';
|
import { DeleteServerCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
export async function handleDeleteServer(command: DeleteServerCommand, dataSource: DataSource): Promise<void> {
|
||||||
const { serverId } = command.payload;
|
const { serverId } = command.payload;
|
||||||
|
|
||||||
await dataSource.getRepository(JoinRequestEntity).delete({ serverId });
|
await dataSource.transaction(async (manager) => {
|
||||||
await dataSource.getRepository(ServerEntity).delete(serverId);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,35 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ServerEntity } from '../../../entities';
|
import { ServerEntity } from '../../../entities';
|
||||||
|
import { replaceServerRelations } from '../../relations';
|
||||||
import { UpsertServerCommand } from '../../types';
|
import { UpsertServerCommand } from '../../types';
|
||||||
|
|
||||||
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
|
export async function handleUpsertServer(command: UpsertServerCommand, dataSource: DataSource): Promise<void> {
|
||||||
const repo = dataSource.getRepository(ServerEntity);
|
|
||||||
const { server } = command.payload;
|
const { server } = command.payload;
|
||||||
|
|
||||||
|
await dataSource.transaction(async (manager) => {
|
||||||
|
const repo = manager.getRepository(ServerEntity);
|
||||||
const entity = repo.create({
|
const entity = repo.create({
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: server.name,
|
name: server.name,
|
||||||
description: server.description ?? null,
|
description: server.description ?? null,
|
||||||
ownerId: server.ownerId,
|
ownerId: server.ownerId,
|
||||||
ownerPublicKey: server.ownerPublicKey,
|
ownerPublicKey: server.ownerPublicKey,
|
||||||
|
passwordHash: server.passwordHash ?? null,
|
||||||
isPrivate: server.isPrivate ? 1 : 0,
|
isPrivate: server.isPrivate ? 1 : 0,
|
||||||
maxUsers: server.maxUsers,
|
maxUsers: server.maxUsers,
|
||||||
currentUsers: server.currentUsers,
|
currentUsers: server.currentUsers,
|
||||||
tags: JSON.stringify(server.tags),
|
slowModeInterval: server.slowModeInterval ?? 0,
|
||||||
createdAt: server.createdAt,
|
createdAt: server.createdAt,
|
||||||
lastSeen: server.lastSeen
|
lastSeen: server.lastSeen
|
||||||
});
|
});
|
||||||
|
|
||||||
await repo.save(entity);
|
await repo.save(entity);
|
||||||
|
await replaceServerRelations(manager, server.id, {
|
||||||
|
tags: server.tags,
|
||||||
|
channels: server.channels ?? [],
|
||||||
|
roles: server.roles ?? [],
|
||||||
|
roleAssignments: server.roleAssignments ?? [],
|
||||||
|
channelPermissions: server.channelPermissions ?? []
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ServerPayload,
|
ServerPayload,
|
||||||
JoinRequestPayload
|
JoinRequestPayload
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { relationRecordToServerPayload } from './relations';
|
||||||
|
|
||||||
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
export function rowToAuthUser(row: AuthUserEntity): AuthUserPayload {
|
||||||
return {
|
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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description ?? undefined,
|
description: row.description ?? undefined,
|
||||||
ownerId: row.ownerId,
|
ownerId: row.ownerId,
|
||||||
ownerPublicKey: row.ownerPublicKey,
|
ownerPublicKey: row.ownerPublicKey,
|
||||||
|
hasPassword: !!row.passwordHash,
|
||||||
|
passwordHash: row.passwordHash ?? undefined,
|
||||||
isPrivate: !!row.isPrivate,
|
isPrivate: !!row.isPrivate,
|
||||||
maxUsers: row.maxUsers,
|
maxUsers: row.maxUsers,
|
||||||
currentUsers: row.currentUsers,
|
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,
|
createdAt: row.createdAt,
|
||||||
lastSeen: row.lastSeen
|
lastSeen: row.lastSeen
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { ServerEntity } from '../../../entities';
|
import { ServerEntity } from '../../../entities';
|
||||||
import { rowToServer } from '../../mappers';
|
import { rowToServer } from '../../mappers';
|
||||||
|
import { loadServerRelationsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetAllPublicServers(dataSource: DataSource) {
|
export async function handleGetAllPublicServers(dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(ServerEntity);
|
const repo = dataSource.getRepository(ServerEntity);
|
||||||
const rows = await repo.find({ where: { isPrivate: 0 } });
|
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)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ import { DataSource } from 'typeorm';
|
|||||||
import { ServerEntity } from '../../../entities';
|
import { ServerEntity } from '../../../entities';
|
||||||
import { GetServerByIdQuery } from '../../types';
|
import { GetServerByIdQuery } from '../../types';
|
||||||
import { rowToServer } from '../../mappers';
|
import { rowToServer } from '../../mappers';
|
||||||
|
import { loadServerRelationsMap } from '../../relations';
|
||||||
|
|
||||||
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
|
export async function handleGetServerById(query: GetServerByIdQuery, dataSource: DataSource) {
|
||||||
const repo = dataSource.getRepository(ServerEntity);
|
const repo = dataSource.getRepository(ServerEntity);
|
||||||
const row = await repo.findOne({ where: { id: query.payload.serverId } });
|
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));
|
||||||
}
|
}
|
||||||
|
|||||||
603
server/src/cqrs/relations.ts
Normal file
603
server/src/cqrs/relations.ts
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,16 +28,70 @@ export interface AuthUserPayload {
|
|||||||
createdAt: number;
|
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 {
|
export interface ServerPayload {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
ownerPublicKey: string;
|
ownerPublicKey: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
|
passwordHash?: string | null;
|
||||||
isPrivate: boolean;
|
isPrivate: boolean;
|
||||||
maxUsers: number;
|
maxUsers: number;
|
||||||
currentUsers: number;
|
currentUsers: number;
|
||||||
|
slowModeInterval?: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
channels: ServerChannelPayload[];
|
||||||
|
roles?: AccessRolePayload[];
|
||||||
|
roleAssignments?: RoleAssignmentPayload[];
|
||||||
|
channelPermissions?: ChannelPermissionPayload[];
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
lastSeen: number;
|
lastSeen: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,90 @@ import { DataSource } from 'typeorm';
|
|||||||
import {
|
import {
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity
|
ServerTagEntity,
|
||||||
|
ServerChannelEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
|
ServerChannelPermissionEntity,
|
||||||
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { serverMigrations } from '../migrations';
|
import { serverMigrations } from '../migrations';
|
||||||
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
import { findExistingPath, resolveRuntimePath } from '../runtime-paths';
|
||||||
|
|
||||||
const DATA_DIR = resolveRuntimePath('data');
|
function resolveDbFile(): string {
|
||||||
const DB_FILE = path.join(DATA_DIR, 'metoyou.sqlite');
|
const envPath = process.env.DB_PATH;
|
||||||
|
|
||||||
|
if (envPath) {
|
||||||
|
return path.resolve(envPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(resolveRuntimePath('data'), 'metoyou.sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
const DB_FILE = resolveDbFile();
|
||||||
|
const DB_BACKUP = DB_FILE + '.bak';
|
||||||
|
const DATA_DIR = path.dirname(DB_FILE);
|
||||||
|
// SQLite files start with this 16-byte header string.
|
||||||
|
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||||
|
|
||||||
let applicationDataSource: DataSource | undefined;
|
let applicationDataSource: DataSource | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true when `data` looks like a valid SQLite file
|
||||||
|
* (correct header magic and at least one complete page).
|
||||||
|
*/
|
||||||
|
function isValidSqlite(data: Uint8Array): boolean {
|
||||||
|
if (data.length < 100)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const header = Buffer.from(data.buffer, data.byteOffset, 16).toString('ascii');
|
||||||
|
|
||||||
|
return header === SQLITE_MAGIC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Back up the current DB file so there is always a recovery point.
|
||||||
|
* If the main file is corrupted/empty but a valid backup exists,
|
||||||
|
* restore the backup before the server loads the database.
|
||||||
|
*/
|
||||||
|
function safeguardDbFile(): Uint8Array | undefined {
|
||||||
|
if (!fs.existsSync(DB_FILE))
|
||||||
|
return undefined;
|
||||||
|
|
||||||
|
const data = new Uint8Array(fs.readFileSync(DB_FILE));
|
||||||
|
|
||||||
|
if (isValidSqlite(data)) {
|
||||||
|
// Good file - rotate it into the backup slot.
|
||||||
|
fs.copyFileSync(DB_FILE, DB_BACKUP);
|
||||||
|
console.log('[DB] Backed up database to', DB_BACKUP);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The main file is corrupt or empty.
|
||||||
|
console.warn(`[DB] ${DB_FILE} appears corrupt (${data.length} bytes) - checking backup`);
|
||||||
|
|
||||||
|
if (fs.existsSync(DB_BACKUP)) {
|
||||||
|
const backup = new Uint8Array(fs.readFileSync(DB_BACKUP));
|
||||||
|
|
||||||
|
if (isValidSqlite(backup)) {
|
||||||
|
fs.copyFileSync(DB_BACKUP, DB_FILE);
|
||||||
|
console.warn('[DB] Restored database from backup', DB_BACKUP);
|
||||||
|
|
||||||
|
return backup;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('[DB] Backup is also invalid - starting with a fresh database');
|
||||||
|
} else {
|
||||||
|
console.error('[DB] No backup available - starting with a fresh database');
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
function resolveSqlJsConfig(): { locateFile: (file: string) => string } {
|
||||||
return {
|
return {
|
||||||
locateFile: (file) => {
|
locateFile: (file) => {
|
||||||
@@ -39,10 +113,7 @@ export async function initDatabase(): Promise<void> {
|
|||||||
if (!fs.existsSync(DATA_DIR))
|
if (!fs.existsSync(DATA_DIR))
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
let database: Uint8Array | undefined;
|
const database = safeguardDbFile();
|
||||||
|
|
||||||
if (fs.existsSync(DB_FILE))
|
|
||||||
database = fs.readFileSync(DB_FILE);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
applicationDataSource = new DataSource({
|
applicationDataSource = new DataSource({
|
||||||
@@ -51,10 +122,18 @@ export async function initDatabase(): Promise<void> {
|
|||||||
entities: [
|
entities: [
|
||||||
AuthUserEntity,
|
AuthUserEntity,
|
||||||
ServerEntity,
|
ServerEntity,
|
||||||
JoinRequestEntity
|
ServerTagEntity,
|
||||||
|
ServerChannelEntity,
|
||||||
|
ServerRoleEntity,
|
||||||
|
ServerUserRoleEntity,
|
||||||
|
ServerChannelPermissionEntity,
|
||||||
|
JoinRequestEntity,
|
||||||
|
ServerMembershipEntity,
|
||||||
|
ServerInviteEntity,
|
||||||
|
ServerBanEntity
|
||||||
],
|
],
|
||||||
migrations: serverMigrations,
|
migrations: serverMigrations,
|
||||||
synchronize: false,
|
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||||
logging: false,
|
logging: false,
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
location: DB_FILE,
|
location: DB_FILE,
|
||||||
@@ -74,8 +153,12 @@ export async function initDatabase(): Promise<void> {
|
|||||||
|
|
||||||
console.log('[DB] Connection initialised at:', DB_FILE);
|
console.log('[DB] Connection initialised at:', DB_FILE);
|
||||||
|
|
||||||
|
if (process.env.DB_SYNCHRONIZE !== 'true') {
|
||||||
await applicationDataSource.runMigrations();
|
await applicationDataSource.runMigrations();
|
||||||
console.log('[DB] Migrations executed');
|
console.log('[DB] Migrations executed');
|
||||||
|
} else {
|
||||||
|
console.log('[DB] Synchronize mode - migrations skipped');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroyDatabase(): Promise<void> {
|
export async function destroyDatabase(): Promise<void> {
|
||||||
|
|||||||
35
server/src/entities/ServerBanEntity.ts
Normal file
35
server/src/entities/ServerBanEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
23
server/src/entities/ServerChannelEntity.ts
Normal file
23
server/src/entities/ServerChannelEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal file
26
server/src/entities/ServerChannelPermissionEntity.ts
Normal 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';
|
||||||
|
}
|
||||||
@@ -21,6 +21,9 @@ export class ServerEntity {
|
|||||||
@Column('text')
|
@Column('text')
|
||||||
ownerPublicKey!: string;
|
ownerPublicKey!: string;
|
||||||
|
|
||||||
|
@Column('text', { nullable: true })
|
||||||
|
passwordHash!: string | null;
|
||||||
|
|
||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
isPrivate!: number;
|
isPrivate!: number;
|
||||||
|
|
||||||
@@ -30,8 +33,8 @@ export class ServerEntity {
|
|||||||
@Column('integer', { default: 0 })
|
@Column('integer', { default: 0 })
|
||||||
currentUsers!: number;
|
currentUsers!: number;
|
||||||
|
|
||||||
@Column('text', { default: '[]' })
|
@Column('integer', { default: 0 })
|
||||||
tags!: string;
|
slowModeInterval!: number;
|
||||||
|
|
||||||
@Column('integer')
|
@Column('integer')
|
||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
|
|||||||
29
server/src/entities/ServerInviteEntity.ts
Normal file
29
server/src/entities/ServerInviteEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
26
server/src/entities/ServerMembershipEntity.ts
Normal file
26
server/src/entities/ServerMembershipEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
59
server/src/entities/ServerRoleEntity.ts
Normal file
59
server/src/entities/ServerRoleEntity.ts
Normal 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';
|
||||||
|
}
|
||||||
17
server/src/entities/ServerTagEntity.ts
Normal file
17
server/src/entities/ServerTagEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
20
server/src/entities/ServerUserRoleEntity.ts
Normal file
20
server/src/entities/ServerUserRoleEntity.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
export { AuthUserEntity } from './AuthUserEntity';
|
export { AuthUserEntity } from './AuthUserEntity';
|
||||||
export { ServerEntity } from './ServerEntity';
|
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 { JoinRequestEntity } from './JoinRequestEntity';
|
||||||
|
export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||||
|
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||||
|
export { ServerBanEntity } from './ServerBanEntity';
|
||||||
|
|||||||
@@ -9,28 +9,44 @@ import { resolveCertificateDirectory, resolveEnvFilePath } from './runtime-paths
|
|||||||
// Load .env from project root (one level up from server/)
|
// Load .env from project root (one level up from server/)
|
||||||
dotenv.config({ path: resolveEnvFilePath() });
|
dotenv.config({ path: resolveEnvFilePath() });
|
||||||
|
|
||||||
import { initDatabase } from './db/database';
|
import { initDatabase, destroyDatabase } from './db/database';
|
||||||
import { deleteStaleJoinRequests } from './cqrs';
|
import { deleteStaleJoinRequests } from './cqrs';
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
import {
|
import {
|
||||||
ensureVariablesConfig,
|
ensureVariablesConfig,
|
||||||
|
getServerHost,
|
||||||
getVariablesConfigPath,
|
getVariablesConfigPath,
|
||||||
hasKlipyApiKey
|
getServerPort,
|
||||||
|
getServerProtocol,
|
||||||
|
ServerHttpProtocol
|
||||||
} from './config/variables';
|
} from './config/variables';
|
||||||
import { setupWebSocket } from './websocket';
|
import { setupWebSocket } from './websocket';
|
||||||
|
|
||||||
const USE_SSL = (process.env.SSL ?? 'false').toLowerCase() === 'true';
|
function formatHostForUrl(host: string): string {
|
||||||
const PORT = process.env.PORT || 3001;
|
if (host.startsWith('[') || !host.includes(':')) {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
function buildServer(app: ReturnType<typeof createApp>) {
|
return `[${host}]`;
|
||||||
if (USE_SSL) {
|
}
|
||||||
|
|
||||||
|
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 certDir = resolveCertificateDirectory();
|
||||||
const certFile = path.join(certDir, 'localhost.crt');
|
const certFile = path.join(certDir, 'localhost.crt');
|
||||||
const keyFile = path.join(certDir, 'localhost.key');
|
const keyFile = path.join(certDir, 'localhost.key');
|
||||||
|
|
||||||
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
if (!fs.existsSync(certFile) || !fs.existsSync(keyFile)) {
|
||||||
console.error(`SSL=true but certs not found in ${certDir}`);
|
console.error(`HTTPS is enabled but certs were not found in ${certDir}`);
|
||||||
console.error('Run ./generate-cert.sh first.');
|
console.error('Add localhost.crt and localhost.key there, or switch serverProtocol to "http".');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,17 +60,31 @@ function buildServer(app: ReturnType<typeof createApp>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
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());
|
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.');
|
console.log('[KLIPY] API key not configured. GIF search is disabled.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = buildServer(app);
|
const server = buildServer(app, serverProtocol);
|
||||||
|
|
||||||
setupWebSocket(server);
|
setupWebSocket(server);
|
||||||
|
|
||||||
@@ -64,16 +94,51 @@ async function bootstrap(): Promise<void> {
|
|||||||
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
.catch(err => console.error('Failed to clean up stale join requests:', err));
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
const onListening = () => {
|
||||||
const proto = USE_SSL ? 'https' : 'http';
|
const displayHost = formatHostForUrl(getDisplayHost(serverHost));
|
||||||
const wsProto = USE_SSL ? 'wss' : 'ws';
|
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(`MetoYou signaling server running on port ${serverPort} (${serverProtocol.toUpperCase()}, bind host=${bindHostLabel})`);
|
||||||
console.log(` REST API: ${proto}://localhost:${PORT}/api`);
|
console.log(` REST API: ${serverProtocol}://${displayHost}:${serverPort}/api`);
|
||||||
console.log(` WebSocket: ${wsProto}://localhost:${PORT}`);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
async function gracefulShutdown(signal: string): Promise<void> {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
|
||||||
|
console.log(`\n[Shutdown] ${signal} received — closing database…`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await destroyDatabase();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Shutdown] Error closing database:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
|
||||||
bootstrap().catch((err) => {
|
bootstrap().catch((err) => {
|
||||||
console.error('Failed to start server:', err);
|
console.error('Failed to start server:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class InitialSchema1000000000000 implements MigrationInterface {
|
|||||||
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
"maxUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
"currentUsers" INTEGER NOT NULL DEFAULT 0,
|
||||||
"tags" TEXT NOT NULL DEFAULT '[]',
|
"tags" TEXT NOT NULL DEFAULT '[]',
|
||||||
|
"channels" TEXT NOT NULL DEFAULT '[]',
|
||||||
"createdAt" INTEGER NOT NULL,
|
"createdAt" INTEGER NOT NULL,
|
||||||
"lastSeen" INTEGER NOT NULL
|
"lastSeen" INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
|
|||||||
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal file
56
server/src/migrations/1000000000001-ServerAccessControl.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
server/src/migrations/1000000000002-ServerChannels.ts
Normal file
17
server/src/migrations/1000000000002-ServerChannels.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal file
119
server/src/migrations/1000000000003-RepairLegacyVoiceChannels.ts
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal file
142
server/src/migrations/1000000000004-NormalizeServerArrays.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal file
196
server/src/migrations/1000000000005-ServerRoleAccessControl.ts
Normal 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"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
import { InitialSchema1000000000000 } from './1000000000000-InitialSchema';
|
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
|
||||||
|
];
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import { getAllPublicServers } from '../cqrs';
|
import { getAllPublicServers } from '../cqrs';
|
||||||
import { getReleaseManifestUrl } from '../config/variables';
|
import { getReleaseManifestUrl } from '../config/variables';
|
||||||
import { SERVER_BUILD_VERSION } from '../generated/build-version';
|
import { SERVER_BUILD_VERSION } from '../generated/build-version';
|
||||||
import { connectedUsers } from '../websocket/state';
|
import { connectedUsers } from '../websocket/state';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
const SERVER_INSTANCE_ID = typeof process.env.METOYOU_SERVER_INSTANCE_ID === 'string'
|
||||||
|
&& process.env.METOYOU_SERVER_INSTANCE_ID.trim().length > 0
|
||||||
|
? process.env.METOYOU_SERVER_INSTANCE_ID.trim()
|
||||||
|
: randomUUID();
|
||||||
|
|
||||||
function getServerProjectVersion(): string {
|
function getServerProjectVersion(): string {
|
||||||
return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0
|
return typeof process.env.METOYOU_SERVER_VERSION === 'string' && process.env.METOYOU_SERVER_VERSION.trim().length > 0
|
||||||
@@ -20,6 +25,7 @@ router.get('/health', async (_req, res) => {
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
serverCount: servers.length,
|
serverCount: servers.length,
|
||||||
connectedUsers: connectedUsers.size,
|
connectedUsers: connectedUsers.size,
|
||||||
|
serverInstanceId: SERVER_INSTANCE_ID,
|
||||||
serverVersion: getServerProjectVersion(),
|
serverVersion: getServerProjectVersion(),
|
||||||
releaseManifestUrl: getReleaseManifestUrl()
|
releaseManifestUrl: getReleaseManifestUrl()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
import { Express } from 'express';
|
import { Express } from 'express';
|
||||||
import healthRouter from './health';
|
import healthRouter from './health';
|
||||||
import klipyRouter from './klipy';
|
import klipyRouter from './klipy';
|
||||||
|
import linkMetadataRouter from './link-metadata';
|
||||||
import proxyRouter from './proxy';
|
import proxyRouter from './proxy';
|
||||||
import usersRouter from './users';
|
import usersRouter from './users';
|
||||||
import serversRouter from './servers';
|
import serversRouter from './servers';
|
||||||
import joinRequestsRouter from './join-requests';
|
import joinRequestsRouter from './join-requests';
|
||||||
|
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||||
|
|
||||||
export function registerRoutes(app: Express): void {
|
export function registerRoutes(app: Express): void {
|
||||||
app.use('/api', healthRouter);
|
app.use('/api', healthRouter);
|
||||||
app.use('/api', klipyRouter);
|
app.use('/api', klipyRouter);
|
||||||
|
app.use('/api', linkMetadataRouter);
|
||||||
app.use('/api', proxyRouter);
|
app.use('/api', proxyRouter);
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/servers', serversRouter);
|
app.use('/api/servers', serversRouter);
|
||||||
|
app.use('/api/invites', invitesApiRouter);
|
||||||
app.use('/api/requests', joinRequestsRouter);
|
app.use('/api/requests', joinRequestsRouter);
|
||||||
|
app.use('/invite', invitePageRouter);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user