fix: Fix multiple bugs with new authentication flow
This commit is contained in:
@@ -25,6 +25,20 @@ Durable rules for AI agents working on this project. Read this file at session s
|
||||
|
||||
## Lessons
|
||||
|
||||
### Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance]
|
||||
|
||||
- **Trigger:** `DatabaseService.ensureReady()` called `initialize()` before every delegated read/write to fix user-scope races.
|
||||
- **Rule:** cache the last validated `metoyou_currentUserId` and only re-run backend initialization when that scope changes or an in-flight initialize completes with a different scope.
|
||||
- **Why:** per-operation revalidation fans out across ban lookups, room loads, and message reads, causing channel/chat UI to stay blank until repeated server clicks eventually win the race.
|
||||
- **Example:** `ensureReady()` returns immediately when `isReady()` and `validatedUserScope` still match `getStoredCurrentUserId()`.
|
||||
|
||||
### Restore local user scope before protected writes [authentication] [persistence]
|
||||
|
||||
- **Trigger:** a logged-in in-memory user can create rooms or messages after `metoyou_currentUserId` was cleared by a late session-expired path.
|
||||
- **Rule:** before protected local persistence or server-directory actions, restore `metoyou_currentUserId` from the current user and avoid treating a live current user as unauthenticated.
|
||||
- **Why:** otherwise rooms/messages fall into the anonymous IndexedDB scope, and route checks redirect to login even though NgRx still has the authenticated user.
|
||||
- **Example:** `MessagesEffects.sendMessage$`, `RoomsEffects.createRoom$`, and server-directory create/join components call `setStoredCurrentUserId(currentUser.id)` before writing or joining.
|
||||
|
||||
### Persisted local user state still requires a session token [authentication] [signaling]
|
||||
|
||||
- **Trigger:** Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped.
|
||||
|
||||
16
agents-docs/adr/0003-multi-client-sessions.md
Normal file
16
agents-docs/adr/0003-multi-client-sessions.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# ADR-0003: Multi-Client Sessions with Connection-Scoped Routing
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
Users expect to stay logged in on multiple devices simultaneously (Discord-style). The signaling server already issued multiple session tokens per user, but WebSocket broadcasts deduplicated by `oderId`, which prevented a user's second device from receiving chat, typing, or voice-state updates from their first device. Voice had no per-device identity, so two clients could both attempt to transmit audio.
|
||||
|
||||
## Decision
|
||||
Introduce a stable per-install `clientInstanceId` on the product client. Route server broadcasts by **connection id** (exclude only the sender socket) while keeping presence `user_joined` / `user_left` identity-scoped. Track `voiceActive` per connection; relay RTC to the voice-active socket. Enforce single voice owner per user via `VoiceState.clientInstanceId` and `voice_client_takeover` handoff between connections.
|
||||
|
||||
## Consequences
|
||||
- **Positive:** Chat and presence sync across a user's devices; voice behaves like Discord (one transmitting client, passive viewers, explicit takeover).
|
||||
- **Positive:** Stale-tab hygiene uses `(oderId, connectionScope, clientInstanceId)` eviction without kicking other devices.
|
||||
- **Negative:** `findUserByOderId` semantics change — RTC now prefers voice-active connections; callers must not assume one socket per user.
|
||||
- **Negative:** Clients must include `clientInstanceId` on identify and voice payloads; older builds without it still work but cannot participate in multi-device voice exclusivity reliably.
|
||||
@@ -25,7 +25,7 @@ Session-token authentication for the signaling server and product client.
|
||||
```
|
||||
|
||||
- Tokens are opaque 64-character hex strings stored in server SQLite (`session_tokens`).
|
||||
- Default TTL: 24 hours (`SESSION_TOKEN_TTL_MS` env override supported).
|
||||
- Default TTL: 10 years (`SESSION_TOKEN_TTL_MS` env override supported on the signaling server).
|
||||
- Passwords are stored with bcrypt; legacy SHA-256 hashes are upgraded transparently on successful login.
|
||||
|
||||
## Protected REST routes
|
||||
@@ -46,13 +46,22 @@ Require `Authorization: Bearer`:
|
||||
"token": "<session-token>",
|
||||
"oderId": "<user-id>",
|
||||
"displayName": "Alice",
|
||||
"connectionScope": "ws://host:3001"
|
||||
"connectionScope": "ws://host:3001",
|
||||
"clientInstanceId": "<per-install-uuid>"
|
||||
}
|
||||
```
|
||||
|
||||
- `oderId` must match the token's user id when provided.
|
||||
- `clientInstanceId` is a stable per-install UUID generated by the product client (`metoyou.clientInstanceId` in `localStorage`). The signaling server uses it to distinguish multiple WebSocket connections for the same user and to route voice ownership.
|
||||
- Server responds with `auth_error` or `auth_required` when authentication fails.
|
||||
|
||||
## Multi-device sessions
|
||||
|
||||
- Each login/register issues a **new** session token; prior tokens remain valid until they expire or the client calls `POST /api/users/logout` with that token.
|
||||
- The same user may keep multiple WebSocket connections open (different devices or browser profiles). Server broadcasts (chat, typing, voice state, status) exclude only the **sending connection**, so other connections for that identity still receive updates.
|
||||
- Voice/WebRTC is exclusive per user: only one `clientInstanceId` may own active voice at a time. Other connections show passive UI and can send `voice_client_takeover` to move voice to the local device.
|
||||
- Stale reconnect hygiene: when a client re-identifies with the same `(oderId, connectionScope, clientInstanceId)` tuple, the server closes the older socket for that tuple.
|
||||
|
||||
## Client storage
|
||||
|
||||
The product client stores tokens per signaling-server base URL in `localStorage` (`metoyou.authTokens`). An HTTP interceptor attaches the bearer token to `/api/*` requests targeting that server.
|
||||
|
||||
@@ -48,7 +48,8 @@ export const test = base.extend<MultiClientFixture>({
|
||||
|
||||
const context = await browser.newContext({
|
||||
permissions: ['microphone', 'camera'],
|
||||
baseURL: 'http://localhost:4200'
|
||||
baseURL: 'http://localhost:4200',
|
||||
viewport: { width: 1440, height: 900 }
|
||||
});
|
||||
|
||||
await installTestServerEndpoint(context, testServer.port);
|
||||
|
||||
205
e2e/helpers/multi-device-session.ts
Normal file
205
e2e/helpers/multi-device-session.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
import { type Client } from '../fixtures/multi-client';
|
||||
import { LoginPage } from '../pages/login.page';
|
||||
import { RegisterPage } from '../pages/register.page';
|
||||
import { ServerSearchPage } from '../pages/server-search.page';
|
||||
import { ChatRoomPage } from '../pages/chat-room.page';
|
||||
import { ChatMessagesPage } from '../pages/chat-messages.page';
|
||||
|
||||
export const MULTI_DEVICE_PASSWORD = 'TestPass123!';
|
||||
export const MULTI_DEVICE_VOICE_CHANNEL = 'General';
|
||||
|
||||
export interface MultiDeviceCredentials {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface MultiDeviceScenario {
|
||||
clientA: Client;
|
||||
clientB: Client;
|
||||
credentials: MultiDeviceCredentials;
|
||||
serverName: string;
|
||||
messagesA: ChatMessagesPage;
|
||||
messagesB: ChatMessagesPage;
|
||||
roomA: ChatRoomPage;
|
||||
roomB: ChatRoomPage;
|
||||
}
|
||||
|
||||
export function uniqueMultiDeviceName(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
|
||||
}
|
||||
|
||||
export async function createMultiDeviceScenario(
|
||||
createClient: () => Promise<Client>,
|
||||
options: { suffix?: string; serverDescription?: string } = {}
|
||||
): Promise<MultiDeviceScenario> {
|
||||
const suffix = options.suffix ?? uniqueMultiDeviceName('multi-device');
|
||||
const credentials: MultiDeviceCredentials = {
|
||||
username: `multi_${suffix}`,
|
||||
displayName: 'Multi Device User',
|
||||
password: MULTI_DEVICE_PASSWORD
|
||||
};
|
||||
const serverName = `Multi Device Server ${suffix}`;
|
||||
|
||||
const clientA = await createClient();
|
||||
const clientB = await createClient();
|
||||
|
||||
await warmClientPage(clientA.page);
|
||||
await warmClientPage(clientB.page);
|
||||
|
||||
const registerPage = new RegisterPage(clientA.page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(credentials.username, credentials.displayName, credentials.password);
|
||||
await expect(clientA.page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const searchA = new ServerSearchPage(clientA.page);
|
||||
|
||||
await searchA.createServer(serverName, {
|
||||
description: options.serverDescription ?? 'Multi-device session coverage'
|
||||
});
|
||||
await expect(clientA.page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
await waitForCurrentRoomName(clientA.page, serverName);
|
||||
|
||||
const roomA = new ChatRoomPage(clientA.page);
|
||||
|
||||
await roomA.ensureVoiceChannelExists(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
|
||||
await loginSecondDeviceIntoServer(clientB.page, credentials, serverName);
|
||||
await waitForCurrentRoomName(clientB.page, serverName);
|
||||
|
||||
const messagesA = new ChatMessagesPage(clientA.page);
|
||||
const messagesB = new ChatMessagesPage(clientB.page);
|
||||
const roomB = new ChatRoomPage(clientB.page);
|
||||
|
||||
await messagesA.waitForReady();
|
||||
await messagesB.waitForReady();
|
||||
|
||||
return {
|
||||
clientA,
|
||||
clientB,
|
||||
credentials,
|
||||
serverName,
|
||||
messagesA,
|
||||
messagesB,
|
||||
roomA,
|
||||
roomB
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginSecondDeviceIntoServer(
|
||||
page: Page,
|
||||
credentials: MultiDeviceCredentials,
|
||||
serverName: string
|
||||
): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.goto();
|
||||
await loginPage.login(credentials.username, credentials.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
|
||||
const search = new ServerSearchPage(page);
|
||||
|
||||
await search.joinServerFromSearch(serverName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(page.locator('app-rooms-side-panel').first()).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
export async function expectCrossDeviceMessage(
|
||||
sender: ChatMessagesPage,
|
||||
receiver: ChatMessagesPage,
|
||||
message: string,
|
||||
timeout = 60_000
|
||||
): Promise<void> {
|
||||
await sender.sendMessage(message);
|
||||
|
||||
await expect.poll(async () => {
|
||||
return await receiver.getMessageItemByText(message).isVisible().catch(() => false);
|
||||
}, { timeout }).toBe(true);
|
||||
}
|
||||
|
||||
async function warmClientPage(page: Page): Promise<void> {
|
||||
await page.goto('/dashboard', { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle').catch(() => undefined);
|
||||
}
|
||||
|
||||
async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
(expectedRoomName) => {
|
||||
interface RoomShape { name?: string }
|
||||
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 false;
|
||||
}
|
||||
|
||||
const component = debugApi.getComponent(host);
|
||||
const currentRoom = (component['currentRoom'] as (() => RoomShape | null) | undefined)?.() ?? null;
|
||||
|
||||
return currentRoom?.name === expectedRoomName;
|
||||
},
|
||||
roomName,
|
||||
{ timeout }
|
||||
);
|
||||
}
|
||||
|
||||
export async function readClientInstanceId(page: Page): Promise<string | null> {
|
||||
return page.evaluate(() => localStorage.getItem('metoyou.clientInstanceId'));
|
||||
}
|
||||
|
||||
export async function logoutFromMenu(page: Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
||||
await menuButton.click();
|
||||
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
||||
await logoutButton.click();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
export function channelsSidePanel(page: Page) {
|
||||
return page.locator('app-rooms-side-panel').first();
|
||||
}
|
||||
|
||||
export function membersSidePanel(page: Page) {
|
||||
return page.locator('app-rooms-side-panel').last();
|
||||
}
|
||||
|
||||
export function passiveVoiceChannelJoinBadge(page: Page, channelName = MULTI_DEVICE_VOICE_CHANNEL) {
|
||||
return page
|
||||
.locator(`button[data-channel-type="voice"][data-channel-name="${channelName}"]`)
|
||||
.getByText('Join', { exact: true });
|
||||
}
|
||||
|
||||
export async function expectPassiveVoiceOnDevice(
|
||||
page: Page,
|
||||
options: { timeout?: number; displayName?: string; channelName?: string } = {}
|
||||
): Promise<void> {
|
||||
const timeout = options.timeout ?? 45_000;
|
||||
const channelName = options.channelName ?? MULTI_DEVICE_VOICE_CHANNEL;
|
||||
const displayName = options.displayName;
|
||||
|
||||
await expect.poll(async () => {
|
||||
const membersLabel = await membersSidePanel(page)
|
||||
.getByText('In voice on another device', { exact: false })
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
const joinBadge = await passiveVoiceChannelJoinBadge(page, channelName).isVisible().catch(() => false);
|
||||
const grayedVoiceUser = displayName
|
||||
? await channelsSidePanel(page).locator('.opacity-50').filter({ hasText: displayName }).first().isVisible().catch(() => false)
|
||||
: false;
|
||||
|
||||
return membersLabel || joinBadge || grayedVoiceUser;
|
||||
}, { timeout }).toBe(true);
|
||||
}
|
||||
|
||||
export async function expectActiveVoiceOnDevice(page: Page, timeout = 20_000): Promise<void> {
|
||||
await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout });
|
||||
}
|
||||
@@ -34,9 +34,22 @@ export class ChatMessagesPage {
|
||||
}
|
||||
|
||||
async sendMessage(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
await this.sendButton.click();
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill(content);
|
||||
await expect(this.composerInput).toHaveValue(content, { timeout: 5_000 });
|
||||
await expect(this.sendButton).toBeEnabled({ timeout: 5_000 });
|
||||
await this.sendButton.click();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error ? lastError : new Error('Failed to send chat message');
|
||||
}
|
||||
|
||||
async typeDraft(content: string): Promise<void> {
|
||||
@@ -44,6 +57,13 @@ export class ChatMessagesPage {
|
||||
await this.composerInput.fill(content);
|
||||
}
|
||||
|
||||
/** Types into the composer in a way that emits input/typing events (not just fill). */
|
||||
async typeDraftWithTypingEvents(content: string): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.click();
|
||||
await this.composerInput.pressSequentially(content, { delay: 40 });
|
||||
}
|
||||
|
||||
async clearDraft(): Promise<void> {
|
||||
await this.waitForReady();
|
||||
await this.composerInput.fill('');
|
||||
|
||||
@@ -10,15 +10,14 @@ export class LoginPage {
|
||||
readonly registerLink: Locator;
|
||||
|
||||
constructor(private page: Page) {
|
||||
this.form = page.locator('#login-username').locator('xpath=ancestor::div[contains(@class, "space-y-3")]')
|
||||
.first();
|
||||
this.form = page.locator('form').filter({ has: page.locator('#login-username') });
|
||||
|
||||
this.usernameInput = page.locator('#login-username');
|
||||
this.passwordInput = page.locator('#login-password');
|
||||
this.serverSelect = page.locator('#login-server');
|
||||
this.submitButton = this.form.getByRole('button', { name: 'Login' });
|
||||
this.errorText = page.locator('.text-destructive');
|
||||
this.registerLink = this.form.getByRole('button', { name: 'Register' });
|
||||
this.registerLink = page.getByRole('button', { name: 'Register' });
|
||||
}
|
||||
|
||||
async goto() {
|
||||
|
||||
27
e2e/run-playwright.mjs
Normal file
27
e2e/run-playwright.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const e2eDirectory = fileURLToPath(new URL('.', import.meta.url));
|
||||
const env = { ...process.env };
|
||||
const browsersPath = env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
|
||||
if (browsersPath?.includes('/cursor-sandbox-cache/')) {
|
||||
delete env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
}
|
||||
|
||||
const [command = 'test', ...args] = process.argv.slice(2);
|
||||
const executable = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
const child = spawn(executable, ['playwright', command, ...args], {
|
||||
cwd: e2eDirectory,
|
||||
env,
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
153
e2e/tests/auth/login-return-url.spec.ts
Normal file
153
e2e/tests/auth/login-return-url.spec.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { test, expect } from '../../fixtures/multi-client';
|
||||
import { LoginPage } from '../../pages/login.page';
|
||||
import { RegisterPage } from '../../pages/register.page';
|
||||
|
||||
interface TestUser {
|
||||
username: string;
|
||||
displayName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
test.describe('Login returnUrl handling', () => {
|
||||
test.describe.configure({ timeout: 120_000 });
|
||||
|
||||
test('unwraps nested login returnUrl chains after successful login', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('nested-return');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Return Url User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Log out and open a deeply nested login returnUrl', async () => {
|
||||
await logout(page);
|
||||
|
||||
const nestedReturnUrl = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
|
||||
|
||||
await page.goto(`/login?returnUrl=${encodeURIComponent(nestedReturnUrl)}`, {
|
||||
waitUntil: 'domcontentloaded'
|
||||
});
|
||||
|
||||
await expect(page.locator('#login-username')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Login lands on the original destination instead of looping on /login', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
});
|
||||
|
||||
test('redirects unauthenticated /servers visits to login and returns there after login', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('servers-return');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Servers Return User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account and log out', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
await logout(page);
|
||||
});
|
||||
|
||||
await test.step('Visiting /servers sends the user to a single-level login returnUrl', async () => {
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
|
||||
await test.step('Logging in returns to /servers', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test('lets a returning user log back in after an expired session redirect', async ({ createClient }) => {
|
||||
const client = await createClient();
|
||||
const { page } = client;
|
||||
const suffix = uniqueName('expired-session');
|
||||
const user: TestUser = {
|
||||
username: `user_${suffix}`,
|
||||
displayName: 'Expired Session User',
|
||||
password: 'TestPass123!'
|
||||
};
|
||||
|
||||
await test.step('Create an account', async () => {
|
||||
const registerPage = new RegisterPage(page);
|
||||
|
||||
await registerPage.goto();
|
||||
await registerPage.register(user.username, user.displayName, user.password);
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 15_000 });
|
||||
});
|
||||
|
||||
await test.step('Simulate an expired session while keeping the persisted user id', async () => {
|
||||
await page.evaluate(() => {
|
||||
const storageKey = 'metoyou.authTokens';
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as Record<string, { token: string; expiresAt: number }>;
|
||||
const expiredStore = Object.fromEntries(
|
||||
Object.entries(parsed).map(([url, entry]) => [url, { ...entry, expiresAt: 0 }])
|
||||
);
|
||||
|
||||
localStorage.setItem(storageKey, JSON.stringify(expiredStore));
|
||||
});
|
||||
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expect(page).toHaveURL(/returnUrl=%2Fservers/);
|
||||
await expect(page).not.toHaveURL(/returnUrl=.*login/);
|
||||
});
|
||||
|
||||
await test.step('The user can authenticate again and reach /servers', async () => {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
await loginPage.login(user.username, user.password);
|
||||
await expect(page).toHaveURL(/\/servers/, { timeout: 15_000 });
|
||||
await expect(page.locator('app-server-browser')).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function logout(page: import('@playwright/test').Page): Promise<void> {
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
const logoutButton = page.getByRole('button', { name: 'Logout' });
|
||||
|
||||
await expect(menuButton).toBeVisible({ timeout: 10_000 });
|
||||
await menuButton.click();
|
||||
await expect(logoutButton).toBeVisible({ timeout: 10_000 });
|
||||
await logoutButton.click();
|
||||
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
function uniqueName(prefix: string): string {
|
||||
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36)
|
||||
.slice(2, 8)}`;
|
||||
}
|
||||
93
e2e/tests/auth/multi-device-session.spec.ts
Normal file
93
e2e/tests/auth/multi-device-session.spec.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
test,
|
||||
expect
|
||||
} from '../../fixtures/multi-client';
|
||||
import {
|
||||
MULTI_DEVICE_VOICE_CHANNEL,
|
||||
channelsSidePanel,
|
||||
createMultiDeviceScenario,
|
||||
expectCrossDeviceMessage,
|
||||
expectActiveVoiceOnDevice,
|
||||
expectPassiveVoiceOnDevice,
|
||||
logoutFromMenu,
|
||||
membersSidePanel,
|
||||
passiveVoiceChannelJoinBadge,
|
||||
readClientInstanceId,
|
||||
uniqueMultiDeviceName
|
||||
} from '../../helpers/multi-device-session';
|
||||
|
||||
test.describe('Multi-device session', () => {
|
||||
test.describe.configure({ timeout: 300_000, retries: 1 });
|
||||
|
||||
test('covers identity, chat sync, typing exclusion, and voice exclusivity', async ({ createClient }) => {
|
||||
const scenario = await createMultiDeviceScenario(createClient);
|
||||
const messageAtoB = `Cross-device A to B ${uniqueMultiDeviceName('msg')}`;
|
||||
const messageBtoA = `Cross-device B to A ${uniqueMultiDeviceName('msg')}`;
|
||||
const typingDraft = `Typing draft ${uniqueMultiDeviceName('draft')}`;
|
||||
|
||||
await test.step('assigns distinct clientInstanceId per browser context', async () => {
|
||||
const instanceA = await readClientInstanceId(scenario.clientA.page);
|
||||
const instanceB = await readClientInstanceId(scenario.clientB.page);
|
||||
|
||||
expect(instanceA).toBeTruthy();
|
||||
expect(instanceB).toBeTruthy();
|
||||
expect(instanceA).not.toEqual(instanceB);
|
||||
});
|
||||
|
||||
await test.step('syncs chat from device A to device B', async () => {
|
||||
await expectCrossDeviceMessage(scenario.messagesA, scenario.messagesB, messageAtoB);
|
||||
});
|
||||
|
||||
await test.step('syncs chat from device B to device A', async () => {
|
||||
await expectCrossDeviceMessage(scenario.messagesB, scenario.messagesA, messageBtoA);
|
||||
});
|
||||
|
||||
await test.step('does not show own typing indicator on the other device for the same user', async () => {
|
||||
await scenario.messagesA.typeDraftWithTypingEvents(typingDraft);
|
||||
|
||||
await expect(
|
||||
scenario.clientB.page.getByText(`${scenario.credentials.displayName} is typing`, { exact: false })
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
});
|
||||
|
||||
await test.step('shows passive in-voice UI on the second device when the first joins voice', async () => {
|
||||
await scenario.roomA.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expectActiveVoiceOnDevice(scenario.clientA.page);
|
||||
|
||||
await expectPassiveVoiceOnDevice(scenario.clientB.page, {
|
||||
displayName: scenario.credentials.displayName
|
||||
});
|
||||
await expect(
|
||||
membersSidePanel(scenario.clientB.page).getByText('In voice on another device', { exact: false })
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
await expect(
|
||||
channelsSidePanel(scenario.clientB.page).locator('.opacity-50').filter({
|
||||
hasText: scenario.credentials.displayName
|
||||
}).first()
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('shows Join takeover affordance on passive device voice channel', async () => {
|
||||
await expect(passiveVoiceChannelJoinBadge(scenario.clientB.page)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
await test.step('transfers voice ownership when the passive device takes over', async () => {
|
||||
await scenario.roomB.joinVoiceChannel(MULTI_DEVICE_VOICE_CHANNEL);
|
||||
await expectActiveVoiceOnDevice(scenario.clientB.page);
|
||||
|
||||
await expectPassiveVoiceOnDevice(scenario.clientA.page, {
|
||||
displayName: scenario.credentials.displayName
|
||||
});
|
||||
});
|
||||
|
||||
await test.step('keeps the second device logged in when the first device logs out', async () => {
|
||||
const message = `Still logged in ${uniqueMultiDeviceName('logout')}`;
|
||||
|
||||
await logoutFromMenu(scenario.clientA.page);
|
||||
|
||||
await scenario.messagesB.sendMessage(message);
|
||||
await expect(scenario.messagesB.getMessageItemByText(message)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(scenario.clientB.page).toHaveURL(/\/room\//, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,14 +48,13 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
await test.step('Alice registers and creates local chat history', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Alice sees the same saved room and message after a full restart', async () => {
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expect(client.page).not.toHaveURL(/\/login/, { timeout: 15_000 });
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
@@ -88,11 +87,11 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
await test.step('Alice creates persisted local data and verifies it survives a restart', async () => {
|
||||
await registerUser(client.page, alice);
|
||||
await createServerAndSendMessage(client.page, aliceServerName, aliceMessage);
|
||||
await createServerAndSendMessage(client.page, alice, aliceServerName, aliceMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
|
||||
await test.step('Bob starts from a blank slate in the same browser profile', async () => {
|
||||
@@ -102,11 +101,11 @@ test.describe('User session data isolation', () => {
|
||||
});
|
||||
|
||||
await test.step('Bob gets only his own saved room and history after a restart', async () => {
|
||||
await createServerAndSendMessage(client.page, bobServerName, bobMessage);
|
||||
await createServerAndSendMessage(client.page, bob, bobServerName, bobMessage);
|
||||
|
||||
await restartPersistentClient(client, testServer.port);
|
||||
await openApp(client.page);
|
||||
await expectSavedRoomAndHistory(client.page, bobServerName, bobMessage);
|
||||
await expectSavedRoomAndHistory(client.page, bob, bobServerName, bobMessage);
|
||||
await expectSavedRoomHidden(client.page, aliceServerName);
|
||||
});
|
||||
|
||||
@@ -117,7 +116,7 @@ test.describe('User session data isolation', () => {
|
||||
|
||||
await expectSavedRoomVisible(client.page, aliceServerName);
|
||||
await expectSavedRoomHidden(client.page, bobServerName);
|
||||
await expectSavedRoomAndHistory(client.page, aliceServerName, aliceMessage);
|
||||
await expectSavedRoomAndHistory(client.page, alice, aliceServerName, aliceMessage);
|
||||
});
|
||||
} finally {
|
||||
await closePersistentClient(client);
|
||||
@@ -194,32 +193,58 @@ async function logoutUser(page: Page): Promise<void> {
|
||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise<void> {
|
||||
async function createServerAndSendMessage(page: Page, user: TestUser, serverName: string, messageText: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
|
||||
await searchPage.createServer(serverName, {
|
||||
description: `User session isolation coverage for ${serverName}`
|
||||
});
|
||||
await loginIfNeeded(page, user);
|
||||
await ensureCurrentUserScope(page, user);
|
||||
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
if (await waitForLoginForm(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
await page.goto('/create-server', { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
await expect(searchPage.serverNameInput).toBeVisible({ timeout: 10_000 });
|
||||
await searchPage.serverNameInput.fill(serverName);
|
||||
await searchPage.serverDescriptionInput.fill(`User session isolation coverage for ${serverName}`);
|
||||
await searchPage.createSubmitButton.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 15_000 });
|
||||
|
||||
await messagesPage.sendMessage(messageText);
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
await expectMessagePersistedInIndexedDb(page, messageText);
|
||||
}
|
||||
|
||||
async function expectSavedRoomAndHistory(page: Page, roomName: string, messageText: string): Promise<void> {
|
||||
const railRoomButton = getRailSavedRoomButton(page, roomName);
|
||||
const messagesPage = new ChatMessagesPage(page);
|
||||
async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise<void> {
|
||||
if (await waitForVisibleText(page, messageText, 5_000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await expect(railRoomButton).toBeVisible({ timeout: 20_000 });
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
const searchRoomButton = getSearchSavedRoomButton(page, roomName);
|
||||
if (await new LoginPage(page).usernameInput.isVisible().catch(() => false)) {
|
||||
await loginUser(page, user);
|
||||
}
|
||||
|
||||
await expect(searchRoomButton).toBeVisible({ timeout: 20_000 });
|
||||
await searchRoomButton.click();
|
||||
await expectMessagePersistedInIndexedDb(page, messageText);
|
||||
|
||||
const persistedRoomId = await getPersistedRoomIdForMessage(page, messageText);
|
||||
|
||||
if (persistedRoomId) {
|
||||
await openPersistedRoomById(page, user, persistedRoomId);
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
if (await openSavedRoomFromRail(page, roomName)) {
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
return;
|
||||
}
|
||||
|
||||
await joinServerFromSearchAfterLogin(page, user, roomName);
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
await expect(messagesPage.getMessageItemByText(messageText)).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(messageText, { exact: false })).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<void> {
|
||||
@@ -232,14 +257,17 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise<
|
||||
}
|
||||
|
||||
async function expectSavedRoomVisible(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getRailSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
if (await page.getByText(roomName, { exact: false }).first()
|
||||
.isVisible()
|
||||
.catch(() => false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
await expect(getSearchSavedRoomButton(page, roomName)).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void> {
|
||||
await expect(getRailSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
|
||||
if (!page.url().includes('/servers')) {
|
||||
await page.goto('/servers', { waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
@@ -247,14 +275,227 @@ async function expectSavedRoomHidden(page: Page, roomName: string): Promise<void
|
||||
await expect(getSearchSavedRoomButton(page, roomName)).toHaveCount(0);
|
||||
}
|
||||
|
||||
function getRailSavedRoomButton(page: Page, roomName: string) {
|
||||
return page.locator(`button[title="${roomName}"]`).first();
|
||||
}
|
||||
|
||||
function getSearchSavedRoomButton(page: Page, roomName: string) {
|
||||
return page.locator('app-server-browser').getByRole('button', { name: roomName, exact: true });
|
||||
}
|
||||
|
||||
async function openSavedRoomFromRail(page: Page, roomName: string): Promise<boolean> {
|
||||
try {
|
||||
await expect(page.locator('app-servers-rail')).toBeVisible({ timeout: 10_000 });
|
||||
const clicked = await page.locator('app-servers-rail button').evaluateAll((buttons, expectedName) => {
|
||||
const expectedPrefix = expectedName.slice(0, 24);
|
||||
const button = buttons.find((candidate) => {
|
||||
const title = (candidate as HTMLButtonElement).title;
|
||||
|
||||
return title === expectedName || title.startsWith(expectedPrefix);
|
||||
}) as HTMLButtonElement | undefined;
|
||||
|
||||
button?.click();
|
||||
return !!button;
|
||||
}, roomName);
|
||||
|
||||
if (!clicked) {
|
||||
return await openSavedRoomFromDashboard(page, roomName);
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return await openSavedRoomFromDashboard(page, roomName);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSavedRoomFromDashboard(page: Page, roomName: string): Promise<boolean> {
|
||||
const roomNamePattern = new RegExp(escapeRegExp(roomName.slice(0, 24)));
|
||||
const roomButton = page.getByRole('button', { name: roomNamePattern }).first();
|
||||
|
||||
try {
|
||||
await expect(roomButton).toBeVisible({ timeout: 10_000 });
|
||||
await roomButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return await joinVisibleServerFromDashboard(page, roomNamePattern);
|
||||
}
|
||||
}
|
||||
|
||||
async function joinVisibleServerFromDashboard(page: Page, roomNamePattern: RegExp): Promise<boolean> {
|
||||
const serverRow = page.locator('div', { hasText: roomNamePattern }).filter({
|
||||
has: page.getByRole('button', { name: 'Join' })
|
||||
})
|
||||
.last();
|
||||
const joinButton = serverRow.getByRole('button', { name: 'Join' });
|
||||
|
||||
try {
|
||||
await expect(joinButton).toBeVisible({ timeout: 10_000 });
|
||||
await joinButton.click();
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function joinServerFromSearchAfterLogin(page: Page, user: TestUser, roomName: string): Promise<void> {
|
||||
const searchPage = new ServerSearchPage(page);
|
||||
|
||||
await loginIfNeeded(page, user);
|
||||
await searchPage.goto();
|
||||
|
||||
if (!await waitForServerSearch(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
await searchPage.goto();
|
||||
}
|
||||
|
||||
await expect(searchPage.searchInput).toBeVisible({ timeout: 15_000 });
|
||||
await searchPage.searchInput.fill(roomName);
|
||||
|
||||
const serverCard = page.locator('div[title]', { hasText: roomName }).first();
|
||||
|
||||
await expect(serverCard).toBeVisible({ timeout: 15_000 });
|
||||
await serverCard.dblclick();
|
||||
}
|
||||
|
||||
async function loginIfNeeded(page: Page, user: TestUser): Promise<void> {
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
if (page.url().includes('/login')) {
|
||||
await expect(loginPage.usernameInput).toBeVisible({ timeout: 15_000 });
|
||||
await loginUser(page, user);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await loginPage.usernameInput.isVisible().catch(() => false)) {
|
||||
await loginUser(page, user);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCurrentUserScope(page: Page, user: TestUser): Promise<void> {
|
||||
if (await hasCurrentUserScope(page)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loginUser(page, user);
|
||||
await expect.poll(() => hasCurrentUserScope(page), { timeout: 10_000 }).toBe(true);
|
||||
}
|
||||
|
||||
async function hasCurrentUserScope(page: Page): Promise<boolean> {
|
||||
return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim());
|
||||
}
|
||||
|
||||
async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise<void> {
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
if (await waitForLoginForm(page, 5_000)) {
|
||||
await loginUser(page, user);
|
||||
continue;
|
||||
}
|
||||
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
|
||||
if (!await waitForLoginForm(page, 2_000)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loginUser(page, user);
|
||||
}
|
||||
|
||||
await page.goto(`/room/${roomId}`, { waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/room\//, { timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function waitForLoginForm(page: Page, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForServerSearch(page: Page, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVisibleText(page: Page, text: string, timeout: number): Promise<boolean> {
|
||||
try {
|
||||
await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise<void> {
|
||||
await expect.poll(
|
||||
() => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId),
|
||||
{ timeout: 10_000 }
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise<string | null> {
|
||||
return page.evaluate(async (expectedContent) => {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId')?.trim();
|
||||
const preferredDatabaseName = `metoyou::${encodeURIComponent(currentUserId || 'anonymous')}`;
|
||||
const discoveredDatabaseNames = typeof indexedDB.databases === 'function'
|
||||
? (await indexedDB.databases())
|
||||
.map((database) => database.name)
|
||||
.filter((name): name is string => !!name && (name === 'metoyou' || name.startsWith('metoyou::')))
|
||||
: null;
|
||||
const databaseNames = discoveredDatabaseNames ?? [preferredDatabaseName];
|
||||
const remainingDatabaseNames = databaseNames.filter((name) => name !== preferredDatabaseName);
|
||||
const orderedDatabaseNames = databaseNames.includes(preferredDatabaseName)
|
||||
? [preferredDatabaseName].concat(remainingDatabaseNames)
|
||||
: remainingDatabaseNames;
|
||||
|
||||
for (const databaseName of orderedDatabaseNames) {
|
||||
const database = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(databaseName);
|
||||
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
|
||||
try {
|
||||
if (!database.objectStoreNames.contains('messages')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const transaction = database.transaction('messages', 'readonly');
|
||||
const request = transaction.objectStore('messages').getAll();
|
||||
const roomId = await new Promise<string | null>((resolve, reject) => {
|
||||
request.onerror = () => reject(request.error);
|
||||
|
||||
request.onsuccess = () => {
|
||||
const match = ((request.result as { content?: string; roomId?: string }[]) ?? [])
|
||||
.find((message) => message.content === expectedContent);
|
||||
|
||||
resolve(match?.roomId ?? null);
|
||||
};
|
||||
});
|
||||
|
||||
if (roomId) {
|
||||
return roomId;
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, messageText);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
async function retryTransientNavigation<T>(navigate: () => Promise<T>, attempts = 4): Promise<T> {
|
||||
let lastError: unknown;
|
||||
|
||||
|
||||
@@ -150,6 +150,8 @@ test.describe('Mixed signal-config voice', () => {
|
||||
}
|
||||
});
|
||||
|
||||
let secondaryRoomId = '';
|
||||
|
||||
// ── Create rooms ────────────────────────────────────────────
|
||||
await test.step('Create voice room on primary and chat room on secondary', async () => {
|
||||
// Use a "both" user (client 0) to create both rooms
|
||||
@@ -198,7 +200,6 @@ test.describe('Mixed signal-config voice', () => {
|
||||
// Group D (secondary-only) needs invite to primary room.
|
||||
let primaryRoomInviteUrl: string;
|
||||
let secondaryRoomInviteUrl: string;
|
||||
let secondaryRoomId = '';
|
||||
|
||||
await test.step('Create invite links for cross-signal rooms', async () => {
|
||||
// Navigate to voice room to get its ID
|
||||
|
||||
14
electron/api/auth-store.spec.ts
Normal file
14
electron/api/auth-store.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { getLocalApiTokenTtlMs } from './auth-store';
|
||||
|
||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('auth-store', () => {
|
||||
it('defaults local API tokens to a very long lifetime', () => {
|
||||
expect(getLocalApiTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,13 @@ export interface IssuedToken {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
const tokens = new Map<string, IssuedToken>();
|
||||
|
||||
export function getLocalApiTokenTtlMs(): number {
|
||||
return DEFAULT_TOKEN_TTL_MS;
|
||||
}
|
||||
|
||||
export function issueToken(params: {
|
||||
userId: string;
|
||||
username: string;
|
||||
@@ -24,7 +28,7 @@ export function issueToken(params: {
|
||||
const issued: IssuedToken = {
|
||||
token,
|
||||
issuedAt,
|
||||
expiresAt: issuedAt + TOKEN_TTL_MS,
|
||||
expiresAt: issuedAt + getLocalApiTokenTtlMs(),
|
||||
userId: params.userId,
|
||||
username: params.username,
|
||||
displayName: params.displayName,
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
setupWindowControlHandlers
|
||||
} from '../ipc';
|
||||
import { startIdleMonitor, stopIdleMonitor } from '../idle/idle-monitor';
|
||||
import {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
shutdownPerfDiagnostics,
|
||||
startPerfDiagnostics
|
||||
} from '../diagnostics';
|
||||
|
||||
function startLocalApiAfterWindowReady(): void {
|
||||
setImmediate(() => {
|
||||
@@ -32,6 +38,8 @@ function startLocalApiAfterWindowReady(): void {
|
||||
}
|
||||
|
||||
export function registerAppLifecycle(): void {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const dockIconPath = getDockIconPath();
|
||||
|
||||
@@ -45,7 +53,15 @@ export function registerAppLifecycle(): void {
|
||||
await migrateLegacyDesktopBranding();
|
||||
await synchronizeAutoStartSetting();
|
||||
initializeDesktopUpdater();
|
||||
startPerfDiagnostics();
|
||||
await createWindow();
|
||||
|
||||
const mainWindow = getMainWindow();
|
||||
|
||||
if (mainWindow) {
|
||||
attachRendererDiagnosticsHooks(mainWindow);
|
||||
}
|
||||
|
||||
startLocalApiAfterWindowReady();
|
||||
startIdleMonitor();
|
||||
|
||||
@@ -67,6 +83,7 @@ export function registerAppLifecycle(): void {
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
prepareWindowForAppQuit();
|
||||
await shutdownPerfDiagnostics();
|
||||
|
||||
if (getDataSource()?.isInitialized) {
|
||||
event.preventDefault();
|
||||
|
||||
27
electron/diagnostics/diagnostics.flags.spec.ts
Normal file
27
electron/diagnostics/diagnostics.flags.spec.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
|
||||
describe('isPerfDiagEnabled', () => {
|
||||
it('returns false when the flag is unset', () => {
|
||||
expect(isPerfDiagEnabled({}, false)).toBe(false);
|
||||
expect(isPerfDiagEnabled({}, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true in development when METOYOU_PERF_DIAG is truthy', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, false)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'true' }, false)).toBe(true);
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: 'on' }, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false in packaged builds unless force is set', () => {
|
||||
expect(isPerfDiagEnabled({ METOYOU_PERF_DIAG: '1' }, true)).toBe(false);
|
||||
expect(isPerfDiagEnabled({
|
||||
METOYOU_PERF_DIAG: '1',
|
||||
METOYOU_PERF_DIAG_FORCE: '1'
|
||||
}, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
29
electron/diagnostics/diagnostics.flags.ts
Normal file
29
electron/diagnostics/diagnostics.flags.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export const PERF_DIAG_ENV = 'METOYOU_PERF_DIAG';
|
||||
export const PERF_DIAG_FORCE_ENV = 'METOYOU_PERF_DIAG_FORCE';
|
||||
|
||||
const TRUTHY = new Set([
|
||||
'1',
|
||||
'true',
|
||||
'yes',
|
||||
'on'
|
||||
]);
|
||||
|
||||
function isTruthyFlag(value: string | undefined): boolean {
|
||||
return TRUTHY.has(String(value ?? '').trim()
|
||||
.toLowerCase());
|
||||
}
|
||||
|
||||
export function isPerfDiagEnabled(
|
||||
env: NodeJS.ProcessEnv,
|
||||
isPackaged: boolean
|
||||
): boolean {
|
||||
if (!isTruthyFlag(env[PERF_DIAG_ENV])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPackaged && !isTruthyFlag(env[PERF_DIAG_FORCE_ENV])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
214
electron/diagnostics/diagnostics.lifecycle.ts
Normal file
214
electron/diagnostics/diagnostics.lifecycle.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
ipcMain
|
||||
} from 'electron';
|
||||
import { collectAppMetricsSnapshot } from '../app-metrics';
|
||||
import { sumWorkingSetKb } from './process-metrics.rules';
|
||||
import { isPerfDiagEnabled } from './diagnostics.flags';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import { PerfDiagWriter } from './diagnostics.writer';
|
||||
|
||||
const PROCESS_POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
let activeWriter: PerfDiagWriter | null = null;
|
||||
let processPollTimer: NodeJS.Timeout | null = null;
|
||||
let diagnosticsEnabled = false;
|
||||
let ipcRegistered = false;
|
||||
|
||||
export function isPerfDiagActive(): boolean {
|
||||
return diagnosticsEnabled;
|
||||
}
|
||||
|
||||
export function ensurePerfDiagIpcRegistered(): void {
|
||||
if (ipcRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
ipcRegistered = true;
|
||||
|
||||
ipcMain.handle('perf-diag-is-enabled', () => diagnosticsEnabled);
|
||||
|
||||
ipcMain.handle('perf-diag-report', (_event, entry: PerfDiagEntry) => {
|
||||
const writer = activeWriter;
|
||||
|
||||
if (!diagnosticsEnabled || !writer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
writer.append(normalizeRendererEntry(entry));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function getActivePerfDiagWriter(): PerfDiagWriter | null {
|
||||
return activeWriter;
|
||||
}
|
||||
|
||||
export function startPerfDiagnostics(): PerfDiagWriter | null {
|
||||
ensurePerfDiagIpcRegistered();
|
||||
diagnosticsEnabled = isPerfDiagEnabled(process.env, app.isPackaged);
|
||||
|
||||
if (!diagnosticsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionId = `${Date.now().toString(36)}-${process.pid}`;
|
||||
const writer = new PerfDiagWriter({
|
||||
userDataPath: app.getPath('userData'),
|
||||
sessionId
|
||||
});
|
||||
|
||||
activeWriter = writer;
|
||||
registerProcessCrashHandlers(writer);
|
||||
startProcessMetricsPolling(writer);
|
||||
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: {
|
||||
event: 'started',
|
||||
sessionId,
|
||||
filePath: writer.snapshotFilePath
|
||||
}
|
||||
});
|
||||
|
||||
return writer;
|
||||
}
|
||||
|
||||
export function attachRendererDiagnosticsHooks(window: BrowserWindow): void {
|
||||
const writer = activeWriter;
|
||||
|
||||
if (!writer) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.webContents.on('render-process-gone', (_event, details) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode
|
||||
}
|
||||
});
|
||||
|
||||
void writer.flushSnapshot('render-process-gone');
|
||||
});
|
||||
|
||||
window.webContents.on('unresponsive', () => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'unresponsive',
|
||||
payload: {}
|
||||
});
|
||||
});
|
||||
|
||||
window.webContents.on('responsive', () => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: { event: 'renderer-responsive' }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function shutdownPerfDiagnostics(): Promise<void> {
|
||||
if (!activeWriter) {
|
||||
return;
|
||||
}
|
||||
|
||||
await activeWriter.flushSnapshot('shutdown');
|
||||
|
||||
if (processPollTimer) {
|
||||
clearInterval(processPollTimer);
|
||||
processPollTimer = null;
|
||||
}
|
||||
|
||||
activeWriter = null;
|
||||
diagnosticsEnabled = false;
|
||||
}
|
||||
|
||||
function registerProcessCrashHandlers(writer: PerfDiagWriter): void {
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
type: details.type,
|
||||
reason: details.reason,
|
||||
exitCode: details.exitCode,
|
||||
serviceName: details.serviceName ?? null,
|
||||
name: details.name ?? null
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
scope: 'main-uncaughtException',
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
|
||||
void writer.flushSnapshot('uncaughtException');
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
writer.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'crash',
|
||||
payload: {
|
||||
scope: 'main-unhandledRejection',
|
||||
reason: String(reason)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function startProcessMetricsPolling(writer: PerfDiagWriter): void {
|
||||
const sample = (): void => {
|
||||
try {
|
||||
const metrics = collectAppMetricsSnapshot();
|
||||
const totalKb = sumWorkingSetKb(metrics.processes);
|
||||
|
||||
writer.append({
|
||||
collectedAt: metrics.collectedAt,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: {
|
||||
totalWorkingSetKb: totalKb,
|
||||
processes: metrics.processes
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Collector failures must never affect the app.
|
||||
}
|
||||
};
|
||||
|
||||
sample();
|
||||
processPollTimer = setInterval(sample, PROCESS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function normalizeRendererEntry(entry: PerfDiagEntry): PerfDiagEntry {
|
||||
return {
|
||||
collectedAt: Number(entry.collectedAt) || Date.now(),
|
||||
source: 'renderer',
|
||||
type: entry.type,
|
||||
payload: entry.payload ?? {}
|
||||
};
|
||||
}
|
||||
17
electron/diagnostics/diagnostics.models.ts
Normal file
17
electron/diagnostics/diagnostics.models.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type PerfDiagSource = 'main' | 'renderer';
|
||||
|
||||
export type PerfDiagEntryType =
|
||||
| 'session'
|
||||
| 'process'
|
||||
| 'store'
|
||||
| 'components'
|
||||
| 'heap'
|
||||
| 'crash'
|
||||
| 'unresponsive';
|
||||
|
||||
export interface PerfDiagEntry {
|
||||
collectedAt: number;
|
||||
source: PerfDiagSource;
|
||||
type: PerfDiagEntryType;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
53
electron/diagnostics/diagnostics.rules.spec.ts
Normal file
53
electron/diagnostics/diagnostics.rules.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import {
|
||||
formatPerfDiagLine,
|
||||
pushRingBuffer,
|
||||
resolveDiagnosticsFilePath
|
||||
} from './diagnostics.rules';
|
||||
|
||||
describe('pushRingBuffer', () => {
|
||||
it('appends items until capacity is reached', () => {
|
||||
expect(pushRingBuffer([1, 2], 3, 4)).toEqual([
|
||||
1,
|
||||
2,
|
||||
3
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the oldest items when capacity is exceeded', () => {
|
||||
expect(pushRingBuffer([
|
||||
1,
|
||||
2,
|
||||
3
|
||||
], 4, 3)).toEqual([
|
||||
2,
|
||||
3,
|
||||
4
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatPerfDiagLine', () => {
|
||||
it('serializes one JSON object per line', () => {
|
||||
const line = formatPerfDiagLine({
|
||||
collectedAt: 1_700_000_000_000,
|
||||
source: 'main',
|
||||
type: 'process',
|
||||
payload: { browserKb: 128 }
|
||||
});
|
||||
|
||||
expect(line).toBe('{"collectedAt":1700000000000,"source":"main","type":"process","payload":{"browserKb":128}}');
|
||||
expect(line.endsWith('\n')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDiagnosticsFilePath', () => {
|
||||
it('places session files under diagnostics/', () => {
|
||||
expect(resolveDiagnosticsFilePath('/tmp/user-data', 'session-1'))
|
||||
.toBe('/tmp/user-data/diagnostics/perf-session-1.jsonl');
|
||||
});
|
||||
});
|
||||
24
electron/diagnostics/diagnostics.rules.ts
Normal file
24
electron/diagnostics/diagnostics.rules.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as path from 'path';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
|
||||
export function pushRingBuffer<T>(items: readonly T[], item: T, capacity: number): T[] {
|
||||
const next = [...items, item];
|
||||
|
||||
if (next.length <= capacity) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return next.slice(next.length - capacity);
|
||||
}
|
||||
|
||||
export function formatPerfDiagLine(entry: PerfDiagEntry): string {
|
||||
return JSON.stringify(entry);
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsFilePath(userDataPath: string, sessionId: string): string {
|
||||
return path.join(userDataPath, 'diagnostics', `perf-${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
export function resolveDiagnosticsDirectory(userDataPath: string): string {
|
||||
return path.join(userDataPath, 'diagnostics');
|
||||
}
|
||||
108
electron/diagnostics/diagnostics.writer.ts
Normal file
108
electron/diagnostics/diagnostics.writer.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as fsp from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import type { PerfDiagEntry } from './diagnostics.models';
|
||||
import {
|
||||
formatPerfDiagLine,
|
||||
pushRingBuffer,
|
||||
resolveDiagnosticsFilePath
|
||||
} from './diagnostics.rules';
|
||||
|
||||
const DEFAULT_RING_CAPACITY = 120;
|
||||
const FLUSH_DEBOUNCE_MS = 250;
|
||||
|
||||
export interface PerfDiagWriterOptions {
|
||||
userDataPath: string;
|
||||
sessionId: string;
|
||||
ringCapacity?: number;
|
||||
}
|
||||
|
||||
export class PerfDiagWriter {
|
||||
private readonly filePath: string;
|
||||
private readonly ringCapacity: number;
|
||||
private readonly pendingLines: string[] = [];
|
||||
private ring: PerfDiagEntry[] = [];
|
||||
private flushTimer: NodeJS.Timeout | null = null;
|
||||
private flushInFlight: Promise<void> | null = null;
|
||||
private disabled = false;
|
||||
|
||||
constructor(options: PerfDiagWriterOptions) {
|
||||
this.filePath = resolveDiagnosticsFilePath(options.userDataPath, options.sessionId);
|
||||
this.ringCapacity = options.ringCapacity ?? DEFAULT_RING_CAPACITY;
|
||||
}
|
||||
|
||||
get snapshotFilePath(): string {
|
||||
return this.filePath;
|
||||
}
|
||||
|
||||
get bufferedEntries(): readonly PerfDiagEntry[] {
|
||||
return this.ring;
|
||||
}
|
||||
|
||||
append(entry: PerfDiagEntry): void {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.ring = pushRingBuffer(this.ring, entry, this.ringCapacity);
|
||||
this.pendingLines.push(`${formatPerfDiagLine(entry)}\n`);
|
||||
this.scheduleFlush();
|
||||
} catch {
|
||||
this.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.disabled || this.pendingLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.flushInFlight) {
|
||||
await this.flushInFlight;
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.pendingLines.splice(0, this.pendingLines.length);
|
||||
|
||||
this.flushInFlight = this.writeLines(lines)
|
||||
.catch(() => {
|
||||
this.disabled = true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.flushInFlight = null;
|
||||
});
|
||||
|
||||
await this.flushInFlight;
|
||||
}
|
||||
|
||||
async flushSnapshot(label: string): Promise<void> {
|
||||
this.append({
|
||||
collectedAt: Date.now(),
|
||||
source: 'main',
|
||||
type: 'session',
|
||||
payload: {
|
||||
event: label,
|
||||
filePath: this.filePath,
|
||||
entries: this.ring
|
||||
}
|
||||
});
|
||||
|
||||
await this.flush();
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
void this.flush();
|
||||
}, FLUSH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async writeLines(lines: string[]): Promise<void> {
|
||||
await fsp.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fsp.appendFile(this.filePath, lines.join(''), 'utf8');
|
||||
}
|
||||
}
|
||||
11
electron/diagnostics/index.ts
Normal file
11
electron/diagnostics/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { isPerfDiagEnabled, PERF_DIAG_ENV, PERF_DIAG_FORCE_ENV } from './diagnostics.flags';
|
||||
export {
|
||||
attachRendererDiagnosticsHooks,
|
||||
ensurePerfDiagIpcRegistered,
|
||||
getActivePerfDiagWriter,
|
||||
isPerfDiagActive,
|
||||
shutdownPerfDiagnostics,
|
||||
startPerfDiagnostics
|
||||
} from './diagnostics.lifecycle';
|
||||
export type { PerfDiagEntry, PerfDiagEntryType, PerfDiagSource } from './diagnostics.models';
|
||||
export { PerfDiagWriter } from './diagnostics.writer';
|
||||
19
electron/diagnostics/process-metrics.rules.ts
Normal file
19
electron/diagnostics/process-metrics.rules.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface ProcessWorkingSetSnapshot {
|
||||
workingSetKb: number | null;
|
||||
}
|
||||
|
||||
export function sumWorkingSetKb(processes: readonly ProcessWorkingSetSnapshot[]): number | null {
|
||||
let total = 0;
|
||||
let hasAny = false;
|
||||
|
||||
for (const process of processes) {
|
||||
if (process.workingSetKb == null || process.workingSetKb < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
total += process.workingSetKb;
|
||||
hasAny = true;
|
||||
}
|
||||
|
||||
return hasAny ? total : null;
|
||||
}
|
||||
@@ -252,6 +252,13 @@ export interface ElectronAPI {
|
||||
workingSetKb: number | null;
|
||||
}[];
|
||||
}>;
|
||||
isPerfDiagEnabled: () => Promise<boolean>;
|
||||
reportPerfDiagSample: (entry: {
|
||||
collectedAt: number;
|
||||
source: 'main' | 'renderer';
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) => Promise<boolean>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
@@ -388,6 +395,8 @@ const electronAPI: ElectronAPI = {
|
||||
};
|
||||
},
|
||||
getAppMetrics: () => ipcRenderer.invoke('get-app-metrics'),
|
||||
isPerfDiagEnabled: () => ipcRenderer.invoke('perf-diag-is-enabled'),
|
||||
reportPerfDiagSample: (entry) => ipcRenderer.invoke('perf-diag-report', entry),
|
||||
getAppDataPath: () => ipcRenderer.invoke('get-app-data-path'),
|
||||
openCurrentDataFolder: () => ipcRenderer.invoke('open-current-data-folder'),
|
||||
exportUserData: () => ipcRenderer.invoke('export-user-data'),
|
||||
|
||||
10
package.json
10
package.json
@@ -52,10 +52,12 @@
|
||||
"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",
|
||||
"i18n:sync": "node tools/sync-app-i18n-catalog.mjs",
|
||||
"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",
|
||||
"test:e2e": "node e2e/run-playwright.mjs test",
|
||||
"test:e2e:ui": "node e2e/run-playwright.mjs test --ui",
|
||||
"test:e2e:debug": "node e2e/run-playwright.mjs test --debug",
|
||||
"test:e2e:report": "node e2e/run-playwright.mjs show-report ../test-results/html-report",
|
||||
"perf:diag:view": "node tools/perf-diag-viewer.js",
|
||||
"perf:diag:tail": "node tools/perf-diag-viewer.js --tail",
|
||||
"cap:sync": "cd toju-app && npx cap sync",
|
||||
"cap:open:android": "node tools/cap-open-android.js",
|
||||
"cap:open:ios": "cd toju-app && npx cap open ios",
|
||||
|
||||
@@ -21,7 +21,9 @@ Owns the shared, internet-reachable runtime: HTTP routes for server directory /
|
||||
| **Server directory** | The catalog of joinable chat servers, exposed by `src/routes/servers.ts` plus invite and join-request routes. | "guild list" |
|
||||
| **SSRF guard** | The outbound-fetch policy enforced by `src/routes/ssrf-guard.ts` — gates link-metadata and proxy routes that fetch user-supplied URLs. | "proxy filter" |
|
||||
| **Variables file** | `data/variables.json` — runtime config (klipy key, server host/protocol, release manifest URL, link-preview toggle) normalized on startup. | "config", ".env" (those are separate) |
|
||||
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. | "API key", "JWT" |
|
||||
| **Session token** | Opaque bearer token issued on login/register, stored in `session_tokens`, required on mutating REST routes and WebSocket `identify`. Multiple valid tokens may exist per user (multi-device login). | "API key", "JWT" |
|
||||
| **Client instance id** | Opaque per-install string on WebSocket `identify` and `voice_state`; used to distinguish connections for the same `oderId` and to track which connection owns active voice. | "device id" |
|
||||
| **Voice-active connection** | WebSocket connection for a user with `voiceActive=true` after a connected `voice_state`; preferred target for RTC relay. | "voice owner socket" |
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
39
server/src/services/session-auth.service.spec.ts
Normal file
39
server/src/services/session-auth.service.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { getSessionTokenTtlMs } from './session-auth.service';
|
||||
|
||||
const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
describe('session-auth.service', () => {
|
||||
const originalTtl = process.env.SESSION_TOKEN_TTL_MS;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTtl === undefined) {
|
||||
delete process.env.SESSION_TOKEN_TTL_MS;
|
||||
} else {
|
||||
process.env.SESSION_TOKEN_TTL_MS = originalTtl;
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults session tokens to a very long lifetime', () => {
|
||||
delete process.env.SESSION_TOKEN_TTL_MS;
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
|
||||
it('honors SESSION_TOKEN_TTL_MS when configured', () => {
|
||||
process.env.SESSION_TOKEN_TTL_MS = '3600000';
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it('falls back to the default when SESSION_TOKEN_TTL_MS is invalid', () => {
|
||||
process.env.SESSION_TOKEN_TTL_MS = 'not-a-number';
|
||||
|
||||
expect(getSessionTokenTtlMs()).toBe(TEN_YEARS_MS);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { SessionTokenEntity } from '../entities/SessionTokenEntity';
|
||||
import { getUserById } from '../cqrs';
|
||||
import type { AuthUserPayload } from '../cqrs/types';
|
||||
|
||||
const DEFAULT_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export interface IssuedSessionToken {
|
||||
token: string;
|
||||
|
||||
102
server/src/websocket/broadcast.spec.ts
Normal file
102
server/src/websocket/broadcast.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { broadcastToServer, findUserByOderId, findVoiceActiveConnection } from './broadcast';
|
||||
|
||||
function createMockWs(): WebSocket & { sentMessages: string[] } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => {},
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[] };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
oderId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const user: ConnectedUser = {
|
||||
oderId,
|
||||
ws: createMockWs(),
|
||||
authenticated: true,
|
||||
serverIds: new Set(['server-1']),
|
||||
displayName: 'Test User',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
describe('broadcastToServer', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('delivers chat_message to every connection in the server except the sender connection', () => {
|
||||
createConnectedUser('conn-a1', 'user-1');
|
||||
const connA2 = createConnectedUser('conn-a2', 'user-1');
|
||||
const connB = createConnectedUser('conn-b', 'user-2');
|
||||
|
||||
broadcastToServer('server-1', { type: 'chat_message', text: 'hello' }, {
|
||||
excludeConnectionId: 'conn-a1'
|
||||
});
|
||||
|
||||
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
expect(connectedUsers.get('conn-a1')?.ws).toBeDefined();
|
||||
expect((connectedUsers.get('conn-a1')!.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes every connection for an identity when excludeIdentityOderId is set', () => {
|
||||
const connA1 = createConnectedUser('conn-a1', 'user-1');
|
||||
const connA2 = createConnectedUser('conn-a2', 'user-1');
|
||||
const connB = createConnectedUser('conn-b', 'user-2');
|
||||
|
||||
broadcastToServer('server-1', { type: 'user_left', oderId: 'user-1' }, {
|
||||
excludeIdentityOderId: 'user-1'
|
||||
});
|
||||
|
||||
expect((connA1.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
expect((connA2.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(0);
|
||||
expect((connB.ws as WebSocket & { sentMessages: string[] }).sentMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findVoiceActiveConnection', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
});
|
||||
|
||||
it('returns the connection marked voiceActive for the user', () => {
|
||||
createConnectedUser('conn-passive', 'user-1', { voiceActive: false });
|
||||
const active = createConnectedUser('conn-active', 'user-1', { voiceActive: true });
|
||||
|
||||
expect(findVoiceActiveConnection('user-1')).toBe(active);
|
||||
});
|
||||
|
||||
it('returns undefined when no voiceActive connection exists', () => {
|
||||
createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
expect(findVoiceActiveConnection('user-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('findUserByOderId falls back to any open connection when no voiceActive connection exists', () => {
|
||||
const fallback = createConnectedUser('conn-1', 'user-1');
|
||||
|
||||
expect(findUserByOderId('user-1')).toBe(fallback);
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,35 @@ interface WsMessage {
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function broadcastToServer(serverId: string, message: WsMessage, excludeOderId?: string): void {
|
||||
console.log(`Broadcasting to server ${serverId}, excluding ${excludeOderId}:`, message.type);
|
||||
export interface BroadcastOptions {
|
||||
/** Skip only the sending WebSocket connection. */
|
||||
excludeConnectionId?: string;
|
||||
/** Skip every open connection for this identity (presence events). */
|
||||
excludeIdentityOderId?: string;
|
||||
}
|
||||
|
||||
// Deduplicate by oderId so users with multiple connections (e.g. from
|
||||
// different signal URLs routing to the same server) receive the
|
||||
// broadcast only once.
|
||||
const sentToOderIds = new Set<string>();
|
||||
export function broadcastToServer(serverId: string, message: WsMessage, options?: BroadcastOptions): void {
|
||||
console.log(
|
||||
`Broadcasting to server ${serverId}, excluding connection ${options?.excludeConnectionId ?? 'none'} ` +
|
||||
`identity ${options?.excludeIdentityOderId ?? 'none'}:`,
|
||||
message.type
|
||||
);
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.serverIds.has(serverId) && user.oderId !== excludeOderId && !sentToOderIds.has(user.oderId)) {
|
||||
sentToOderIds.add(user.oderId);
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId})`);
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (
|
||||
!user.serverIds.has(serverId)
|
||||
|| connectionId === options?.excludeConnectionId
|
||||
|| (options?.excludeIdentityOderId && user.oderId === options.excludeIdentityOderId)
|
||||
|| user.ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(` -> Sending to ${user.displayName} (${user.oderId}) via ${connectionId}`);
|
||||
user.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to broadcast ${message.type} to ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -77,7 +93,45 @@ export function notifyUser(oderId: string, message: WsMessage): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyOtherConnectionsForOderId(
|
||||
oderId: string,
|
||||
message: WsMessage,
|
||||
excludeConnectionId?: string
|
||||
): void {
|
||||
connectedUsers.forEach((user, connectionId) => {
|
||||
if (
|
||||
connectionId === excludeConnectionId
|
||||
|| user.oderId !== oderId
|
||||
|| user.ws.readyState !== WebSocket.OPEN
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
user.ws.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.warn(`Failed to notify ${user.displayName ?? 'Unknown'} (${user.oderId})`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function findUserByOderId(oderId: string) {
|
||||
return findVoiceActiveConnection(oderId) ?? findAnyConnectionForOderId(oderId);
|
||||
}
|
||||
|
||||
export function findVoiceActiveConnection(oderId: string): ConnectedUser | undefined {
|
||||
let voiceActiveMatch: ConnectedUser | undefined;
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
if (user.oderId === oderId && user.voiceActive && user.ws.readyState === WebSocket.OPEN) {
|
||||
voiceActiveMatch = user;
|
||||
}
|
||||
});
|
||||
|
||||
return voiceActiveMatch;
|
||||
}
|
||||
|
||||
export function findAnyConnectionForOderId(oderId: string): ConnectedUser | undefined {
|
||||
let match: ConnectedUser | undefined;
|
||||
|
||||
connectedUsers.forEach((user) => {
|
||||
|
||||
219
server/src/websocket/handler-multi-client.spec.ts
Normal file
219
server/src/websocket/handler-multi-client.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { connectedUsers } from './state';
|
||||
import { ConnectedUser } from './types';
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
|
||||
vi.mock('../services/server-access.service', () => ({
|
||||
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const })),
|
||||
findServerMembership: vi.fn(async () => ({ id: 'membership-1' })),
|
||||
usersShareServerMembership: vi.fn(async () => true)
|
||||
}));
|
||||
|
||||
vi.mock('../services/session-auth.service', () => ({
|
||||
consumeSessionToken: vi.fn(async (token: string) => {
|
||||
if (token !== 'test-token') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'alice',
|
||||
displayName: 'Alice',
|
||||
passwordHash: 'hash',
|
||||
createdAt: Date.now()
|
||||
},
|
||||
issuedAt: Date.now(),
|
||||
expiresAt: Date.now() + 60_000
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('../services/plugin-support.service', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../services/plugin-support.service')>();
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getPluginRequirementsSnapshot: vi.fn(async () => ({
|
||||
requirements: [],
|
||||
eventDefinitions: []
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
function createMockWs(): WebSocket & { sentMessages: string[]; closeCalled: boolean } {
|
||||
const sent: string[] = [];
|
||||
const ws = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => { sent.push(data); },
|
||||
close: () => { ws.closeCalled = true; },
|
||||
terminate: () => { ws.closeCalled = true; },
|
||||
closeCalled: false,
|
||||
sentMessages: sent
|
||||
} as unknown as WebSocket & { sentMessages: string[]; closeCalled: boolean };
|
||||
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const ws = createMockWs();
|
||||
const user: ConnectedUser = {
|
||||
oderId: connectionId,
|
||||
ws,
|
||||
authenticated: false,
|
||||
serverIds: new Set(),
|
||||
displayName: 'Alice',
|
||||
lastPong: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function getSentMessages(user: ConnectedUser): string[] {
|
||||
return (user.ws as WebSocket & { sentMessages: string[] }).sentMessages;
|
||||
}
|
||||
|
||||
describe('server websocket handler - multi-client sessions', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('relays voice_state to other connections for the same user', async () => {
|
||||
const sender = createConnectedUser('conn-a1', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
const passive = createConnectedUser('conn-a2', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
|
||||
getSentMessages(passive).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-a1', {
|
||||
type: 'voice_state',
|
||||
serverId: 'server-1',
|
||||
voiceState: {
|
||||
isConnected: true,
|
||||
roomId: 'voice-1',
|
||||
serverId: 'server-1',
|
||||
clientInstanceId: 'device-a'
|
||||
}
|
||||
});
|
||||
|
||||
const messages = getSentMessages(passive).map((raw) => JSON.parse(raw) as { type: string });
|
||||
const voiceState = messages.find((message) => message.type === 'voice_state');
|
||||
|
||||
expect(voiceState).toBeDefined();
|
||||
expect(connectedUsers.get('conn-a1')?.voiceActive).toBe(true);
|
||||
expect(connectedUsers.get('conn-a2')?.voiceActive).toBeFalsy();
|
||||
});
|
||||
|
||||
it('forwards RTC offers to the voice-active connection for the target user', async () => {
|
||||
const sender = createConnectedUser('conn-sender', {
|
||||
authenticated: true,
|
||||
oderId: 'user-2',
|
||||
serverIds: new Set(['server-1'])
|
||||
});
|
||||
createConnectedUser('conn-passive', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-passive'
|
||||
});
|
||||
const active = createConnectedUser('conn-active', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
voiceActive: true,
|
||||
clientInstanceId: 'device-active'
|
||||
});
|
||||
|
||||
getSentMessages(active).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-sender', {
|
||||
type: 'offer',
|
||||
targetUserId: 'user-1',
|
||||
serverId: 'server-1',
|
||||
payload: { sdp: { type: 'offer', sdp: 'v=0' } }
|
||||
});
|
||||
|
||||
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string });
|
||||
|
||||
expect(messages.some((message) => message.type === 'offer')).toBe(true);
|
||||
});
|
||||
|
||||
it('relays voice_client_takeover to other connections for the same user', async () => {
|
||||
createConnectedUser('conn-requester', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
const active = createConnectedUser('conn-active', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
serverIds: new Set(['server-1']),
|
||||
voiceActive: true,
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
getSentMessages(active).length = 0;
|
||||
|
||||
await handleWebSocketMessage('conn-requester', {
|
||||
type: 'voice_client_takeover',
|
||||
clientInstanceId: 'device-b'
|
||||
});
|
||||
|
||||
const messages = getSentMessages(active).map((raw) => JSON.parse(raw) as { type: string; clientInstanceId?: string });
|
||||
const takeover = messages.find((message) => message.type === 'voice_client_takeover');
|
||||
|
||||
expect(takeover?.clientInstanceId).toBe('device-b');
|
||||
});
|
||||
|
||||
it('evicts a stale connection with the same identity scope and client instance', async () => {
|
||||
const stale = createConnectedUser('conn-stale', {
|
||||
authenticated: true,
|
||||
oderId: 'user-1',
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
createConnectedUser('conn-new', {
|
||||
authenticated: false,
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
await handleWebSocketMessage('conn-new', {
|
||||
type: 'identify',
|
||||
token: 'test-token',
|
||||
oderId: 'user-1',
|
||||
displayName: 'Alice',
|
||||
connectionScope: 'ws://localhost:3001',
|
||||
clientInstanceId: 'device-a'
|
||||
});
|
||||
|
||||
expect(connectedUsers.has('conn-stale')).toBe(false);
|
||||
expect((stale.ws as WebSocket & { closeCalled: boolean }).closeCalled).toBe(true);
|
||||
expect(connectedUsers.get('conn-new')?.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
findUserByOderId,
|
||||
getServerIdsForOderId,
|
||||
getUniqueUsersInServer,
|
||||
isOderIdConnectedToServer
|
||||
isOderIdConnectedToServer,
|
||||
notifyOtherConnectionsForOderId
|
||||
} from './broadcast';
|
||||
import {
|
||||
authorizeWebSocketJoin,
|
||||
@@ -72,6 +73,74 @@ function buildPresenceUserPayload(user: ConnectedUser): {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeClientInstanceId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function readVoiceConnected(message: WsMessage): boolean {
|
||||
const voiceState = message['voiceState'];
|
||||
|
||||
if (!voiceState || typeof voiceState !== 'object') {
|
||||
return message['isConnected'] === true;
|
||||
}
|
||||
|
||||
return (voiceState as { isConnected?: boolean }).isConnected === true;
|
||||
}
|
||||
|
||||
function evictStaleClientInstanceConnections(
|
||||
oderId: string,
|
||||
connectionScope: string | undefined,
|
||||
clientInstanceId: string | undefined,
|
||||
keepConnectionId: string
|
||||
): void {
|
||||
if (!clientInstanceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
connectedUsers.forEach((candidate, connectionId) => {
|
||||
if (
|
||||
connectionId === keepConnectionId
|
||||
|| candidate.oderId !== oderId
|
||||
|| candidate.connectionScope !== connectionScope
|
||||
|| candidate.clientInstanceId !== clientInstanceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
candidate.ws.close();
|
||||
} catch {
|
||||
console.warn(`Failed to close stale connection ${connectionId} for ${oderId}`);
|
||||
}
|
||||
|
||||
connectedUsers.delete(connectionId);
|
||||
});
|
||||
}
|
||||
|
||||
function clearVoiceActiveForOderId(oderId: string, exceptConnectionId?: string): void {
|
||||
connectedUsers.forEach((candidate, connectionId) => {
|
||||
if (candidate.oderId !== oderId || connectionId === exceptConnectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
candidate.voiceActive = false;
|
||||
connectedUsers.set(connectionId, candidate);
|
||||
});
|
||||
}
|
||||
|
||||
function sendVoiceStateSnapshotToConnection(user: ConnectedUser, snapshot: Record<string, unknown>): void {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'voice_state',
|
||||
...snapshot
|
||||
}));
|
||||
}
|
||||
|
||||
function readMessageId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
@@ -198,13 +267,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
||||
|
||||
const newOderId = session.user.id;
|
||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||
const newClientInstanceId = normalizeClientInstanceId(message['clientInstanceId']);
|
||||
const previousDisplayName = normalizeDisplayName(user.displayName);
|
||||
const previousDescription = user.description;
|
||||
const previousProfileUpdatedAt = user.profileUpdatedAt;
|
||||
const previousHomeSignalServerUrl = user.homeSignalServerUrl;
|
||||
|
||||
evictStaleClientInstanceConnections(newOderId, newScope, newClientInstanceId, connectionId);
|
||||
|
||||
user.oderId = newOderId;
|
||||
user.authenticated = true;
|
||||
user.clientInstanceId = newClientInstanceId;
|
||||
user.displayName = normalizeDisplayName(message['displayName'], normalizeDisplayName(user.displayName));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(message, 'description')) {
|
||||
@@ -223,6 +296,17 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User identified: ${user.displayName} (${user.oderId})`);
|
||||
|
||||
const voiceSnapshot = Array.from(connectedUsers.entries()).find(([otherConnectionId, otherUser]) =>
|
||||
otherConnectionId !== connectionId
|
||||
&& otherUser.oderId === newOderId
|
||||
&& otherUser.voiceActive
|
||||
&& otherUser.voiceStateSnapshot
|
||||
)?.[1]?.voiceStateSnapshot;
|
||||
|
||||
if (voiceSnapshot) {
|
||||
sendVoiceStateSnapshotToConnection(user, voiceSnapshot);
|
||||
}
|
||||
|
||||
if (
|
||||
user.displayName === previousDisplayName
|
||||
&& user.description === previousDescription
|
||||
@@ -240,7 +324,7 @@ async function handleIdentify(user: ConnectedUser, message: WsMessage, connectio
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -287,7 +371,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
...buildPresenceUserPayload(user),
|
||||
serverId: sid
|
||||
},
|
||||
user.oderId
|
||||
{ excludeIdentityOderId: user.oderId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -338,7 +422,7 @@ function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId
|
||||
serverId: leaveSid,
|
||||
serverIds: remainingServerIds
|
||||
},
|
||||
user.oderId
|
||||
{ excludeIdentityOderId: user.oderId }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -394,7 +478,7 @@ async function forwardRtcMessage(user: ConnectedUser, message: WsMessage): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleChatMessage(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const chatSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
|
||||
if (chatSid && user.serverIds.has(chatSid)) {
|
||||
@@ -404,18 +488,38 @@ function handleChatMessage(user: ConnectedUser, message: WsMessage): void {
|
||||
message: message['message'],
|
||||
senderId: user.oderId,
|
||||
senderName: user.displayName,
|
||||
clientInstanceId: user.clientInstanceId,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}, { excludeConnectionId: connectionId });
|
||||
}
|
||||
}
|
||||
|
||||
function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleVoiceState(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
|
||||
if (!serverId || !user.serverIds.has(serverId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isConnected = readVoiceConnected(message);
|
||||
|
||||
if (isConnected) {
|
||||
clearVoiceActiveForOderId(user.oderId, connectionId);
|
||||
user.voiceActive = true;
|
||||
user.voiceStateSnapshot = {
|
||||
...message,
|
||||
type: 'voice_state',
|
||||
serverId,
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName)
|
||||
};
|
||||
} else {
|
||||
user.voiceActive = false;
|
||||
user.voiceStateSnapshot = undefined;
|
||||
}
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
|
||||
broadcastToServer(
|
||||
serverId,
|
||||
{
|
||||
@@ -425,11 +529,19 @@ function handleVoiceState(user: ConnectedUser, message: WsMessage): void {
|
||||
oderId: user.oderId,
|
||||
displayName: normalizeDisplayName(user.displayName)
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
function handleVoiceClientTakeover(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
notifyOtherConnectionsForOderId(user.oderId, {
|
||||
type: 'voice_client_takeover',
|
||||
clientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId,
|
||||
requestedByClientInstanceId: normalizeClientInstanceId(message['clientInstanceId']) ?? user.clientInstanceId
|
||||
}, connectionId);
|
||||
}
|
||||
|
||||
function handleTyping(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const typingSid = (message['serverId'] as string | undefined) ?? user.viewedServerId;
|
||||
const channelId = typeof message['channelId'] === 'string' && message['channelId'].trim() ? message['channelId'].trim() : 'general';
|
||||
const isTyping = message['isTyping'] !== false;
|
||||
@@ -443,9 +555,10 @@ function handleTyping(user: ConnectedUser, message: WsMessage): void {
|
||||
channelId,
|
||||
isTyping,
|
||||
oderId: user.oderId,
|
||||
displayName: user.displayName
|
||||
displayName: user.displayName,
|
||||
clientInstanceId: user.clientInstanceId
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -475,7 +588,7 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
||||
oderId: user.oderId,
|
||||
status
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -520,7 +633,7 @@ function handleServerIconSyncRequest(user: ConnectedUser, message: WsMessage): v
|
||||
user.ws.send(JSON.stringify({ type: 'server_icon_sync_peers', serverId, users }));
|
||||
}
|
||||
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
const pluginId = readMessageId(message['pluginId']);
|
||||
const eventName = readMessageId(message['eventName']);
|
||||
@@ -565,7 +678,7 @@ async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promi
|
||||
sourceUserId: user.oderId,
|
||||
emittedAt: Date.now()
|
||||
},
|
||||
user.oderId
|
||||
{ excludeConnectionId: connectionId }
|
||||
);
|
||||
} catch (error) {
|
||||
sendPluginError(user, error, message);
|
||||
@@ -623,15 +736,19 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
break;
|
||||
|
||||
case 'chat_message':
|
||||
handleChatMessage(user, message);
|
||||
handleChatMessage(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'voice_state':
|
||||
handleVoiceState(user, message);
|
||||
handleVoiceState(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'voice_client_takeover':
|
||||
handleVoiceClientTakeover(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
handleTyping(user, message);
|
||||
handleTyping(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'status_update':
|
||||
@@ -647,7 +764,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
break;
|
||||
|
||||
case 'plugin_event':
|
||||
await handlePluginEvent(user, message);
|
||||
await handlePluginEvent(user, message, connectionId);
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -39,7 +39,7 @@ function removeDeadConnection(connectionId: string): void {
|
||||
displayName: user.displayName,
|
||||
serverId: sid,
|
||||
serverIds: remainingServerIds
|
||||
}, user.oderId);
|
||||
}, { excludeIdentityOderId: user.oderId });
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -22,6 +22,12 @@ export interface ConnectedUser {
|
||||
status?: 'online' | 'away' | 'busy' | 'offline';
|
||||
/** Latest server icon timestamp this connection can provide over P2P. */
|
||||
serverIconUpdatedAtByServerId?: Map<string, number>;
|
||||
/** Stable per-install client id sent by the product client. */
|
||||
clientInstanceId?: string;
|
||||
/** Whether this connection currently owns active voice/WebRTC for the user. */
|
||||
voiceActive?: boolean;
|
||||
/** Cached voice state snapshot used to bootstrap newly connected client instances. */
|
||||
voiceStateSnapshot?: Record<string, unknown>;
|
||||
/** Timestamp of the last pong or client message received (used to detect dead connections). */
|
||||
lastPong: number;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ Owns the user-facing Angular 21 desktop chat experience: rendering and orchestra
|
||||
| **Custom emoji** | User-created image emoji assets stored locally, synced peer-to-peer, and referenced from messages/reactions by stable `:emoji[id](name)` tokens. | "sticker", "emote" |
|
||||
| **App locale** | The active UI language for the product client, resolved by `resolveAppLocale()` in `core/i18n/`; only `en` is shipped today. | "language", "i18n locale" |
|
||||
| **Translation catalog** | JSON string tables under `public/i18n/catalog/*.json`, merged to `public/i18n/en.json` via `npm run i18n:sync`, loaded at startup by `AppI18nService`. | "locale file", "messages file" |
|
||||
| **Client instance** | Stable per-install UUID (`metoyou.clientInstanceId`) sent on WebSocket `identify` and voice-state payloads so the signaling server can route multi-device sessions. | "device id", "session id" |
|
||||
| **Voice owner connection** | The single client instance whose `clientInstanceId` matches the user's active `voiceState.clientInstanceId` and therefore owns mic/WebRTC for that identity. | "active voice client" |
|
||||
|
||||
## Relationships
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"resizeChat": "Resize chat",
|
||||
"yourCamera": "Your camera",
|
||||
"yourScreen": "Your screen",
|
||||
"waiting": "Waiting"
|
||||
"waiting": "Waiting",
|
||||
"voiceOnOtherDevice": "Active on another device"
|
||||
},
|
||||
"notifications": {
|
||||
"inProgress": "Call in progress"
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"playing": "Playing {{game}}",
|
||||
"inVoice": "In voice",
|
||||
"voiceOnOtherDevice": "In voice on another device",
|
||||
"takeOverVoice": "Join",
|
||||
"plugins": "Plugins",
|
||||
"viewPlugins": "View plugins",
|
||||
"you": "You",
|
||||
|
||||
@@ -97,7 +97,8 @@
|
||||
"resizeChat": "Resize chat",
|
||||
"yourCamera": "Your camera",
|
||||
"yourScreen": "Your screen",
|
||||
"waiting": "Waiting"
|
||||
"waiting": "Waiting",
|
||||
"voiceOnOtherDevice": "Active on another device"
|
||||
},
|
||||
"notifications": {
|
||||
"inProgress": "Call in progress"
|
||||
@@ -768,6 +769,8 @@
|
||||
"latencyMs": "{{ms}} ms",
|
||||
"playing": "Playing {{game}}",
|
||||
"inVoice": "In voice",
|
||||
"voiceOnOtherDevice": "In voice on another device",
|
||||
"takeOverVoice": "Join",
|
||||
"plugins": "Plugins",
|
||||
"viewPlugins": "View plugins",
|
||||
"you": "You",
|
||||
|
||||
@@ -58,6 +58,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
|
||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||
import { ROOM_URL_PATTERN } from './core/constants';
|
||||
import { clearStoredCurrentUserId, getStoredCurrentUserId } from './core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from './domains/authentication/domain/logic/auth-navigation.rules';
|
||||
import { runWhenIdle } from './shared/rxjs';
|
||||
import {
|
||||
ThemeNodeDirective,
|
||||
@@ -319,9 +320,7 @@ export class App implements OnInit, OnDestroy {
|
||||
this.router.navigate(['/dashboard'], { replaceUrl: true }).catch(() => {});
|
||||
} else {
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: currentUrl
|
||||
}
|
||||
queryParams: buildLoginReturnQueryParams(currentUrl)
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
import { ClientInstanceService } from './client-instance.service';
|
||||
|
||||
const STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||
|
||||
describe('ClientInstanceService', () => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
beforeEach(() => {
|
||||
storage.clear();
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { storage.set(key, value); },
|
||||
removeItem: (key: string) => { storage.delete(key); }
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('creates and persists a stable client instance id', () => {
|
||||
const service = new ClientInstanceService();
|
||||
const first = service.getClientInstanceId();
|
||||
const second = new ClientInstanceService().getClientInstanceId();
|
||||
|
||||
expect(first).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(second).toBe(first);
|
||||
expect(storage.get(STORAGE_KEY)).toBe(first);
|
||||
});
|
||||
|
||||
it('reuses a stored client instance id', () => {
|
||||
storage.set(STORAGE_KEY, 'device-123');
|
||||
|
||||
expect(new ClientInstanceService().getClientInstanceId()).toBe('device-123');
|
||||
});
|
||||
});
|
||||
38
toju-app/src/app/core/platform/client-instance.service.ts
Normal file
38
toju-app/src/app/core/platform/client-instance.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
const STORAGE_KEY = 'metoyou.clientInstanceId';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ClientInstanceService {
|
||||
private cachedId: string | null = null;
|
||||
|
||||
getClientInstanceId(): string {
|
||||
if (this.cachedId) {
|
||||
return this.cachedId;
|
||||
}
|
||||
|
||||
const stored = this.readStoredId();
|
||||
|
||||
if (stored) {
|
||||
this.cachedId = stored;
|
||||
return stored;
|
||||
}
|
||||
|
||||
const created = crypto.randomUUID();
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, created);
|
||||
this.cachedId = created;
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
private readStoredId(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)?.trim();
|
||||
|
||||
return raw || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,13 @@ export interface ElectronAppMetricsSnapshot {
|
||||
processes: ElectronAppMetricsProcess[];
|
||||
}
|
||||
|
||||
export interface ElectronPerfDiagEntry {
|
||||
collectedAt: number;
|
||||
source: 'main' | 'renderer';
|
||||
type: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
linuxDisplayServer: string;
|
||||
minimizeWindow: () => void;
|
||||
@@ -263,6 +270,8 @@ export interface ElectronApi {
|
||||
onLinuxScreenShareMonitorAudioChunk: (listener: (payload: LinuxScreenShareMonitorAudioChunkPayload) => void) => () => void;
|
||||
onLinuxScreenShareMonitorAudioEnded: (listener: (payload: LinuxScreenShareMonitorAudioEndedPayload) => void) => () => void;
|
||||
getAppMetrics: () => Promise<ElectronAppMetricsSnapshot>;
|
||||
isPerfDiagEnabled?: () => Promise<boolean>;
|
||||
reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise<boolean>;
|
||||
getAppDataPath: () => Promise<string>;
|
||||
openCurrentDataFolder: () => Promise<boolean>;
|
||||
exportUserData: () => Promise<ExportUserDataResult>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './platform.service';
|
||||
export * from './external-link.service';
|
||||
export * from './viewport.service';
|
||||
export * from './client-instance.service';
|
||||
|
||||
@@ -15,8 +15,8 @@ authentication/
|
||||
│ └── authentication.model.ts LoginResponse interface
|
||||
│
|
||||
├── feature/
|
||||
│ ├── login/ Login form component
|
||||
│ ├── register/ Registration form component
|
||||
│ ├── login/ Login form (`<form ngSubmit>`; autofocus + select-on-focus via shared directives)
|
||||
│ ├── register/ Registration form (same form-field UX as login)
|
||||
│ └── user-bar/ Displays current user or login/register links
|
||||
│
|
||||
└── index.ts Barrel exports
|
||||
|
||||
@@ -6,7 +6,54 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { waitForAuthenticationOutcome } from './auth-navigation.rules';
|
||||
import {
|
||||
buildLoginReturnQueryParams,
|
||||
resolveSafeReturnUrl,
|
||||
waitForAuthenticationOutcome
|
||||
} from './auth-navigation.rules';
|
||||
|
||||
describe('resolveSafeReturnUrl', () => {
|
||||
it('returns the requested in-app path unchanged', () => {
|
||||
expect(resolveSafeReturnUrl('/servers')).toBe('/servers');
|
||||
expect(resolveSafeReturnUrl('/room/abc')).toBe('/room/abc');
|
||||
});
|
||||
|
||||
it('unwraps nested login returnUrl chains to the original destination', () => {
|
||||
const nested = '/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers';
|
||||
|
||||
expect(resolveSafeReturnUrl(nested)).toBe('/servers');
|
||||
expect(resolveSafeReturnUrl(`/login?returnUrl=${encodeURIComponent(nested)}`)).toBe('/servers');
|
||||
});
|
||||
|
||||
it('falls back to dashboard for auth-only return targets', () => {
|
||||
expect(resolveSafeReturnUrl('/login')).toBe('/dashboard');
|
||||
expect(resolveSafeReturnUrl('/register')).toBe('/dashboard');
|
||||
expect(resolveSafeReturnUrl(null)).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('rejects open redirects and protocol-relative paths', () => {
|
||||
expect(resolveSafeReturnUrl('//evil.example/phish')).toBe('/dashboard');
|
||||
expect(resolveSafeReturnUrl('https://evil.example/phish')).toBe('/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLoginReturnQueryParams', () => {
|
||||
it('preserves a safe destination when redirecting from protected routes', () => {
|
||||
expect(buildLoginReturnQueryParams('/servers')).toEqual({ returnUrl: '/servers' });
|
||||
});
|
||||
|
||||
it('does not nest login returnUrl values', () => {
|
||||
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Fservers')).toEqual({ returnUrl: '/servers' });
|
||||
expect(buildLoginReturnQueryParams('/login?returnUrl=%2Flogin%3FreturnUrl%3D%252Fservers')).toEqual({
|
||||
returnUrl: '/servers'
|
||||
});
|
||||
});
|
||||
|
||||
it('omits returnUrl when there is no meaningful destination', () => {
|
||||
expect(buildLoginReturnQueryParams('/login')).toEqual({});
|
||||
expect(buildLoginReturnQueryParams('/register')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForAuthenticationOutcome', () => {
|
||||
it('resolves when authentication storage preparation succeeds', async () => {
|
||||
|
||||
@@ -8,10 +8,88 @@ import {
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
|
||||
export const DEFAULT_POST_AUTH_URL = '/dashboard';
|
||||
|
||||
const AUTH_ROUTE_PATHS = new Set(['/login', '/register']);
|
||||
const MAX_RETURN_URL_DEPTH = 10;
|
||||
|
||||
export type AuthenticationOutcome =
|
||||
| { kind: 'success'; user: User }
|
||||
| { kind: 'failure'; error: string };
|
||||
|
||||
export function isAuthRoutePath(path: string): boolean {
|
||||
return AUTH_ROUTE_PATHS.has(path);
|
||||
}
|
||||
|
||||
export function getRoutePathFromUrl(url: string): string {
|
||||
if (!url) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const [path] = url.split(/[?#]/, 1);
|
||||
|
||||
return path || '/';
|
||||
}
|
||||
|
||||
export function extractReturnUrlParam(url: string): string | null {
|
||||
const queryStart = url.indexOf('?');
|
||||
|
||||
if (queryStart === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashStart = url.indexOf('#', queryStart + 1);
|
||||
const query = hashStart === -1
|
||||
? url.slice(queryStart + 1)
|
||||
: url.slice(queryStart + 1, hashStart);
|
||||
|
||||
return new URLSearchParams(query).get('returnUrl');
|
||||
}
|
||||
|
||||
export function resolveSafeReturnUrl(
|
||||
url: string | null | undefined,
|
||||
fallback = DEFAULT_POST_AUTH_URL
|
||||
): string {
|
||||
let candidate = url?.trim() ?? '';
|
||||
let depth = 0;
|
||||
|
||||
while (candidate && depth < MAX_RETURN_URL_DEPTH) {
|
||||
if (!candidate.startsWith('/') || candidate.startsWith('//')) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const path = getRoutePathFromUrl(candidate);
|
||||
|
||||
if (!isAuthRoutePath(path)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const nestedReturnUrl = extractReturnUrlParam(candidate)?.trim();
|
||||
|
||||
if (!nestedReturnUrl) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
candidate = nestedReturnUrl;
|
||||
depth += 1;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function buildLoginReturnQueryParams(
|
||||
currentUrl: string,
|
||||
fallback = DEFAULT_POST_AUTH_URL
|
||||
): { returnUrl?: string } {
|
||||
const safeReturnUrl = resolveSafeReturnUrl(currentUrl, fallback);
|
||||
|
||||
if (safeReturnUrl === fallback) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return { returnUrl: safeReturnUrl };
|
||||
}
|
||||
|
||||
export function waitForAuthenticationOutcome(
|
||||
actions$: Observable<{ type: string; user?: User; error?: string }>
|
||||
): Observable<AuthenticationOutcome> {
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.login.title' | translate }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<form
|
||||
class="space-y-3"
|
||||
(ngSubmit)="submit()"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="login-username"
|
||||
@@ -19,6 +22,9 @@
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="login-username"
|
||||
name="username"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -32,6 +38,7 @@
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="login-password"
|
||||
name="password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -44,6 +51,7 @@
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="login-server"
|
||||
name="serverId"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
@@ -55,22 +63,21 @@
|
||||
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
type="submit"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ 'auth.login.submit' | translate }}
|
||||
</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
{{ 'auth.login.noAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goRegister()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ 'auth.login.registerLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
{{ 'auth.login.noAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goRegister()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ 'auth.login.registerLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
@@ -11,14 +12,24 @@ import { Actions } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideLogIn } from '@ng-icons/lucide';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
filter,
|
||||
firstValueFrom,
|
||||
take
|
||||
} from 'rxjs';
|
||||
|
||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
||||
import {
|
||||
buildLoginReturnQueryParams,
|
||||
resolveSafeReturnUrl,
|
||||
waitForAuthenticationOutcome
|
||||
} from '../../domain/logic/auth-navigation.rules';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
@@ -27,6 +38,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideLogIn })],
|
||||
@@ -35,7 +48,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
/**
|
||||
* Login form allowing existing users to authenticate against a selected server.
|
||||
*/
|
||||
export class LoginComponent {
|
||||
export class LoginComponent implements OnInit {
|
||||
serversSvc = inject(ServerDirectoryFacade);
|
||||
|
||||
servers = this.serversSvc.servers;
|
||||
@@ -54,6 +67,18 @@ export class LoginComponent {
|
||||
/** TrackBy function for server list rendering. */
|
||||
trackById(_index: number, item: { id: string }) { return item.id; }
|
||||
|
||||
ngOnInit(): void {
|
||||
this.store.select(selectCurrentUser).pipe(
|
||||
filter(Boolean),
|
||||
take(1)
|
||||
)
|
||||
.subscribe(() => {
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
|
||||
void this.router.navigateByUrl(returnUrl);
|
||||
});
|
||||
}
|
||||
|
||||
/** Validate and submit the login form, then navigate to search on success. */
|
||||
submit() {
|
||||
this.error.set(null);
|
||||
@@ -88,14 +113,9 @@ export class LoginComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(['/dashboard']);
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed'));
|
||||
@@ -105,10 +125,8 @@ export class LoginComponent {
|
||||
|
||||
/** Navigate to the registration page. */
|
||||
goRegister() {
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
this.router.navigate(['/register'], {
|
||||
queryParams: returnUrl ? { returnUrl } : undefined
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<h1 class="text-lg font-semibold text-foreground">{{ 'auth.register.title' | translate }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<form
|
||||
class="space-y-3"
|
||||
(ngSubmit)="submit()"
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
for="register-username"
|
||||
@@ -19,6 +22,9 @@
|
||||
[(ngModel)]="username"
|
||||
type="text"
|
||||
id="register-username"
|
||||
name="username"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -32,6 +38,8 @@
|
||||
[(ngModel)]="displayName"
|
||||
type="text"
|
||||
id="register-display-name"
|
||||
name="displayName"
|
||||
appSelectOnFocus
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,6 +53,7 @@
|
||||
[(ngModel)]="password"
|
||||
type="password"
|
||||
id="register-password"
|
||||
name="password"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
/>
|
||||
</div>
|
||||
@@ -57,6 +66,7 @@
|
||||
<select
|
||||
[(ngModel)]="serverId"
|
||||
id="register-server"
|
||||
name="serverId"
|
||||
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
|
||||
>
|
||||
@for (s of servers(); track s.id) {
|
||||
@@ -68,22 +78,21 @@
|
||||
<p class="text-xs text-destructive">{{ error() }}</p>
|
||||
}
|
||||
<button
|
||||
(click)="submit()"
|
||||
type="button"
|
||||
type="submit"
|
||||
class="w-full px-3 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{{ 'auth.register.submit' | translate }}
|
||||
</button>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
{{ 'auth.register.haveAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goLogin()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ 'auth.register.loginLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-xs text-muted-foreground text-center mt-2">
|
||||
{{ 'auth.register.haveAccount' | translate }}
|
||||
<button
|
||||
type="button"
|
||||
(click)="goLogin()"
|
||||
class="text-primary hover:underline"
|
||||
>
|
||||
{{ 'auth.register.loginLink' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,10 +15,15 @@ import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { AuthenticationService } from '../../application/services/authentication.service';
|
||||
import { ServerDirectoryFacade } from '../../../server-directory';
|
||||
import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
|
||||
import {
|
||||
buildLoginReturnQueryParams,
|
||||
resolveSafeReturnUrl,
|
||||
waitForAuthenticationOutcome
|
||||
} from '../../domain/logic/auth-navigation.rules';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
@@ -27,6 +32,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideUserPlus })],
|
||||
@@ -90,14 +97,9 @@ export class RegisterComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
|
||||
if (returnUrl?.startsWith('/')) {
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.router.navigate(['/dashboard']);
|
||||
await this.router.navigateByUrl(returnUrl);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set(err?.error?.error || this.appI18n.instant('auth.register.failed'));
|
||||
@@ -107,10 +109,8 @@ export class RegisterComponent {
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
|
||||
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: returnUrl ? { returnUrl } : undefined
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { selectActiveChannelId, selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import {
|
||||
merge,
|
||||
interval,
|
||||
@@ -47,6 +48,7 @@ export class TypingIndicatorComponent {
|
||||
private readonly store = inject(Store);
|
||||
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
|
||||
private readonly activeChannelId = this.store.selectSignal(selectActiveChannelId);
|
||||
private readonly currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
private lastRoomId: string | null = null;
|
||||
private lastConversationKey: string | null = null;
|
||||
|
||||
@@ -145,9 +147,22 @@ export class TypingIndicatorComponent {
|
||||
private recomputeDisplay(): void {
|
||||
const now = Date.now();
|
||||
const activeChannelId = this.activeChannelId() ?? 'general';
|
||||
const names = Array.from(this.typingMap.values())
|
||||
.filter((entry) => entry.expiresAt > now && entry.channelId === activeChannelId)
|
||||
.map((e) => e.name);
|
||||
const currentUserId = this.currentUser()?.id || this.currentUser()?.oderId;
|
||||
const names = Array.from(this.typingMap.entries())
|
||||
.filter(([typingKey, entry]) => {
|
||||
if (entry.expiresAt <= now || entry.channelId !== activeChannelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!currentUserId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [, oderId] = typingKey.split(':');
|
||||
|
||||
return oderId !== currentUserId;
|
||||
})
|
||||
.map(([, entry]) => entry.name);
|
||||
|
||||
this.typingDisplay.set(names.slice(0, MAX_SHOWN));
|
||||
this.typingOthersCount.set(Math.max(0, names.length - MAX_SHOWN));
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
class="w-full rounded-md border border-border bg-background py-2 pl-8 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="{{ 'emoji.picker.searchPlaceholder' | translate }}"
|
||||
aria-label="{{ 'emoji.picker.searchAria' | translate }}"
|
||||
|
||||
@@ -22,6 +22,10 @@ import {
|
||||
import { CustomEmoji, EmojiShortcutEntry } from '../../../../shared-kernel';
|
||||
import { CustomEmojiService } from '../../application/custom-emoji.service';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import {
|
||||
CUSTOM_EMOJI_ACCEPT_ATTRIBUTE,
|
||||
UNICODE_EMOJI_PICKER_ENTRIES,
|
||||
@@ -34,7 +38,13 @@ import {
|
||||
@Component({
|
||||
selector: 'app-custom-emoji-picker',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucidePlus, lucideSearch, lucideSmile, lucideUpload, lucideX })],
|
||||
templateUrl: './custom-emoji-picker.component.html'
|
||||
})
|
||||
|
||||
@@ -21,7 +21,11 @@ import {
|
||||
VoiceConnectionFacade,
|
||||
VoicePlaybackService
|
||||
} from '../../../voice-connection';
|
||||
import { VoiceSessionFacade } from '../../../voice-session';
|
||||
import {
|
||||
VoiceSessionFacade,
|
||||
isVoiceOnAnotherClient
|
||||
} from '../../../voice-session';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { DirectMessageService, PeerDeliveryService } from '../../../direct-message';
|
||||
import type { DirectMessageConversation } from '../../../direct-message';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
@@ -43,6 +47,7 @@ export class DirectCallService {
|
||||
private readonly audio = inject(NotificationAudioService);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
private readonly viewport = inject(ViewportService);
|
||||
@@ -325,6 +330,11 @@ export class DirectCallService {
|
||||
this.leaveCurrentVoiceTargetForCall(callId);
|
||||
this.audio.stop(AppSound.Call);
|
||||
|
||||
if (isVoiceOnAnotherClient(me.voiceState, this.realtime.getClientInstanceId())) {
|
||||
this.realtime.requestVoiceClientTakeover();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
const ok = await this.voice.ensureSignalingConnected();
|
||||
|
||||
if (!ok || !navigator.mediaDevices?.getUserMedia) {
|
||||
@@ -941,7 +951,8 @@ export class DirectCallService {
|
||||
isMuted: connected ? this.voice.isMuted() : false,
|
||||
isDeafened: connected ? this.voice.isDeafened() : false,
|
||||
roomId: connected ? session.callId : undefined,
|
||||
serverId: connected ? session.callId : undefined
|
||||
serverId: connected ? session.callId : undefined,
|
||||
clientInstanceId: connected ? this.realtime.getClientInstanceId() : undefined
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[attr.aria-label]="'dm.find.searchAriaLabel' | translate"
|
||||
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[placeholder]="'dm.find.searchPlaceholder' | translate"
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { UserSearchListComponent } from '../user-search-list/user-search-list.component';
|
||||
import { selectAllUsers } from '../../../../store/users/users.selectors';
|
||||
import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
@@ -35,6 +39,8 @@ import { selectSavedRooms } from '../../../../store/rooms/rooms.selectors';
|
||||
RouterLink,
|
||||
NgIcon,
|
||||
UserSearchListComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideArrowLeft, lucideSearch, lucideUsers })],
|
||||
|
||||
@@ -74,8 +74,11 @@
|
||||
<label class="relative flex min-w-0 flex-1">
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addSourceUrl()"
|
||||
[(ngModel)]="newSourceUrl"
|
||||
(keyup.enter)="addSourceUrl()"
|
||||
[placeholder]="'plugins.store.sourcePlaceholder' | translate"
|
||||
[attr.aria-label]="'plugins.store.sourceAria' | translate"
|
||||
class="min-h-9 w-full rounded-lg border border-border bg-secondary px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
@@ -244,6 +247,7 @@
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
appSelectOnFocus
|
||||
[ngModel]="searchTerm()"
|
||||
(ngModelChange)="searchTerm.set($event)"
|
||||
[placeholder]="'plugins.store.searchPlaceholder' | translate"
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
lucideX
|
||||
} from '@ng-icons/lucide';
|
||||
import { ExternalLinkService } from '../../../../core/platform';
|
||||
import { resolveSafeReturnUrl, getRoutePathFromUrl } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { ChatMessageMarkdownComponent } from '../../../chat';
|
||||
import { resolveLegacyRole, resolveRoomPermission } from '../../../access-control';
|
||||
@@ -40,6 +41,11 @@ import { selectCurrentRoom, selectSavedRooms } from '../../../../store/rooms/roo
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ModalBackdropComponent } from '../../../../shared';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { PluginCapabilityService } from '../../application/services/plugin-capability.service';
|
||||
import { PluginStoreService } from '../../application/services/plugin-store.service';
|
||||
import type {
|
||||
@@ -64,6 +70,9 @@ interface ServerPluginInstallDialog {
|
||||
ChatMessageMarkdownComponent,
|
||||
NgIcon,
|
||||
ModalBackdropComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -598,13 +607,14 @@ export class PluginStoreComponent implements OnInit {
|
||||
}
|
||||
|
||||
private getReturnUrl(): string {
|
||||
const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl');
|
||||
const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
|
||||
const path = getRoutePathFromUrl(returnUrl);
|
||||
|
||||
if (returnUrl?.startsWith('/') && !returnUrl.startsWith('//') && !returnUrl.startsWith('/plugin-store')) {
|
||||
return returnUrl;
|
||||
if (path.startsWith('/plugin-store')) {
|
||||
return '/dashboard';
|
||||
}
|
||||
|
||||
return '/dashboard';
|
||||
return returnUrl;
|
||||
}
|
||||
|
||||
private canManageServerPlugins(room: Room, user: User): boolean {
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
{{ 'common.actions.cancel' | translate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
type="submit"
|
||||
form="create-server-dialog-form"
|
||||
class="min-h-11 flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
[disabled]="!canCreate"
|
||||
(click)="create()"
|
||||
>
|
||||
{{ 'servers.create.submit' | translate }}
|
||||
</button>
|
||||
@@ -60,10 +60,10 @@
|
||||
</button>
|
||||
<button
|
||||
id="create-server-dialog-submit"
|
||||
type="button"
|
||||
type="submit"
|
||||
form="create-server-dialog-form"
|
||||
class="flex-1 rounded-lg bg-primary px-3 py-2 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
[disabled]="!canCreate"
|
||||
(click)="create()"
|
||||
>
|
||||
{{ 'servers.create.submit' | translate }}
|
||||
</button>
|
||||
@@ -73,7 +73,11 @@
|
||||
|
||||
<!-- Shared form body for both presentations. -->
|
||||
<ng-template #form>
|
||||
<div class="space-y-5 p-4">
|
||||
<form
|
||||
id="create-server-dialog-form"
|
||||
class="space-y-5 p-4"
|
||||
(ngSubmit)="create()"
|
||||
>
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -104,6 +108,9 @@
|
||||
<input
|
||||
id="create-server-dialog-name"
|
||||
type="text"
|
||||
name="serverName"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[ngModel]="name()"
|
||||
(ngModelChange)="name.set($event)"
|
||||
[placeholder]="'servers.create.namePlaceholder' | translate"
|
||||
@@ -119,6 +126,7 @@
|
||||
>
|
||||
<textarea
|
||||
id="create-server-dialog-description"
|
||||
name="serverDescription"
|
||||
[ngModel]="description()"
|
||||
(ngModelChange)="description.set($event)"
|
||||
[placeholder]="'servers.create.descriptionPlaceholder' | translate"
|
||||
@@ -152,6 +160,8 @@
|
||||
<input
|
||||
id="create-server-dialog-topic"
|
||||
type="text"
|
||||
name="serverTopic"
|
||||
appSelectOnFocus
|
||||
[ngModel]="topic()"
|
||||
(ngModelChange)="topic.set($event)"
|
||||
[placeholder]="'servers.create.topicPlaceholder' | translate"
|
||||
@@ -167,6 +177,7 @@
|
||||
>
|
||||
<select
|
||||
id="create-server-dialog-signal-endpoint"
|
||||
name="sourceId"
|
||||
[ngModel]="sourceId()"
|
||||
(ngModelChange)="sourceId.set($event)"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
@@ -182,6 +193,7 @@
|
||||
<input
|
||||
id="create-server-dialog-private"
|
||||
type="checkbox"
|
||||
name="isPrivate"
|
||||
[ngModel]="isPrivate()"
|
||||
(ngModelChange)="isPrivate.set($event)"
|
||||
class="h-4 w-4 rounded border-border bg-secondary"
|
||||
@@ -202,6 +214,7 @@
|
||||
<input
|
||||
id="create-server-dialog-password"
|
||||
type="password"
|
||||
name="serverPassword"
|
||||
[ngModel]="password()"
|
||||
(ngModelChange)="password.set($event)"
|
||||
[placeholder]="'servers.create.passwordPlaceholder' | translate"
|
||||
@@ -212,5 +225,5 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ng-template>
|
||||
|
||||
@@ -14,11 +14,16 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
||||
import { lucideChevronDown, lucideChevronUp } from '@ng-icons/lucide';
|
||||
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { ThemeNodeDirective } from '../../../theme';
|
||||
import { ViewportService } from '../../../../core/platform';
|
||||
import { BottomSheetComponent, ModalBackdropComponent } from '../../../../shared';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective
|
||||
} from '../../../../shared/directives';
|
||||
import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-server.component';
|
||||
|
||||
/**
|
||||
@@ -37,6 +42,8 @@ import { CATEGORY_PRESETS, ServerCategoryPreset } from '../create-server/create-
|
||||
ThemeNodeDirective,
|
||||
BottomSheetComponent,
|
||||
ModalBackdropComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideChevronDown, lucideChevronUp })],
|
||||
@@ -104,7 +111,9 @@ export class CreateServerDialogComponent {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
</header>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div class="mx-auto w-full max-w-lg space-y-6 p-4 sm:p-6">
|
||||
<form
|
||||
class="mx-auto w-full max-w-lg space-y-6 p-4 sm:p-6"
|
||||
(ngSubmit)="createServer()"
|
||||
>
|
||||
<div>
|
||||
<span class="mb-2 block text-sm font-medium text-foreground">{{ 'servers.create.pickCategory' | translate }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -48,6 +51,9 @@
|
||||
<input
|
||||
id="create-server-name"
|
||||
type="text"
|
||||
name="serverName"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[ngModel]="name()"
|
||||
(ngModelChange)="name.set($event)"
|
||||
[placeholder]="'servers.create.namePlaceholder' | translate"
|
||||
@@ -63,6 +69,7 @@
|
||||
>
|
||||
<textarea
|
||||
id="create-server-description"
|
||||
name="serverDescription"
|
||||
[ngModel]="description()"
|
||||
(ngModelChange)="description.set($event)"
|
||||
[placeholder]="'servers.create.descriptionPlaceholder' | translate"
|
||||
@@ -96,6 +103,8 @@
|
||||
<input
|
||||
id="create-server-topic"
|
||||
type="text"
|
||||
name="serverTopic"
|
||||
appSelectOnFocus
|
||||
[ngModel]="topic()"
|
||||
(ngModelChange)="topic.set($event)"
|
||||
[placeholder]="'servers.create.topicPlaceholder' | translate"
|
||||
@@ -111,6 +120,7 @@
|
||||
>
|
||||
<select
|
||||
id="create-server-signal-endpoint"
|
||||
name="sourceId"
|
||||
[(ngModel)]="sourceId"
|
||||
class="w-full rounded-lg border border-border bg-secondary px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
@@ -125,6 +135,7 @@
|
||||
<input
|
||||
id="create-server-private"
|
||||
type="checkbox"
|
||||
name="isPrivate"
|
||||
[ngModel]="isPrivate()"
|
||||
(ngModelChange)="isPrivate.set($event)"
|
||||
class="h-4 w-4 rounded border-border bg-secondary"
|
||||
@@ -145,6 +156,7 @@
|
||||
<input
|
||||
id="create-server-password"
|
||||
type="password"
|
||||
name="serverPassword"
|
||||
[ngModel]="password()"
|
||||
(ngModelChange)="password.set($event)"
|
||||
[placeholder]="'servers.create.passwordPlaceholder' | translate"
|
||||
@@ -167,14 +179,13 @@
|
||||
</button>
|
||||
<button
|
||||
id="create-server-submit"
|
||||
type="button"
|
||||
type="submit"
|
||||
class="flex-1 rounded-lg bg-primary px-4 py-2 font-semibold text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
[disabled]="!canCreate"
|
||||
(click)="createServer()"
|
||||
>
|
||||
{{ 'servers.create.submit' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,11 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import { selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
|
||||
/** Preset categories that pre-fill the server topic to speed up creation. */
|
||||
@@ -47,6 +51,8 @@ export const CATEGORY_PRESETS: ServerCategoryPreset[] = [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [provideIcons({ lucideArrowLeft, lucideChevronDown, lucideChevronUp })],
|
||||
@@ -56,6 +62,7 @@ export class CreateServerComponent implements OnInit {
|
||||
private store = inject(Store);
|
||||
private router = inject(Router);
|
||||
private serverDirectory = inject(ServerDirectoryFacade);
|
||||
private currentUser = this.store.selectSignal(selectCurrentUser);
|
||||
|
||||
readonly categories = CATEGORY_PRESETS;
|
||||
activeEndpoints = this.serverDirectory.activeServers;
|
||||
@@ -102,13 +109,19 @@ export class CreateServerComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setStoredCurrentUserId(currentUserId);
|
||||
|
||||
this.store.dispatch(
|
||||
RoomsActions.createRoom({
|
||||
name: this.name().trim(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { STORAGE_KEY_CURRENT_USER_ID } from '../../../../core/constants';
|
||||
import { DatabaseService } from '../../../../infrastructure/persistence';
|
||||
import { ServerDirectoryFacade } from '../../application/facades/server-directory.facade';
|
||||
import { User } from '../../../../shared-kernel';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
|
||||
@Component({
|
||||
selector: 'app-invite',
|
||||
@@ -168,9 +169,7 @@ export class InviteComponent implements OnInit {
|
||||
this.message.set(this.i18n.instant('servers.invite.messages.redirectingLogin'));
|
||||
|
||||
await this.router.navigate(['/login'], {
|
||||
queryParams: {
|
||||
returnUrl: this.router.url
|
||||
}
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[attr.aria-label]="'servers.browser.search.ariaLabel' | translate"
|
||||
class="h-10 w-full rounded-lg border border-border bg-secondary py-2 pl-10 pr-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[placeholder]="resolvedSearchPlaceholder"
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
} from '@ng-icons/lucide';
|
||||
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import { setStoredCurrentUserId } from '../../../../core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from '../../../authentication/domain/logic/auth-navigation.rules';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
|
||||
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
|
||||
import {
|
||||
selectSearchResults,
|
||||
@@ -93,6 +96,8 @@ export interface ServerDiscoverySection {
|
||||
ConfirmDialogComponent,
|
||||
LeaveServerDialogComponent,
|
||||
ModalBackdropComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -260,13 +265,19 @@ export class ServerBrowserComponent implements OnInit {
|
||||
}
|
||||
|
||||
async joinServer(server: ServerInfo): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setStoredCurrentUserId(currentUserId);
|
||||
|
||||
if (await this.isServerBanned(server)) {
|
||||
this.bannedServerName.set(server.name);
|
||||
this.showBannedDialog.set(true);
|
||||
@@ -492,14 +503,19 @@ export class ServerBrowserComponent implements OnInit {
|
||||
password?: string,
|
||||
options: { acceptedRequirements?: PluginRequirementSummary[]; skipPluginConsent?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
const currentUser = this.currentUser();
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId') || currentUser?.id;
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setStoredCurrentUserId(currentUserId);
|
||||
|
||||
this.joinErrorMessage.set(null);
|
||||
this.joinPasswordError.set(null);
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ A reactive `speakingMap` signal (a `Map<string, boolean>`) is published whenever
|
||||
|
||||
## Voice playback
|
||||
|
||||
`VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline:
|
||||
`VoicePlaybackService` handles audio output for remote peers. Each peer gets an independent Web Audio pipeline. Pipelines are rebuilt only when that peer's live voice audio track set changes — composite remote-stream notifications (camera, screen share, SDP renegotiation) reuse the existing graph so AudioContexts are not churned.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Injector,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
ɵChangeDetectionScheduler as ChangeDetectionScheduler,
|
||||
ɵEffectScheduler as EffectScheduler
|
||||
} from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { Subject } from 'rxjs';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../../../store/users/users.selectors';
|
||||
import { ScreenShareFacade } from '../../../screen-share';
|
||||
import { VoiceConnectionFacade } from '../facades/voice-connection.facade';
|
||||
import { VoicePlaybackService } from './voice-playback.service';
|
||||
|
||||
let audioContextCount = 0;
|
||||
|
||||
describe('VoicePlaybackService', () => {
|
||||
beforeEach(() => {
|
||||
audioContextCount = 0;
|
||||
installAudioDomMocks();
|
||||
installLocalStorageMock();
|
||||
});
|
||||
|
||||
it('creates one audio pipeline per peer on first connect', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reuses the existing pipeline when the same voice stream is handled again', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('reuses the pipeline when only the MediaStream wrapper changes but live audio tracks are unchanged', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const track = createMockAudioTrack('track-a');
|
||||
const firstStream = createMockAudioStreamFromTracks([track]);
|
||||
const secondStream = createMockAudioStreamFromTracks([track]);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('rebuilds the pipeline when the live audio track set changes', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const firstStream = createMockAudioStream(['track-a']);
|
||||
const secondStream = createMockAudioStream(['track-b']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', firstStream, connectedOptions());
|
||||
context.service.handleRemoteStream('peer-1', secondStream, connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not recreate pipelines for unrelated remote stream notifications', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const voiceStream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.voiceConnection.getRemoteVoiceStream.mockReturnValue(voiceStream);
|
||||
context.service.handleRemoteStream('peer-1', voiceStream, connectedOptions());
|
||||
|
||||
context.remoteStream$.next({ peerId: 'peer-1', stream: createMockAudioStream(['screen-audio']) });
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
});
|
||||
|
||||
it('creates pipelines for each peer when multiple friends join voice', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
|
||||
context.service.handleRemoteStream('peer-1', createMockAudioStream(['track-1']), connectedOptions());
|
||||
context.service.handleRemoteStream('peer-2', createMockAudioStream(['track-2']), connectedOptions());
|
||||
|
||||
expect(audioContextCount).toBe(2);
|
||||
});
|
||||
|
||||
it('still applies updated playback options when reusing an existing pipeline', () => {
|
||||
const context = createServiceContext({ isVoiceConnected: true });
|
||||
const stream = createMockAudioStream(['track-a']);
|
||||
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 1 }));
|
||||
context.service.handleRemoteStream('peer-1', stream, connectedOptions({ outputVolume: 0.5, isDeafened: true }));
|
||||
|
||||
expect(audioContextCount).toBe(1);
|
||||
expect(context.service.getUserVolume('peer-1')).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
interface ServiceContext {
|
||||
service: VoicePlaybackService;
|
||||
voiceConnection: {
|
||||
isVoiceConnected: ReturnType<typeof signal<boolean>>;
|
||||
onRemoteStream: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
onVoiceConnected: Subject<void>;
|
||||
onPeerDisconnected: Subject<string>;
|
||||
getRemoteVoiceStream: ReturnType<typeof vi.fn>;
|
||||
getConnectedPeers: ReturnType<typeof vi.fn>;
|
||||
syncOutgoingVoiceRouting: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
remoteStream$: Subject<{ peerId: string; stream: MediaStream }>;
|
||||
}
|
||||
|
||||
function createServiceContext(options: { isVoiceConnected?: boolean } = {}): ServiceContext {
|
||||
const remoteStream$ = new Subject<{ peerId: string; stream: MediaStream }>();
|
||||
const voiceConnection = {
|
||||
isVoiceConnected: signal(options.isVoiceConnected ?? false),
|
||||
onRemoteStream: remoteStream$,
|
||||
onVoiceConnected: new Subject<void>(),
|
||||
onPeerDisconnected: new Subject<string>(),
|
||||
getRemoteVoiceStream: vi.fn(() => null),
|
||||
getConnectedPeers: vi.fn(() => []),
|
||||
syncOutgoingVoiceRouting: vi.fn()
|
||||
};
|
||||
const screenShare = {
|
||||
isScreenShareRemotePlaybackSuppressed: signal(false),
|
||||
forceDefaultRemotePlaybackOutput: signal(false)
|
||||
};
|
||||
const store = {
|
||||
selectSignal: vi.fn((selector: unknown) => {
|
||||
if (selector === selectCurrentUser) {
|
||||
return signal(null);
|
||||
}
|
||||
|
||||
if (selector === selectAllUsers) {
|
||||
return signal([]);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected selector in VoicePlaybackService test: ${String(selector)}`);
|
||||
})
|
||||
};
|
||||
const scheduledEffects = new Set<{ dirty: boolean; run: () => void }>();
|
||||
const effectScheduler = {
|
||||
add: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.add(scheduledEffect);
|
||||
}),
|
||||
flush: vi.fn(() => {
|
||||
for (const scheduledEffect of scheduledEffects) {
|
||||
if (scheduledEffect.dirty) {
|
||||
scheduledEffect.run();
|
||||
}
|
||||
}
|
||||
}),
|
||||
remove: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.delete(scheduledEffect);
|
||||
}),
|
||||
schedule: vi.fn((scheduledEffect: { dirty: boolean; run: () => void }) => {
|
||||
scheduledEffects.add(scheduledEffect);
|
||||
})
|
||||
};
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
VoicePlaybackService,
|
||||
{
|
||||
provide: ChangeDetectionScheduler,
|
||||
useValue: { notify: vi.fn() }
|
||||
},
|
||||
{ provide: EffectScheduler, useValue: effectScheduler },
|
||||
{ provide: VoiceConnectionFacade, useValue: voiceConnection },
|
||||
{ provide: ScreenShareFacade, useValue: screenShare },
|
||||
{ provide: Store, useValue: store }
|
||||
]
|
||||
});
|
||||
|
||||
return {
|
||||
service: runInInjectionContext(injector, () => injector.get(VoicePlaybackService)),
|
||||
voiceConnection,
|
||||
remoteStream$
|
||||
};
|
||||
}
|
||||
|
||||
function connectedOptions(overrides: Partial<{ outputVolume: number; isDeafened: boolean }> = {}) {
|
||||
return {
|
||||
isConnected: true,
|
||||
outputVolume: overrides.outputVolume ?? 1,
|
||||
isDeafened: overrides.isDeafened ?? false
|
||||
};
|
||||
}
|
||||
|
||||
function createMockAudioTrack(id: string, readyState: MediaStreamTrackState = 'live'): MediaStreamTrack {
|
||||
return {
|
||||
id,
|
||||
kind: 'audio',
|
||||
readyState,
|
||||
enabled: true
|
||||
} as MediaStreamTrack;
|
||||
}
|
||||
|
||||
function createMockAudioStream(trackIds: string[]): MediaStream {
|
||||
return createMockAudioStreamFromTracks(trackIds.map((id) => createMockAudioTrack(id)));
|
||||
}
|
||||
|
||||
function createMockAudioStreamFromTracks(tracks: MediaStreamTrack[]): MediaStream {
|
||||
return {
|
||||
getAudioTracks: () => tracks,
|
||||
getTracks: () => tracks
|
||||
} as unknown as MediaStream;
|
||||
}
|
||||
|
||||
function installAudioDomMocks(): void {
|
||||
vi.stubGlobal('MediaStream', class MediaStream {
|
||||
constructor(private readonly tracks: MediaStreamTrack[] = []) {}
|
||||
|
||||
getAudioTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
|
||||
getTracks(): MediaStreamTrack[] {
|
||||
return this.tracks;
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('Audio', class {
|
||||
muted = false;
|
||||
volume = 1;
|
||||
srcObject: MediaStream | null = null;
|
||||
|
||||
play = vi.fn().mockResolvedValue(undefined);
|
||||
remove = vi.fn();
|
||||
});
|
||||
|
||||
vi.stubGlobal('AudioContext', class {
|
||||
state = 'running';
|
||||
close = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
constructor() {
|
||||
audioContextCount += 1;
|
||||
}
|
||||
|
||||
createGain() {
|
||||
return {
|
||||
gain: { value: 0 },
|
||||
connect: vi.fn()
|
||||
};
|
||||
}
|
||||
|
||||
createMediaStreamSource() {
|
||||
return { connect: vi.fn() };
|
||||
}
|
||||
|
||||
createMediaStreamDestination() {
|
||||
return { stream: createMockAudioStream(['destination']) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function installLocalStorageMock(): void {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
storage.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -112,10 +112,17 @@ export class VoicePlaybackService {
|
||||
return;
|
||||
}
|
||||
|
||||
this.removePipeline(peerId);
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.masterVolume = options.outputVolume;
|
||||
this.deafened = options.isDeafened;
|
||||
|
||||
if (this.shouldReusePipeline(peerId, stream)) {
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.applyGain(peerId);
|
||||
return;
|
||||
}
|
||||
|
||||
this.removePipeline(peerId);
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.createPipeline(peerId, stream);
|
||||
}
|
||||
|
||||
@@ -142,13 +149,19 @@ export class VoicePlaybackService {
|
||||
for (const peerId of peers) {
|
||||
const stream = this.voiceConnection.getRemoteVoiceStream(peerId);
|
||||
|
||||
if (stream && this.hasAudio(stream)) {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
if (!trackedRaw || trackedRaw !== stream) {
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
if (!stream || !this.hasAudio(stream)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.shouldReusePipeline(peerId, stream)) {
|
||||
this.masterVolume = options.outputVolume;
|
||||
this.deafened = options.isDeafened;
|
||||
this.rawRemoteStreams.set(peerId, stream);
|
||||
this.applyGain(peerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.handleRemoteStream(peerId, stream, options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -433,4 +446,41 @@ export class VoicePlaybackService {
|
||||
private hasAudio(stream: MediaStream): boolean {
|
||||
return stream.getAudioTracks().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote composite-stream notifications (camera, screen share, renegotiation)
|
||||
* can arrive without any change to the underlying voice audio tracks.
|
||||
* Rebuilding the Web Audio graph in that case only churns AudioContexts.
|
||||
*/
|
||||
private shouldReusePipeline(peerId: string, stream: MediaStream): boolean {
|
||||
const trackedRaw = this.rawRemoteStreams.get(peerId);
|
||||
|
||||
return this.peerPipelines.has(peerId)
|
||||
&& !!trackedRaw
|
||||
&& this.streamsShareLiveAudioTracks(trackedRaw, stream);
|
||||
}
|
||||
|
||||
private streamsShareLiveAudioTracks(previous: MediaStream, next: MediaStream): boolean {
|
||||
const previousTrackIds = this.getLiveAudioTrackIds(previous);
|
||||
const nextTrackIds = this.getLiveAudioTrackIds(next);
|
||||
|
||||
if (previousTrackIds.length === 0 || nextTrackIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (previousTrackIds.length !== nextTrackIds.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousIds = new Set(previousTrackIds);
|
||||
|
||||
return nextTrackIds.every((trackId) => previousIds.has(trackId));
|
||||
}
|
||||
|
||||
private getLiveAudioTrackIds(stream: MediaStream): string[] {
|
||||
return stream
|
||||
.getAudioTracks()
|
||||
.filter((track) => track.readyState === 'live')
|
||||
.map((track) => track.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,16 @@ When a voice session is active and the user navigates away from the voice-connec
|
||||
|
||||
Joining a new voice target is exclusive: entering another voice channel or private call first disconnects the current call/channel, clears local voice state, and broadcasts the leave for the previous target. Users never need to manually leave one voice target before joining another.
|
||||
|
||||
## Multi-device voice (Discord-style)
|
||||
|
||||
Each install has a stable `clientInstanceId` (`ClientInstanceService`). `VoiceState.clientInstanceId` records which device currently owns the microphone/WebRTC session for that user.
|
||||
|
||||
- **Local voice owner** — this device's `clientInstanceId` matches `voiceState.clientInstanceId`; mic, heartbeat, and WebRTC transmit run normally.
|
||||
- **Passive client** — another device owns voice; this client still receives chat/presence and shows grayed "in voice on another device" UI in the room sidebar and private-call cards.
|
||||
- **Takeover** — clicking **Join** on a passive client sends `voice_client_takeover` through signaling; the active device releases voice via `VoiceClientTakeoverService`, then the passive client completes a normal join.
|
||||
|
||||
Rules live in `domain/logic/client-voice-session.rules.ts`.
|
||||
|
||||
Remote voice playback is scoped to the active voice channel, not the whole server. Users stay connected to the shared peer mesh for text, presence, and screen-share control, but voice transport and playback only stay active for peers whose `voiceState.roomId` and `voiceState.serverId` match the local user's current voice session.
|
||||
|
||||
Owners and admins can also move connected users between voice channels from the room sidebar by dragging a user onto a different voice channel. The moved client updates its local heartbeat and voice-session metadata to the new channel, so routing, floating controls, and occupancy stay in sync after the move.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Store } from '@ngrx/store';
|
||||
import type { User } from '../../../../shared-kernel';
|
||||
import { VoiceConnectionFacade } from '../../../voice-connection';
|
||||
import { ClientInstanceService } from '../../../../core/platform/client-instance.service';
|
||||
import { UsersActions } from '../../../../store/users/users.actions';
|
||||
import { isLocalVoiceOwner } from '../../domain/logic/client-voice-session.rules';
|
||||
import { VoiceSessionFacade } from '../facades/voice-session.facade';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class VoiceClientTakeoverService {
|
||||
private readonly store = inject(Store);
|
||||
private readonly voiceConnection = inject(VoiceConnectionFacade);
|
||||
private readonly voiceSession = inject(VoiceSessionFacade);
|
||||
private readonly clientInstance = inject(ClientInstanceService);
|
||||
|
||||
releaseLocalVoiceForTakeover(currentUser: User | null): void {
|
||||
if (!currentUser?.voiceState?.isConnected) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLocalVoiceOwner(currentUser.voiceState, this.clientInstance.getClientInstanceId())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVoiceState = currentUser.voiceState;
|
||||
|
||||
this.voiceConnection.stopVoiceHeartbeat();
|
||||
this.voiceConnection.disableVoice();
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateVoiceState({
|
||||
userId: currentUser.id,
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.store.dispatch(
|
||||
UsersActions.updateCameraState({
|
||||
userId: currentUser.id,
|
||||
cameraState: { isEnabled: false }
|
||||
})
|
||||
);
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: currentUser.oderId || currentUser.id,
|
||||
displayName: currentUser.displayName || 'User',
|
||||
voiceState: {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: previousVoiceState.roomId,
|
||||
serverId: previousVoiceState.serverId,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
this.voiceSession.endSession();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { VoiceState } from '../../../../shared-kernel';
|
||||
import {
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient,
|
||||
shouldTransmitVoice
|
||||
} from './client-voice-session.rules';
|
||||
|
||||
describe('client-voice-session.rules', () => {
|
||||
const localClientInstanceId = 'device-a';
|
||||
|
||||
it('treats the matching client instance as the local voice owner', () => {
|
||||
const voiceState: VoiceState = {
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
clientInstanceId: localClientInstanceId
|
||||
};
|
||||
|
||||
expect(isLocalVoiceOwner(voiceState, localClientInstanceId)).toBe(true);
|
||||
expect(isVoiceOnAnotherClient(voiceState, localClientInstanceId)).toBe(false);
|
||||
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a different client instance as passive voice', () => {
|
||||
const voiceState: VoiceState = {
|
||||
isConnected: true,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false,
|
||||
clientInstanceId: 'device-b'
|
||||
};
|
||||
|
||||
expect(isLocalVoiceOwner(voiceState, localClientInstanceId)).toBe(false);
|
||||
expect(isVoiceOnAnotherClient(voiceState, localClientInstanceId)).toBe(true);
|
||||
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows transmission when disconnected', () => {
|
||||
const voiceState: VoiceState = {
|
||||
isConnected: false,
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
isSpeaking: false
|
||||
};
|
||||
|
||||
expect(shouldTransmitVoice(voiceState, localClientInstanceId)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { VoiceState } from '../../../../shared-kernel';
|
||||
|
||||
export function isLocalVoiceOwner(
|
||||
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
|
||||
clientInstanceId: string
|
||||
): boolean {
|
||||
return !!voiceState?.isConnected
|
||||
&& !!voiceState.clientInstanceId
|
||||
&& voiceState.clientInstanceId === clientInstanceId;
|
||||
}
|
||||
|
||||
export function isVoiceOnAnotherClient(
|
||||
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
|
||||
clientInstanceId: string
|
||||
): boolean {
|
||||
return !!voiceState?.isConnected
|
||||
&& !!voiceState.clientInstanceId
|
||||
&& voiceState.clientInstanceId !== clientInstanceId;
|
||||
}
|
||||
|
||||
export function shouldTransmitVoice(
|
||||
voiceState: Pick<VoiceState, 'isConnected' | 'clientInstanceId'> | null | undefined,
|
||||
clientInstanceId: string
|
||||
): boolean {
|
||||
if (!voiceState?.isConnected) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!voiceState.clientInstanceId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return voiceState.clientInstanceId === clientInstanceId;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './application/facades/voice-session.facade';
|
||||
export * from './application/services/voice-client-takeover.service';
|
||||
export * from './application/services/voice-workspace.service';
|
||||
export * from './domain/logic/client-voice-session.rules';
|
||||
export * from './domain/models/voice-session.model';
|
||||
export * from './infrastructure/util/voice-settings-storage.util';
|
||||
|
||||
|
||||
@@ -21,12 +21,15 @@
|
||||
<input
|
||||
#searchInput
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="submitSearch()"
|
||||
[attr.aria-label]="'dashboard.searchAriaLabel' | translate"
|
||||
class="h-12 w-full min-w-0 rounded-xl border border-border bg-secondary py-2 pl-11 pr-4 text-base text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary sm:pr-20"
|
||||
[placeholder]="isMobile() ? ('dashboard.searchPlaceholderMobile' | translate) : ('dashboard.searchPlaceholderDesktop' | translate)"
|
||||
[ngModel]="searchQuery()"
|
||||
(ngModelChange)="onSearchChange($event)"
|
||||
(keydown.enter)="submitSearch()"
|
||||
/>
|
||||
<kbd
|
||||
class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 items-center gap-1 rounded border border-border bg-card px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:flex"
|
||||
|
||||
@@ -46,6 +46,11 @@ import { FriendButtonComponent } from '../../domains/direct-message/feature/frie
|
||||
import { UserAvatarComponent } from '../../shared/components/user-avatar/user-avatar.component';
|
||||
import { parseInviteQuery } from './invite-query.util';
|
||||
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../shared/directives';
|
||||
|
||||
/** Maximum quick-search rows shown per group on the dashboard. */
|
||||
const QUICK_RESULT_LIMIT = 5;
|
||||
@@ -72,6 +77,9 @@ const RECENT_SEARCHES_STORAGE_KEY = 'metoyou_dashboard_recent_searches';
|
||||
NgIcon,
|
||||
FriendButtonComponent,
|
||||
UserAvatarComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
[class.shadow-[0_0_0_6px_rgba(16,185,129,0.12)]]="speaking() && compact()"
|
||||
[class.shadow-[0_0_0_8px_rgba(16,185,129,0.12)]]="speaking() && !compact()"
|
||||
[class.ring-border]="!speaking()"
|
||||
[class.opacity-55]="!connected()"
|
||||
[class.opacity-55]="!connected() || passive()"
|
||||
>
|
||||
@if (user().avatarUrl) {
|
||||
<img
|
||||
|
||||
@@ -18,6 +18,7 @@ export class PrivateCallParticipantCardComponent {
|
||||
readonly speaking = input.required<boolean>();
|
||||
readonly issueLabel = input<string | null>(null);
|
||||
readonly compact = input(false);
|
||||
readonly passive = input(false);
|
||||
|
||||
avatarSize(): string {
|
||||
return this.compact() ? '5.75rem' : 'clamp(6.5rem, 38vw, 13rem)';
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
[passive]="isPassiveCallParticipant(user)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@@ -164,6 +165,7 @@
|
||||
[connected]="isParticipantConnected(user)"
|
||||
[speaking]="isSpeaking(user)"
|
||||
[issueLabel]="participantIssueLabel(user)"
|
||||
[passive]="isPassiveCallParticipant(user)"
|
||||
[compact]="true"
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
import { loadVoiceSettingsFromStorage, saveVoiceSettingsToStorage } from '../../domains/voice-session';
|
||||
import { ScreenShareQualityDialogComponent } from '../../shared';
|
||||
import { ViewportService } from '../../core/platform';
|
||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||
import {
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient
|
||||
} from '../../domains/voice-session';
|
||||
import { MobileMediaService, MobilePlatformService } from '../../infrastructure/mobile';
|
||||
import { selectAllUsers, selectCurrentUser } from '../../store/users/users.selectors';
|
||||
import { UsersActions } from '../../store/users/users.actions';
|
||||
@@ -85,6 +90,7 @@ export class PrivateCallComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly store = inject(Store);
|
||||
private readonly calls = inject(DirectCallService);
|
||||
private readonly realtime = inject(RealtimeSessionFacade);
|
||||
private readonly voice = inject(VoiceConnectionFacade);
|
||||
private readonly voiceActivity = inject(VoiceActivityService);
|
||||
private readonly playback = inject(VoicePlaybackService);
|
||||
@@ -437,18 +443,38 @@ export class PrivateCallComponent {
|
||||
isParticipantConnected(user: User): boolean {
|
||||
const session = this.session();
|
||||
const userId = this.userKey(user);
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!!session.participants[userId]?.joined ||
|
||||
!!(user.voiceState?.isConnected && user.voiceState.roomId === session.callId && user.voiceState.serverId === session.callId)
|
||||
const inCallVoice = !!(
|
||||
user.voiceState?.isConnected
|
||||
&& user.voiceState.roomId === session.callId
|
||||
&& user.voiceState.serverId === session.callId
|
||||
);
|
||||
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
|
||||
|
||||
if (isSelf && inCallVoice) {
|
||||
return isLocalVoiceOwner(user.voiceState, this.realtime.getClientInstanceId());
|
||||
}
|
||||
|
||||
return !!session.participants[userId]?.joined || inCallVoice;
|
||||
}
|
||||
|
||||
isPassiveCallParticipant(user: User): boolean {
|
||||
const current = this.currentUser();
|
||||
const isSelf = !!current && (user.id === current.id || user.oderId === current.oderId);
|
||||
|
||||
return isSelf && isVoiceOnAnotherClient(user.voiceState, this.realtime.getClientInstanceId());
|
||||
}
|
||||
|
||||
participantIssueLabel(user: User): string | null {
|
||||
if (this.isPassiveCallParticipant(user)) {
|
||||
return this.i18n.instant('call.private.voiceOnOtherDevice');
|
||||
}
|
||||
|
||||
return this.isParticipantConnected(user) ? null : this.i18n.instant('call.private.waiting');
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +98,8 @@
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[value]="ch.name"
|
||||
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
|
||||
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
|
||||
@@ -173,7 +175,7 @@
|
||||
(contextmenu)="openChannelContextMenu($event, ch)"
|
||||
[class.bg-secondary]="isCurrentRoom(ch.id)"
|
||||
[disabled]="!voiceEnabled()"
|
||||
[title]="isCurrentRoom(ch.id) ? ('room.panel.openStreamWorkspace' | translate) : ('room.panel.joinVoiceChannel' | translate)"
|
||||
[title]="voiceChannelActionLabel(ch.id)"
|
||||
data-channel-type="voice"
|
||||
[attr.data-channel-name]="ch.name"
|
||||
>
|
||||
@@ -186,6 +188,8 @@
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
[value]="ch.name"
|
||||
[class.border-destructive]="renamingChannelId() === ch.id && !!channelNameError()"
|
||||
[title]="renamingChannelId() === ch.id ? (channelNameError() ? (channelNameError()! | translate) : '') : ''"
|
||||
@@ -205,6 +209,10 @@
|
||||
<span class="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
{{ isVoiceWorkspaceExpanded() ? ('room.panel.open' | translate) : ('room.panel.view' | translate) }}
|
||||
</span>
|
||||
} @else if (isPassiveInVoiceRoom(ch.id)) {
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{{ 'room.panel.takeOverVoice' | translate }}
|
||||
</span>
|
||||
} @else if (voiceOccupancy(ch.id) > 0) {
|
||||
<span class="text-xs text-muted-foreground">{{ voiceOccupancy(ch.id) }}</span>
|
||||
}
|
||||
@@ -220,6 +228,7 @@
|
||||
appThemeNode="roomVoiceUserItem"
|
||||
class="flex items-center gap-2 rounded-md px-2 py-1.5 transition-colors hover:bg-secondary/50"
|
||||
[class.cursor-pointer]="canDragVoiceUser(u)"
|
||||
[class.opacity-50]="isPassiveVoiceUser(u)"
|
||||
[class.opacity-60]="draggedVoiceUserId() === (u.id || u.oderId)"
|
||||
[draggable]="canDragVoiceUser(u)"
|
||||
(dragstart)="onVoiceUserDragStart($event, u)"
|
||||
@@ -368,6 +377,7 @@
|
||||
<h4 class="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-2 px-1">{{ 'room.panel.you' | translate }}</h4>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-md bg-secondary/60 px-3 py-2 hover:bg-secondary/80 transition-colors cursor-pointer"
|
||||
[class.opacity-50]="isPassiveVoiceClient()"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
(click)="openProfileCard($event, currentUser()!, true); $event.stopPropagation()"
|
||||
@@ -413,7 +423,11 @@
|
||||
name="lucideMic"
|
||||
class="w-2.5 h-2.5"
|
||||
/>
|
||||
{{ 'room.panel.inVoice' | translate }}
|
||||
@if (isPassiveVoiceClient()) {
|
||||
{{ 'room.panel.voiceOnOtherDevice' | translate }}
|
||||
} @else {
|
||||
{{ 'room.panel.inVoice' | translate }}
|
||||
}
|
||||
</p>
|
||||
}
|
||||
@if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) {
|
||||
@@ -763,7 +777,6 @@
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.border-destructive]="!!channelNameError()"
|
||||
(ngModelChange)="clearChannelNameError()"
|
||||
(keydown.enter)="confirmCreateChannel()"
|
||||
/>
|
||||
@if (channelNameError()) {
|
||||
<p class="mt-2 text-sm text-destructive">{{ channelNameError()! | translate }}</p>
|
||||
|
||||
@@ -52,7 +52,12 @@ import {
|
||||
VoiceConnectionFacade,
|
||||
VoiceConnectivityHealthService
|
||||
} from '../../../domains/voice-connection';
|
||||
import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
|
||||
import {
|
||||
VoiceSessionFacade,
|
||||
VoiceWorkspaceService,
|
||||
isLocalVoiceOwner,
|
||||
isVoiceOnAnotherClient
|
||||
} from '../../../domains/voice-session';
|
||||
import { DirectMessageService } from '../../../domains/direct-message';
|
||||
import { DirectCallService } from '../../../domains/direct-call';
|
||||
import { VoicePlaybackService } from '../../../domains/voice-connection';
|
||||
@@ -88,6 +93,7 @@ import {
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { visibilityAwareInterval$ } from '../../../shared/rxjs';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
|
||||
import { AutoFocusDirective, SelectOnFocusDirective } from '../../../shared/directives';
|
||||
|
||||
type PanelMode = 'channels' | 'users';
|
||||
|
||||
@@ -109,6 +115,8 @@ const SKELETON_REVEAL_DELAY_MS = 180;
|
||||
ThemeNodeDirective,
|
||||
SkeletonComponent,
|
||||
SkeletonListComponent,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
@@ -689,7 +697,13 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
|
||||
if (!room || !current?.voiceState?.isConnected || current.voiceState.roomId !== roomId || current.voiceState.serverId !== room.id) {
|
||||
if (
|
||||
!room
|
||||
|| !current?.voiceState?.isConnected
|
||||
|| current.voiceState.roomId !== roomId
|
||||
|| current.voiceState.serverId !== room.id
|
||||
|| !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -697,6 +711,47 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
return true;
|
||||
}
|
||||
|
||||
isPassiveInVoiceRoom(roomId: string): boolean {
|
||||
const current = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!current?.voiceState?.isConnected
|
||||
&& current.voiceState.roomId === roomId
|
||||
&& current.voiceState.serverId === room?.id
|
||||
&& isVoiceOnAnotherClient(current.voiceState, this.realtime.getClientInstanceId());
|
||||
}
|
||||
|
||||
isPassiveVoiceClient(): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
return isVoiceOnAnotherClient(current?.voiceState, this.realtime.getClientInstanceId());
|
||||
}
|
||||
|
||||
isPassiveVoiceUser(user: User | null): boolean {
|
||||
const current = this.currentUser();
|
||||
|
||||
if (!user || !current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (user.id === current.id || user.oderId === current.oderId)
|
||||
&& this.isPassiveVoiceClient();
|
||||
}
|
||||
|
||||
voiceChannelActionLabel(roomId: string): string {
|
||||
if (this.isCurrentRoom(roomId)) {
|
||||
return this.isVoiceWorkspaceExpanded()
|
||||
? this.appI18n.instant('room.panel.open')
|
||||
: this.appI18n.instant('room.panel.view');
|
||||
}
|
||||
|
||||
if (this.isPassiveInVoiceRoom(roomId)) {
|
||||
return this.appI18n.instant('room.panel.takeOverVoice');
|
||||
}
|
||||
|
||||
return this.appI18n.instant('room.panel.joinVoiceChannel');
|
||||
}
|
||||
|
||||
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
|
||||
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
|
||||
}
|
||||
@@ -737,9 +792,19 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
this.directCalls.leaveCurrentJoinedCall();
|
||||
this.prepareVoiceJoin(room, current ?? null);
|
||||
|
||||
this.enableVoiceForJoin(room, current ?? null, roomId)
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch((error) => this.handleVoiceJoinFailure(error));
|
||||
const startJoin = () => {
|
||||
this.enableVoiceForJoin(room, current ?? null, roomId)
|
||||
.then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
|
||||
.catch((error) => this.handleVoiceJoinFailure(error));
|
||||
};
|
||||
|
||||
if (this.isPassiveInVoiceRoom(roomId) || this.isPassiveVoiceClient()) {
|
||||
this.realtime.requestVoiceClientTakeover();
|
||||
window.setTimeout(startJoin, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
startJoin();
|
||||
}
|
||||
|
||||
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
|
||||
@@ -786,7 +851,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isMuted: current.voiceState?.isMuted ?? false,
|
||||
isDeafened: current.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
serverId: room.id,
|
||||
clientInstanceId: this.realtime.getClientInstanceId()
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -797,6 +863,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
|
||||
const clientInstanceId = this.realtime.getClientInstanceId();
|
||||
|
||||
this.voiceConnection.broadcastMessage({
|
||||
type: 'voice-state',
|
||||
oderId: current?.oderId || current?.id,
|
||||
@@ -806,7 +874,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isMuted: current?.voiceState?.isMuted ?? false,
|
||||
isDeafened: current?.voiceState?.isDeafened ?? false,
|
||||
roomId,
|
||||
serverId: room.id
|
||||
serverId: room.id,
|
||||
clientInstanceId
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -851,7 +920,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: undefined,
|
||||
serverId: undefined
|
||||
serverId: undefined,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -873,7 +943,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
isMuted: false,
|
||||
isDeafened: false,
|
||||
roomId: previousVoiceState?.roomId,
|
||||
serverId: previousVoiceState?.serverId
|
||||
serverId: previousVoiceState?.serverId,
|
||||
clientInstanceId: undefined
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1110,7 +1181,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
|
||||
const me = this.currentUser();
|
||||
const room = this.currentRoom();
|
||||
|
||||
return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
|
||||
return !!(
|
||||
me?.voiceState?.isConnected
|
||||
&& me.voiceState.roomId === roomId
|
||||
&& me.voiceState.serverId === room?.id
|
||||
&& isLocalVoiceOwner(me.voiceState, this.realtime.getClientInstanceId())
|
||||
);
|
||||
}
|
||||
|
||||
voiceEnabled(): boolean {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
} from 'rxjs';
|
||||
|
||||
import { Room, User } from '../../../shared-kernel';
|
||||
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
|
||||
import { VoiceSessionFacade } from '../../../domains/voice-session';
|
||||
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
|
||||
@@ -276,7 +277,9 @@ export class ServersRailComponent {
|
||||
const currentUserId = localStorage.getItem('metoyou_currentUserId');
|
||||
|
||||
if (!currentUserId) {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -154,11 +154,13 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addIgnoredProcess()"
|
||||
class="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[placeholder]="'settings.general.gameDetection.processPlaceholder' | translate"
|
||||
[value]="ignoredProcessDraft()"
|
||||
(input)="onIgnoredProcessDraftChange($event)"
|
||||
(keydown.enter)="addIgnoredProcess()"
|
||||
[attr.aria-label]="'settings.general.gameDetection.processAria' | translate"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -14,11 +14,21 @@ import { ElectronBridgeService } from '../../../../core/platform/electron/electr
|
||||
import { PlatformService } from '../../../../core/platform';
|
||||
import { ExperimentalMediaSettingsService } from '../../../../domains/experimental-media/application/services/experimental-media-settings.service';
|
||||
import { APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-general-settings',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NgIcon, ...APP_TRANSLATE_IMPORTS],
|
||||
imports: [
|
||||
CommonModule,
|
||||
NgIcon,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
provideIcons({
|
||||
lucidePower
|
||||
|
||||
@@ -113,6 +113,10 @@
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
appAutoFocus
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addEntry()"
|
||||
[(ngModel)]="newUrl"
|
||||
data-testid="ice-url-input"
|
||||
[placeholder]="(newType === 'stun' ? 'settings.network.ice.stunPlaceholder' : 'settings.network.ice.turnPlaceholder') | translate"
|
||||
@@ -123,6 +127,7 @@
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
[(ngModel)]="newUsername"
|
||||
data-testid="ice-username-input"
|
||||
[placeholder]="'settings.network.ice.username' | translate"
|
||||
@@ -130,6 +135,8 @@
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addEntry()"
|
||||
[(ngModel)]="newCredential"
|
||||
data-testid="ice-credential-input"
|
||||
[placeholder]="'settings.network.ice.credential' | translate"
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
|
||||
import { IceServerSettingsService, IceServerEntry } from '../../../../infrastructure/realtime/ice-server-settings.service';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-ice-server-settings',
|
||||
@@ -29,6 +34,9 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
AutoFocusDirective,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -126,12 +126,16 @@
|
||||
<div class="flex-1 space-y-1.5">
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
[(ngModel)]="newServerName"
|
||||
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholderShort' | translate"
|
||||
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addServer()"
|
||||
[(ngModel)]="newServerUrl"
|
||||
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
|
||||
class="w-full px-3 py-1.5 bg-secondary rounded-lg border border-border text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
|
||||
@@ -22,6 +22,10 @@ import { ServerDirectoryFacade } from '../../../../domains/server-directory';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS } from '../../../../core/constants';
|
||||
import { IceServerSettingsComponent } from '../ice-server-settings/ice-server-settings.component';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-network-settings',
|
||||
@@ -31,6 +35,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
IceServerSettingsComponent,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -110,6 +110,9 @@
|
||||
}}</span>
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="saveRoleDetails()"
|
||||
[ngModel]="roleName"
|
||||
(ngModelChange)="roleName = $event"
|
||||
[disabled]="!canEditSelectedRoleMetadata()"
|
||||
|
||||
@@ -39,6 +39,10 @@ import {
|
||||
withUpdatedRole
|
||||
} from '../../../../domains/access-control';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
function upsertRoleChannelOverride(
|
||||
overrides: readonly ChannelPermissionOverride[] | undefined,
|
||||
@@ -75,6 +79,8 @@ function upsertRoleChannelOverride(
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -82,6 +82,9 @@
|
||||
[(ngModel)]="roomName"
|
||||
[readOnly]="!isAdmin()"
|
||||
id="room-name"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="saveServerSettings()"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
[class.opacity-60]="!isAdmin()"
|
||||
[class.cursor-not-allowed]="!isAdmin()"
|
||||
|
||||
@@ -26,6 +26,10 @@ import { ConfirmDialogComponent } from '../../../../shared';
|
||||
import { SettingsModalService } from '../../../../core/services/settings-modal.service';
|
||||
import { ServerIconImageService } from '../../../../domains/server-directory/infrastructure/services/server-icon-image.service';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-server-settings',
|
||||
@@ -35,6 +39,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../../core/i18n';
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
ConfirmDialogComponent,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -163,12 +163,16 @@
|
||||
<div class="flex-1 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
appSelectOnFocus
|
||||
[(ngModel)]="newServerName"
|
||||
[placeholder]="'settings.network.serverEndpoints.serverNamePlaceholder' | translate"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
appSelectOnFocus
|
||||
appSubmitOnEnter
|
||||
(submitOnEnter)="addServer()"
|
||||
[(ngModel)]="newServerUrl"
|
||||
[placeholder]="'settings.network.serverEndpoints.serverUrlPlaceholder' | translate"
|
||||
class="w-full px-3 py-2 bg-secondary rounded-lg border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
|
||||
@@ -29,6 +29,10 @@ import { VoiceConnectionFacade } from '../../domains/voice-connection';
|
||||
import { NotificationAudioService, AppSound } from '../../core/services/notification-audio.service';
|
||||
import { STORAGE_KEY_CONNECTION_SETTINGS, STORAGE_KEY_VOICE_SETTINGS } from '../../core/constants';
|
||||
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
|
||||
import {
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective
|
||||
} from '../../shared/directives';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -37,6 +41,8 @@ import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../core/i18n';
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
NgIcon,
|
||||
SelectOnFocusDirective,
|
||||
SubmitOnEnterDirective,
|
||||
...APP_TRANSLATE_IMPORTS
|
||||
],
|
||||
viewProviders: [
|
||||
|
||||
@@ -40,6 +40,7 @@ import { RealtimeSessionFacade } from '../../../core/realtime';
|
||||
import { ServerDirectoryFacade } from '../../../domains/server-directory';
|
||||
import { PlatformService } from '../../../core/platform';
|
||||
import { clearStoredCurrentUserId } from '../../../core/storage/current-user-storage';
|
||||
import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
|
||||
import { SettingsModalService } from '../../../core/services/settings-modal.service';
|
||||
import { LeaveServerDialogComponent, ModalBackdropComponent } from '../../../shared';
|
||||
import { Room, type PluginRequirementSummary } from '../../../shared-kernel';
|
||||
@@ -211,7 +212,9 @@ export class TitleBarComponent {
|
||||
|
||||
/** Navigate to the login page. */
|
||||
goLogin() {
|
||||
this.router.navigate(['/login']);
|
||||
this.router.navigate(['/login'], {
|
||||
queryParams: buildLoginReturnQueryParams(this.router.url)
|
||||
});
|
||||
}
|
||||
|
||||
openPluginStore(): void {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect
|
||||
} from 'vitest';
|
||||
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
|
||||
|
||||
describe('aggregateComponentCountsByDomain', () => {
|
||||
it('groups component counts under inferred domains', () => {
|
||||
expect(aggregateComponentCountsByDomain({
|
||||
VoiceChannelPanelComponent: 2,
|
||||
DmChatComponent: 1,
|
||||
App: 1
|
||||
})).toEqual({
|
||||
'voice-connection': 2,
|
||||
'direct-message': 1,
|
||||
core: 1
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectSuspectedComponentLeaks', () => {
|
||||
it('flags components that exceed the expected live count', () => {
|
||||
expect(detectSuspectedComponentLeaks({
|
||||
DmChatComponent: 3,
|
||||
App: 1
|
||||
}, {
|
||||
DmChatComponent: 0,
|
||||
App: 1
|
||||
})).toEqual([
|
||||
{
|
||||
name: 'DmChatComponent',
|
||||
count: 3,
|
||||
expected: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when counts are within expectations', () => {
|
||||
expect(detectSuspectedComponentLeaks({
|
||||
App: 1
|
||||
}, {
|
||||
App: 1
|
||||
})).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mapComponentNameToDomain } from './domain-mapping.rules';
|
||||
|
||||
export interface SuspectedComponentLeak {
|
||||
name: string;
|
||||
count: number;
|
||||
expected: number;
|
||||
}
|
||||
|
||||
export function aggregateComponentCountsByDomain(
|
||||
componentCounts: Record<string, number>
|
||||
): Record<string, number> {
|
||||
const domains: Record<string, number> = {};
|
||||
|
||||
for (const [componentName, count] of Object.entries(componentCounts)) {
|
||||
const domain = mapComponentNameToDomain(componentName);
|
||||
|
||||
domains[domain] = (domains[domain] ?? 0) + count;
|
||||
}
|
||||
|
||||
return domains;
|
||||
}
|
||||
|
||||
export function detectSuspectedComponentLeaks(
|
||||
componentCounts: Record<string, number>,
|
||||
expectedCounts: Record<string, number>
|
||||
): SuspectedComponentLeak[] {
|
||||
const leaks: SuspectedComponentLeak[] = [];
|
||||
|
||||
for (const [name, count] of Object.entries(componentCounts)) {
|
||||
const expected = expectedCounts[name] ?? 0;
|
||||
|
||||
if (count > expected) {
|
||||
leaks.push({ name, count, expected });
|
||||
}
|
||||
}
|
||||
|
||||
return leaks.sort((left, right) => right.count - left.count);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApplicationRef } from '@angular/core';
|
||||
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
|
||||
|
||||
const DEFAULT_DOM_SCAN_BUDGET = 400;
|
||||
|
||||
interface NgDebugApi {
|
||||
getComponent?: (element: Element) => { constructor: { name: string } } | null;
|
||||
}
|
||||
|
||||
export interface ComponentTreeScanResult {
|
||||
components: Record<string, number>;
|
||||
domains: Record<string, number>;
|
||||
suspectedLeaks: ReturnType<typeof detectSuspectedComponentLeaks>;
|
||||
scannedNodes: number;
|
||||
scanMode: 'application-ref' | 'ng-global';
|
||||
}
|
||||
|
||||
export function scanComponentTree(
|
||||
appRef: ApplicationRef,
|
||||
expectedCounts: Record<string, number>,
|
||||
options: { domScanBudget?: number } = {}
|
||||
): ComponentTreeScanResult {
|
||||
const domScanBudget = options.domScanBudget ?? DEFAULT_DOM_SCAN_BUDGET;
|
||||
const ngApi = (globalThis as { ng?: NgDebugApi }).ng;
|
||||
const components: Record<string, number> = {};
|
||||
|
||||
let scannedNodes = 0;
|
||||
let scanMode: ComponentTreeScanResult['scanMode'] = 'application-ref';
|
||||
|
||||
if (ngApi?.getComponent && typeof document !== 'undefined') {
|
||||
scanMode = 'ng-global';
|
||||
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
||||
|
||||
while (walker.nextNode() && scannedNodes < domScanBudget) {
|
||||
scannedNodes += 1;
|
||||
|
||||
try {
|
||||
const component = ngApi.getComponent?.(walker.currentNode as Element) as {
|
||||
constructor: { name: string };
|
||||
} | null;
|
||||
|
||||
if (!component) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = component.constructor.name;
|
||||
|
||||
components[name] = (components[name] ?? 0) + 1;
|
||||
} catch {
|
||||
// Ignore elements that are not component hosts.
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const componentRef of appRef.components) {
|
||||
const name = componentRef.componentType.name;
|
||||
|
||||
components[name] = (components[name] ?? 0) + 1;
|
||||
scannedNodes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
components,
|
||||
domains: aggregateComponentCountsByDomain(components),
|
||||
suspectedLeaks: detectSuspectedComponentLeaks(components, expectedCounts),
|
||||
scannedNodes,
|
||||
scanMode
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
EnvironmentInjector,
|
||||
inject,
|
||||
runInInjectionContext
|
||||
} from '@angular/core';
|
||||
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
|
||||
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
|
||||
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
|
||||
|
||||
const SAMPLE_INTERVAL_MS = 10_000;
|
||||
|
||||
let started = false;
|
||||
let sampleTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export async function bootstrapPerfDiagnostics(
|
||||
api: ElectronApi,
|
||||
injector: EnvironmentInjector
|
||||
): Promise<void> {
|
||||
const reportSample = api.reportPerfDiagSample;
|
||||
|
||||
if (started || !api.isPerfDiagEnabled || !reportSample) {
|
||||
return;
|
||||
}
|
||||
|
||||
let enabled = false;
|
||||
|
||||
try {
|
||||
enabled = await api.isPerfDiagEnabled();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
started = true;
|
||||
|
||||
const reporter: PerfDiagReporter = {
|
||||
report: (entry: PerfDiagEntry) => reportSample(entry)
|
||||
};
|
||||
const runSample = (): void => {
|
||||
void runInInjectionContext(injector, async () => {
|
||||
try {
|
||||
const collector = inject(PerfDiagnosticsCollector);
|
||||
|
||||
await publishRendererDiagnosticsSample(reporter, collector);
|
||||
} catch {
|
||||
stopPerfDiagnosticsSampling();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
scheduleSample(runSample);
|
||||
sampleTimer = setInterval(() => scheduleSample(runSample), SAMPLE_INTERVAL_MS);
|
||||
|
||||
window.addEventListener('error', () => {
|
||||
void reporter.report({
|
||||
collectedAt: Date.now(),
|
||||
source: 'renderer',
|
||||
type: 'crash',
|
||||
payload: { scope: 'window-error' }
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', () => {
|
||||
void reporter.report({
|
||||
collectedAt: Date.now(),
|
||||
source: 'renderer',
|
||||
type: 'crash',
|
||||
payload: { scope: 'unhandled-rejection' }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSample(runSample: () => void): void {
|
||||
const idle = (globalThis as {
|
||||
requestIdleCallback?: (handler: () => void, options?: { timeout: number }) => number;
|
||||
}).requestIdleCallback;
|
||||
|
||||
if (idle) {
|
||||
idle(() => runSample(), { timeout: SAMPLE_INTERVAL_MS });
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(runSample, 0);
|
||||
}
|
||||
|
||||
function stopPerfDiagnosticsSampling(): void {
|
||||
if (sampleTimer) {
|
||||
clearInterval(sampleTimer);
|
||||
sampleTimer = null;
|
||||
}
|
||||
|
||||
started = false;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user