532 lines
13 KiB
TypeScript
532 lines
13 KiB
TypeScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {action, makeAutoObservable, reaction, runInAction} from 'mobx';
|
|
import Config from '~/Config';
|
|
import {FAVORITES_GUILD_ID, GatewayIdentifyFlags} from '~/Constants';
|
|
import {getPreferredCompression} from '~/lib/GatewayCompression';
|
|
import {type GatewayErrorData, GatewaySocket, type GatewaySocketProperties, GatewayState} from '~/lib/GatewaySocket';
|
|
import {Logger} from '~/lib/Logger';
|
|
import SessionManager from '~/lib/SessionManager';
|
|
import FavoriteMemeStore from '~/stores/FavoriteMemeStore';
|
|
import GeoIPStore from '~/stores/GeoIPStore';
|
|
import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
|
|
import InitializationStore from '~/stores/InitializationStore';
|
|
import LayerManager from '~/stores/LayerManager';
|
|
import LocalPresenceStore from '~/stores/LocalPresenceStore';
|
|
import MemberSearchStore from '~/stores/MemberSearchStore';
|
|
import MessageStore from '~/stores/MessageStore';
|
|
import PermissionStore from '~/stores/PermissionStore';
|
|
import QuickSwitcherStore from '~/stores/QuickSwitcherStore';
|
|
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
|
import SelectedGuildStore from '~/stores/SelectedGuildStore';
|
|
import TypingStore from '~/stores/TypingStore';
|
|
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
|
import {getGatewayClientProperties} from '~/utils/ClientInfoUtils';
|
|
import {createHandlerRegistry, type GatewayHandlerContext, type GatewayHandlerRegistry} from './handlers';
|
|
|
|
const logger = new Logger('ConnectionStore');
|
|
|
|
interface DesiredSession {
|
|
token: string | null;
|
|
userIdHint: string | null;
|
|
}
|
|
|
|
class ConnectionStore {
|
|
socket: GatewaySocket | null = null;
|
|
|
|
isConnected: boolean = false;
|
|
isConnecting: boolean = false;
|
|
isReady: boolean = false;
|
|
|
|
sessionId: string | null = null;
|
|
|
|
private handlerRegistry: GatewayHandlerRegistry;
|
|
|
|
private onlineListener: (() => void) | null = null;
|
|
private offlineListener: (() => void) | null = null;
|
|
private netInfoUnsubscribe: (() => void) | null = null;
|
|
|
|
private generation: number = 0;
|
|
private desired: DesiredSession | null = null;
|
|
|
|
private pendingGuildSyncId: string | null = null;
|
|
private syncedGuildSessions: Record<string, string | null> = {};
|
|
private initialGuildIdAtIdentify: string | null = null;
|
|
|
|
constructor() {
|
|
makeAutoObservable<
|
|
this,
|
|
'cleanupSocket' | 'handleGatewayDispatch' | 'ensureGuildActiveAndSynced' | 'flushPendingGuildSync'
|
|
>(
|
|
this,
|
|
{
|
|
startSession: action.bound,
|
|
logout: action.bound,
|
|
handleConnectionOpen: action.bound,
|
|
handleConnectionResumed: action.bound,
|
|
handleConnectionClosed: action.bound,
|
|
cleanupSocket: action.bound,
|
|
handleGatewayDispatch: action.bound,
|
|
ensureGuildActiveAndSynced: action.bound,
|
|
flushPendingGuildSync: action.bound,
|
|
syncGuildIfNeeded: action.bound,
|
|
},
|
|
{autoBind: true},
|
|
);
|
|
|
|
this.handlerRegistry = createHandlerRegistry();
|
|
|
|
this.setupPresenceSync();
|
|
this.setupSelectedGuildSync();
|
|
}
|
|
|
|
private setupPresenceSync(): void {
|
|
reaction(
|
|
() => LocalPresenceStore.presenceFingerprint,
|
|
() => {
|
|
const presence = LocalPresenceStore.getPresence();
|
|
this.socket?.updatePresence(presence.status, presence.afk, presence.mobile, presence.custom_status);
|
|
},
|
|
);
|
|
}
|
|
|
|
private setupSelectedGuildSync(): void {
|
|
reaction(
|
|
() => ({
|
|
guildId: SelectedGuildStore.selectedGuildId,
|
|
nonce: SelectedGuildStore.selectionNonce,
|
|
}),
|
|
({guildId}) => {
|
|
if (!guildId || guildId === FAVORITES_GUILD_ID) {
|
|
this.pendingGuildSyncId = null;
|
|
return;
|
|
}
|
|
|
|
this.ensureGuildActiveAndSynced(guildId, {reason: 'select'});
|
|
},
|
|
);
|
|
|
|
reaction(
|
|
() => this.isReady,
|
|
(ready) => {
|
|
if (ready) {
|
|
this.flushPendingGuildSync();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
private createHandlerContext(): GatewayHandlerContext {
|
|
return {
|
|
socket: this.socket,
|
|
previousSessionId: null,
|
|
setPreviousSessionId: (_id: string) => {},
|
|
setReady: () => {
|
|
runInAction(() => {
|
|
this.isReady = true;
|
|
this.isConnecting = false;
|
|
});
|
|
|
|
this.flushPendingGuildSync();
|
|
},
|
|
};
|
|
}
|
|
|
|
private removeNetworkListeners(): void {
|
|
if (this.netInfoUnsubscribe) {
|
|
this.netInfoUnsubscribe();
|
|
this.netInfoUnsubscribe = null;
|
|
}
|
|
|
|
if (this.onlineListener) {
|
|
window.removeEventListener('online', this.onlineListener);
|
|
this.onlineListener = null;
|
|
}
|
|
|
|
if (this.offlineListener) {
|
|
window.removeEventListener('offline', this.offlineListener);
|
|
this.offlineListener = null;
|
|
}
|
|
}
|
|
|
|
private cleanupSocket(): void {
|
|
const socket = this.socket;
|
|
this.socket = null;
|
|
|
|
if (socket) {
|
|
try {
|
|
socket.disconnect(1000, 'Cleaning up socket', false);
|
|
} catch (err) {
|
|
logger.warn('Error while disconnecting socket during cleanup', err);
|
|
}
|
|
}
|
|
|
|
this.removeNetworkListeners();
|
|
}
|
|
|
|
private createGatewaySocket(token: string, properties: GatewaySocketProperties, generation: number): GatewaySocket {
|
|
const gatewayUrl = RuntimeConfigStore.gatewayEndpoint;
|
|
const presence = LocalPresenceStore.getPresence();
|
|
const compression = getPreferredCompression();
|
|
|
|
logger.info(`Using gateway compression: ${compression}`);
|
|
|
|
const identifyFlags = Config.PUBLIC_PROJECT_ENV === 'canary' ? GatewayIdentifyFlags.USE_CANARY_API : 0;
|
|
const initialGuildId = SelectedGuildStore.selectedGuildId ?? null;
|
|
this.initialGuildIdAtIdentify = initialGuildId;
|
|
|
|
const socket = new GatewaySocket(
|
|
gatewayUrl,
|
|
{
|
|
apiVersion: Config.PUBLIC_API_VERSION,
|
|
token,
|
|
properties,
|
|
presence: {
|
|
status: presence.status,
|
|
afk: presence.afk,
|
|
mobile: presence.mobile,
|
|
custom_status: presence.custom_status,
|
|
},
|
|
compression,
|
|
identifyFlags,
|
|
initialGuildId,
|
|
},
|
|
(url: string) => RuntimeConfigStore.wrapGatewayUrlWithProxy(url),
|
|
);
|
|
|
|
const isCurrent = (): boolean => this.socket === socket && this.generation === generation;
|
|
|
|
socket.on('dispatch', (eventType: string, data: unknown) => {
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
this.handleGatewayDispatch(eventType, data);
|
|
});
|
|
|
|
socket.on('gatewayError', (error: GatewayErrorData) => {
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
this.handleGatewayError(error);
|
|
});
|
|
|
|
this.removeNetworkListeners();
|
|
|
|
this.onlineListener = () => {
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
socket.handleNetworkStatusChange(true);
|
|
};
|
|
this.offlineListener = () => {
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
socket.handleNetworkStatusChange(false);
|
|
};
|
|
window.addEventListener('online', this.onlineListener);
|
|
window.addEventListener('offline', this.offlineListener);
|
|
|
|
socket.on(
|
|
'stateChange',
|
|
action((newState: GatewayState) => {
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
|
|
this.isConnected = newState === GatewayState.Connected;
|
|
this.isConnecting = newState === GatewayState.Connecting || newState === GatewayState.Reconnecting;
|
|
|
|
if (newState === GatewayState.Disconnected) {
|
|
this.isReady = false;
|
|
SessionManager.handleConnectionFailed();
|
|
|
|
this.syncedGuildSessions = {};
|
|
}
|
|
}),
|
|
);
|
|
|
|
socket.on(
|
|
'ready',
|
|
action((data: unknown) => {
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
const readyData = data as {session_id: string};
|
|
this.handleConnectionOpen(readyData.session_id);
|
|
}),
|
|
);
|
|
|
|
socket.on(
|
|
'resumed',
|
|
action(() => {
|
|
if (!isCurrent()) {
|
|
return;
|
|
}
|
|
this.handleConnectionResumed();
|
|
}),
|
|
);
|
|
|
|
return socket;
|
|
}
|
|
|
|
private ensureGuildActiveAndSynced(guildId: string, options: {force?: boolean; reason?: string} = {}): void {
|
|
if (!guildId || guildId === FAVORITES_GUILD_ID) {
|
|
return;
|
|
}
|
|
|
|
const socket = this.socket;
|
|
|
|
if (!socket || !this.isReady) {
|
|
this.pendingGuildSyncId = guildId;
|
|
return;
|
|
}
|
|
|
|
const sessionId = this.sessionId ?? null;
|
|
const force = options.force ?? false;
|
|
const alreadySyncedSession = this.syncedGuildSessions[guildId] ?? null;
|
|
|
|
if (!force && alreadySyncedSession === sessionId) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
socket.updateGuildSubscriptions({
|
|
subscriptions: {
|
|
[guildId]: {
|
|
active: true,
|
|
sync: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
this.syncedGuildSessions[guildId] = sessionId;
|
|
this.pendingGuildSyncId = null;
|
|
} catch (err) {
|
|
logger.warn('Failed to update guild subscriptions; will retry when possible', err);
|
|
this.pendingGuildSyncId = guildId;
|
|
}
|
|
}
|
|
|
|
syncGuildIfNeeded(guildId: string, reason?: string): void {
|
|
this.ensureGuildActiveAndSynced(guildId, {reason});
|
|
}
|
|
|
|
private flushPendingGuildSync(): void {
|
|
const guildId = this.pendingGuildSyncId ?? SelectedGuildStore.selectedGuildId;
|
|
if (!guildId || guildId === FAVORITES_GUILD_ID) {
|
|
return;
|
|
}
|
|
|
|
this.ensureGuildActiveAndSynced(guildId, {reason: 'flush'});
|
|
}
|
|
|
|
async startSession(token?: string): Promise<void> {
|
|
const userIdHint = SessionManager.userId ?? null;
|
|
|
|
const desired: DesiredSession = {
|
|
token: token ?? null,
|
|
userIdHint,
|
|
};
|
|
|
|
if (this.isConnecting && this.desired) {
|
|
const sameToken = this.desired.token === desired.token;
|
|
const sameUser = this.desired.userIdHint === desired.userIdHint;
|
|
|
|
if (sameToken && sameUser) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.desired = desired;
|
|
|
|
const generation = ++this.generation;
|
|
|
|
runInAction(() => {
|
|
this.isConnecting = true;
|
|
this.isReady = false;
|
|
});
|
|
|
|
SessionManager.handleConnectionStarted();
|
|
|
|
if (this.socket) {
|
|
this.cleanupSocket();
|
|
}
|
|
|
|
InitializationStore.setConnecting();
|
|
|
|
let gatewayToken: string | null = desired.token;
|
|
|
|
try {
|
|
if (!gatewayToken) {
|
|
const stored = SessionManager.token;
|
|
if (!stored) {
|
|
runInAction(() => {
|
|
this.isConnecting = false;
|
|
this.isReady = false;
|
|
});
|
|
SessionManager.handleConnectionFailed();
|
|
return;
|
|
}
|
|
gatewayToken = stored;
|
|
}
|
|
|
|
if (this.generation !== generation) {
|
|
return;
|
|
}
|
|
|
|
let properties: GatewaySocketProperties;
|
|
try {
|
|
properties = await getGatewayClientProperties({
|
|
latitude: GeoIPStore.latitude,
|
|
longitude: GeoIPStore.longitude,
|
|
});
|
|
} catch (err) {
|
|
logger.error('Failed to gather client metadata for gateway identification', err);
|
|
runInAction(() => {
|
|
this.isConnecting = false;
|
|
this.isReady = false;
|
|
});
|
|
SessionManager.handleConnectionFailed();
|
|
return;
|
|
}
|
|
|
|
if (this.generation !== generation) {
|
|
return;
|
|
}
|
|
|
|
const socket = this.createGatewaySocket(gatewayToken, properties, generation);
|
|
|
|
runInAction(() => {
|
|
if (this.generation === generation) {
|
|
this.socket = socket;
|
|
}
|
|
});
|
|
|
|
if (this.socket) {
|
|
this.socket.connect();
|
|
}
|
|
} catch (err) {
|
|
logger.error('Failed to connect to gateway', err);
|
|
runInAction(() => {
|
|
this.isConnecting = false;
|
|
this.isReady = false;
|
|
});
|
|
SessionManager.handleConnectionFailed();
|
|
}
|
|
}
|
|
|
|
logout(): void {
|
|
this.cleanupSocket();
|
|
|
|
MessageStore.handleSessionInvalidated();
|
|
FavoriteMemeStore.reset();
|
|
GuildNSFWAgreeStore.reset();
|
|
InitializationStore.reset();
|
|
MemberSearchStore.handleLogout();
|
|
|
|
this.isConnected = false;
|
|
this.isConnecting = false;
|
|
this.isReady = false;
|
|
|
|
this.sessionId = null;
|
|
this.desired = null;
|
|
|
|
this.pendingGuildSyncId = null;
|
|
this.syncedGuildSessions = {};
|
|
}
|
|
|
|
handleConnectionOpen(sessionId: string): void {
|
|
this.isConnected = true;
|
|
this.isConnecting = false;
|
|
this.isReady = true;
|
|
this.sessionId = sessionId;
|
|
this.markInitialGuildSynced(sessionId);
|
|
|
|
SessionManager.handleConnectionReady();
|
|
|
|
LocalPresenceStore.updatePresence();
|
|
TypingStore.reset();
|
|
QuickSwitcherStore.recomputeIfOpen();
|
|
|
|
this.flushPendingGuildSync();
|
|
}
|
|
|
|
handleConnectionResumed(): void {
|
|
this.isConnected = true;
|
|
this.isConnecting = false;
|
|
this.isReady = true;
|
|
|
|
SessionManager.handleConnectionReady();
|
|
this.markInitialGuildSynced(this.sessionId);
|
|
|
|
LocalPresenceStore.updatePresence();
|
|
TypingStore.reset();
|
|
QuickSwitcherStore.recomputeIfOpen();
|
|
|
|
this.flushPendingGuildSync();
|
|
}
|
|
|
|
handleConnectionClosed(code: number): void {
|
|
SessionManager.handleConnectionClosed(code);
|
|
|
|
this.isConnected = false;
|
|
this.isConnecting = false;
|
|
this.isReady = false;
|
|
|
|
LocalPresenceStore.updatePresence();
|
|
PermissionStore.handleConnectionClose();
|
|
|
|
this.syncedGuildSessions = {};
|
|
|
|
if (code === 4004) {
|
|
LayerManager.closeAll();
|
|
MessageStore.handleConnectionClosed();
|
|
this.cleanupSocket();
|
|
this.sessionId = null;
|
|
}
|
|
}
|
|
|
|
private markInitialGuildSynced(sessionId: string | null): void {
|
|
const guildId = this.initialGuildIdAtIdentify;
|
|
if (!guildId || !sessionId) {
|
|
return;
|
|
}
|
|
|
|
this.syncedGuildSessions[guildId] = sessionId;
|
|
}
|
|
|
|
private handleGatewayError(error: GatewayErrorData): void {
|
|
logger.warn(`Gateway error: [${error.code}] ${error.message}`);
|
|
MediaEngineStore.handleGatewayError(error);
|
|
}
|
|
|
|
private handleGatewayDispatch(eventType: string, data: unknown): void {
|
|
const handler = this.handlerRegistry.get(eventType);
|
|
if (!handler) {
|
|
return;
|
|
}
|
|
const context = this.createHandlerContext();
|
|
handler(data, context);
|
|
}
|
|
}
|
|
|
|
export default new ConnectionStore();
|