fix: Fix multiple bugs with new authentication flow

This commit is contained in:
2026-06-07 15:04:21 +02:00
parent 9fc26b1ccf
commit 83456c018c
137 changed files with 4710 additions and 281 deletions

View File

@@ -0,0 +1,46 @@
import {
describe,
it,
expect
} from 'vitest';
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
describe('aggregateComponentCountsByDomain', () => {
it('groups component counts under inferred domains', () => {
expect(aggregateComponentCountsByDomain({
VoiceChannelPanelComponent: 2,
DmChatComponent: 1,
App: 1
})).toEqual({
'voice-connection': 2,
'direct-message': 1,
core: 1
});
});
});
describe('detectSuspectedComponentLeaks', () => {
it('flags components that exceed the expected live count', () => {
expect(detectSuspectedComponentLeaks({
DmChatComponent: 3,
App: 1
}, {
DmChatComponent: 0,
App: 1
})).toEqual([
{
name: 'DmChatComponent',
count: 3,
expected: 0
}
]);
});
it('returns an empty list when counts are within expectations', () => {
expect(detectSuspectedComponentLeaks({
App: 1
}, {
App: 1
})).toEqual([]);
});
});

View File

@@ -0,0 +1,38 @@
import { mapComponentNameToDomain } from './domain-mapping.rules';
export interface SuspectedComponentLeak {
name: string;
count: number;
expected: number;
}
export function aggregateComponentCountsByDomain(
componentCounts: Record<string, number>
): Record<string, number> {
const domains: Record<string, number> = {};
for (const [componentName, count] of Object.entries(componentCounts)) {
const domain = mapComponentNameToDomain(componentName);
domains[domain] = (domains[domain] ?? 0) + count;
}
return domains;
}
export function detectSuspectedComponentLeaks(
componentCounts: Record<string, number>,
expectedCounts: Record<string, number>
): SuspectedComponentLeak[] {
const leaks: SuspectedComponentLeak[] = [];
for (const [name, count] of Object.entries(componentCounts)) {
const expected = expectedCounts[name] ?? 0;
if (count > expected) {
leaks.push({ name, count, expected });
}
}
return leaks.sort((left, right) => right.count - left.count);
}

View File

@@ -0,0 +1,69 @@
import { ApplicationRef } from '@angular/core';
import { aggregateComponentCountsByDomain, detectSuspectedComponentLeaks } from './component-tree.rules';
const DEFAULT_DOM_SCAN_BUDGET = 400;
interface NgDebugApi {
getComponent?: (element: Element) => { constructor: { name: string } } | null;
}
export interface ComponentTreeScanResult {
components: Record<string, number>;
domains: Record<string, number>;
suspectedLeaks: ReturnType<typeof detectSuspectedComponentLeaks>;
scannedNodes: number;
scanMode: 'application-ref' | 'ng-global';
}
export function scanComponentTree(
appRef: ApplicationRef,
expectedCounts: Record<string, number>,
options: { domScanBudget?: number } = {}
): ComponentTreeScanResult {
const domScanBudget = options.domScanBudget ?? DEFAULT_DOM_SCAN_BUDGET;
const ngApi = (globalThis as { ng?: NgDebugApi }).ng;
const components: Record<string, number> = {};
let scannedNodes = 0;
let scanMode: ComponentTreeScanResult['scanMode'] = 'application-ref';
if (ngApi?.getComponent && typeof document !== 'undefined') {
scanMode = 'ng-global';
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode() && scannedNodes < domScanBudget) {
scannedNodes += 1;
try {
const component = ngApi.getComponent?.(walker.currentNode as Element) as {
constructor: { name: string };
} | null;
if (!component) {
continue;
}
const name = component.constructor.name;
components[name] = (components[name] ?? 0) + 1;
} catch {
// Ignore elements that are not component hosts.
}
}
} else {
for (const componentRef of appRef.components) {
const name = componentRef.componentType.name;
components[name] = (components[name] ?? 0) + 1;
scannedNodes += 1;
}
}
return {
components,
domains: aggregateComponentCountsByDomain(components),
suspectedLeaks: detectSuspectedComponentLeaks(components, expectedCounts),
scannedNodes,
scanMode
};
}

View File

@@ -0,0 +1,96 @@
import {
EnvironmentInjector,
inject,
runInInjectionContext
} from '@angular/core';
import type { ElectronApi } from '../../core/platform/electron/electron-api.models';
import { PerfDiagnosticsCollector, publishRendererDiagnosticsSample } from './diagnostics.collector';
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
const SAMPLE_INTERVAL_MS = 10_000;
let started = false;
let sampleTimer: ReturnType<typeof setInterval> | null = null;
export async function bootstrapPerfDiagnostics(
api: ElectronApi,
injector: EnvironmentInjector
): Promise<void> {
const reportSample = api.reportPerfDiagSample;
if (started || !api.isPerfDiagEnabled || !reportSample) {
return;
}
let enabled = false;
try {
enabled = await api.isPerfDiagEnabled();
} catch {
return;
}
if (!enabled) {
return;
}
started = true;
const reporter: PerfDiagReporter = {
report: (entry: PerfDiagEntry) => reportSample(entry)
};
const runSample = (): void => {
void runInInjectionContext(injector, async () => {
try {
const collector = inject(PerfDiagnosticsCollector);
await publishRendererDiagnosticsSample(reporter, collector);
} catch {
stopPerfDiagnosticsSampling();
}
});
};
scheduleSample(runSample);
sampleTimer = setInterval(() => scheduleSample(runSample), SAMPLE_INTERVAL_MS);
window.addEventListener('error', () => {
void reporter.report({
collectedAt: Date.now(),
source: 'renderer',
type: 'crash',
payload: { scope: 'window-error' }
});
});
window.addEventListener('unhandledrejection', () => {
void reporter.report({
collectedAt: Date.now(),
source: 'renderer',
type: 'crash',
payload: { scope: 'unhandled-rejection' }
});
});
}
function scheduleSample(runSample: () => void): void {
const idle = (globalThis as {
requestIdleCallback?: (handler: () => void, options?: { timeout: number }) => number;
}).requestIdleCallback;
if (idle) {
idle(() => runSample(), { timeout: SAMPLE_INTERVAL_MS });
return;
}
setTimeout(runSample, 0);
}
function stopPerfDiagnosticsSampling(): void {
if (sampleTimer) {
clearInterval(sampleTimer);
sampleTimer = null;
}
started = false;
}

View File

@@ -0,0 +1,185 @@
import {
ApplicationRef,
inject,
Injectable
} from '@angular/core';
import { Store } from '@ngrx/store';
import { Router } from '@angular/router';
import type { AppState } from '../../store';
import { mapStoreSliceToDomain } from './domain-mapping.rules';
import { estimateStructuredBytes } from './state-size.rules';
import { scanComponentTree } from './component-tree.scanner';
import type { PerfDiagEntry, PerfDiagReporter } from './diagnostics.models';
const SAMPLE_BUDGET_MS = 8;
export interface RendererDiagnosticsSample {
storeDomains: Record<string, number>;
storeBytes: Record<string, number>;
components: Record<string, number>;
componentDomains: Record<string, number>;
suspectedLeaks: { name: string; count: number; expected: number }[];
heap: {
usedJsHeapMb: number | null;
totalJsHeapMb: number | null;
};
route: string | null;
durationMs: number;
}
@Injectable({ providedIn: 'root' })
export class PerfDiagnosticsCollector {
private readonly appRef = inject(ApplicationRef);
private readonly store = inject(Store<AppState>);
private readonly router = inject(Router);
private disabled = false;
private readonly expectedComponentCounts: Record<string, number> = {
App: 1
};
collectSample(): RendererDiagnosticsSample | null {
if (this.disabled) {
return null;
}
const startedAt = performance.now();
try {
const state = this.collectStoreState();
const storeBytes: Record<string, number> = {};
const storeDomains: Record<string, number> = {};
for (const [sliceKey, sliceValue] of Object.entries(state)) {
const bytes = estimateStructuredBytes(sliceValue, { maxNodes: 200, maxDepth: 5 });
const domain = mapStoreSliceToDomain(sliceKey);
storeBytes[sliceKey] = bytes;
storeDomains[domain] = (storeDomains[domain] ?? 0) + bytes;
}
const componentScan = scanComponentTree(this.appRef, this.expectedComponentCounts);
const heap = readJsHeapSnapshot();
return {
storeDomains,
storeBytes,
components: componentScan.components,
componentDomains: componentScan.domains,
suspectedLeaks: componentScan.suspectedLeaks,
heap,
route: this.router.url,
durationMs: performance.now() - startedAt
};
} catch {
this.disabled = true;
return null;
} finally {
if (performance.now() - startedAt > SAMPLE_BUDGET_MS) {
// Skip future samples if this collector cannot stay within budget.
this.disabled = true;
}
}
}
buildEntries(sample: RendererDiagnosticsSample): PerfDiagEntry[] {
const collectedAt = Date.now();
return [
{
collectedAt,
source: 'renderer',
type: 'store',
payload: {
domains: sample.storeDomains,
slices: sample.storeBytes,
route: sample.route
}
},
{
collectedAt,
source: 'renderer',
type: 'components',
payload: {
components: sample.components,
domains: sample.componentDomains,
suspectedLeaks: sample.suspectedLeaks,
route: sample.route,
durationMs: sample.durationMs
}
},
{
collectedAt,
source: 'renderer',
type: 'heap',
payload: {
...sample.heap,
route: sample.route
}
}
];
}
private collectStoreState(): AppState {
let latest: AppState | undefined;
this.store.subscribe((state) => {
latest = state;
}).unsubscribe();
if (!latest) {
throw new Error('Store state unavailable');
}
return latest;
}
}
export async function publishRendererDiagnosticsSample(
reporter: PerfDiagReporter,
collector: PerfDiagnosticsCollector
): Promise<boolean> {
const sample = collector.collectSample();
if (!sample) {
return false;
}
const entries = collector.buildEntries(sample);
let reported = true;
for (const entry of entries) {
const accepted = await reporter.report(entry);
if (!accepted) {
reported = false;
}
}
return reported;
}
function readJsHeapSnapshot(): { usedJsHeapMb: number | null; totalJsHeapMb: number | null } {
const memory = (performance as Performance & {
memory?: {
usedJSHeapSize: number;
totalJSHeapSize: number;
};
}).memory;
if (!memory) {
return {
usedJsHeapMb: null,
totalJsHeapMb: null
};
}
return {
usedJsHeapMb: roundMb(memory.usedJSHeapSize),
totalJsHeapMb: roundMb(memory.totalJSHeapSize)
};
}
function roundMb(bytes: number): number {
return Math.round((bytes / (1024 * 1024)) * 100) / 100;
}

View File

@@ -0,0 +1,21 @@
export type PerfDiagSource = 'main' | 'renderer';
export type PerfDiagEntryType =
| 'session'
| 'process'
| 'store'
| 'components'
| 'heap'
| 'crash'
| 'unresponsive';
export interface PerfDiagEntry {
collectedAt: number;
source: PerfDiagSource;
type: PerfDiagEntryType;
payload: Record<string, unknown>;
}
export interface PerfDiagReporter {
report(entry: PerfDiagEntry): Promise<boolean>;
}

View File

@@ -0,0 +1,30 @@
import {
describe,
it,
expect
} from 'vitest';
import { mapComponentNameToDomain, mapStoreSliceToDomain } from './domain-mapping.rules';
describe('mapStoreSliceToDomain', () => {
it('maps known NgRx slices to domains', () => {
expect(mapStoreSliceToDomain('messages')).toBe('chat');
expect(mapStoreSliceToDomain('users')).toBe('users');
expect(mapStoreSliceToDomain('rooms')).toBe('rooms');
});
it('falls back to the slice name for unknown keys', () => {
expect(mapStoreSliceToDomain('custom')).toBe('custom');
});
});
describe('mapComponentNameToDomain', () => {
it('maps component class prefixes to domains', () => {
expect(mapComponentNameToDomain('VoiceChannelPanelComponent')).toBe('voice-connection');
expect(mapComponentNameToDomain('DmChatComponent')).toBe('direct-message');
expect(mapComponentNameToDomain('PluginStoreComponent')).toBe('plugins');
});
it('falls back to core for unknown components', () => {
expect(mapComponentNameToDomain('App')).toBe('core');
});
});

View File

@@ -0,0 +1,48 @@
const STORE_SLICE_DOMAINS: Record<string, string> = {
messages: 'chat',
users: 'users',
rooms: 'rooms'
};
const COMPONENT_PREFIX_DOMAINS: [string, string][] = [
['Voice', 'voice-connection'],
['ScreenShare', 'screen-share'],
['Dm', 'direct-message'],
['DirectCall', 'direct-call'],
['DirectMessage', 'direct-message'],
['Plugin', 'plugins'],
['Chat', 'chat'],
['Message', 'chat'],
['Server', 'server-directory'],
['Room', 'rooms'],
['Theme', 'theme'],
['Emoji', 'custom-emoji'],
['Notification', 'notifications'],
['Game', 'game-activity'],
['Profile', 'profile-avatar'],
['Attachment', 'attachment'],
['Auth', 'authentication'],
['Login', 'authentication'],
['Register', 'authentication']
];
export function mapStoreSliceToDomain(sliceKey: string): string {
return STORE_SLICE_DOMAINS[sliceKey] ?? sliceKey;
}
export function mapComponentNameToDomain(componentName: string): string {
if (!componentName || componentName === 'App') {
return 'core';
}
for (const [prefix, domain] of COMPONENT_PREFIX_DOMAINS) {
if (componentName.startsWith(prefix)) {
return domain;
}
}
if (componentName.endsWith('Component')) {
return 'features';
}
return 'core';
}

View File

@@ -0,0 +1,34 @@
import {
describe,
it,
expect
} from 'vitest';
import { estimateStructuredBytes } from './state-size.rules';
describe('estimateStructuredBytes', () => {
it('returns zero for nullish values', () => {
expect(estimateStructuredBytes(null)).toBe(0);
expect(estimateStructuredBytes(undefined)).toBe(0);
});
it('estimates primitive and shallow object sizes', () => {
expect(estimateStructuredBytes('hello')).toBeGreaterThan(0);
expect(estimateStructuredBytes({ a: 1, b: 'two' })).toBeGreaterThan(estimateStructuredBytes({ a: 1 }));
});
it('stops walking once the node budget is exhausted', () => {
const deep = {
level1: {
level2: {
level3: {
payload: 'value'
}
}
}
};
const full = estimateStructuredBytes(deep, { maxNodes: 50 });
const shallow = estimateStructuredBytes(deep, { maxNodes: 2 });
expect(shallow).toBeLessThan(full);
});
});

View File

@@ -0,0 +1,147 @@
export interface StructuredByteEstimateOptions {
maxNodes?: number;
maxDepth?: number;
}
const DEFAULT_MAX_NODES = 250;
const DEFAULT_MAX_DEPTH = 6;
export function estimateStructuredBytes(
value: unknown,
options: StructuredByteEstimateOptions = {}
): number {
const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const budget = { remaining: maxNodes };
return walkValue(value, 0, maxDepth, budget);
}
function walkValue(
value: unknown,
depth: number,
maxDepth: number,
budget: { remaining: number }
): number {
if (budget.remaining <= 0) {
return 0;
}
budget.remaining -= 1;
const primitiveBytes = estimatePrimitiveBytes(value);
if (primitiveBytes != null) {
return primitiveBytes;
}
if (depth >= maxDepth) {
return 32;
}
if (Array.isArray(value)) {
return walkArray(value, depth, maxDepth, budget);
}
if (value instanceof Map || value instanceof Set) {
return walkIterable(value, depth, maxDepth, budget);
}
if (typeof value === 'object') {
return walkObject(value as Record<string, unknown>, depth, maxDepth, budget);
}
return 16;
}
function estimatePrimitiveBytes(value: unknown): number | null {
if (value == null) {
return 0;
}
if (typeof value === 'string') {
return value.length * 2;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return 8;
}
if (typeof value === 'bigint') {
return 16;
}
if (value instanceof Date) {
return 24;
}
if (ArrayBuffer.isView(value)) {
return value.byteLength;
}
if (value instanceof ArrayBuffer) {
return value.byteLength;
}
return null;
}
function walkArray(
value: unknown[],
depth: number,
maxDepth: number,
budget: { remaining: number }
): number {
let total = 16;
for (const item of value) {
if (budget.remaining <= 0) {
break;
}
total += walkValue(item, depth + 1, maxDepth, budget);
}
return total;
}
function walkIterable(
value: Map<unknown, unknown> | Set<unknown>,
depth: number,
maxDepth: number,
budget: { remaining: number }
): number {
let total = 32;
let walked = 0;
for (const item of value) {
if (budget.remaining <= 0 || walked >= 25) {
break;
}
walked += 1;
total += walkValue(item, depth + 1, maxDepth, budget);
}
return total;
}
function walkObject(
value: Record<string, unknown>,
depth: number,
maxDepth: number,
budget: { remaining: number }
): number {
let total = 16;
for (const [key, nested] of Object.entries(value)) {
if (budget.remaining <= 0) {
break;
}
total += key.length * 2;
total += walkValue(nested, depth + 1, maxDepth, budget);
}
return total;
}

View File

@@ -1,3 +1,4 @@
import '@angular/compiler';
import { Injector, runInInjectionContext } from '@angular/core';
import {
beforeEach,
@@ -13,6 +14,21 @@ import { CapacitorDatabaseService } from './capacitor-database.service';
import { DatabaseService } from './database.service';
import { ElectronDatabaseService } from './electron-database.service';
function installLocalStorageMock(): void {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, String(value)),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size;
}
});
}
describe('DatabaseService', () => {
let browserDatabase: {
getBansForRoom: ReturnType<typeof vi.fn>;
@@ -28,6 +44,9 @@ describe('DatabaseService', () => {
};
beforeEach(() => {
installLocalStorageMock();
localStorage.clear();
browserDatabase = {
getBansForRoom: vi.fn(() => Promise.resolve([])),
initialize: vi.fn(() => Promise.resolve())
@@ -69,6 +88,56 @@ describe('DatabaseService', () => {
expect(service.isReady()).toBe(true);
});
it('rechecks backend initialization when the user scope changes during an in-flight initialize call', async () => {
let finishInitialInitialize!: () => void;
browserDatabase.initialize = vi.fn()
.mockImplementationOnce(() => new Promise<void>((resolve) => {
finishInitialInitialize = resolve;
}))
.mockResolvedValue(undefined);
localStorage.setItem('metoyou_currentUserId', 'user-a');
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
const initialInitialize = service.initialize();
localStorage.setItem('metoyou_currentUserId', 'user-b');
const initializeAfterScopeChange = service.initialize();
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
finishInitialInitialize();
await Promise.all([initialInitialize, initializeAfterScopeChange]);
expect(browserDatabase.initialize).toHaveBeenCalledTimes(2);
expect(service.isReady()).toBe(true);
});
it('does not reinitialize the browser backend for repeated reads in the same user scope', async () => {
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
await service.getBansForRoom('room-1');
await service.getBansForRoom('room-2');
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-2');
});
it('reinitializes the browser backend when the stored user scope changes', async () => {
localStorage.setItem('metoyou_currentUserId', 'user-a');
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
await service.getBansForRoom('room-1');
localStorage.setItem('metoyou_currentUserId', 'user-b');
await service.getBansForRoom('room-2');
expect(browserDatabase.initialize).toHaveBeenCalledTimes(2);
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-2');
});
it('routes Capacitor shells to native SQLite instead of IndexedDB', async () => {
const service = createService({ isBrowser: false, isElectron: false, isCapacitor: true });

View File

@@ -14,6 +14,7 @@ import {
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { PlatformService } from '../../core/platform';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import { BrowserDatabaseService } from './browser-database.service';
import { CapacitorDatabaseService } from './capacitor-database.service';
import { resolveDatabaseBackend } from './database-backend.rules';
@@ -42,6 +43,7 @@ export class DatabaseService {
private readonly capacitorDb = inject(CapacitorDatabaseService);
private readonly electronDb = inject(ElectronDatabaseService);
private initializationPromise: Promise<void> | null = null;
private validatedUserScope: string | null | undefined;
/** Reactive flag: `true` once {@link initialize} has completed. */
isReady = signal(false);
@@ -66,8 +68,15 @@ export class DatabaseService {
/** Initialise the platform-specific database. */
async initialize(): Promise<void> {
const userScope = getStoredCurrentUserId();
if (this.initializationPromise) {
await this.initializationPromise;
if (this.isReady() && this.validatedUserScope === userScope) {
return;
}
} else if (this.isReady() && this.validatedUserScope === userScope) {
return;
}
@@ -75,6 +84,7 @@ export class DatabaseService {
this.initializationPromise = backend.initialize()
.then(() => {
this.validatedUserScope = userScope;
this.isReady.set(true);
})
.finally(() => {
@@ -85,8 +95,11 @@ export class DatabaseService {
}
private async ensureReady(): Promise<void> {
if (this.isReady())
const userScope = getStoredCurrentUserId();
if (this.isReady() && this.validatedUserScope === userScope) {
return;
}
await this.initialize();
}

View File

@@ -164,7 +164,11 @@ The browser also sends a lightweight `keepalive` message on the signaling socket
### Server-side connection hygiene
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). The server's `handleIdentify` now closes any existing connection that shares the same `oderId` but a different `connectionId`. This guarantees `findUserByOderId` always routes offers and presence events to the freshest socket, eliminating a class of bugs where signaling messages landed on a dead tab's socket and were silently lost.
Browsers do not reliably fire WebSocket close events during page refresh or navigation (especially Chromium). On `identify`, the server evicts stale sockets that share the same `(oderId, connectionScope, clientInstanceId)` tuple so a refreshed tab does not leave a zombie connection behind.
Multi-device sessions keep **multiple** open connections for the same `oderId` (different `clientInstanceId` values). Server broadcasts exclude only the sending **connection id**, not the whole identity, so chat/typing/voice-state updates reach every logged-in device. Presence `user_joined` / `user_left` broadcasts still exclude the whole identity so other users never see duplicate join/leave events.
RTC offers/answers/ICE are routed to the connection marked `voiceActive` for the target user (fallback: any open connection). Voice ownership is tracked per connection from `voice_state` payloads that include `clientInstanceId`.
Join and leave broadcasts are also identity-aware: `handleJoinServer` only broadcasts `user_joined` when the identity is genuinely new to that server (not just a second WebSocket connection for the same user), and `handleLeaveServer` / dead-connection cleanup only broadcast `user_left` when no other open connection for that identity remains in the server. The `user_left` payload includes `serverIds` listing the rooms the identity still belongs to, so the client can subtract correctly without over-removing.

View File

@@ -41,6 +41,7 @@ import { SignalingManager } from './signaling/signaling.manager';
import { SignalingTransportHandler } from './signaling/signaling-transport-handler';
import { WebRtcStateController } from './state/webrtc-state-controller';
import { AuthTokenStoreService } from '../../domains/authentication';
import { ClientInstanceService } from '../../core/platform/client-instance.service';
@Injectable({
providedIn: 'root'
@@ -51,6 +52,7 @@ export class WebRTCService implements OnDestroy {
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly iceServerSettings = inject(IceServerSettingsService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly clientInstance = inject(ClientInstanceService);
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private readonly state = new WebRtcStateController();
@@ -161,7 +163,8 @@ export class WebRTCService implements OnDestroy {
}
return null;
}
},
getClientInstanceId: () => this.clientInstance.getClientInstanceId()
});
// Now wire up cross-references (all managers are instantiated)
@@ -691,11 +694,17 @@ export class WebRTCService implements OnDestroy {
}
private relayBroadcastEvent(event: ChatEvent): void {
const clientInstanceId = this.clientInstance.getClientInstanceId();
if (event.type === 'chat-message' && event.message?.roomId) {
this.signalingTransportHandler.sendRawMessage({
type: 'chat_message',
serverId: event.message.roomId,
message: event.message
message: {
...event.message,
clientInstanceId
},
clientInstanceId
});
return;
@@ -705,11 +714,27 @@ export class WebRTCService implements OnDestroy {
this.signalingTransportHandler.sendRawMessage({
...event,
type: 'voice_state',
serverId: event.voiceState.serverId
serverId: event.voiceState.serverId,
voiceState: {
...event.voiceState,
clientInstanceId
},
clientInstanceId
});
}
}
requestVoiceClientTakeover(): void {
this.signalingTransportHandler.sendRawMessage({
type: 'voice_client_takeover',
clientInstanceId: this.clientInstance.getClientInstanceId()
});
}
getClientInstanceId(): string {
return this.clientInstance.getClientInstanceId();
}
/** Disconnect from the signaling server and clean up all state. */
disconnect(): void {
this.leaveRoom();

View File

@@ -44,6 +44,8 @@ export interface IdentifyCredentials {
profileUpdatedAt?: number;
/** Public signal-server URL where this user registered. */
homeSignalServerUrl?: string;
/** Stable per-install client id used for multi-device session routing. */
clientInstanceId?: string;
}
/** Last-joined server info, used for reconnection. */
@@ -76,4 +78,6 @@ export interface VoiceStateSnapshot {
roomId?: string;
/** The voice channel server ID, if applicable. */
serverId?: string;
/** Install-scoped client id that owns active voice for this snapshot. */
clientInstanceId?: string;
}

View File

@@ -14,6 +14,7 @@ interface SignalingTransportHandlerDependencies<TMessage> {
logger: WebRTCLogger;
getLocalPeerId(): string;
resolveSessionToken(signalUrl?: string): string | null;
getClientInstanceId(): string;
}
export class SignalingTransportHandler<TMessage> {
@@ -201,13 +202,16 @@ export class SignalingTransportHandler<TMessage> {
return;
}
const clientInstanceId = this.dependencies.getClientInstanceId();
this.lastIdentifyCredentials = {
oderId,
token,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl
homeSignalServerUrl: normalizedHomeSignalServerUrl,
clientInstanceId
};
if (signalUrl) {
@@ -219,7 +223,8 @@ export class SignalingTransportHandler<TMessage> {
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
connectionScope: signalUrl
connectionScope: signalUrl,
clientInstanceId
});
return;
@@ -240,7 +245,8 @@ export class SignalingTransportHandler<TMessage> {
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
connectionScope: managerSignalUrl
connectionScope: managerSignalUrl,
clientInstanceId
});
}
}

View File

@@ -379,7 +379,8 @@ export class SignalingManager {
description: credentials.description,
profileUpdatedAt: credentials.profileUpdatedAt,
homeSignalServerUrl: credentials.homeSignalServerUrl,
connectionScope: this.lastSignalingUrl ?? undefined
connectionScope: this.lastSignalingUrl ?? undefined,
clientInstanceId: credentials.clientInstanceId
});
}