Introducing the new client service provider and removing the old obsolete statistics service
parent
474ad7af01
commit
bf5d1c4771
File diff suppressed because it is too large
Load Diff
|
@ -109,6 +109,7 @@
|
|||
"remarkable": "^2.0.1",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"sdp-transform": "^2.14.0",
|
||||
"simple-jsonp-promise": "^1.1.0",
|
||||
"simplebar-react": "^2.2.0",
|
||||
"twemoji": "^13.0.0",
|
||||
"url-knife": "^3.1.3",
|
||||
|
|
|
@ -0,0 +1,173 @@
|
|||
import * as loader from "tc-loader";
|
||||
import {Stage} from "tc-loader";
|
||||
import {LogCategory, logTrace} from "tc-shared/log";
|
||||
import jsonp from 'simple-jsonp-promise';
|
||||
|
||||
interface GeoLocationInfo {
|
||||
/* The country code */
|
||||
country: string,
|
||||
|
||||
city?: string,
|
||||
region?: string,
|
||||
timezone?: string
|
||||
}
|
||||
|
||||
interface GeoLocationResolver {
|
||||
name() : string;
|
||||
resolve() : Promise<GeoLocationInfo>;
|
||||
}
|
||||
|
||||
const kLocalCacheKey = "geo-info";
|
||||
type GeoLocationCache = {
|
||||
version: 1,
|
||||
|
||||
timestamp: number,
|
||||
info: GeoLocationInfo,
|
||||
}
|
||||
|
||||
class GeoLocationProvider {
|
||||
private readonly resolver: GeoLocationResolver[];
|
||||
private currentResolverIndex: number;
|
||||
|
||||
private cachedInfo: GeoLocationInfo | undefined;
|
||||
private lookupPromise: Promise<GeoLocationInfo>;
|
||||
|
||||
constructor() {
|
||||
this.resolver = [
|
||||
new GeoResolverIpInfo(),
|
||||
new GeoResolverIpData()
|
||||
];
|
||||
this.currentResolverIndex = 0;
|
||||
}
|
||||
|
||||
loadCache() {
|
||||
this.doLoadCache();
|
||||
if(!this.cachedInfo) {
|
||||
this.lookupPromise = this.doQueryInfo();
|
||||
}
|
||||
}
|
||||
|
||||
private doLoadCache() : GeoLocationInfo {
|
||||
try {
|
||||
const rawItem = localStorage.getItem(kLocalCacheKey);
|
||||
if(!rawItem) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const info: GeoLocationCache = JSON.parse(rawItem);
|
||||
if(info.version !== 1) {
|
||||
throw tr("invalid version number");
|
||||
}
|
||||
|
||||
if(info.timestamp + 2 * 24 * 60 * 60 * 1000 < Date.now()) {
|
||||
throw tr("cache is too old");
|
||||
}
|
||||
|
||||
if(info.timestamp + 2 * 60 * 60 * 1000 > Date.now()) {
|
||||
logTrace(LogCategory.GENERAL, tr("Geo cache is less than 2hrs old. Don't updating."));
|
||||
this.lookupPromise = Promise.resolve(info.info);
|
||||
} else {
|
||||
this.lookupPromise = this.doQueryInfo();
|
||||
}
|
||||
|
||||
this.cachedInfo = info.info;
|
||||
} catch (error) {
|
||||
logTrace(LogCategory.GENERAL, tr("Failed to load geo resolve cache: %o"), error);
|
||||
}
|
||||
}
|
||||
|
||||
async queryInfo(timeout: number) : Promise<GeoLocationInfo | undefined> {
|
||||
return await new Promise<GeoLocationInfo>(resolve => {
|
||||
if(!this.lookupPromise) {
|
||||
resolve(this.cachedInfo);
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = typeof timeout === "number" ? setTimeout(() => resolve(this.cachedInfo), timeout) : -1;
|
||||
this.lookupPromise.then(result => {
|
||||
clearTimeout(timeoutId);
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private async doQueryInfo() : Promise<GeoLocationInfo | undefined> {
|
||||
while(this.currentResolverIndex < this.resolver.length) {
|
||||
const resolver = this.resolver[this.currentResolverIndex++];
|
||||
try {
|
||||
const info = await resolver.resolve();
|
||||
logTrace(LogCategory.GENERAL, tr("Successfully resolved geo info from %s: %o"), resolver.name(), info);
|
||||
|
||||
localStorage.setItem(kLocalCacheKey, JSON.stringify({
|
||||
version: 1,
|
||||
timestamp: Date.now(),
|
||||
info: info
|
||||
} as GeoLocationCache));
|
||||
return info;
|
||||
} catch (error) {
|
||||
logTrace(LogCategory.GENERAL, tr("Geo resolver %s failed: %o. Trying next one."), resolver.name(), error);
|
||||
}
|
||||
}
|
||||
|
||||
logTrace(LogCategory.GENERAL, tr("All geo resolver failed."));
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class GeoResolverIpData implements GeoLocationResolver {
|
||||
name(): string {
|
||||
return "ipdata.co";
|
||||
}
|
||||
|
||||
async resolve(): Promise<GeoLocationInfo> {
|
||||
const response = await fetch("https://api.ipdata.co/?api-key=test");
|
||||
const json = await response.json();
|
||||
|
||||
if(!("country_code" in json)) {
|
||||
throw tr("missing country code");
|
||||
}
|
||||
|
||||
return {
|
||||
country: json["country_code"],
|
||||
region: json["region"],
|
||||
city: json["city"],
|
||||
timezone: json["time_zone"]["name"]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class GeoResolverIpInfo implements GeoLocationResolver {
|
||||
name(): string {
|
||||
return "ipinfo.io";
|
||||
}
|
||||
|
||||
async resolve(): Promise<GeoLocationInfo> {
|
||||
const response = await jsonp("http://ipinfo.io");
|
||||
if(!("country" in response)) {
|
||||
throw tr("missing country");
|
||||
}
|
||||
|
||||
return {
|
||||
country: response["country"],
|
||||
|
||||
city: response["city"],
|
||||
region: response["region"],
|
||||
timezone: response["timezone"]
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export let geoLocationProvider: GeoLocationProvider;
|
||||
|
||||
/* The client services depend on this */
|
||||
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||
priority: 35,
|
||||
function: async () => {
|
||||
geoLocationProvider = new GeoLocationProvider();
|
||||
geoLocationProvider.loadCache();
|
||||
},
|
||||
name: "geo services"
|
||||
});
|
|
@ -0,0 +1,442 @@
|
|||
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";
|
||||
|
||||
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"
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
/* Basic message declarations */
|
||||
export type Message =
|
||||
| { type: "Command"; token: string; command: MessageCommand }
|
||||
| { type: "CommandResult"; token: string | null; result: MessageCommandResult }
|
||||
| { type: "Notify"; notify: MessageNotify };
|
||||
|
||||
export type MessageCommand =
|
||||
| { type: "SessionInitialize"; payload: CommandSessionInitialize }
|
||||
| { type: "SessionInitializeAgent"; payload: CommandSessionInitializeAgent }
|
||||
| { type: "SessionUpdateLocale"; payload: CommandSessionUpdateLocale };
|
||||
|
||||
export type MessageCommandResult =
|
||||
| { type: "Success" }
|
||||
| { type: "GenericError"; error: string }
|
||||
| { type: "ConnectionTimeout" }
|
||||
| { type: "ConnectionClosed" }
|
||||
| { type: "ClientSessionUninitialized" }
|
||||
| { type: "ServerInternalError" }
|
||||
| { type: "ParameterInvalid"; parameter: string }
|
||||
| { type: "CommandParseError"; error: string }
|
||||
| { type: "CommandEnqueueError" }
|
||||
| { type: "CommandNotFound" }
|
||||
| { type: "SessionAlreadyInitialized" }
|
||||
| { type: "SessionAgentAlreadyInitialized" }
|
||||
| { type: "SessionNotInitialized" };
|
||||
|
||||
export type MessageNotify =
|
||||
| { type: "NotifyClientsOnline"; payload: NotifyClientsOnline };
|
||||
|
||||
/* All commands */
|
||||
export type CommandSessionInitialize = { anonymize_ip: boolean };
|
||||
|
||||
export type CommandSessionInitializeAgent = { session_type: number; platform: string | null; platform_version: string | null; architecture: string | null; client_version: string | null; ui_version: string | null };
|
||||
|
||||
export type CommandSessionUpdateLocale = { ip_country: string | null; selected_locale: string | null; local_timestamp: number };
|
||||
|
||||
/* Notifies */
|
||||
export type NotifyClientsOnline = { users_online: { [key: number]: number }; unique_users_online: { [key: number]: number }; total_users_online: number; total_unique_users_online: number };
|
|
@ -2,7 +2,6 @@ import * as loader from "tc-loader";
|
|||
import * as bipc from "./ipc/BrowserIPC";
|
||||
import * as sound from "./sound/Sounds";
|
||||
import * as i18n from "./i18n/localize";
|
||||
import * as stats from "./stats";
|
||||
import * as fidentity from "./profiles/identities/TeaForumIdentity";
|
||||
import * as aplayer from "tc-backend/audio/player";
|
||||
import * as ppt from "tc-backend/ppt";
|
||||
|
@ -46,6 +45,7 @@ import "./ui/frames/menu-bar/MainMenu";
|
|||
import "./ui/modal/connect/Controller";
|
||||
import "./ui/elements/ContextDivider";
|
||||
import "./ui/elements/Tab";
|
||||
import "./clientservice";
|
||||
import {initializeKeyControl} from "./KeyControl";
|
||||
|
||||
let preventWelcomeUI = false;
|
||||
|
@ -89,6 +89,7 @@ async function initializeApp() {
|
|||
}
|
||||
}
|
||||
|
||||
/* Used by the native client... We can't refactor this yet */
|
||||
export function handle_connect_request(properties: ConnectRequestData, connection: ConnectionHandler) {
|
||||
const profile = findConnectProfile(properties.profile) || defaultConnectProfile();
|
||||
const username = properties.username || profile.connectUsername();
|
||||
|
@ -183,16 +184,6 @@ function main() {
|
|||
fidentity.update_forum();
|
||||
initializeKeyControl();
|
||||
|
||||
stats.initialize({
|
||||
verbose: true,
|
||||
anonymize_ip_addresses: true,
|
||||
volatile_collection_only: false
|
||||
});
|
||||
|
||||
stats.registerUserCountListener(status => {
|
||||
logInfo(LogCategory.STATISTICS, tr("Received user count update: %o"), status);
|
||||
});
|
||||
|
||||
checkForUpdatedApp();
|
||||
|
||||
if(settings.getValue(Settings.KEY_USER_IS_NEW) && !preventWelcomeUI) {
|
||||
|
|
|
@ -1,239 +0,0 @@
|
|||
import {LogCategory, logDebug, logInfo, logTrace, logWarn} from "./log";
|
||||
import { tr } from "./i18n/localize";
|
||||
|
||||
enum CloseCodes {
|
||||
UNSET = 3000,
|
||||
RECONNECT = 3001,
|
||||
INTERNAL_ERROR = 3002,
|
||||
|
||||
BANNED = 3100,
|
||||
}
|
||||
|
||||
enum ConnectionState {
|
||||
CONNECTING,
|
||||
INITIALIZING,
|
||||
CONNECTED,
|
||||
UNSET
|
||||
}
|
||||
|
||||
export class SessionConfig {
|
||||
/*
|
||||
* All collected statistics will only be cached by the stats server.
|
||||
* No data will be saved.
|
||||
*/
|
||||
volatile_collection_only?: boolean;
|
||||
|
||||
/*
|
||||
* Anonymize all IP addresses which will be provided while the stats collection.
|
||||
* This option is quite useless when volatile_collection_only is active.
|
||||
*/
|
||||
anonymize_ip_addresses?: boolean;
|
||||
}
|
||||
|
||||
export class Config extends SessionConfig {
|
||||
verbose?: boolean;
|
||||
|
||||
reconnect_interval?: number;
|
||||
}
|
||||
|
||||
export interface UserCountData {
|
||||
online_users: number;
|
||||
unique_online_users: number;
|
||||
}
|
||||
|
||||
export type UserCountListener = (data: UserCountData) => any;
|
||||
|
||||
let reconnect_timer: number;
|
||||
let current_config: Config;
|
||||
|
||||
let last_user_count_update: number;
|
||||
let user_count_listener: UserCountListener[] = [];
|
||||
|
||||
const DEFAULT_CONFIG: Config = {
|
||||
verbose: true,
|
||||
reconnect_interval: 5000,
|
||||
anonymize_ip_addresses: true,
|
||||
volatile_collection_only: false
|
||||
};
|
||||
|
||||
function initialize_config_object(target_object: any, source_object: any) : any {
|
||||
for(const key of Object.keys(source_object)) {
|
||||
if(typeof(source_object[key]) === 'object')
|
||||
initialize_config_object(target_object[key] || (target_object[key] = {}), source_object[key]);
|
||||
|
||||
if(typeof(target_object[key]) !== 'undefined')
|
||||
continue;
|
||||
|
||||
target_object[key] = source_object[key];
|
||||
}
|
||||
|
||||
return target_object;
|
||||
}
|
||||
|
||||
export function initialize(config: Config) {
|
||||
current_config = initialize_config_object(config || {}, DEFAULT_CONFIG);
|
||||
if(current_config.verbose)
|
||||
logInfo(LogCategory.STATISTICS, tr("Initializing statistics with this config: %o"), current_config);
|
||||
|
||||
connection.start_connection();
|
||||
}
|
||||
|
||||
export function registerUserCountListener(listener: UserCountListener) {
|
||||
user_count_listener.push(listener);
|
||||
}
|
||||
|
||||
export function unregisterUserCountListener(listener: UserCountListener) {
|
||||
user_count_listener.remove(listener);
|
||||
}
|
||||
|
||||
namespace connection {
|
||||
let connection: WebSocket;
|
||||
export let connection_state: ConnectionState = ConnectionState.UNSET;
|
||||
|
||||
export function start_connection() {
|
||||
cancel_reconnect();
|
||||
close_connection();
|
||||
|
||||
connection_state = ConnectionState.CONNECTING;
|
||||
|
||||
connection = new WebSocket('wss://web-stats.teaspeak.de:27790');
|
||||
if(!connection) {
|
||||
connection = new WebSocket('wss://localhost:27788');
|
||||
}
|
||||
|
||||
{
|
||||
const connection_copy = connection;
|
||||
connection.onclose = (event: CloseEvent) => {
|
||||
if(connection_copy !== connection) return;
|
||||
|
||||
if(current_config.verbose)
|
||||
logWarn(LogCategory.STATISTICS, tr("Lost connection to statistics server (Connection closed). Reason: %o. Event object: %o"), CloseCodes[event.code] || event.code, event);
|
||||
|
||||
if(event.code != CloseCodes.BANNED)
|
||||
invoke_reconnect();
|
||||
};
|
||||
|
||||
connection.onopen = () => {
|
||||
if(connection_copy !== connection) return;
|
||||
|
||||
if(current_config.verbose)
|
||||
logDebug(LogCategory.STATISTICS, tr("Successfully connected to server. Initializing session."));
|
||||
|
||||
connection_state = ConnectionState.INITIALIZING;
|
||||
initialize_session();
|
||||
};
|
||||
|
||||
connection.onerror = (event: ErrorEvent) => {
|
||||
if(connection_copy !== connection) return;
|
||||
|
||||
if(current_config.verbose)
|
||||
logWarn(LogCategory.STATISTICS, tr("Received an error. Closing connection. Object: %o"), event);
|
||||
|
||||
connection.close(CloseCodes.INTERNAL_ERROR);
|
||||
invoke_reconnect();
|
||||
};
|
||||
|
||||
connection.onmessage = (event: MessageEvent) => {
|
||||
if(connection_copy !== connection) return;
|
||||
|
||||
if(typeof(event.data) !== 'string') {
|
||||
if(current_config.verbose)
|
||||
logWarn(LogCategory.STATISTICS, tr("Received an message which isn't a string. Event object: %o"), event);
|
||||
return;
|
||||
}
|
||||
|
||||
handle_message(event.data as string);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function close_connection() {
|
||||
if(connection) {
|
||||
const connection_copy = connection;
|
||||
connection = undefined;
|
||||
|
||||
try {
|
||||
connection_copy.close(3001);
|
||||
} catch(_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function invoke_reconnect() {
|
||||
close_connection();
|
||||
|
||||
if(reconnect_timer) {
|
||||
clearTimeout(reconnect_timer);
|
||||
reconnect_timer = undefined;
|
||||
}
|
||||
|
||||
if(current_config.verbose)
|
||||
logInfo(LogCategory.STATISTICS, tr("Scheduled reconnect in %dms"), current_config.reconnect_interval);
|
||||
|
||||
reconnect_timer = setTimeout(() => {
|
||||
if(current_config.verbose)
|
||||
logInfo(LogCategory.STATISTICS, tr("Reconnecting"));
|
||||
start_connection();
|
||||
}, current_config.reconnect_interval);
|
||||
}
|
||||
|
||||
export function cancel_reconnect() {
|
||||
if(reconnect_timer) {
|
||||
clearTimeout(reconnect_timer);
|
||||
reconnect_timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function send_message(type: string, data: any) {
|
||||
connection.send(JSON.stringify({
|
||||
type: type,
|
||||
data: data
|
||||
}));
|
||||
}
|
||||
|
||||
function initialize_session() {
|
||||
const config_object = {};
|
||||
for(const key in SessionConfig) {
|
||||
if(SessionConfig.hasOwnProperty(key))
|
||||
config_object[key] = current_config[key];
|
||||
}
|
||||
|
||||
send_message('initialize', {
|
||||
config: config_object
|
||||
})
|
||||
}
|
||||
|
||||
function handle_message(message: string) {
|
||||
const data_object = JSON.parse(message);
|
||||
const type = data_object.type as string;
|
||||
const data = data_object.data;
|
||||
|
||||
if(typeof(handler[type]) === 'function') {
|
||||
if(current_config.verbose)
|
||||
logTrace(LogCategory.STATISTICS, tr("Handling message of type %s"), type);
|
||||
handler[type](data);
|
||||
} else if(current_config.verbose) {
|
||||
logWarn(LogCategory.STATISTICS, tr("Received message with an unknown type (%s). Dropping message. Full message: %o"), type, data_object);
|
||||
}
|
||||
}
|
||||
|
||||
namespace handler {
|
||||
interface NotifyUserCount extends UserCountData { }
|
||||
|
||||
function handle_notify_user_count(data: NotifyUserCount) {
|
||||
last_user_count_update = Date.now();
|
||||
for(const listener of [...user_count_listener])
|
||||
listener(data);
|
||||
}
|
||||
|
||||
interface NotifyInitialized {}
|
||||
function handle_notify_initialized(json: NotifyInitialized) {
|
||||
if(current_config.verbose)
|
||||
logInfo(LogCategory.STATISTICS, tr("Session successfully initialized."));
|
||||
|
||||
connection_state = ConnectionState.CONNECTED;
|
||||
}
|
||||
|
||||
handler["notifyinitialized"] = handle_notify_initialized;
|
||||
handler["notifyusercount"] = handle_notify_user_count;
|
||||
}
|
||||
}
|
|
@ -26,7 +26,7 @@ function initializeController(events: Registry<ModalGlobalSettingsEditorEvents>)
|
|||
key: setting.key,
|
||||
description: setting.description,
|
||||
type: setting.valueType,
|
||||
defaultValue: "defaultValue" in setting ? setting.defaultValue : undefined
|
||||
defaultValue: "defaultValue" in setting ? (setting as any).defaultValue : undefined
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -54,7 +54,7 @@ function initializeController(events: Registry<ModalGlobalSettingsEditorEvents>)
|
|||
key: setting.key,
|
||||
description: setting.description,
|
||||
type: setting.valueType,
|
||||
defaultValue: "defaultValue" in setting ? setting.defaultValue : undefined
|
||||
defaultValue: "defaultValue" in setting ? (setting as any).defaultValue : undefined
|
||||
},
|
||||
value: settings.getValue(setting, undefined)
|
||||
});
|
||||
|
|
|
@ -21,6 +21,7 @@
|
|||
],
|
||||
"include": [
|
||||
"../js/proto.ts",
|
||||
"../js/main.tsx",
|
||||
"../backend.d",
|
||||
"../js/**/*.ts",
|
||||
"../../webpack/build-definitions.d.ts"
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import * as log from "tc-shared/log";
|
||||
import {LogCategory, logWarn} from "tc-shared/log";
|
||||
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
|
||||
import { tr } from "tc-shared/i18n/localize";
|
||||
|
@ -46,8 +45,9 @@ export class WrappedWebSocket {
|
|||
let result = "";
|
||||
result += this.address.secure ? "wss://" : "ws://";
|
||||
result += this.address.host + ":" + this.address.port;
|
||||
if(this.address.path)
|
||||
if(this.address.path) {
|
||||
result += (this.address.path.startsWith("/") ? "" : "/") + this.address.path;
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue