import {AbstractServerConnection} from "./connection/ConnectionBase"; import {PermissionManager} from "./permission/PermissionManager"; import {GroupManager} from "./permission/GroupManager"; import {Settings, settings} from "./settings"; import {Sound, SoundManager} from "./audio/Sounds"; import {LogCategory, logError, logInfo, logTrace, logWarn} from "./log"; import {createErrorModal, createInputModal, Modal} from "./ui/elements/Modal"; import {hashPassword} from "./utils/helpers"; import {HandshakeHandler} from "./connection/HandshakeHandler"; import {FilterMode, InputStartError, InputState} from "./voice/RecorderBase"; import {defaultRecorder, RecorderProfile} from "./voice/RecorderProfile"; import {formatMessage} from "./ui/frames/chat"; import {EventHandler, Registry} from "./events"; import {FileManager} from "./file/FileManager"; import {tr, tra} from "./i18n/localize"; import {guid} from "./crypto/uid"; import {PluginCmdRegistry} from "./connection/PluginCmdHandler"; import {VoiceConnectionStatus, WhisperSessionInitializeData} from "./connection/VoiceConnection"; import {getServerConnectionFactory} from "./connection/ConnectionFactory"; import {WhisperSession} from "./voice/VoiceWhisper"; import {ServerFeature, ServerFeatures} from "./connection/ServerFeatures"; import {ChannelTree} from "./tree/ChannelTree"; import {LocalClientEntry} from "./tree/Client"; import {parseServerAddress} from "./tree/Server"; import {ChannelVideoFrame} from "tc-shared/ui/frames/video/Controller"; import {global_client_actions} from "tc-shared/events/GlobalEvents"; import {ChannelConversationManager} from "./conversations/ChannelConversationManager"; import {PrivateConversationManager} from "tc-shared/conversations/PrivateConversationManager"; import {SelectedClientInfo} from "./SelectedClientInfo"; import {SideBarManager} from "tc-shared/SideBarManager"; import {ServerEventLog} from "tc-shared/connectionlog/ServerEventLog"; import {PlaylistManager} from "tc-shared/music/PlaylistManager"; import {connectionHistory} from "tc-shared/connectionlog/History"; import {ConnectParameters} from "tc-shared/ui/modal/connect/Controller"; import {assertMainApplication} from "tc-shared/ui/utils"; import {getDNSProvider} from "tc-shared/dns"; import {W2GPluginCmdHandler} from "tc-shared/ui/modal/video-viewer/W2GPlugin"; import ipRegex from "ip-regex"; import * as htmltags from "./ui/htmltags"; import {ServerSettings} from "tc-shared/ServerSettings"; import {ignorePromise} from "tc-shared/proto"; import {InvokerInfo} from "tc-shared/tree/ChannelDefinitions"; assertMainApplication(); export enum InputHardwareState { MISSING, START_FAILED, VALID } export enum DisconnectReason { HANDLER_DESTROYED, REQUESTED, DNS_FAILED, CONNECT_FAILURE, CONNECTION_CLOSED, CONNECTION_FATAL_ERROR, CONNECTION_PING_TIMEOUT, CLIENT_KICKED, CLIENT_BANNED, HANDSHAKE_FAILED, HANDSHAKE_TEAMSPEAK_REQUIRED, HANDSHAKE_BANNED, SERVER_CLOSED, SERVER_REQUIRES_PASSWORD, SERVER_HOSTMESSAGE, IDENTITY_TOO_LOW, UNKNOWN } export type ClientDisconnectInfo = { reason: "handler-destroy" | "requested" } | { reason: "connection-closed" | "connection-ping-timeout" } | { reason: "connect-failure" | "connect-dns-fail" | "connect-identity-too-low" } | { reason: "connect-identity-unsupported", unsupportedReason: "ts3-server" } | { /* Connect fail since client has been banned */ reason: "connect-banned", message: string } | { /* Connection got closed because the client got kicked */ reason: "client-kicked", message: string, invoker: InvokerInfo } | { /* Connection got closed because the client got banned */ reason: "client-banned", message: string, length: number | 0, invoker?: InvokerInfo, } | { reason: "server-shutdown", message: string, /* TODO: Do we have an invoker here? */ } | { reason: "connect-host-message-disconnect", message: string } | { reason: "generic-connection-error", message: string, allowReconnect: boolean } export enum ConnectionState { UNCONNECTED, /* no connection is currenting running */ CONNECTING, /* we try to establish a connection to the target server */ INITIALISING, /* we're setting up the connection encryption */ AUTHENTICATING, /* we're authenticating our self so we get a unique ID */ CONNECTED, /* we're connected to the server. Server init has been done, may not everything is initialized */ DISCONNECTING/* we're curently disconnecting from the server and awaiting disconnect acknowledge */ } export namespace ConnectionState { export function socketConnected(state: ConnectionState) { switch (state) { case ConnectionState.CONNECTED: case ConnectionState.AUTHENTICATING: //case ConnectionState.INITIALISING: /* its not yet possible to send any data */ return true; default: return false; } } export function fullyConnected(state: ConnectionState) { return state === ConnectionState.CONNECTED; } } export enum ViewReasonId { VREASON_USER_ACTION = 0, VREASON_MOVED = 1, VREASON_SYSTEM = 2, VREASON_TIMEOUT = 3, VREASON_CHANNEL_KICK = 4, VREASON_SERVER_KICK = 5, VREASON_BAN = 6, VREASON_SERVER_STOPPED = 7, VREASON_SERVER_LEFT = 8, VREASON_CHANNEL_UPDATED = 9, VREASON_EDITED = 10, VREASON_SERVER_SHUTDOWN = 11 } export interface LocalClientStatus { input_muted: boolean; output_muted: boolean; lastChannelCodecWarned: number, away: boolean | string; channel_subscribe_all: boolean; queries_visible: boolean; } export class ConnectionHandler { readonly handlerId: string; private readonly events_: Registry; channelTree: ChannelTree; connection_state: ConnectionState = ConnectionState.UNCONNECTED; serverConnection: AbstractServerConnection; currentConnectId: number; /* Id used for the connection history */ fileManager: FileManager; permissions: PermissionManager; groups: GroupManager; video_frame: ChannelVideoFrame; settings: ServerSettings; sound: SoundManager; serverFeatures: ServerFeatures; log: ServerEventLog; private sideBar: SideBarManager; private playlistManager: PlaylistManager; private channelConversations: ChannelConversationManager; private privateConversations: PrivateConversationManager; private clientInfoManager: SelectedClientInfo; private localClientId: number = 0; private localClient: LocalClientEntry; private autoReconnectTimer: number; private isReconnectAttempt: boolean; private connectAttemptId: number = 1; private echoTestRunning = false; private pluginCmdRegistry: PluginCmdRegistry; private handlerState: LocalClientStatus = { input_muted: false, output_muted: false, away: false, channel_subscribe_all: true, queries_visible: false, lastChannelCodecWarned: -1 }; private clientStatusSync = false; private inputHardwareState: InputHardwareState = InputHardwareState.MISSING; private listenerRecorderInputDeviceChanged: (() => void); constructor() { this.handlerId = guid(); this.events_ = new Registry(); this.events_.enableDebug("connection-handler"); this.settings = new ServerSettings(); this.serverConnection = getServerConnectionFactory().create(this); this.serverConnection.events.on("notify_connection_state_changed", event => { logTrace(LogCategory.CLIENT, tr("From %s to %s"), ConnectionState[event.oldState], ConnectionState[event.newState]); this.events_.fire("notify_connection_state_changed", { oldState: event.oldState, newState: event.newState }); }); this.serverConnection.getVoiceConnection().events.on("notify_recorder_changed", event => { this.setInputHardwareState(this.getVoiceRecorder() ? InputHardwareState.VALID : InputHardwareState.MISSING); this.updateVoiceStatus(); if(this.listenerRecorderInputDeviceChanged) { this.listenerRecorderInputDeviceChanged(); this.listenerRecorderInputDeviceChanged = undefined; } if(event.newRecorder) { this.listenerRecorderInputDeviceChanged = event.newRecorder.input?.events.on("notify_device_changed", () => { this.setInputHardwareState(InputHardwareState.VALID); this.updateVoiceStatus(); }); } }); this.serverConnection.getVoiceConnection().events.on("notify_connection_status_changed", () => this.update_voice_status()); this.serverConnection.getVoiceConnection().setWhisperSessionInitializer(this.initializeWhisperSession.bind(this)); this.serverFeatures = new ServerFeatures(this); this.groups = new GroupManager(this); this.channelTree = new ChannelTree(this); this.fileManager = new FileManager(this); this.permissions = new PermissionManager(this); this.playlistManager = new PlaylistManager(this); this.sideBar = new SideBarManager(this); this.privateConversations = new PrivateConversationManager(this); this.channelConversations = new ChannelConversationManager(this); this.clientInfoManager = new SelectedClientInfo(this); this.pluginCmdRegistry = new PluginCmdRegistry(this); this.video_frame = new ChannelVideoFrame(this); this.log = new ServerEventLog(this); this.sound = new SoundManager(this); this.localClient = new LocalClientEntry(this); this.localClient.channelTree = this.channelTree; this.events_.registerHandler(this); this.pluginCmdRegistry.registerHandler(new W2GPluginCmdHandler()); this.events_.fire("notify_handler_initialized"); } initializeHandlerState(source?: ConnectionHandler) { if(source) { this.handlerState.input_muted = source.handlerState.input_muted; this.handlerState.output_muted = source.handlerState.output_muted; this.update_voice_status(); this.setAway(source.handlerState.away); this.setQueriesShown(source.handlerState.queries_visible); this.setSubscribeToAllChannels(source.handlerState.channel_subscribe_all); /* Ignore lastChannelCodecWarned */ } else { this.handlerState.input_muted = settings.getValue(Settings.KEY_CLIENT_STATE_MICROPHONE_MUTED); this.handlerState.output_muted = settings.getValue(Settings.KEY_CLIENT_STATE_SPEAKER_MUTED); this.update_voice_status(); this.setSubscribeToAllChannels(settings.getValue(Settings.KEY_CLIENT_STATE_SUBSCRIBE_ALL_CHANNELS)); this.doSetAway(settings.getValue(Settings.KEY_CLIENT_STATE_AWAY) ? settings.getValue(Settings.KEY_CLIENT_AWAY_MESSAGE) : false, false); this.setQueriesShown(settings.getValue(Settings.KEY_CLIENT_STATE_QUERY_SHOWN)); } } events() : Registry { return this.events_; } async startConnectionNew(parameters: ConnectParameters, isReconnectAttempt: boolean) { this.cancelAutoReconnect(true); this.isReconnectAttempt = isReconnectAttempt; this.handleDisconnect(DisconnectReason.REQUESTED); const localConnectionAttemptId = ++this.connectAttemptId; const parsedAddress = parseServerAddress(parameters.targetAddress); const resolvedAddress = Object.assign({}, parsedAddress); this.log.log("connection.begin", { address: { server_hostname: parsedAddress.host, server_port: parsedAddress.port }, client_nickname: parameters.nickname }); this.channelTree.initialiseHead(parameters.targetAddress, parsedAddress); /* hash the password if not already hashed */ if(parameters.serverPassword && !parameters.serverPasswordHashed) { try { parameters.serverPassword = await hashPassword(parameters.serverPassword); parameters.serverPasswordHashed = true; } catch(error) { logError(LogCategory.CLIENT, tr("Failed to hash connect password: %o"), error); createErrorModal(tr("Error while hashing password"), tr("Failed to hash server password!
") + error).open(); /* FIXME: Abort connection attempt */ } if(this.connectAttemptId !== localConnectionAttemptId) { /* Our attempt has been aborted */ return; } } if(parameters.defaultChannelPassword && !parameters.defaultChannelPasswordHashed) { try { parameters.defaultChannelPassword = await hashPassword(parameters.defaultChannelPassword); parameters.defaultChannelPasswordHashed = true; } catch(error) { logError(LogCategory.CLIENT, tr("Failed to hash channel password: %o"), error); createErrorModal(tr("Error while hashing password"), tr("Failed to hash channel password!
") + error).open(); /* FIXME: Abort connection attempt */ } if(this.connectAttemptId !== localConnectionAttemptId) { /* Our attempt has been aborted */ return; } } if(ipRegex({ exact: true }).test(resolvedAddress.host)) { /* We don't have to resolve the target host */ } else { this.log.log("connection.hostname.resolve", {}); try { const resolved = await getDNSProvider().resolveAddress({ hostname: parsedAddress.host, port: parsedAddress.port }, { timeout: 5000 }); if(this.connectAttemptId !== localConnectionAttemptId) { /* Our attempt has been aborted */ return; } if(resolved.status === "empty-result") { throw tr("address resolve result empty"); } else if(resolved.status === "error") { throw resolved.message; } resolvedAddress.host = resolved.resolvedAddress.hostname; resolvedAddress.port = resolved.resolvedAddress.port; if(typeof resolvedAddress.port !== "number") { resolvedAddress.port = parsedAddress.port; } this.log.log("connection.hostname.resolved", { address: { server_port: resolvedAddress.port, server_hostname: resolvedAddress.host } }); } catch(error) { if(this.connectAttemptId !== localConnectionAttemptId) { /* Our attempt has been aborted */ return; } this.handleDisconnect(DisconnectReason.DNS_FAILED, error); return; } } if(this.isReconnectAttempt) { /* this.currentConnectId = 0; */ /* Reconnect attempts are connecting to the last server. No need to update the general attempt id */ } else { this.currentConnectId = await connectionHistory.logConnectionAttempt({ nickname: parameters.nicknameSpecified ? parameters.nickname : undefined, hashedPassword: parameters.serverPassword, /* Password will be hashed by now! */ targetAddress: parameters.targetAddress, }); } await this.serverConnection.connect(resolvedAddress, new HandshakeHandler(parameters)); } async disconnectFromServer(reason?: string) { this.cancelAutoReconnect(true); if(!this.connected) { return; } this.handleDisconnect(DisconnectReason.REQUESTED); try { await this.serverConnection.disconnect(); } catch (error) { logWarn(LogCategory.CLIENT, tr("Failed to successfully disconnect from server: {}"), error); } this.sound.play(Sound.CONNECTION_DISCONNECTED); this.log.log("disconnected", {}); } getClient() : LocalClientEntry { return this.localClient; } getClientId() { return this.localClientId; } getPrivateConversations() : PrivateConversationManager { return this.privateConversations; } getChannelConversations() : ChannelConversationManager { return this.channelConversations; } getSelectedClientInfo() : SelectedClientInfo { return this.clientInfoManager; } getSideBar() : SideBarManager { return this.sideBar; } getPlaylistManager() : PlaylistManager { return this.playlistManager; } initializeLocalClient(clientId: number, acceptedName: string) { this.localClientId = clientId; this.localClient["_clientId"] = clientId; this.channelTree.registerClient(this.localClient); this.localClient.updateVariables( { key: "client_nickname", value: acceptedName }); } getServerConnection() : AbstractServerConnection { return this.serverConnection; } @EventHandler("notify_connection_state_changed") private handleConnectionStateChanged(event: ConnectionEvents["notify_connection_state_changed"]) { this.connection_state = event.newState; if(event.newState === ConnectionState.CONNECTED) { logInfo(LogCategory.CLIENT, tr("Client connected")); this.log.log("connection.connected", { serverAddress: { server_port: this.channelTree.server.remote_address.port, server_hostname: this.channelTree.server.remote_address.host }, serverName: this.channelTree.server.properties.virtualserver_name, own_client: this.getClient().log_data() }); this.sound.play(Sound.CONNECTION_CONNECTED); this.permissions.requestPermissionList(); /* There is no need to request the server groups since they must be send by the server if(this.groups.serverGroups.length == 0) { this.groups.requestGroups(); } */ this.settings.setServerUniqueId(this.channelTree.server.properties.virtualserver_unique_identifier); /* apply the server settings */ if(this.handlerState.channel_subscribe_all) { this.channelTree.subscribe_all_channels(); } else { this.channelTree.unsubscribe_all_channels(); } this.channelTree.toggle_server_queries(this.handlerState.queries_visible); this.sync_status_with_server(); this.channelTree.server.updateProperties(); /* No need to update the voice stuff because as soon we see ourself we're doing it this.update_voice_status(); if(control_bar.current_connection_handler() === this) control_bar.apply_server_voice_state(); */ if(__build.target === "web" && settings.getValue(Settings.KEY_VOICE_ECHO_TEST_ENABLED)) { this.serverFeatures.awaitFeatures().then(result => { if(!result) { return; } if(this.serverFeatures.supportsFeature(ServerFeature.WHISPER_ECHO)) { global_client_actions.fire("action_open_window", { window: "server-echo-test", connection: this }); } }); } } else { this.setInputHardwareState(this.getVoiceRecorder() ? InputHardwareState.VALID : InputHardwareState.MISSING); } } get connected() : boolean { return this.serverConnection && this.serverConnection.connected(); } private generate_ssl_certificate_accept() : HTMLAnchorElement { const properties = { connect_default: true, connect_profile: this.serverConnection.handshake_handler().parameters.profile.id, connect_address: this.serverConnection.remote_address().host + (this.serverConnection.remote_address().port !== 9987 ? ":" + this.serverConnection.remote_address().port : "") }; const build_url = (base: string, search: string, props: any) => { const parameters: string[] = []; for(const key of Object.keys(props)) { parameters.push(key + "=" + encodeURIComponent(props[key])); } let callback = base + search; /* don't use document.URL because it may contains a #! */ if(!search) { callback += "?" + parameters.join("&"); } else { callback += "&" + parameters.join("&"); } return "https://" + this.serverConnection.remote_address().host + ":" + this.serverConnection.remote_address().port + "/?forward_url=" + encodeURIComponent(callback); }; /* generate the tag */ const tag = document.createElement("a"); tag.text = tr("here"); let pathname = document.location.pathname; if(pathname.endsWith(".php")) pathname = pathname.substring(0, pathname.lastIndexOf("/")); tag.href = build_url(document.location.origin + pathname, document.location.search, properties); return tag; } /** * This method dispatches a connection disconnect. * The method can be called out of every context and will properly terminate * all resources related to the current connection. */ handleDisconnectNew(reason: ClientDisconnectInfo) { /* TODO: */ } private _certificate_modal: Modal; handleDisconnect(type: DisconnectReason, data: any = {}) { this.connectAttemptId++; let autoReconnect = false; switch (type) { case DisconnectReason.REQUESTED: case DisconnectReason.SERVER_HOSTMESSAGE: /* already handled */ break; case DisconnectReason.HANDLER_DESTROYED: if(data) { this.sound.play(Sound.CONNECTION_DISCONNECTED); this.log.log("disconnected", {}); } break; case DisconnectReason.DNS_FAILED: logError(LogCategory.CLIENT, tr("Failed to resolve hostname: %o"), data); this.log.log("connection.hostname.resolve.error", { message: data as any }); this.sound.play(Sound.CONNECTION_REFUSED); break; case DisconnectReason.CONNECT_FAILURE: if(this.isReconnectAttempt) { autoReconnect = true; break; } if(data) { logError(LogCategory.CLIENT, tr("Could not connect to remote host! Extra data: %o"), data); } else { logError(LogCategory.CLIENT, tr("Could not connect to remote host!"), data); } if(__build.target === "client") { createErrorModal( tr("Could not connect"), tr("Could not connect to remote host (Connection refused)") ).open(); } else { const generateAddressPart = () => Math.floor(Math.random() * 256); const addressParts = [generateAddressPart(), generateAddressPart(), generateAddressPart(), generateAddressPart()]; getDNSProvider().resolveAddressIPv4({ hostname: addressParts.join("-") + ".con-gate.work", port: 9987 }, { timeout: 5000 }).then(async result => { if(result.status === "empty-result") { throw tr("empty result"); } else if(result.status === "error") { throw result.message; } if(result.resolvedAddress.hostname !== addressParts.join(".")) { throw "miss matching address"; } createErrorModal( tr("Could not connect"), tr("Could not connect to remote host (Connection refused)") ).open(); }).catch(() => { const error_message_format = "Could not connect to remote host (Connection refused)\n" + "If you're sure that the remote host is up, than you may not allow unsigned certificates or that con-gate.work works properly.\n" + "Click {0} to accept the remote certificate"; this._certificate_modal = createErrorModal( tr("Could not connect"), formatMessage(/* @tr-ignore */ tr(error_message_format), this.generate_ssl_certificate_accept()) ); this._certificate_modal.close_listener.push(() => this._certificate_modal = undefined); this._certificate_modal.open(); }); } this.log.log("connection.failed", { serverAddress: { server_hostname: this.serverConnection.remote_address().host, server_port: this.serverConnection.remote_address().port } }); this.sound.play(Sound.CONNECTION_REFUSED); break; case DisconnectReason.HANDSHAKE_FAILED: //TODO sound logError(LogCategory.CLIENT, tr("Failed to process handshake: %o"), data); createErrorModal( tr("Could not connect"), tr("Failed to process handshake: ") + data as string ).open(); break; case DisconnectReason.HANDSHAKE_TEAMSPEAK_REQUIRED: createErrorModal( tr("Target server is a TeamSpeak server"), tr("The target server is a TeamSpeak 3 server!\nOnly TeamSpeak 3 based identities are able to connect.\nPlease select another profile or change the identify type.") ).open(); this.sound.play(Sound.CONNECTION_DISCONNECTED); autoReconnect = false; break; case DisconnectReason.IDENTITY_TOO_LOW: createErrorModal( tr("Identity level is too low"), formatMessage(tr("You've been disconnected, because your Identity level is too low.\nYou need at least a level of {0}"), data["extra_message"]) ).open(); this.sound.play(Sound.CONNECTION_DISCONNECTED); autoReconnect = false; break; case DisconnectReason.CONNECTION_CLOSED: logError(LogCategory.CLIENT, tr("Lost connection to remote server!")); if(!this.isReconnectAttempt) { createErrorModal( tr("Connection closed"), tr("The connection was closed by remote host") ).open(); this.sound.play(Sound.CONNECTION_DISCONNECTED); } autoReconnect = true; break; case DisconnectReason.CONNECTION_PING_TIMEOUT: logError(LogCategory.CLIENT, tr("Connection ping timeout")); this.sound.play(Sound.CONNECTION_DISCONNECTED_TIMEOUT); createErrorModal( tr("Connection lost"), tr("Lost connection to remote host (Ping timeout)
Even possible?") ).open(); autoReconnect = true; break; case DisconnectReason.SERVER_CLOSED: this.log.log("server.closed", {message: data.reasonmsg}); createErrorModal( tr("Server closed"), "The server is closed.
" + //TODO tr "Reason: " + data.reasonmsg ).open(); this.sound.play(Sound.CONNECTION_DISCONNECTED); autoReconnect = true; break; case DisconnectReason.SERVER_REQUIRES_PASSWORD: this.log.log("server.requires.password", {}); const reconnectParameters = this.generateReconnectParameters(); createInputModal(tr("Server password"), tr("Enter server password:"), password => password.length != 0, password => { if(typeof password !== "string") { return; } reconnectParameters.serverPassword = password; reconnectParameters.serverPasswordHashed = false; ignorePromise(this.startConnectionNew(reconnectParameters, false)); }).open(); break; case DisconnectReason.CLIENT_KICKED: const have_invoker = typeof(data["invokerid"]) !== "undefined" && parseInt(data["invokerid"]) !== 0; const modal = createErrorModal( tr("You've been kicked"), formatMessage( have_invoker ? tr("You've been kicked from the server by {0}:\n{1}") : tr("You've been kicked from the server:\n{1}"), have_invoker ? htmltags.generate_client_object({ client_id: parseInt(data["invokerid"]), client_unique_id: data["invokeruid"], client_name: data["invokername"]}) : "", data["extra_message"] || data["reasonmsg"] || "" ) ); modal.htmlTag.find(".modal-body").addClass("modal-disconnect-kick"); modal.open(); this.sound.play(Sound.SERVER_KICKED); autoReconnect = false; break; case DisconnectReason.HANDSHAKE_BANNED: //Reason message already printed because of the command error handling this.sound.play(Sound.CONNECTION_BANNED); break; case DisconnectReason.CLIENT_BANNED: this.log.log("server.banned", { invoker: { client_name: data["invokername"], client_id: parseInt(data["invokerid"]), client_unique_id: data["invokeruid"] }, message: data["reasonmsg"], time: parseInt(data["time"]) }); this.sound.play(Sound.CONNECTION_BANNED); break; default: logError(LogCategory.CLIENT, tr("Got uncaught disconnect!")); logError(LogCategory.CLIENT, tr("Type: %o Data: %o"), type, data); break; } this.channelTree.unregisterClient(this.localClient); /* if we dont unregister our client here the client will be destroyed */ this.channelTree.reset(); ignorePromise(this.serverConnection?.disconnect()); this.handlerState.lastChannelCodecWarned = 0; if(autoReconnect) { if(!this.serverConnection) { logInfo(LogCategory.NETWORKING, tr("Allowed to auto reconnect but cant reconnect because we dont have any information left...")); return; } this.log.log("reconnect.scheduled", {timeout: 5000}); logInfo(LogCategory.NETWORKING, tr("Allowed to auto reconnect. Reconnecting in 5000ms")); const reconnectParameters = this.generateReconnectParameters(); this.autoReconnectTimer = setTimeout(() => { this.autoReconnectTimer = undefined; this.log.log("reconnect.execute", {}); logInfo(LogCategory.NETWORKING, tr("Reconnecting...")); ignorePromise(this.startConnectionNew(reconnectParameters, true)); }, 5000); } this.serverConnection.updateConnectionState(ConnectionState.UNCONNECTED); /* Fix for the native client... */ } cancelAutoReconnect(log_event: boolean) { if(this.autoReconnectTimer) { if(log_event) { this.log.log("reconnect.canceled", {}); } clearTimeout(this.autoReconnectTimer); this.autoReconnectTimer = undefined; } } private updateVoiceStatus() { if(!this.localClient) { /* we've been destroyed */ return; } let shouldRecord = false; const voiceConnection = this.serverConnection.getVoiceConnection(); if(this.serverConnection.connected()) { let localClientUpdates: { client_output_hardware?: boolean, client_input_hardware?: boolean, client_input_muted?: boolean, client_output_muted?: boolean } = {}; const currentChannel = this.getClient().currentChannel(); if(!currentChannel) { /* Don't update the voice state, firstly await for us to be fully connected */ } else if(voiceConnection.getConnectionState() !== VoiceConnectionStatus.Connected) { /* We're currently not having a valid voice connection. We need to await that. */ localClientUpdates.client_input_hardware = false; localClientUpdates.client_output_hardware = false; } else { let codecSupportEncode = voiceConnection.encodingSupported(currentChannel.properties.channel_codec); let codecSupportDecode = voiceConnection.decodingSupported(currentChannel.properties.channel_codec); localClientUpdates.client_input_hardware = codecSupportEncode; localClientUpdates.client_output_hardware = codecSupportDecode; if(this.handlerState.lastChannelCodecWarned !== currentChannel.getChannelId()) { this.handlerState.lastChannelCodecWarned = currentChannel.getChannelId(); if(!codecSupportEncode || !codecSupportDecode) { let message; if(!codecSupportEncode && !codecSupportDecode) { message = tr("This channel has an unsupported codec.
You cant speak or listen to anybody within this channel!"); } else if(!codecSupportEncode) { message = tr("This channel has an unsupported codec.
You cant speak within this channel!"); } else if(!codecSupportDecode) { message = tr("This channel has an unsupported codec.
You cant listen to anybody within this channel!"); } createErrorModal(tr("Channel codec unsupported"), message).open(); } } shouldRecord = codecSupportEncode && !!voiceConnection.voiceRecorder()?.input; } localClientUpdates.client_input_hardware = localClientUpdates.client_input_hardware && this.inputHardwareState === InputHardwareState.VALID; localClientUpdates.client_output_muted = this.handlerState.output_muted; localClientUpdates.client_input_muted = this.handlerState.input_muted; if(localClientUpdates.client_input_muted || localClientUpdates.client_output_muted) { shouldRecord = false; } /* update our owns client properties */ { const currentClientProperties = this.getClient().properties; if(this.clientStatusSync) { for(const key of Object.keys(localClientUpdates)) { if(currentClientProperties[key] === localClientUpdates[key]) delete localClientUpdates[key]; } } if(Object.keys(localClientUpdates).length > 0) { /* directly update all update locally so we don't send updates twice */ const updates = []; for(const key of Object.keys(localClientUpdates)) { updates.push({ key: key, value: localClientUpdates[key] ? "1" : "0" }); } this.getClient().updateVariables(...updates); this.clientStatusSync = true; if(this.connected) { this.serverConnection.send_command("clientupdate", localClientUpdates).catch(error => { logWarn(LogCategory.GENERAL, tr("Failed to update client audio hardware properties. Error: %o"), error); this.log.log("error.custom", { message: tr("Failed to update audio hardware properties.") }); this.clientStatusSync = false; }); } } } } else { /* we're not connect, so we should not record either */ } /* update the recorder state */ const currentInput = voiceConnection.voiceRecorder()?.input; if(currentInput) { if(shouldRecord || this.echoTestRunning) { if(this.getInputHardwareState() !== InputHardwareState.START_FAILED) { this.startVoiceRecorder(Date.now() - this.lastRecordErrorPopup > 10 * 1000).then(() => { this.events_.fire("notify_state_updated", { state: "microphone" }); }); } } else { currentInput.stop().catch(error => { logWarn(LogCategory.AUDIO, tr("Failed to stop the microphone input recorder: %o"), error); }).then(() => { this.events_.fire("notify_state_updated", { state: "microphone" }); }); } } } private lastRecordErrorPopup: number = 0; update_voice_status() { this.updateVoiceStatus(); return; } sync_status_with_server() { if(this.serverConnection.connected()) this.serverConnection.send_command("clientupdate", { client_input_muted: this.handlerState.input_muted, client_output_muted: this.handlerState.output_muted, client_away: typeof(this.handlerState.away) === "string" || this.handlerState.away, client_away_message: typeof(this.handlerState.away) === "string" ? this.handlerState.away : "", /* TODO: Somehow store this? */ //client_input_hardware: this.client_status.sound_record_supported && this.getInputHardwareState() === InputHardwareState.VALID, //client_output_hardware: this.client_status.sound_playback_supported }).catch(error => { logWarn(LogCategory.GENERAL, tr("Failed to sync handler state with server. Error: %o"), error); this.log.log("error.custom", {message: tr("Failed to sync handler state with server.")}); }); } /* can be called as much as you want, does nothing if nothing changed */ async acquireInputHardware() { try { await this.serverConnection.getVoiceConnection().acquireVoiceRecorder(defaultRecorder); } catch (error) { logError(LogCategory.AUDIO, tr("Failed to acquire recorder: %o"), error); createErrorModal(tr("Failed to acquire recorder"), tr("Failed to acquire recorder.\nLookup the console for more details.")).open(); return; } /* our voice status will be updated automatically due to the notify_recorder_changed event which should be fired when the acquired recorder changed */ } async startVoiceRecorder(notifyError: boolean) : Promise<{ state: "success" | "no-input" } | { state: "error", message: string }> { const input = this.getVoiceRecorder()?.input; if(!input) { return { state: "no-input" }; } if(input.currentState() === InputState.PAUSED && this.connection_state === ConnectionState.CONNECTED) { try { const result = await input.start(); if(result !== true) { throw result; } this.setInputHardwareState(InputHardwareState.VALID); this.updateVoiceStatus(); return { state: "success" }; } catch (error) { this.setInputHardwareState(InputHardwareState.START_FAILED); this.updateVoiceStatus(); let errorMessage; if(error === InputStartError.ENOTSUPPORTED) { errorMessage = tr("Your browser does not support voice recording"); } else if(error === InputStartError.EBUSY) { errorMessage = tr("The input device is busy"); } else if(error === InputStartError.EDEVICEUNKNOWN) { errorMessage = tr("Invalid input device"); } else if(error === InputStartError.ENOTALLOWED) { errorMessage = tr("No permissions"); } else if(error === InputStartError.ESYSTEMUNINITIALIZED) { errorMessage = tr("Window audio not initialized."); } else if(error instanceof Error) { errorMessage = error.message; } else if(typeof error === "string") { errorMessage = error; } else { errorMessage = tr("lookup the console"); } logWarn(LogCategory.VOICE, tr("Failed to start microphone input (%o)."), error); if(notifyError) { this.lastRecordErrorPopup = Date.now(); createErrorModal(tr("Failed to start recording"), tra("Microphone start failed.\nError: {}", errorMessage)).open(); } return { state: "error", message: errorMessage }; } } else { this.setInputHardwareState(InputHardwareState.VALID); return { state: "success" }; } } getVoiceRecorder() : RecorderProfile | undefined { return this.serverConnection?.getVoiceConnection().voiceRecorder(); } generateReconnectParameters() : ConnectParameters | undefined { const baseProfile = this.serverConnection.handshake_handler()?.parameters; if(!baseProfile) { /* We never tried to connect to anywhere */ return undefined; } baseProfile.nickname = this.getClient()?.clientNickName() || baseProfile.nickname; baseProfile.nicknameSpecified = false; const targetChannel = this.getClient()?.currentChannel(); if(targetChannel) { baseProfile.defaultChannel = targetChannel.channelId; baseProfile.defaultChannelPassword = targetChannel.getCachedPasswordHash(); baseProfile.defaultChannelPasswordHashed = true; } return baseProfile; } private async initializeWhisperSession(session: WhisperSession) : Promise { /* TODO: Try to load the clients unique via a clientgetuidfromclid */ if(!session.getClientUniqueId()) throw "missing clients unique id"; logInfo(LogCategory.CLIENT, tr("Initializing a whisper session for client %d (%s | %s)"), session.getClientId(), session.getClientUniqueId(), session.getClientName()); return { clientName: session.getClientName(), clientUniqueId: session.getClientUniqueId(), blocked: session.getClientId() !== this.getClient().clientId(), volume: 1, sessionTimeout: 5 * 1000 } } destroy() { this.events_.unregisterHandler(this); this.cancelAutoReconnect(true); this.pluginCmdRegistry?.destroy(); this.pluginCmdRegistry = undefined; if(this.localClient) { const voiceHandle = this.localClient.getVoiceClient(); if(voiceHandle) { logWarn(LogCategory.GENERAL, tr("Local voice client has received a voice handle. This should never happen!")); this.localClient.setVoiceClient(undefined); this.serverConnection.getVoiceConnection().unregisterVoiceClient(voiceHandle); } const videoHandle = this.localClient.getVideoClient(); if(videoHandle) { logWarn(LogCategory.GENERAL, tr("Local voice client has received a video handle. This should never happen!")); this.localClient.setVoiceClient(undefined); this.serverConnection.getVideoConnection().unregisterVideoClient(videoHandle); } this.localClient.destroy(); } this.localClient = undefined; this.privateConversations?.destroy(); this.privateConversations = undefined; this.channelConversations?.destroy(); this.channelConversations = undefined; this.channelTree?.destroy(); this.channelTree = undefined; this.sideBar?.destroy(); this.sideBar = undefined; this.playlistManager?.destroy(); this.playlistManager = undefined; this.clientInfoManager?.destroy(); this.clientInfoManager = undefined; this.log?.destroy(); this.log = undefined; this.permissions?.destroy(); this.permissions = undefined; this.groups?.destroy(); this.groups = undefined; this.fileManager?.destroy(); this.fileManager = undefined; this.serverFeatures?.destroy(); this.serverFeatures = undefined; this.settings?.destroy(); this.settings = undefined; if(this.listenerRecorderInputDeviceChanged) { this.listenerRecorderInputDeviceChanged(); this.listenerRecorderInputDeviceChanged = undefined; } if(this.serverConnection) { getServerConnectionFactory().destroy(this.serverConnection); } this.serverConnection = undefined; this.sound = undefined; this.localClient = undefined; } /* state changing methods */ setMicrophoneMuted(muted: boolean, dontPlaySound?: boolean) { if(this.handlerState.input_muted === muted) { return; } this.handlerState.input_muted = muted; if(!dontPlaySound) { this.sound.play(muted ? Sound.MICROPHONE_MUTED : Sound.MICROPHONE_ACTIVATED); } this.update_voice_status(); this.events_.fire("notify_state_updated", { state: "microphone" }); } toggleMicrophone() { this.setMicrophoneMuted(!this.isMicrophoneMuted()); } isMicrophoneMuted() { return this.handlerState.input_muted; } isMicrophoneDisabled() { return this.inputHardwareState !== InputHardwareState.VALID; } setSpeakerMuted(muted: boolean, dontPlaySound?: boolean) { if(this.handlerState.output_muted === muted) { return; } if(muted && !dontPlaySound) { /* play the sound *before* we're setting the muted state */ this.sound.play(Sound.SOUND_MUTED); } this.handlerState.output_muted = muted; this.events_.fire("notify_state_updated", { state: "speaker" }); if(!muted && !dontPlaySound) { /* play the sound *after* we're setting we've unmuted the sound */ this.sound.play(Sound.SOUND_ACTIVATED); } this.update_voice_status(); this.serverConnection.getVoiceConnection().stopAllVoiceReplays(); } toggleSpeakerMuted() { this.setSpeakerMuted(!this.isSpeakerMuted()); } isSpeakerMuted() { return this.handlerState.output_muted; } /* * Returns whatever the client is able to playback sound (voice). Reasons for returning true could be: * - Channel codec isn't supported * - Voice bridge hasn't been set upped yet */ //TODO: This currently returns false isSpeakerDisabled() : boolean { return false; } setSubscribeToAllChannels(flag: boolean) { if(this.handlerState.channel_subscribe_all === flag) { return; } this.handlerState.channel_subscribe_all = flag; if(flag) { this.channelTree.subscribe_all_channels(); } else { this.channelTree.unsubscribe_all_channels(); } this.events_.fire("notify_state_updated", { state: "subscribe" }); } isSubscribeToAllChannels() : boolean { return this.handlerState.channel_subscribe_all; } setAway(state: boolean | string) { this.doSetAway(state, true); } private doSetAway(state: boolean | string, playSound: boolean) { if(this.handlerState.away === state) { return; } const wasAway = this.isAway(); const willAway = typeof state === "boolean" ? state : true; if(wasAway != willAway && playSound) { this.sound.play(willAway ? Sound.AWAY_ACTIVATED : Sound.AWAY_DEACTIVATED); } this.handlerState.away = state; this.serverConnection.send_command("clientupdate", { client_away: typeof(this.handlerState.away) === "string" || this.handlerState.away, client_away_message: typeof(this.handlerState.away) === "string" ? this.handlerState.away : "", }).catch(error => { logWarn(LogCategory.GENERAL, tr("Failed to update away status. Error: %o"), error); this.log.log("error.custom", {message: tr("Failed to update away status.")}); }); this.events_.fire("notify_state_updated", { state: "away" }); } toggleAway() { this.setAway(!this.isAway()); } isAway() : boolean { return typeof this.handlerState.away !== "boolean" || this.handlerState.away; } setQueriesShown(flag: boolean) { if(this.handlerState.queries_visible === flag) { return; } this.handlerState.queries_visible = flag; this.channelTree.toggle_server_queries(flag); this.events_.fire("notify_state_updated", { state: "query" }); } areQueriesShown() : boolean { return this.handlerState.queries_visible; } getInputHardwareState() : InputHardwareState { return this.inputHardwareState; } private setInputHardwareState(state: InputHardwareState) { if(this.inputHardwareState === state) { return; } this.inputHardwareState = state; this.events_.fire("notify_state_updated", { state: "microphone" }); } hasOutputHardware() : boolean { return true; } getPluginCmdRegistry() : PluginCmdRegistry { return this.pluginCmdRegistry; } async startEchoTest() : Promise { await this.serverConnection.getVoiceConnection().startWhisper({ target: "echo" }); this.update_voice_status(); try { this.echoTestRunning = true; const startResult = await this.startVoiceRecorder(false); /* FIXME: Don't do it like that! */ this.getVoiceRecorder()?.input?.setFilterMode(FilterMode.Bypass); if(startResult.state === "error") { throw startResult.message; } } catch (error) { this.echoTestRunning = false; /* TODO: Restore voice recorder state! */ throw error; } } stopEchoTest() { this.echoTestRunning = false; this.serverConnection.getVoiceConnection().stopWhisper(); this.getVoiceRecorder()?.input?.setFilterMode(FilterMode.Filter); this.update_voice_status(); } getCurrentServerUniqueId() { return this.channelTree.server.properties.virtualserver_unique_identifier; } } export type ConnectionStateUpdateType = "microphone" | "speaker" | "away" | "subscribe" | "query"; export interface ConnectionEvents { notify_state_updated: { state: ConnectionStateUpdateType; } notify_connection_state_changed: { oldState: ConnectionState, newState: ConnectionState }, /* fill only trigger once, after everything has been constructed */ notify_handler_initialized: {} }