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
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user