diff --git a/agents-docs/LESSONS.md b/agents-docs/LESSONS.md index 39dbe71..3890b26 100644 --- a/agents-docs/LESSONS.md +++ b/agents-docs/LESSONS.md @@ -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. diff --git a/agents-docs/adr/0003-multi-client-sessions.md b/agents-docs/adr/0003-multi-client-sessions.md new file mode 100644 index 0000000..1494220 --- /dev/null +++ b/agents-docs/adr/0003-multi-client-sessions.md @@ -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. diff --git a/agents-docs/features/authentication.md b/agents-docs/features/authentication.md index fdbbbf1..9ed4237 100644 --- a/agents-docs/features/authentication.md +++ b/agents-docs/features/authentication.md @@ -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": "", "oderId": "", "displayName": "Alice", - "connectionScope": "ws://host:3001" + "connectionScope": "ws://host:3001", + "clientInstanceId": "" } ``` - `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. diff --git a/e2e/fixtures/multi-client.ts b/e2e/fixtures/multi-client.ts index 1b384b6..28af183 100644 --- a/e2e/fixtures/multi-client.ts +++ b/e2e/fixtures/multi-client.ts @@ -48,7 +48,8 @@ export const test = base.extend({ 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); diff --git a/e2e/helpers/multi-device-session.ts b/e2e/helpers/multi-device-session.ts new file mode 100644 index 0000000..b3aa89b --- /dev/null +++ b/e2e/helpers/multi-device-session.ts @@ -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, + options: { suffix?: string; serverDescription?: string } = {} +): Promise { + 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 { + 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 { + 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 { + await page.goto('/dashboard', { waitUntil: 'domcontentloaded' }); + await page.waitForLoadState('networkidle').catch(() => undefined); +} + +async function waitForCurrentRoomName(page: Page, roomName: string, timeout = 20_000): Promise { + await page.waitForFunction( + (expectedRoomName) => { + interface RoomShape { name?: string } + interface AngularDebugApi { + getComponent: (element: Element) => Record; + } + + 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 { + return page.evaluate(() => localStorage.getItem('metoyou.clientInstanceId')); +} + +export async function logoutFromMenu(page: Page): Promise { + 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 { + 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 { + await expect(page.locator('app-voice-controls, app-voice-workspace').first()).toBeVisible({ timeout }); +} diff --git a/e2e/pages/chat-messages.page.ts b/e2e/pages/chat-messages.page.ts index 378de96..dca5ad0 100644 --- a/e2e/pages/chat-messages.page.ts +++ b/e2e/pages/chat-messages.page.ts @@ -34,9 +34,22 @@ export class ChatMessagesPage { } async sendMessage(content: string): Promise { - 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 { @@ -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 { + await this.waitForReady(); + await this.composerInput.click(); + await this.composerInput.pressSequentially(content, { delay: 40 }); + } + async clearDraft(): Promise { await this.waitForReady(); await this.composerInput.fill(''); diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts index 992e753..2d6676b 100644 --- a/e2e/pages/login.page.ts +++ b/e2e/pages/login.page.ts @@ -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() { diff --git a/e2e/run-playwright.mjs b/e2e/run-playwright.mjs new file mode 100644 index 0000000..503cb89 --- /dev/null +++ b/e2e/run-playwright.mjs @@ -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); +}); diff --git a/e2e/tests/auth/login-return-url.spec.ts b/e2e/tests/auth/login-return-url.spec.ts new file mode 100644 index 0000000..589e44b --- /dev/null +++ b/e2e/tests/auth/login-return-url.spec.ts @@ -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; + 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 { + 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)}`; +} diff --git a/e2e/tests/auth/multi-device-session.spec.ts b/e2e/tests/auth/multi-device-session.spec.ts new file mode 100644 index 0000000..d23947e --- /dev/null +++ b/e2e/tests/auth/multi-device-session.spec.ts @@ -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 }); + }); + }); +}); diff --git a/e2e/tests/auth/user-session-data-isolation.spec.ts b/e2e/tests/auth/user-session-data-isolation.spec.ts index 98ee2bd..6f3af2e 100644 --- a/e2e/tests/auth/user-session-data-isolation.spec.ts +++ b/e2e/tests/auth/user-session-data-isolation.spec.ts @@ -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 { await expect(loginPage.usernameInput).toBeVisible({ timeout: 10_000 }); } -async function createServerAndSendMessage(page: Page, serverName: string, messageText: string): Promise { +async function createServerAndSendMessage(page: Page, user: TestUser, serverName: string, messageText: string): Promise { 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 { - const railRoomButton = getRailSavedRoomButton(page, roomName); - const messagesPage = new ChatMessagesPage(page); +async function expectSavedRoomAndHistory(page: Page, user: TestUser, roomName: string, messageText: string): Promise { + 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 { @@ -232,14 +257,17 @@ async function expectBlankSlate(page: Page, hiddenRoomNames: string[]): Promise< } async function expectSavedRoomVisible(page: Page, roomName: string): Promise { - 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 { - 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return page.evaluate(() => !!localStorage.getItem('metoyou_currentUserId')?.trim()); +} + +async function openPersistedRoomById(page: Page, user: TestUser, roomId: string): Promise { + 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 { + try { + await expect(new LoginPage(page).usernameInput).toBeVisible({ timeout }); + return true; + } catch { + return false; + } +} + +async function waitForServerSearch(page: Page, timeout: number): Promise { + try { + await expect(new ServerSearchPage(page).searchInput).toBeVisible({ timeout }); + return true; + } catch { + return false; + } +} + +async function waitForVisibleText(page: Page, text: string, timeout: number): Promise { + try { + await expect(page.getByText(text, { exact: false })).toBeVisible({ timeout }); + return true; + } catch { + return false; + } +} + +async function expectMessagePersistedInIndexedDb(page: Page, messageText: string): Promise { + await expect.poll( + () => getPersistedRoomIdForMessage(page, messageText).then((roomId) => !!roomId), + { timeout: 10_000 } + ).toBe(true); +} + +async function getPersistedRoomIdForMessage(page: Page, messageText: string): Promise { + 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((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((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(navigate: () => Promise, attempts = 4): Promise { let lastError: unknown; diff --git a/e2e/tests/voice/mixed-signal-config-voice.spec.ts b/e2e/tests/voice/mixed-signal-config-voice.spec.ts index 1472891..6f3cb0c 100644 --- a/e2e/tests/voice/mixed-signal-config-voice.spec.ts +++ b/e2e/tests/voice/mixed-signal-config-voice.spec.ts @@ -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 diff --git a/electron/api/auth-store.spec.ts b/electron/api/auth-store.spec.ts new file mode 100644 index 0000000..5c41747 --- /dev/null +++ b/electron/api/auth-store.spec.ts @@ -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); + }); +}); diff --git a/electron/api/auth-store.ts b/electron/api/auth-store.ts index 792ed47..404e9aa 100644 --- a/electron/api/auth-store.ts +++ b/electron/api/auth-store.ts @@ -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(); +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, diff --git a/electron/app/lifecycle.ts b/electron/app/lifecycle.ts index a25d32d..3b0b372 100644 --- a/electron/app/lifecycle.ts +++ b/electron/app/lifecycle.ts @@ -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(); diff --git a/electron/diagnostics/diagnostics.flags.spec.ts b/electron/diagnostics/diagnostics.flags.spec.ts new file mode 100644 index 0000000..a583c0c --- /dev/null +++ b/electron/diagnostics/diagnostics.flags.spec.ts @@ -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); + }); +}); diff --git a/electron/diagnostics/diagnostics.flags.ts b/electron/diagnostics/diagnostics.flags.ts new file mode 100644 index 0000000..7ea1421 --- /dev/null +++ b/electron/diagnostics/diagnostics.flags.ts @@ -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; +} diff --git a/electron/diagnostics/diagnostics.lifecycle.ts b/electron/diagnostics/diagnostics.lifecycle.ts new file mode 100644 index 0000000..529d114 --- /dev/null +++ b/electron/diagnostics/diagnostics.lifecycle.ts @@ -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 { + 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 ?? {} + }; +} diff --git a/electron/diagnostics/diagnostics.models.ts b/electron/diagnostics/diagnostics.models.ts new file mode 100644 index 0000000..92fa24e --- /dev/null +++ b/electron/diagnostics/diagnostics.models.ts @@ -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; +} diff --git a/electron/diagnostics/diagnostics.rules.spec.ts b/electron/diagnostics/diagnostics.rules.spec.ts new file mode 100644 index 0000000..4fdf823 --- /dev/null +++ b/electron/diagnostics/diagnostics.rules.spec.ts @@ -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'); + }); +}); diff --git a/electron/diagnostics/diagnostics.rules.ts b/electron/diagnostics/diagnostics.rules.ts new file mode 100644 index 0000000..54f45b3 --- /dev/null +++ b/electron/diagnostics/diagnostics.rules.ts @@ -0,0 +1,24 @@ +import * as path from 'path'; +import type { PerfDiagEntry } from './diagnostics.models'; + +export function pushRingBuffer(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'); +} diff --git a/electron/diagnostics/diagnostics.writer.ts b/electron/diagnostics/diagnostics.writer.ts new file mode 100644 index 0000000..c800af7 --- /dev/null +++ b/electron/diagnostics/diagnostics.writer.ts @@ -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 | 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 { + 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 { + 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 { + await fsp.mkdir(path.dirname(this.filePath), { recursive: true }); + await fsp.appendFile(this.filePath, lines.join(''), 'utf8'); + } +} diff --git a/electron/diagnostics/index.ts b/electron/diagnostics/index.ts new file mode 100644 index 0000000..533f6f3 --- /dev/null +++ b/electron/diagnostics/index.ts @@ -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'; diff --git a/electron/diagnostics/process-metrics.rules.ts b/electron/diagnostics/process-metrics.rules.ts new file mode 100644 index 0000000..adb7120 --- /dev/null +++ b/electron/diagnostics/process-metrics.rules.ts @@ -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; +} diff --git a/electron/preload.ts b/electron/preload.ts index 38a40a8..72dd110 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -252,6 +252,13 @@ export interface ElectronAPI { workingSetKb: number | null; }[]; }>; + isPerfDiagEnabled: () => Promise; + reportPerfDiagSample: (entry: { + collectedAt: number; + source: 'main' | 'renderer'; + type: string; + payload: Record; + }) => Promise; getAppDataPath: () => Promise; openCurrentDataFolder: () => Promise; exportUserData: () => Promise; @@ -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'), diff --git a/package.json b/package.json index 5168c4d..ada693d 100644 --- a/package.json +++ b/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", diff --git a/server/CONTEXT.md b/server/CONTEXT.md index 3757afc..761a2b2 100644 --- a/server/CONTEXT.md +++ b/server/CONTEXT.md @@ -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 diff --git a/server/src/services/session-auth.service.spec.ts b/server/src/services/session-auth.service.spec.ts new file mode 100644 index 0000000..72806c4 --- /dev/null +++ b/server/src/services/session-auth.service.spec.ts @@ -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); + }); +}); diff --git a/server/src/services/session-auth.service.ts b/server/src/services/session-auth.service.ts index f004e52..1750910 100644 --- a/server/src/services/session-auth.service.ts +++ b/server/src/services/session-auth.service.ts @@ -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; diff --git a/server/src/websocket/broadcast.spec.ts b/server/src/websocket/broadcast.spec.ts new file mode 100644 index 0000000..0d1beca --- /dev/null +++ b/server/src/websocket/broadcast.spec.ts @@ -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 { + 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); + }); +}); diff --git a/server/src/websocket/broadcast.ts b/server/src/websocket/broadcast.ts index 3aa7f29..ef7f412 100644 --- a/server/src/websocket/broadcast.ts +++ b/server/src/websocket/broadcast.ts @@ -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(); +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) => { diff --git a/server/src/websocket/handler-multi-client.spec.ts b/server/src/websocket/handler-multi-client.spec.ts new file mode 100644 index 0000000..4572aa9 --- /dev/null +++ b/server/src/websocket/handler-multi-client.spec.ts @@ -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(); + + 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 { + 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); + }); +}); diff --git a/server/src/websocket/handler.ts b/server/src/websocket/handler.ts index 14d0bf2..75e8cea 100644 --- a/server/src/websocket/handler.ts +++ b/server/src/websocket/handler.ts @@ -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): 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 { +async function handlePluginEvent(user: ConnectedUser, message: WsMessage, connectionId: string): Promise { 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: diff --git a/server/src/websocket/index.ts b/server/src/websocket/index.ts index ed3b0ac..aae9b1a 100644 --- a/server/src/websocket/index.ts +++ b/server/src/websocket/index.ts @@ -39,7 +39,7 @@ function removeDeadConnection(connectionId: string): void { displayName: user.displayName, serverId: sid, serverIds: remainingServerIds - }, user.oderId); + }, { excludeIdentityOderId: user.oderId }); }); try { diff --git a/server/src/websocket/types.ts b/server/src/websocket/types.ts index 7525028..de3c8d3 100644 --- a/server/src/websocket/types.ts +++ b/server/src/websocket/types.ts @@ -22,6 +22,12 @@ export interface ConnectedUser { status?: 'online' | 'away' | 'busy' | 'offline'; /** Latest server icon timestamp this connection can provide over P2P. */ serverIconUpdatedAtByServerId?: Map; + /** 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; /** Timestamp of the last pong or client message received (used to detect dead connections). */ lastPong: number; } diff --git a/toju-app/CONTEXT.md b/toju-app/CONTEXT.md index dc6e7e7..196aa82 100644 --- a/toju-app/CONTEXT.md +++ b/toju-app/CONTEXT.md @@ -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 diff --git a/toju-app/public/i18n/catalog/call.json b/toju-app/public/i18n/catalog/call.json index 87a8c1d..d0d4868 100644 --- a/toju-app/public/i18n/catalog/call.json +++ b/toju-app/public/i18n/catalog/call.json @@ -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" diff --git a/toju-app/public/i18n/catalog/room.json b/toju-app/public/i18n/catalog/room.json index 2daa590..a902453 100644 --- a/toju-app/public/i18n/catalog/room.json +++ b/toju-app/public/i18n/catalog/room.json @@ -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", diff --git a/toju-app/public/i18n/en.json b/toju-app/public/i18n/en.json index 956d769..a2d6c35 100644 --- a/toju-app/public/i18n/en.json +++ b/toju-app/public/i18n/en.json @@ -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", diff --git a/toju-app/src/app/app.ts b/toju-app/src/app/app.ts index f928d75..408a5a4 100644 --- a/toju-app/src/app/app.ts +++ b/toju-app/src/app/app.ts @@ -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(() => {}); } } diff --git a/toju-app/src/app/core/platform/client-instance.service.spec.ts b/toju-app/src/app/core/platform/client-instance.service.spec.ts new file mode 100644 index 0000000..06ab226 --- /dev/null +++ b/toju-app/src/app/core/platform/client-instance.service.spec.ts @@ -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(); + + 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'); + }); +}); diff --git a/toju-app/src/app/core/platform/client-instance.service.ts b/toju-app/src/app/core/platform/client-instance.service.ts new file mode 100644 index 0000000..32a8d6f --- /dev/null +++ b/toju-app/src/app/core/platform/client-instance.service.ts @@ -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; + } + } +} diff --git a/toju-app/src/app/core/platform/electron/electron-api.models.ts b/toju-app/src/app/core/platform/electron/electron-api.models.ts index 780cd60..3fa2e22 100644 --- a/toju-app/src/app/core/platform/electron/electron-api.models.ts +++ b/toju-app/src/app/core/platform/electron/electron-api.models.ts @@ -244,6 +244,13 @@ export interface ElectronAppMetricsSnapshot { processes: ElectronAppMetricsProcess[]; } +export interface ElectronPerfDiagEntry { + collectedAt: number; + source: 'main' | 'renderer'; + type: string; + payload: Record; +} + 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; + isPerfDiagEnabled?: () => Promise; + reportPerfDiagSample?: (entry: ElectronPerfDiagEntry) => Promise; getAppDataPath: () => Promise; openCurrentDataFolder: () => Promise; exportUserData: () => Promise; diff --git a/toju-app/src/app/core/platform/index.ts b/toju-app/src/app/core/platform/index.ts index 72e022d..afd8c45 100644 --- a/toju-app/src/app/core/platform/index.ts +++ b/toju-app/src/app/core/platform/index.ts @@ -1,3 +1,4 @@ export * from './platform.service'; export * from './external-link.service'; export * from './viewport.service'; +export * from './client-instance.service'; diff --git a/toju-app/src/app/domains/authentication/README.md b/toju-app/src/app/domains/authentication/README.md index 41ea7f2..b496c98 100644 --- a/toju-app/src/app/domains/authentication/README.md +++ b/toju-app/src/app/domains/authentication/README.md @@ -15,8 +15,8 @@ authentication/ │ └── authentication.model.ts LoginResponse interface │ ├── feature/ -│ ├── login/ Login form component -│ ├── register/ Registration form component +│ ├── login/ Login form (`
`; 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 diff --git a/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.spec.ts b/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.spec.ts index 8969d5a..41bcfb9 100644 --- a/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.spec.ts +++ b/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.spec.ts @@ -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 () => { diff --git a/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts b/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts index 01651ef..d05dc18 100644 --- a/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts +++ b/toju-app/src/app/domains/authentication/domain/logic/auth-navigation.rules.ts @@ -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 { diff --git a/toju-app/src/app/domains/authentication/feature/login/login.component.html b/toju-app/src/app/domains/authentication/feature/login/login.component.html index ea77d95..6088072 100644 --- a/toju-app/src/app/domains/authentication/feature/login/login.component.html +++ b/toju-app/src/app/domains/authentication/feature/login/login.component.html @@ -8,7 +8,10 @@

{{ 'auth.login.title' | translate }}

-
+
@@ -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" />
@@ -44,6 +51,7 @@ @for (s of servers(); track s.id) { @@ -68,22 +78,21 @@

{{ error() }}

} -
- {{ 'auth.register.haveAccount' | translate }} - -
+ +
+ {{ 'auth.register.haveAccount' | translate }} +
diff --git a/toju-app/src/app/domains/authentication/feature/register/register.component.ts b/toju-app/src/app/domains/authentication/feature/register/register.component.ts index 92b2708..d075430 100644 --- a/toju-app/src/app/domains/authentication/feature/register/register.component.ts +++ b/toju-app/src/app/domains/authentication/feature/register/register.component.ts @@ -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) }); } } diff --git a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts index dc225bc..1c789f8 100644 --- a/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts +++ b/toju-app/src/app/domains/chat/feature/typing-indicator/typing-indicator.component.ts @@ -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)); diff --git a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.html b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.html index 6f87edb..0d52c6f 100644 --- a/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.html +++ b/toju-app/src/app/domains/custom-emoji/feature/custom-emoji-picker/custom-emoji-picker.component.html @@ -72,6 +72,8 @@ /> 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 } })); } diff --git a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.html b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.html index 7d7584a..cc92c1e 100644 --- a/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.html +++ b/toju-app/src/app/domains/direct-message/feature/find-people/find-people.component.html @@ -25,6 +25,8 @@ /> @@ -60,10 +60,10 @@ @@ -73,7 +73,11 @@ -
+
{{ 'servers.create.pickCategory' | translate }}
@@ -104,6 +108,9 @@