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",
|
"remarkable": "^2.0.1",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"sdp-transform": "^2.14.0",
|
"sdp-transform": "^2.14.0",
|
||||||
|
"simple-jsonp-promise": "^1.1.0",
|
||||||
"simplebar-react": "^2.2.0",
|
"simplebar-react": "^2.2.0",
|
||||||
"twemoji": "^13.0.0",
|
"twemoji": "^13.0.0",
|
||||||
"url-knife": "^3.1.3",
|
"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 bipc from "./ipc/BrowserIPC";
|
||||||
import * as sound from "./sound/Sounds";
|
import * as sound from "./sound/Sounds";
|
||||||
import * as i18n from "./i18n/localize";
|
import * as i18n from "./i18n/localize";
|
||||||
import * as stats from "./stats";
|
|
||||||
import * as fidentity from "./profiles/identities/TeaForumIdentity";
|
import * as fidentity from "./profiles/identities/TeaForumIdentity";
|
||||||
import * as aplayer from "tc-backend/audio/player";
|
import * as aplayer from "tc-backend/audio/player";
|
||||||
import * as ppt from "tc-backend/ppt";
|
import * as ppt from "tc-backend/ppt";
|
||||||
|
@ -46,6 +45,7 @@ import "./ui/frames/menu-bar/MainMenu";
|
||||||
import "./ui/modal/connect/Controller";
|
import "./ui/modal/connect/Controller";
|
||||||
import "./ui/elements/ContextDivider";
|
import "./ui/elements/ContextDivider";
|
||||||
import "./ui/elements/Tab";
|
import "./ui/elements/Tab";
|
||||||
|
import "./clientservice";
|
||||||
import {initializeKeyControl} from "./KeyControl";
|
import {initializeKeyControl} from "./KeyControl";
|
||||||
|
|
||||||
let preventWelcomeUI = false;
|
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) {
|
export function handle_connect_request(properties: ConnectRequestData, connection: ConnectionHandler) {
|
||||||
const profile = findConnectProfile(properties.profile) || defaultConnectProfile();
|
const profile = findConnectProfile(properties.profile) || defaultConnectProfile();
|
||||||
const username = properties.username || profile.connectUsername();
|
const username = properties.username || profile.connectUsername();
|
||||||
|
@ -183,16 +184,6 @@ function main() {
|
||||||
fidentity.update_forum();
|
fidentity.update_forum();
|
||||||
initializeKeyControl();
|
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();
|
checkForUpdatedApp();
|
||||||
|
|
||||||
if(settings.getValue(Settings.KEY_USER_IS_NEW) && !preventWelcomeUI) {
|
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,
|
key: setting.key,
|
||||||
description: setting.description,
|
description: setting.description,
|
||||||
type: setting.valueType,
|
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,
|
key: setting.key,
|
||||||
description: setting.description,
|
description: setting.description,
|
||||||
type: setting.valueType,
|
type: setting.valueType,
|
||||||
defaultValue: "defaultValue" in setting ? setting.defaultValue : undefined
|
defaultValue: "defaultValue" in setting ? (setting as any).defaultValue : undefined
|
||||||
},
|
},
|
||||||
value: settings.getValue(setting, undefined)
|
value: settings.getValue(setting, undefined)
|
||||||
});
|
});
|
||||||
|
|
|
@ -21,6 +21,7 @@
|
||||||
],
|
],
|
||||||
"include": [
|
"include": [
|
||||||
"../js/proto.ts",
|
"../js/proto.ts",
|
||||||
|
"../js/main.tsx",
|
||||||
"../backend.d",
|
"../backend.d",
|
||||||
"../js/**/*.ts",
|
"../js/**/*.ts",
|
||||||
"../../webpack/build-definitions.d.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 {LogCategory, logWarn} from "tc-shared/log";
|
||||||
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
|
import {ConnectionStatistics} from "tc-shared/connection/ConnectionBase";
|
||||||
import { tr } from "tc-shared/i18n/localize";
|
import { tr } from "tc-shared/i18n/localize";
|
||||||
|
@ -46,8 +45,9 @@ export class WrappedWebSocket {
|
||||||
let result = "";
|
let result = "";
|
||||||
result += this.address.secure ? "wss://" : "ws://";
|
result += this.address.secure ? "wss://" : "ws://";
|
||||||
result += this.address.host + ":" + this.address.port;
|
result += this.address.host + ":" + this.address.port;
|
||||||
if(this.address.path)
|
if(this.address.path) {
|
||||||
result += (this.address.path.startsWith("/") ? "" : "/") + this.address.path;
|
result += (this.address.path.startsWith("/") ? "" : "/") + this.address.path;
|
||||||
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue