From 83456c018c6901e2e8bcfd1d3678b0f598f58147 Mon Sep 17 00:00:00 2001
From: Myx
Date: Sun, 7 Jun 2026 15:04:21 +0200
Subject: [PATCH] fix: Fix multiple bugs with new authentication flow
---
agents-docs/LESSONS.md | 14 +
agents-docs/adr/0003-multi-client-sessions.md | 16 +
agents-docs/features/authentication.md | 13 +-
e2e/fixtures/multi-client.ts | 3 +-
e2e/helpers/multi-device-session.ts | 205 ++++++++++++
e2e/pages/chat-messages.page.ts | 26 +-
e2e/pages/login.page.ts | 5 +-
e2e/run-playwright.mjs | 27 ++
e2e/tests/auth/login-return-url.spec.ts | 153 +++++++++
e2e/tests/auth/multi-device-session.spec.ts | 93 ++++++
.../auth/user-session-data-isolation.spec.ts | 297 ++++++++++++++++--
.../voice/mixed-signal-config-voice.spec.ts | 3 +-
electron/api/auth-store.spec.ts | 14 +
electron/api/auth-store.ts | 8 +-
electron/app/lifecycle.ts | 17 +
.../diagnostics/diagnostics.flags.spec.ts | 27 ++
electron/diagnostics/diagnostics.flags.ts | 29 ++
electron/diagnostics/diagnostics.lifecycle.ts | 214 +++++++++++++
electron/diagnostics/diagnostics.models.ts | 17 +
.../diagnostics/diagnostics.rules.spec.ts | 53 ++++
electron/diagnostics/diagnostics.rules.ts | 24 ++
electron/diagnostics/diagnostics.writer.ts | 108 +++++++
electron/diagnostics/index.ts | 11 +
electron/diagnostics/process-metrics.rules.ts | 19 ++
electron/preload.ts | 9 +
package.json | 10 +-
server/CONTEXT.md | 4 +-
.../src/services/session-auth.service.spec.ts | 39 +++
server/src/services/session-auth.service.ts | 2 +-
server/src/websocket/broadcast.spec.ts | 102 ++++++
server/src/websocket/broadcast.ts | 74 ++++-
.../websocket/handler-multi-client.spec.ts | 219 +++++++++++++
server/src/websocket/handler.ts | 153 +++++++--
server/src/websocket/index.ts | 2 +-
server/src/websocket/types.ts | 6 +
toju-app/CONTEXT.md | 2 +
toju-app/public/i18n/catalog/call.json | 3 +-
toju-app/public/i18n/catalog/room.json | 2 +
toju-app/public/i18n/en.json | 5 +-
toju-app/src/app/app.ts | 5 +-
.../platform/client-instance.service.spec.ts | 43 +++
.../core/platform/client-instance.service.ts | 38 +++
.../platform/electron/electron-api.models.ts | 9 +
toju-app/src/app/core/platform/index.ts | 1 +
.../src/app/domains/authentication/README.md | 4 +-
.../logic/auth-navigation.rules.spec.ts | 49 ++-
.../domain/logic/auth-navigation.rules.ts | 78 +++++
.../feature/login/login.component.html | 33 +-
.../feature/login/login.component.ts | 44 ++-
.../feature/register/register.component.html | 35 ++-
.../feature/register/register.component.ts | 22 +-
.../typing-indicator.component.ts | 21 +-
.../custom-emoji-picker.component.html | 2 +
.../custom-emoji-picker.component.ts | 12 +-
.../services/direct-call.service.ts | 15 +-
.../find-people/find-people.component.html | 2 +
.../find-people/find-people.component.ts | 6 +
.../plugin-store/plugin-store.component.html | 6 +-
.../plugin-store/plugin-store.component.ts | 18 +-
.../create-server-dialog.component.html | 25 +-
.../create-server-dialog.component.ts | 11 +-
.../create-server.component.html | 19 +-
.../create-server/create-server.component.ts | 17 +-
.../feature/invite/invite.component.ts | 5 +-
.../server-browser.component.html | 2 +
.../server-browser.component.ts | 24 +-
.../app/domains/voice-connection/README.md | 2 +-
.../services/voice-playback.service.spec.ts | 271 ++++++++++++++++
.../services/voice-playback.service.ts | 66 +++-
.../src/app/domains/voice-session/README.md | 10 +
.../services/voice-client-takeover.service.ts | 68 ++++
.../logic/client-voice-session.rules.spec.ts | 50 +++
.../logic/client-voice-session.rules.ts | 34 ++
.../src/app/domains/voice-session/index.ts | 2 +
.../dashboard/dashboard.component.html | 5 +-
.../features/dashboard/dashboard.component.ts | 8 +
...ivate-call-participant-card.component.html | 2 +-
...private-call-participant-card.component.ts | 1 +
.../direct-call/private-call.component.html | 2 +
.../direct-call/private-call.component.ts | 32 +-
.../rooms-side-panel.component.html | 19 +-
.../rooms-side-panel.component.ts | 96 +++++-
.../servers-rail/servers-rail.component.ts | 5 +-
.../general-settings.component.html | 4 +-
.../general-settings.component.ts | 12 +-
.../ice-server-settings.component.html | 7 +
.../ice-server-settings.component.ts | 8 +
.../network-settings.component.html | 4 +
.../network-settings.component.ts | 6 +
.../permissions-settings.component.html | 3 +
.../permissions-settings.component.ts | 6 +
.../server-settings.component.html | 3 +
.../server-settings.component.ts | 6 +
.../features/settings/settings.component.html | 4 +
.../features/settings/settings.component.ts | 6 +
.../shell/title-bar/title-bar.component.ts | 5 +-
.../diagnostics/component-tree.rules.spec.ts | 46 +++
.../diagnostics/component-tree.rules.ts | 38 +++
.../diagnostics/component-tree.scanner.ts | 69 ++++
.../diagnostics/diagnostics.bootstrap.ts | 96 ++++++
.../diagnostics/diagnostics.collector.ts | 185 +++++++++++
.../diagnostics/diagnostics.models.ts | 21 ++
.../diagnostics/domain-mapping.rules.spec.ts | 30 ++
.../diagnostics/domain-mapping.rules.ts | 48 +++
.../diagnostics/state-size.rules.spec.ts | 34 ++
.../diagnostics/state-size.rules.ts | 147 +++++++++
.../persistence/database.service.spec.ts | 69 ++++
.../persistence/database.service.ts | 15 +-
.../src/app/infrastructure/realtime/README.md | 6 +-
.../realtime/realtime-session.service.ts | 31 +-
.../infrastructure/realtime/realtime.types.ts | 4 +
.../signaling/signaling-transport-handler.ts | 12 +-
.../realtime/signaling/signaling.manager.ts | 3 +-
.../src/app/shared-kernel/message.models.ts | 2 +
.../app/shared-kernel/voice-state.models.ts | 2 +
.../confirm-dialog.component.ts | 43 ++-
.../profile-card-mobile.component.html | 6 +
.../profile-card-mobile.component.ts | 8 +
.../profile-card/profile-card.component.html | 6 +
.../profile-card/profile-card.component.ts | 8 +
.../shared/directives/auto-focus.directive.ts | 23 ++
.../directives/form-field-focus.rules.spec.ts | 65 ++++
.../directives/form-field-focus.rules.ts | 97 ++++++
toju-app/src/app/shared/directives/index.ts | 8 +
.../directives/select-on-focus.directive.ts | 36 +++
.../directives/submit-on-enter.directive.ts | 29 ++
toju-app/src/app/shared/index.ts | 5 +
.../messages-incoming.handlers.spec.ts | 33 ++
.../messages/messages-incoming.handlers.ts | 11 +-
.../app/store/messages/messages.effects.ts | 44 +--
.../store/rooms/room-state-sync.effects.ts | 50 ++-
toju-app/src/app/store/rooms/rooms.effects.ts | 84 ++---
toju-app/src/app/store/rooms/rooms.helpers.ts | 15 +
toju-app/src/app/store/users/users.effects.ts | 23 +-
toju-app/src/app/store/users/users.reducer.ts | 4 +-
toju-app/src/main.ts | 12 +
tools/perf-diag-viewer.js | 202 ++++++++++++
137 files changed, 4710 insertions(+), 281 deletions(-)
create mode 100644 agents-docs/adr/0003-multi-client-sessions.md
create mode 100644 e2e/helpers/multi-device-session.ts
create mode 100644 e2e/run-playwright.mjs
create mode 100644 e2e/tests/auth/login-return-url.spec.ts
create mode 100644 e2e/tests/auth/multi-device-session.spec.ts
create mode 100644 electron/api/auth-store.spec.ts
create mode 100644 electron/diagnostics/diagnostics.flags.spec.ts
create mode 100644 electron/diagnostics/diagnostics.flags.ts
create mode 100644 electron/diagnostics/diagnostics.lifecycle.ts
create mode 100644 electron/diagnostics/diagnostics.models.ts
create mode 100644 electron/diagnostics/diagnostics.rules.spec.ts
create mode 100644 electron/diagnostics/diagnostics.rules.ts
create mode 100644 electron/diagnostics/diagnostics.writer.ts
create mode 100644 electron/diagnostics/index.ts
create mode 100644 electron/diagnostics/process-metrics.rules.ts
create mode 100644 server/src/services/session-auth.service.spec.ts
create mode 100644 server/src/websocket/broadcast.spec.ts
create mode 100644 server/src/websocket/handler-multi-client.spec.ts
create mode 100644 toju-app/src/app/core/platform/client-instance.service.spec.ts
create mode 100644 toju-app/src/app/core/platform/client-instance.service.ts
create mode 100644 toju-app/src/app/domains/voice-connection/application/services/voice-playback.service.spec.ts
create mode 100644 toju-app/src/app/domains/voice-session/application/services/voice-client-takeover.service.ts
create mode 100644 toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.spec.ts
create mode 100644 toju-app/src/app/domains/voice-session/domain/logic/client-voice-session.rules.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/component-tree.rules.spec.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/component-tree.rules.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/component-tree.scanner.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/diagnostics.bootstrap.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/diagnostics.collector.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/diagnostics.models.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/domain-mapping.rules.spec.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/domain-mapping.rules.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/state-size.rules.spec.ts
create mode 100644 toju-app/src/app/infrastructure/diagnostics/state-size.rules.ts
create mode 100644 toju-app/src/app/shared/directives/auto-focus.directive.ts
create mode 100644 toju-app/src/app/shared/directives/form-field-focus.rules.spec.ts
create mode 100644 toju-app/src/app/shared/directives/form-field-focus.rules.ts
create mode 100644 toju-app/src/app/shared/directives/index.ts
create mode 100644 toju-app/src/app/shared/directives/select-on-focus.directive.ts
create mode 100644 toju-app/src/app/shared/directives/submit-on-enter.directive.ts
create mode 100644 tools/perf-diag-viewer.js
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 (`
+
+ {{ 'auth.login.noAccount' | translate }}
+
diff --git a/toju-app/src/app/domains/authentication/feature/login/login.component.ts b/toju-app/src/app/domains/authentication/feature/login/login.component.ts
index c96dbbe..82667e5 100644
--- a/toju-app/src/app/domains/authentication/feature/login/login.component.ts
+++ b/toju-app/src/app/domains/authentication/feature/login/login.component.ts
@@ -2,6 +2,7 @@
import {
Component,
inject,
+ OnInit,
signal
} from '@angular/core';
import { CommonModule } from '@angular/common';
@@ -11,14 +12,24 @@ import { Actions } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideLogIn } from '@ng-icons/lucide';
-import { firstValueFrom } from 'rxjs';
+import {
+ filter,
+ firstValueFrom,
+ take
+} from 'rxjs';
import { AuthenticationService } from '../../application/services/authentication.service';
import { ServerDirectoryFacade } from '../../../server-directory';
-import { waitForAuthenticationOutcome } from '../../domain/logic/auth-navigation.rules';
+import {
+ buildLoginReturnQueryParams,
+ resolveSafeReturnUrl,
+ waitForAuthenticationOutcome
+} from '../../domain/logic/auth-navigation.rules';
import { UsersActions } from '../../../../store/users/users.actions';
import { User } from '../../../../shared-kernel';
import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
+import { AutoFocusDirective, SelectOnFocusDirective } from '../../../../shared/directives';
+import { selectCurrentUser } from '../../../../store/users/users.selectors';
@Component({
selector: 'app-login',
@@ -27,6 +38,8 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
CommonModule,
FormsModule,
NgIcon,
+ AutoFocusDirective,
+ SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [provideIcons({ lucideLogIn })],
@@ -35,7 +48,7 @@ import { AppI18nService, APP_TRANSLATE_IMPORTS } from '../../../../core/i18n';
/**
* Login form allowing existing users to authenticate against a selected server.
*/
-export class LoginComponent {
+export class LoginComponent implements OnInit {
serversSvc = inject(ServerDirectoryFacade);
servers = this.serversSvc.servers;
@@ -54,6 +67,18 @@ export class LoginComponent {
/** TrackBy function for server list rendering. */
trackById(_index: number, item: { id: string }) { return item.id; }
+ ngOnInit(): void {
+ this.store.select(selectCurrentUser).pipe(
+ filter(Boolean),
+ take(1)
+ )
+ .subscribe(() => {
+ const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
+
+ void this.router.navigateByUrl(returnUrl);
+ });
+ }
+
/** Validate and submit the login form, then navigate to search on success. */
submit() {
this.error.set(null);
@@ -88,14 +113,9 @@ export class LoginComponent {
return;
}
- const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
+ const returnUrl = resolveSafeReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'));
- if (returnUrl?.startsWith('/')) {
- await this.router.navigateByUrl(returnUrl);
- return;
- }
-
- await this.router.navigate(['/dashboard']);
+ await this.router.navigateByUrl(returnUrl);
},
error: (err) => {
this.error.set(err?.error?.error || this.appI18n.instant('auth.login.failed'));
@@ -105,10 +125,8 @@ export class LoginComponent {
/** Navigate to the registration page. */
goRegister() {
- const returnUrl = this.route.snapshot.queryParamMap.get('returnUrl')?.trim();
-
this.router.navigate(['/register'], {
- queryParams: returnUrl ? { returnUrl } : undefined
+ queryParams: buildLoginReturnQueryParams(this.router.url)
});
}
}
diff --git a/toju-app/src/app/domains/authentication/feature/register/register.component.html b/toju-app/src/app/domains/authentication/feature/register/register.component.html
index 847a42d..826c2ba 100644
--- a/toju-app/src/app/domains/authentication/feature/register/register.component.html
+++ b/toju-app/src/app/domains/authentication/feature/register/register.component.html
@@ -8,7 +8,10 @@
{{ 'auth.register.title' | translate }}
-
+
@@ -45,6 +53,7 @@
[(ngModel)]="password"
type="password"
id="register-password"
+ name="password"
class="w-full px-3 py-2 rounded border border-border bg-secondary text-foreground"
/>
@@ -57,6 +66,7 @@
}
@if (currentUser() && isUserStreaming(currentUser()!.oderId || currentUser()!.id)) {
@@ -763,7 +777,6 @@
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
[class.border-destructive]="!!channelNameError()"
(ngModelChange)="clearChannelNameError()"
- (keydown.enter)="confirmCreateChannel()"
/>
@if (channelNameError()) {
{{ channelNameError()! | translate }}
diff --git a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts
index f30013d..900501d 100644
--- a/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts
+++ b/toju-app/src/app/features/room/rooms-side-panel/rooms-side-panel.component.ts
@@ -52,7 +52,12 @@ import {
VoiceConnectionFacade,
VoiceConnectivityHealthService
} from '../../../domains/voice-connection';
-import { VoiceSessionFacade, VoiceWorkspaceService } from '../../../domains/voice-session';
+import {
+ VoiceSessionFacade,
+ VoiceWorkspaceService,
+ isLocalVoiceOwner,
+ isVoiceOnAnotherClient
+} from '../../../domains/voice-session';
import { DirectMessageService } from '../../../domains/direct-message';
import { DirectCallService } from '../../../domains/direct-call';
import { VoicePlaybackService } from '../../../domains/voice-connection';
@@ -88,6 +93,7 @@ import {
import { v4 as uuidv4 } from 'uuid';
import { visibilityAwareInterval$ } from '../../../shared/rxjs';
import { APP_TRANSLATE_IMPORTS, AppI18nService } from '../../../core/i18n';
+import { AutoFocusDirective, SelectOnFocusDirective } from '../../../shared/directives';
type PanelMode = 'channels' | 'users';
@@ -109,6 +115,8 @@ const SKELETON_REVEAL_DELAY_MS = 180;
ThemeNodeDirective,
SkeletonComponent,
SkeletonListComponent,
+ AutoFocusDirective,
+ SelectOnFocusDirective,
...APP_TRANSLATE_IMPORTS
],
viewProviders: [
@@ -689,7 +697,13 @@ export class RoomsSidePanelComponent implements OnDestroy {
}
private openExistingVoiceWorkspace(room: Room | null, current: User | null, roomId: string): boolean {
- if (!room || !current?.voiceState?.isConnected || current.voiceState.roomId !== roomId || current.voiceState.serverId !== room.id) {
+ if (
+ !room
+ || !current?.voiceState?.isConnected
+ || current.voiceState.roomId !== roomId
+ || current.voiceState.serverId !== room.id
+ || !isLocalVoiceOwner(current.voiceState, this.realtime.getClientInstanceId())
+ ) {
return false;
}
@@ -697,6 +711,47 @@ export class RoomsSidePanelComponent implements OnDestroy {
return true;
}
+ isPassiveInVoiceRoom(roomId: string): boolean {
+ const current = this.currentUser();
+ const room = this.currentRoom();
+
+ return !!current?.voiceState?.isConnected
+ && current.voiceState.roomId === roomId
+ && current.voiceState.serverId === room?.id
+ && isVoiceOnAnotherClient(current.voiceState, this.realtime.getClientInstanceId());
+ }
+
+ isPassiveVoiceClient(): boolean {
+ const current = this.currentUser();
+
+ return isVoiceOnAnotherClient(current?.voiceState, this.realtime.getClientInstanceId());
+ }
+
+ isPassiveVoiceUser(user: User | null): boolean {
+ const current = this.currentUser();
+
+ if (!user || !current) {
+ return false;
+ }
+
+ return (user.id === current.id || user.oderId === current.oderId)
+ && this.isPassiveVoiceClient();
+ }
+
+ voiceChannelActionLabel(roomId: string): string {
+ if (this.isCurrentRoom(roomId)) {
+ return this.isVoiceWorkspaceExpanded()
+ ? this.appI18n.instant('room.panel.open')
+ : this.appI18n.instant('room.panel.view');
+ }
+
+ if (this.isPassiveInVoiceRoom(roomId)) {
+ return this.appI18n.instant('room.panel.takeOverVoice');
+ }
+
+ return this.appI18n.instant('room.panel.joinVoiceChannel');
+ }
+
private canJoinRequestedVoiceRoom(room: Room, current: User | null, roomId: string): boolean {
return !current || resolveRoomPermission(room, current, 'joinVoice', roomId);
}
@@ -737,9 +792,19 @@ export class RoomsSidePanelComponent implements OnDestroy {
this.directCalls.leaveCurrentJoinedCall();
this.prepareVoiceJoin(room, current ?? null);
- this.enableVoiceForJoin(room, current ?? null, roomId)
- .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
- .catch((error) => this.handleVoiceJoinFailure(error));
+ const startJoin = () => {
+ this.enableVoiceForJoin(room, current ?? null, roomId)
+ .then(() => this.onVoiceJoinSucceeded(roomId, room, current ?? null))
+ .catch((error) => this.handleVoiceJoinFailure(error));
+ };
+
+ if (this.isPassiveInVoiceRoom(roomId) || this.isPassiveVoiceClient()) {
+ this.realtime.requestVoiceClientTakeover();
+ window.setTimeout(startJoin, 300);
+ return;
+ }
+
+ startJoin();
}
private onVoiceJoinSucceeded(roomId: string, room: Room, current: User | null): void {
@@ -786,7 +851,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: current.voiceState?.isMuted ?? false,
isDeafened: current.voiceState?.isDeafened ?? false,
roomId,
- serverId: room.id
+ serverId: room.id,
+ clientInstanceId: this.realtime.getClientInstanceId()
}
})
);
@@ -797,6 +863,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
}
private broadcastVoiceConnected(roomId: string, room: Room, current: User | null): void {
+ const clientInstanceId = this.realtime.getClientInstanceId();
+
this.voiceConnection.broadcastMessage({
type: 'voice-state',
oderId: current?.oderId || current?.id,
@@ -806,7 +874,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: current?.voiceState?.isMuted ?? false,
isDeafened: current?.voiceState?.isDeafened ?? false,
roomId,
- serverId: room.id
+ serverId: room.id,
+ clientInstanceId
}
});
}
@@ -851,7 +920,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: false,
isDeafened: false,
roomId: undefined,
- serverId: undefined
+ serverId: undefined,
+ clientInstanceId: undefined
}
})
);
@@ -873,7 +943,8 @@ export class RoomsSidePanelComponent implements OnDestroy {
isMuted: false,
isDeafened: false,
roomId: previousVoiceState?.roomId,
- serverId: previousVoiceState?.serverId
+ serverId: previousVoiceState?.serverId,
+ clientInstanceId: undefined
}
});
@@ -1110,7 +1181,12 @@ export class RoomsSidePanelComponent implements OnDestroy {
const me = this.currentUser();
const room = this.currentRoom();
- return !!(me?.voiceState?.isConnected && me.voiceState?.roomId === roomId && me.voiceState?.serverId === room?.id);
+ return !!(
+ me?.voiceState?.isConnected
+ && me.voiceState.roomId === roomId
+ && me.voiceState.serverId === room?.id
+ && isLocalVoiceOwner(me.voiceState, this.realtime.getClientInstanceId())
+ );
}
voiceEnabled(): boolean {
diff --git a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts
index 349db40..b04d909 100644
--- a/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts
+++ b/toju-app/src/app/features/servers/servers-rail/servers-rail.component.ts
@@ -27,6 +27,7 @@ import {
} from 'rxjs';
import { Room, User } from '../../../shared-kernel';
+import { buildLoginReturnQueryParams } from '../../../domains/authentication/domain/logic/auth-navigation.rules';
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
import { VoiceSessionFacade } from '../../../domains/voice-session';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
@@ -276,7 +277,9 @@ export class ServersRailComponent {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
if (!currentUserId) {
- this.router.navigate(['/login']);
+ this.router.navigate(['/login'], {
+ queryParams: buildLoginReturnQueryParams(this.router.url)
+ });
return;
}
diff --git a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html
index 5b8c03a..3365d0d 100644
--- a/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html
+++ b/toju-app/src/app/features/settings/settings-modal/general-settings/general-settings.component.html
@@ -154,11 +154,13 @@