443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
import * as loader from "tc-loader";
|
|
import {Stage} from "tc-loader";
|
|
import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "tc-shared/log";
|
|
import {Registry} from "tc-shared/events";
|
|
import {
|
|
CommandSessionInitializeAgent, CommandSessionUpdateLocale,
|
|
Message,
|
|
MessageCommand,
|
|
MessageCommandResult,
|
|
MessageNotify,
|
|
NotifyClientsOnline
|
|
} from "./Messages";
|
|
import {config, tr} from "tc-shared/i18n/localize";
|
|
import {geoLocationProvider} from "tc-shared/clientservice/GeoLocation";
|
|
import translation_config = config.translation_config;
|
|
import {getBackend} from "tc-shared/backend";
|
|
|
|
const kApiVersion = 1;
|
|
const kVerbose = true;
|
|
|
|
type ConnectionState = "disconnected" | "connecting" | "connected" | "reconnect-pending";
|
|
type PendingCommand = {
|
|
resolve: (result: MessageCommandResult) => void,
|
|
timeout: number
|
|
};
|
|
|
|
interface ClientServiceConnectionEvents {
|
|
notify_state_changed: { oldState: ConnectionState, newState: ConnectionState },
|
|
notify_notify_received: { notify: MessageNotify }
|
|
}
|
|
|
|
let tokenIndex = 0;
|
|
class ClientServiceConnection {
|
|
readonly events: Registry<ClientServiceConnectionEvents>;
|
|
readonly verbose: boolean;
|
|
readonly reconnectInterval: number;
|
|
|
|
private reconnectTimeout: number;
|
|
private connectionState: ConnectionState;
|
|
private connection: WebSocket;
|
|
|
|
private pendingCommands: {[key: string]: PendingCommand} = {};
|
|
|
|
constructor(reconnectInterval: number, verbose: boolean) {
|
|
this.events = new Registry<ClientServiceConnectionEvents>();
|
|
this.reconnectInterval = reconnectInterval;
|
|
this.verbose = verbose;
|
|
}
|
|
|
|
destroy() {
|
|
this.disconnect();
|
|
this.events.destroy();
|
|
}
|
|
|
|
getState() : ConnectionState {
|
|
return this.connectionState;
|
|
}
|
|
|
|
private setState(newState: ConnectionState) {
|
|
if(this.connectionState === newState) {
|
|
return;
|
|
}
|
|
|
|
const oldState = this.connectionState;
|
|
this.connectionState = newState;
|
|
this.events.fire("notify_state_changed", { oldState, newState })
|
|
}
|
|
|
|
connect() {
|
|
this.disconnect();
|
|
|
|
this.setState("connecting");
|
|
|
|
let address;
|
|
address = "client-services.teaspeak.de:27791";
|
|
//address = "localhost:1244";
|
|
//address = "192.168.40.135:1244";
|
|
|
|
this.connection = new WebSocket(`wss://${address}/ws-api/v${kApiVersion}`);
|
|
this.connection.onclose = event => {
|
|
if(this.verbose) {
|
|
logInfo(LogCategory.STATISTICS, tr("Lost connection to statistics server (Connection closed). Reason: %s"), event.reason ? `${event.reason} (${event.code})` : event.code);
|
|
}
|
|
|
|
this.handleConnectionLost();
|
|
};
|
|
|
|
this.connection.onopen = () => {
|
|
if(this.verbose) {
|
|
logDebug(LogCategory.STATISTICS, tr("Connection established."));
|
|
}
|
|
|
|
this.setState("connected");
|
|
}
|
|
|
|
this.connection.onerror = () => {
|
|
if(this.connectionState === "connecting") {
|
|
if(this.verbose) {
|
|
logDebug(LogCategory.STATISTICS, tr("Failed to connect to target server."));
|
|
}
|
|
|
|
this.handleConnectFail();
|
|
} else {
|
|
if(this.verbose) {
|
|
logWarn(LogCategory.STATISTICS, tr("Received web socket error which indicates that the connection has been closed."));
|
|
}
|
|
|
|
this.handleConnectionLost();
|
|
}
|
|
};
|
|
|
|
this.connection.onmessage = event => {
|
|
if(typeof event.data !== "string") {
|
|
if(this.verbose) {
|
|
logWarn(LogCategory.STATISTICS, tr("Receved non text message: %o"), event.data);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this.handleServerMessage(event.data);
|
|
};
|
|
}
|
|
|
|
disconnect() {
|
|
if(this.connection) {
|
|
this.connection.onclose = undefined;
|
|
this.connection.onopen = undefined;
|
|
this.connection.onmessage = undefined;
|
|
this.connection.onerror = undefined;
|
|
|
|
this.connection.close();
|
|
this.connection = undefined;
|
|
}
|
|
|
|
for(const command of Object.values(this.pendingCommands)) {
|
|
command.resolve({ type: "ConnectionClosed" });
|
|
}
|
|
this.pendingCommands = {};
|
|
|
|
clearTimeout(this.reconnectTimeout);
|
|
this.reconnectTimeout = undefined;
|
|
|
|
this.setState("disconnected");
|
|
}
|
|
|
|
cancelReconnect() {
|
|
clearTimeout(this.reconnectTimeout);
|
|
this.reconnectTimeout = undefined;
|
|
|
|
if(this.connectionState === "reconnect-pending") {
|
|
this.setState("disconnected");
|
|
}
|
|
}
|
|
|
|
async executeCommand(command: MessageCommand) : Promise<MessageCommandResult> {
|
|
if(this.connectionState !== "connected") {
|
|
return { type: "ConnectionClosed" };
|
|
}
|
|
|
|
const token = "tk-" + ++tokenIndex;
|
|
try {
|
|
this.connection.send(JSON.stringify({
|
|
type: "Command",
|
|
token: token,
|
|
command: command
|
|
} as Message));
|
|
} catch (error) {
|
|
if(this.verbose) {
|
|
logError(LogCategory.STATISTICS, tr("Failed to send command: %o"), error);
|
|
}
|
|
|
|
return { type: "GenericError", error: tr("Failed to send command") };
|
|
}
|
|
|
|
return await new Promise(resolve => {
|
|
const proxiedResolve = (result: MessageCommandResult) => {
|
|
clearTimeout(this.pendingCommands[token]?.timeout);
|
|
delete this.pendingCommands[token];
|
|
resolve(result);
|
|
};
|
|
|
|
this.pendingCommands[token] = {
|
|
resolve: proxiedResolve,
|
|
timeout: setTimeout(() => proxiedResolve({ type: "ConnectionTimeout" }), 5000)
|
|
};
|
|
});
|
|
}
|
|
|
|
private handleConnectFail() {
|
|
this.disconnect();
|
|
this.executeReconnect();
|
|
}
|
|
|
|
private handleConnectionLost() {
|
|
this.disconnect();
|
|
this.executeReconnect();
|
|
}
|
|
|
|
private executeReconnect() {
|
|
if(!this.reconnectInterval) {
|
|
return;
|
|
}
|
|
|
|
if(this.verbose) {
|
|
logInfo(LogCategory.STATISTICS, tr("Scheduling reconnect in %dms"), this.reconnectInterval);
|
|
}
|
|
|
|
this.reconnectTimeout = setTimeout(() => this.connect(), this.reconnectInterval);
|
|
this.setState("reconnect-pending");
|
|
}
|
|
|
|
private handleServerMessage(message: string) {
|
|
let data: Message;
|
|
try {
|
|
data = JSON.parse(message);
|
|
} catch (_error) {
|
|
if(this.verbose) {
|
|
logWarn(LogCategory.STATISTICS, tr("Received message which isn't parsable as JSON."));
|
|
}
|
|
return;
|
|
}
|
|
|
|
if(data.type === "Command") {
|
|
if(this.verbose) {
|
|
logWarn(LogCategory.STATISTICS, tr("Received message of type command. The server should not send these. Message: %o"), data);
|
|
}
|
|
|
|
/* Well this is odd. We should never receive such */
|
|
} else if(data.type === "CommandResult") {
|
|
if(data.token === null) {
|
|
if(this.verbose) {
|
|
logWarn(LogCategory.STATISTICS, tr("Received general error: %o"), data.result);
|
|
}
|
|
} else if(this.pendingCommands[data.token]) {
|
|
/* The entry itself will be cleaned up by the resolve callback */
|
|
this.pendingCommands[data.token].resolve(data.result);
|
|
} else if(this.verbose) {
|
|
logWarn(LogCategory.STATISTICS, tr("Received command result for unknown token: %o"), data.token);
|
|
}
|
|
} else if(data.type === "Notify") {
|
|
this.events.fire("notify_notify_received", { notify: data.notify });
|
|
} else if(this.verbose) {
|
|
logWarn(LogCategory.STATISTICS, tr("Received message with invalid type: %o"), (data as any).type);
|
|
}
|
|
}
|
|
}
|
|
|
|
export class ClientServices {
|
|
private connection: ClientServiceConnection;
|
|
|
|
private sessionInitialized: boolean;
|
|
private retryTimer: number;
|
|
|
|
private initializeAgentId: number;
|
|
private initializeLocaleId: number;
|
|
|
|
constructor() {
|
|
this.initializeAgentId = 0;
|
|
this.initializeLocaleId = 0;
|
|
|
|
this.sessionInitialized = false;
|
|
this.connection = new ClientServiceConnection(5000, kVerbose);
|
|
this.connection.events.on("notify_state_changed", event => {
|
|
if(event.newState !== "connected") {
|
|
this.sessionInitialized = false;
|
|
return;
|
|
}
|
|
|
|
logInfo(LogCategory.STATISTICS, tr("Connected successfully. Initializing session."));
|
|
this.executeCommandWithRetry({ type: "SessionInitialize", payload: { anonymize_ip: false }}, 2500).then(result => {
|
|
if(result.type !== "Success") {
|
|
if(result.type === "ConnectionClosed") {
|
|
return;
|
|
}
|
|
|
|
if(kVerbose) {
|
|
logError(LogCategory.STATISTICS, tr("Failed to initialize session. Retrying in 120 seconds. Result: %o"), result);
|
|
}
|
|
|
|
this.scheduleRetry(120 * 1000);
|
|
return;
|
|
}
|
|
|
|
this.sendInitializeAgent().then(undefined);
|
|
this.sendLocaleUpdate();
|
|
});
|
|
});
|
|
|
|
this.connection.events.on("notify_notify_received", event => {
|
|
switch (event.notify.type) {
|
|
case "NotifyClientsOnline":
|
|
this.handleNotifyClientsOnline(event.notify.payload);
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
});
|
|
}
|
|
|
|
start() {
|
|
this.connection.connect();
|
|
}
|
|
|
|
stop() {
|
|
this.connection.disconnect();
|
|
clearTimeout(this.retryTimer);
|
|
|
|
this.initializeAgentId++;
|
|
this.initializeLocaleId++;
|
|
}
|
|
|
|
private scheduleRetry(time: number) {
|
|
this.stop();
|
|
|
|
this.retryTimer = setTimeout(() => this.connection.connect(), time);
|
|
}
|
|
|
|
/**
|
|
* Returns as soon the result indicates that something else went wrong rather than transmitting.
|
|
* @param command
|
|
* @param retryInterval
|
|
*/
|
|
private async executeCommandWithRetry(command: MessageCommand, retryInterval: number) : Promise<MessageCommandResult> {
|
|
while(true) {
|
|
const result = await this.connection.executeCommand(command);
|
|
switch (result.type) {
|
|
case "ServerInternalError":
|
|
case "CommandEnqueueError":
|
|
case "ClientSessionUninitialized":
|
|
const shouldRetry = await new Promise<boolean>(resolve => {
|
|
const timeout = setTimeout(() => {
|
|
listener();
|
|
resolve(true);
|
|
}, 2500);
|
|
|
|
const listener = this.connection.events.on("notify_state_changed", event => {
|
|
if(event.newState !== "connected") {
|
|
resolve(false);
|
|
clearTimeout(timeout);
|
|
}
|
|
})
|
|
});
|
|
|
|
if(shouldRetry) {
|
|
continue;
|
|
} else {
|
|
return result;
|
|
}
|
|
|
|
default:
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async sendInitializeAgent() {
|
|
const taskId = ++this.initializeAgentId;
|
|
const payload: CommandSessionInitializeAgent = {
|
|
session_type: __build.target === "web" ? 0 : 1,
|
|
architecture: null,
|
|
platform_version: null,
|
|
platform: null,
|
|
client_version: null,
|
|
ui_version: __build.version
|
|
};
|
|
|
|
if(__build.target === "client") {
|
|
const info = getBackend("native").getVersionInfo();
|
|
|
|
payload.client_version = info.version;
|
|
payload.architecture = info.os_architecture;
|
|
payload.platform = info.os_platform;
|
|
payload.platform_version = info.os_platform_version;
|
|
} else {
|
|
const os = window.detectedBrowser.os;
|
|
const osParts = os.split(" ");
|
|
if(osParts.last().match(/^[0-9\.]+$/)) {
|
|
payload.platform_version = osParts.last();
|
|
osParts.splice(osParts.length - 1, 1);
|
|
}
|
|
|
|
payload.platform = osParts.join(" ");
|
|
payload.architecture = window.detectedBrowser.name;
|
|
payload.client_version = window.detectedBrowser.version;
|
|
}
|
|
|
|
if(this.initializeAgentId !== taskId) {
|
|
/* We don't want to send that stuff any more */
|
|
return;
|
|
}
|
|
|
|
this.executeCommandWithRetry({ type: "SessionInitializeAgent", payload }, 2500).then(result => {
|
|
if(kVerbose) {
|
|
logTrace(LogCategory.STATISTICS, tr("Agent initialize result: %o"), result);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async sendLocaleUpdate() {
|
|
const taskId = ++this.initializeLocaleId;
|
|
|
|
const payload: CommandSessionUpdateLocale = {
|
|
ip_country: null,
|
|
selected_locale: null,
|
|
local_timestamp: Date.now()
|
|
};
|
|
|
|
const geoInfo = await geoLocationProvider.queryInfo(2500);
|
|
payload.ip_country = geoInfo?.country?.toLowerCase() || null;
|
|
|
|
const trConfig = translation_config();
|
|
payload.selected_locale = trConfig?.current_translation_url || null;
|
|
|
|
if(this.initializeLocaleId !== taskId) {
|
|
return;
|
|
}
|
|
|
|
this.connection.executeCommand({ type: "SessionUpdateLocale", payload }).then(result => {
|
|
if(kVerbose) {
|
|
logTrace(LogCategory.STATISTICS, tr("Agent local update result: %o"), result);
|
|
}
|
|
});
|
|
}
|
|
|
|
private handleNotifyClientsOnline(notify: NotifyClientsOnline) {
|
|
logInfo(LogCategory.GENERAL, tr("Received user count update: %o"), notify);
|
|
}
|
|
}
|
|
|
|
export let clientServices: ClientServices;
|
|
|
|
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
|
priority: 30,
|
|
function: async () => {
|
|
clientServices = new ClientServices();
|
|
clientServices.start();
|
|
|
|
(window as any).clientServices = clientServices;
|
|
},
|
|
name: "client services"
|
|
}); |