fix: Major bug cleanup pass 1
All checks were successful
Queue Release Build / prepare (push) Successful in 19s
Deploy Web Apps / deploy (push) Successful in 8m12s
Queue Release Build / build-windows (push) Successful in 27m44s
Queue Release Build / build-linux (push) Successful in 48m1s
Queue Release Build / build-android (push) Successful in 22m7s
Queue Release Build / finalize (push) Successful in 2m42s

This commit is contained in:
2026-06-09 17:59:54 +02:00
parent 80d7728e66
commit eb51f043ac
127 changed files with 2731 additions and 322 deletions

View File

@@ -51,17 +51,11 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
types: [
{
id: 'INCOMING_CALL_ACTIONS',
actions: [
{ id: 'answer', title: mobileLabel('mobile.notifications.answer') },
{ id: 'hangup', title: mobileLabel('mobile.notifications.decline') }
]
actions: [{ id: 'answer', title: mobileLabel('mobile.notifications.answer') }, { id: 'hangup', title: mobileLabel('mobile.notifications.decline') }]
},
{
id: 'ACTIVE_CALL_ACTIONS',
actions: [
{ id: 'mute', title: mobileLabel('mobile.notifications.mute') },
{ id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') }
]
actions: [{ id: 'mute', title: mobileLabel('mobile.notifications.mute') }, { id: 'hangup', title: mobileLabel('mobile.notifications.hangUp') }]
}
]
});
@@ -167,4 +161,4 @@ export class CapacitorMobileNotificationsAdapter implements MobileNotificationAd
onActionSelected(handler: (input: { callId: string; intent: CallNotificationActionIntent }) => void): void {
this.actionHandler = handler;
}
}
}

View File

@@ -137,11 +137,7 @@ const SCHEMA_V2_MESSAGE_COLUMNS = [
'ALTER TABLE messages ADD COLUMN kind TEXT',
'ALTER TABLE messages ADD COLUMN systemEvent TEXT'
];
const SCHEMA_V3_MESSAGE_COLUMNS = [
'ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE messages ADD COLUMN headHash TEXT'
];
const SCHEMA_V3_MESSAGE_COLUMNS = ['ALTER TABLE messages ADD COLUMN revision INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE messages ADD COLUMN headHash TEXT'];
/** Returns DDL statements that still need to run for the stored schema version. */
export function resolveMobileSqliteMigrationStatements(storedVersion: number): string[] {

View File

@@ -7,7 +7,11 @@ import {
type Room,
type User
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji, MessageRevision } from '../../shared-kernel';
import type {
ChatAttachmentMeta,
CustomEmoji,
MessageRevision
} from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import {
attachmentToValues,

View File

@@ -119,6 +119,8 @@ The signaling layer gets peers to exchange SDP offers/answers and ICE candidates
Each signaling URL gets its own `SignalingManager` (one WebSocket each). `SignalingTransportHandler` picks the right socket based on which server the message is for. `ServerSignalingCoordinator` tracks which peers belong to which servers and which signaling URLs, so we know when it is safe to tear down a peer connection after leaving a server.
**Identify-before-join invariant.** The server drops any `join_server` / `view_server` that arrives on a connection that has not yet `identify`-ed, so a join that races ahead of identify is silently lost and the user never appears in the presence roster. On every (re)connect, `SignalingManager.reIdentifyAndRejoin` therefore re-`identify`s and only then re-joins. For this to work the manager's credential lookup (`SignalingTransportHandler.getIdentifyCredentialsForSignalUrl`) must resolve a credential as soon as one exists for that signal URL — it falls back to the credential store when the per-URL identify cache has not been populated yet. Do not narrow that lookup to only the in-memory cache; doing so lets a fresh socket emit a join before any identify and reintroduces the dropped-presence bug.
Room affinity is authoritative at this layer as well. The renderer repairs each room's saved `sourceId` / `sourceUrl` from server-directory responses and routes `join_server`, `view_server`, and room-scoped signaling traffic to that room's signaling URL first. If that route fails, alternate endpoints can be tried temporarily, but server-scoped raw messages are no longer broadcast to every connected signaling manager when the route is unknown.
Server-relayed fallbacks are intentionally narrow. Room chat (`chat_message`), direct-message events (`direct-message`, `direct-message-status`, `direct-message-mutation`), and voice presence (`voice_state`) may flow over signaling so users can still see written chat and voice roster state while P2P data channels are down. Media, attachments, message inventory sync, screen/camera state, and plugin data-channel traffic remain peer-plane responsibilities.

View File

@@ -40,8 +40,11 @@ import { ServerSignalingCoordinator } from './signaling/server-signaling-coordin
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 { SignalServerAuthService } from '../../domains/authentication/application/services/signal-server-auth.service';
import { ClientInstanceService } from '../../core/platform/client-instance.service';
import { Store } from '@ngrx/store';
import { UsersActions } from '../../store/users/users.actions';
import { selectCurrentUser } from '../../store/users/users.selectors';
@Injectable({
providedIn: 'root'
@@ -51,8 +54,10 @@ export class WebRTCService implements OnDestroy {
private readonly debugging = inject(DebuggingService);
private readonly screenShareSourcePicker = inject(ScreenShareSourcePickerService);
private readonly iceServerSettings = inject(IceServerSettingsService);
private readonly authTokenStore = inject(AuthTokenStoreService);
private readonly signalServerAuth = inject(SignalServerAuthService);
private readonly store = inject(Store);
private readonly clientInstance = inject(ClientInstanceService);
private currentHomeUser: { id: string; homeSignalServerUrl?: string; displayName: string } | null = null;
private readonly logger = new WebRTCLogger(() => this.debugging.enabled());
private readonly state = new WebRtcStateController();
@@ -110,6 +115,16 @@ export class WebRTCService implements OnDestroy {
private readonly remoteScreenShareRequestController: RemoteScreenShareRequestController;
constructor() {
this.store.select(selectCurrentUser).subscribe((user) => {
this.currentHomeUser = user
? {
id: user.id,
homeSignalServerUrl: user.homeSignalServerUrl,
displayName: user.displayName
}
: null;
});
// Create managers with null callbacks first to break circular initialization
this.peerManager = new PeerConnectionManager(this.logger, null!);
@@ -133,9 +148,9 @@ export class WebRTCService implements OnDestroy {
});
this.signalingCoordinator = new ServerSignalingCoordinator({
createManager: (_signalUrl, getLastJoinedServer, getMemberServerIds) => new SignalingManager(
createManager: (signalUrl, getLastJoinedServer, getMemberServerIds) => new SignalingManager(
this.logger,
() => this.signalingTransportHandler.getIdentifyCredentials(),
() => this.signalingTransportHandler.getIdentifyCredentialsForSignalUrl(signalUrl),
getLastJoinedServer,
getMemberServerIds
),
@@ -149,20 +164,21 @@ export class WebRTCService implements OnDestroy {
signalingCoordinator: this.signalingCoordinator,
logger: this.logger,
getLocalPeerId: () => this.state.getLocalPeerId(),
resolveSessionToken: (signalUrl) => {
if (signalUrl) {
return this.authTokenStore.getToken(signalUrl.replace(/^ws/, 'http'));
resolveCredential: (signalUrl) => {
if (!signalUrl) {
return null;
}
for (const { signalUrl: connectedUrl } of this.signalingCoordinator.getConnectedSignalingManagers()) {
const token = this.authTokenStore.getToken(connectedUrl.replace(/^ws/, 'http'));
return this.signalServerAuth.resolveCredentialForSignalUrl(signalUrl, this.currentHomeUser);
},
getHomeCredential: () => {
const homeSignalServerUrl = this.currentHomeUser?.homeSignalServerUrl;
if (token) {
return token;
}
if (!homeSignalServerUrl) {
return null;
}
return null;
return this.signalServerAuth.resolveCredentialForSignalUrl(homeSignalServerUrl, this.currentHomeUser);
},
getClientInstanceId: () => this.clientInstance.getClientInstanceId()
});
@@ -283,6 +299,11 @@ export class WebRTCService implements OnDestroy {
}
private handleSignalingMessage(message: IncomingSignalingMessage, signalUrl: string): void {
if (message.type === 'auth_required' || message.type === 'auth_error') {
this.store.dispatch(UsersActions.signalServerAuthFailed({ signalUrl }));
return;
}
this.signalingMessage$.next(message);
this.signalingMessageHandler.handleMessage(message, signalUrl);
}

View File

@@ -9,35 +9,78 @@ import { IdentifyCredentials } from '../realtime.types';
import { ConnectedSignalingManager, ServerSignalingCoordinator } from './server-signaling-coordinator';
import { WebRTCLogger } from '../logging/webrtc-logger';
export interface ResolvedSignalCredential {
userId: string;
token: string;
displayName: string;
homeSignalServerUrl?: string;
}
interface SignalingTransportHandlerDependencies<TMessage> {
signalingCoordinator: ServerSignalingCoordinator<TMessage>;
logger: WebRTCLogger;
getLocalPeerId(): string;
resolveSessionToken(signalUrl?: string): string | null;
resolveCredential(signalUrl?: string): ResolvedSignalCredential | null;
getHomeCredential(): ResolvedSignalCredential | null;
getClientInstanceId(): string;
}
export class SignalingTransportHandler<TMessage> {
private lastIdentifyCredentials: IdentifyCredentials | null = null;
private readonly lastIdentifyCredentialsBySignalUrl = new Map<string, IdentifyCredentials>();
constructor(
private readonly dependencies: SignalingTransportHandlerDependencies<TMessage>
) {}
getIdentifyCredentials(): IdentifyCredentials | null {
return this.lastIdentifyCredentials;
const homeCredential = this.dependencies.getHomeCredential();
if (!homeCredential) {
const firstCredential = this.lastIdentifyCredentialsBySignalUrl.values().next().value;
return firstCredential ?? null;
}
return this.toIdentifyCredentials(homeCredential);
}
getIdentifyCredentialsForSignalUrl(signalUrl: string): IdentifyCredentials | null {
const stored = this.lastIdentifyCredentialsBySignalUrl.get(signalUrl);
if (stored) {
return stored;
}
// Fall back to resolving the credential directly from the credential store so a
// freshly (re)connected socket can re-identify *before* it sends any join_server /
// view_server. Without this, a new socket can come up and emit a join while the
// per-URL identify cache is still empty, and the server drops that join as
// unauthenticated, leaving the user permanently absent from the presence roster.
const resolved = this.dependencies.resolveCredential(signalUrl);
if (!resolved) {
return null;
}
return {
oderId: resolved.userId,
token: resolved.token,
displayName: resolved.displayName,
homeSignalServerUrl: resolved.homeSignalServerUrl,
clientInstanceId: this.dependencies.getClientInstanceId()
};
}
getIdentifyOderId(): string {
return this.lastIdentifyCredentials?.oderId || this.dependencies.getLocalPeerId();
return this.getIdentifyCredentials()?.oderId || this.dependencies.getLocalPeerId();
}
getIdentifyDisplayName(): string {
return this.lastIdentifyCredentials?.displayName || DEFAULT_DISPLAY_NAME;
return this.getIdentifyCredentials()?.displayName || DEFAULT_DISPLAY_NAME;
}
getIdentifyDescription(): string | undefined {
return this.lastIdentifyCredentials?.description;
return this.getIdentifyCredentials()?.description;
}
getConnectedSignalingManagers(): ConnectedSignalingManager[] {
@@ -195,35 +238,15 @@ export class SignalingTransportHandler<TMessage> {
const normalizedHomeSignalServerUrl = typeof profile?.homeSignalServerUrl === 'string'
? (profile.homeSignalServerUrl.trim().replace(/\/+$/, '') || undefined)
: undefined;
const token = this.dependencies.resolveSessionToken(signalUrl);
if (!token) {
this.dependencies.logger.warn('Skipping identify because no session token is available', { signalUrl, oderId });
return;
}
const clientInstanceId = this.dependencies.getClientInstanceId();
this.lastIdentifyCredentials = {
oderId,
token,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
clientInstanceId
};
if (signalUrl) {
this.sendRawMessageToSignalUrl(signalUrl, {
type: SIGNALING_TYPE_IDENTIFY,
token,
oderId,
this.identifyOnSignalUrl(signalUrl, {
fallbackOderId: oderId,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
connectionScope: signalUrl,
clientInstanceId
});
@@ -237,17 +260,87 @@ export class SignalingTransportHandler<TMessage> {
}
for (const { signalUrl: managerSignalUrl, manager } of connectedManagers) {
manager.sendRawMessage({
type: SIGNALING_TYPE_IDENTIFY,
token,
oderId,
const credentials = this.identifyOnSignalUrl(managerSignalUrl, {
fallbackOderId: oderId,
displayName: normalizedDisplayName,
description: normalizedDescription,
profileUpdatedAt: normalizedProfileUpdatedAt,
homeSignalServerUrl: normalizedHomeSignalServerUrl,
connectionScope: managerSignalUrl,
clientInstanceId
});
if (!credentials) {
continue;
}
manager.sendRawMessage({
type: SIGNALING_TYPE_IDENTIFY,
token: credentials.token,
oderId: credentials.oderId,
displayName: credentials.displayName,
description: credentials.description,
profileUpdatedAt: credentials.profileUpdatedAt,
homeSignalServerUrl: credentials.homeSignalServerUrl,
connectionScope: managerSignalUrl,
clientInstanceId: credentials.clientInstanceId
});
}
}
private identifyOnSignalUrl(
signalUrl: string,
params: {
fallbackOderId: string;
displayName: string;
description?: string;
profileUpdatedAt?: number;
homeSignalServerUrl?: string;
clientInstanceId: string;
}
): IdentifyCredentials | null {
const resolved = this.dependencies.resolveCredential(signalUrl);
if (!resolved) {
this.dependencies.logger.warn('Skipping identify because no session token is available', {
signalUrl,
oderId: params.fallbackOderId
});
return null;
}
const credentials: IdentifyCredentials = {
oderId: resolved.userId,
token: resolved.token,
displayName: resolved.displayName || params.displayName,
description: params.description,
profileUpdatedAt: params.profileUpdatedAt,
homeSignalServerUrl: params.homeSignalServerUrl ?? resolved.homeSignalServerUrl,
clientInstanceId: params.clientInstanceId
};
this.lastIdentifyCredentialsBySignalUrl.set(signalUrl, credentials);
this.sendRawMessageToSignalUrl(signalUrl, {
type: SIGNALING_TYPE_IDENTIFY,
token: credentials.token,
oderId: credentials.oderId,
displayName: credentials.displayName,
description: credentials.description,
profileUpdatedAt: credentials.profileUpdatedAt,
homeSignalServerUrl: credentials.homeSignalServerUrl,
connectionScope: signalUrl,
clientInstanceId: credentials.clientInstanceId
});
return credentials;
}
private toIdentifyCredentials(credential: ResolvedSignalCredential): IdentifyCredentials {
return {
oderId: credential.userId,
token: credential.token,
displayName: credential.displayName,
homeSignalServerUrl: credential.homeSignalServerUrl
};
}
}

View File

@@ -370,20 +370,28 @@ export class SignalingManager {
private reIdentifyAndRejoin(): void {
const credentials = this.getLastIdentify();
if (credentials) {
this.sendRawMessage({
type: SIGNALING_TYPE_IDENTIFY,
token: credentials.token,
oderId: credentials.oderId,
displayName: credentials.displayName,
description: credentials.description,
profileUpdatedAt: credentials.profileUpdatedAt,
homeSignalServerUrl: credentials.homeSignalServerUrl,
connectionScope: this.lastSignalingUrl ?? undefined,
clientInstanceId: credentials.clientInstanceId
});
// Never (re)join or view a server before we have re-identified on this
// connection. Sending join_server/view_server while unauthenticated makes
// the server reply with auth_required/auth_error, which the auth-failure
// handler would otherwise treat as a hard session expiry and tear down or
// re-provision the home credential. The higher-level identify flow will
// populate credentials and re-run this rejoin once available.
if (!credentials) {
return;
}
this.sendRawMessage({
type: SIGNALING_TYPE_IDENTIFY,
token: credentials.token,
oderId: credentials.oderId,
displayName: credentials.displayName,
description: credentials.description,
profileUpdatedAt: credentials.profileUpdatedAt,
homeSignalServerUrl: credentials.homeSignalServerUrl,
connectionScope: this.lastSignalingUrl ?? undefined,
clientInstanceId: credentials.clientInstanceId
});
const memberIds = this.getMemberServerIds();
if (memberIds.size > 0) {