commit
fe69287185
|
@ -4,3 +4,9 @@
|
||||||
[submodule "web/native-codec/libraries/opus"]
|
[submodule "web/native-codec/libraries/opus"]
|
||||||
path = web/native-codec/libraries/opus
|
path = web/native-codec/libraries/opus
|
||||||
url = https://github.com/xiph/opus.git
|
url = https://github.com/xiph/opus.git
|
||||||
|
[submodule "vendor/TeaEventBus"]
|
||||||
|
path = vendor/TeaEventBus
|
||||||
|
url = https://github.com/WolverinDEV/TeaEventBus.git
|
||||||
|
[submodule "vendor/TeaClientServices"]
|
||||||
|
path = vendor/TeaClientServices
|
||||||
|
url = https://github.com/WolverinDEV/TeaClientServices.git
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
# Changelog:
|
# Changelog:
|
||||||
|
* **20.02.21**
|
||||||
|
- Improved the browser IPC module
|
||||||
|
- Added support for client invite links
|
||||||
|
|
||||||
* **15.02.21**
|
* **15.02.21**
|
||||||
- Fixed critical bug within the event registry class
|
- Fixed critical bug within the event registry class
|
||||||
- Added a dropdown for the microphone control button to quickly change microphones
|
- Added a dropdown for the microphone control button to quickly change microphones
|
||||||
|
|
|
@ -281,17 +281,32 @@ export class ConnectionHandler {
|
||||||
client_nickname: parameters.nickname
|
client_nickname: parameters.nickname
|
||||||
});
|
});
|
||||||
|
|
||||||
this.channelTree.initialiseHead(parameters.targetAddress, resolvedAddress);
|
this.channelTree.initialiseHead(parameters.targetAddress, parsedAddress);
|
||||||
|
|
||||||
/* hash the password if not already hashed */
|
/* hash the password if not already hashed */
|
||||||
if(parameters.targetPassword && !parameters.targetPasswordHashed) {
|
if(parameters.serverPassword && !parameters.serverPasswordHashed) {
|
||||||
try {
|
try {
|
||||||
parameters.targetPassword = await hashPassword(parameters.targetPassword);
|
parameters.serverPassword = await hashPassword(parameters.serverPassword);
|
||||||
parameters.targetPasswordHashed = true;
|
parameters.serverPasswordHashed = true;
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
logError(LogCategory.CLIENT, tr("Failed to hash connect password: %o"), error);
|
logError(LogCategory.CLIENT, tr("Failed to hash connect password: %o"), error);
|
||||||
createErrorModal(tr("Error while hashing password"), tr("Failed to hash server password!<br>") + error).open();
|
createErrorModal(tr("Error while hashing password"), tr("Failed to hash server password!<br>") + 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!<br>") + error).open();
|
||||||
/* FIXME: Abort connection attempt */
|
/* FIXME: Abort connection attempt */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -332,6 +347,7 @@ export class ConnectionHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handleDisconnect(DisconnectReason.DNS_FAILED, error);
|
this.handleDisconnect(DisconnectReason.DNS_FAILED, error);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.handleDisconnect(DisconnectReason.DNS_FAILED, tr("Unable to resolve hostname"));
|
this.handleDisconnect(DisconnectReason.DNS_FAILED, tr("Unable to resolve hostname"));
|
||||||
|
@ -343,7 +359,7 @@ export class ConnectionHandler {
|
||||||
} else {
|
} else {
|
||||||
this.currentConnectId = await connectionHistory.logConnectionAttempt({
|
this.currentConnectId = await connectionHistory.logConnectionAttempt({
|
||||||
nickname: parameters.nicknameSpecified ? parameters.nickname : undefined,
|
nickname: parameters.nicknameSpecified ? parameters.nickname : undefined,
|
||||||
hashedPassword: parameters.targetPassword, /* Password will be hashed by now! */
|
hashedPassword: parameters.serverPassword, /* Password will be hashed by now! */
|
||||||
targetAddress: parameters.targetAddress,
|
targetAddress: parameters.targetAddress,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -359,8 +375,8 @@ export class ConnectionHandler {
|
||||||
nickname: parameters.nickname,
|
nickname: parameters.nickname,
|
||||||
nicknameSpecified: true,
|
nicknameSpecified: true,
|
||||||
|
|
||||||
targetPassword: parameters.password?.password,
|
serverPassword: parameters.password?.password,
|
||||||
targetPasswordHashed: parameters.password?.hashed,
|
serverPasswordHashed: parameters.password?.hashed,
|
||||||
|
|
||||||
defaultChannel: parameters?.channel?.target,
|
defaultChannel: parameters?.channel?.target,
|
||||||
defaultChannelPassword: parameters?.channel?.password,
|
defaultChannelPassword: parameters?.channel?.password,
|
||||||
|
@ -968,11 +984,11 @@ export class ConnectionHandler {
|
||||||
const connectParameters = this.serverConnection.handshake_handler().parameters;
|
const connectParameters = this.serverConnection.handshake_handler().parameters;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
channel: targetChannel ? {target: "/" + targetChannel.channelId, password: targetChannel.cached_password()} : undefined,
|
channel: targetChannel ? {target: "/" + targetChannel.channelId, password: targetChannel.getCachedPasswordHash()} : undefined,
|
||||||
nickname: name,
|
nickname: name,
|
||||||
password: connectParameters.targetPassword ? {
|
password: connectParameters.serverPassword ? {
|
||||||
password: connectParameters.targetPassword,
|
password: connectParameters.serverPassword,
|
||||||
hashed: connectParameters.targetPasswordHashed
|
hashed: connectParameters.serverPasswordHashed
|
||||||
} : undefined
|
} : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,173 +0,0 @@
|
||||||
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"
|
|
||||||
});
|
|
|
@ -1,38 +0,0 @@
|
||||||
/* 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 };
|
|
|
@ -1,443 +1,70 @@
|
||||||
import * as loader from "tc-loader";
|
import * as loader from "tc-loader";
|
||||||
import {Stage} from "tc-loader";
|
import {Stage} from "tc-loader";
|
||||||
import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "tc-shared/log";
|
import {config} from "tc-shared/i18n/localize";
|
||||||
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";
|
import {getBackend} from "tc-shared/backend";
|
||||||
|
import {ClientServiceConfig, ClientServiceInvite, ClientServices, ClientSessionType, LocalAgent} from "tc-services";
|
||||||
|
|
||||||
const kApiVersion = 1;
|
import translation_config = config.translation_config;
|
||||||
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;
|
export let clientServices: ClientServices;
|
||||||
|
export let clientServiceInvite: ClientServiceInvite;
|
||||||
|
|
||||||
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
priority: 30,
|
priority: 30,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
clientServices = new ClientServices();
|
clientServices = new ClientServices(new class implements ClientServiceConfig {
|
||||||
clientServices.start();
|
getServiceHost(): string {
|
||||||
|
//return "localhost:1244";
|
||||||
|
return "client-services.teaspeak.de:27791";
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessionType(): ClientSessionType {
|
||||||
|
return __build.target === "web" ? ClientSessionType.WebClient : ClientSessionType.TeaClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateHostInfo(): LocalAgent {
|
||||||
|
if(__build.target === "client") {
|
||||||
|
const info = getBackend("native").getVersionInfo();
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientVersion: info.version,
|
||||||
|
uiVersion: __build.version,
|
||||||
|
|
||||||
|
architecture: info.os_architecture,
|
||||||
|
platform: info.os_platform,
|
||||||
|
platformVersion: info.os_platform_version
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const os = window.detectedBrowser.os;
|
||||||
|
const osParts = os.split(" ");
|
||||||
|
let platformVersion;
|
||||||
|
if(osParts.last().match(/^[0-9.]+$/)) {
|
||||||
|
platformVersion = osParts.last();
|
||||||
|
osParts.splice(osParts.length - 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uiVersion: __build.version,
|
||||||
|
|
||||||
|
platform: osParts.join(" "),
|
||||||
|
platformVersion: platformVersion,
|
||||||
|
architecture: window.detectedBrowser.name,
|
||||||
|
clientVersion: window.detectedBrowser.version,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedLocaleUrl(): string | null {
|
||||||
|
const trConfig = translation_config();
|
||||||
|
return trConfig?.current_translation_url || null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientServices.start();
|
||||||
(window as any).clientServices = clientServices;
|
(window as any).clientServices = clientServices;
|
||||||
|
|
||||||
|
clientServiceInvite = new ClientServiceInvite(clientServices);
|
||||||
|
(window as any).clientServiceInvite = clientServiceInvite;
|
||||||
},
|
},
|
||||||
name: "client services"
|
name: "client services"
|
||||||
});
|
});
|
|
@ -87,7 +87,7 @@ export class HandshakeHandler {
|
||||||
client_default_channel_password: this.parameters.defaultChannelPassword || "",
|
client_default_channel_password: this.parameters.defaultChannelPassword || "",
|
||||||
client_default_token: this.parameters.token,
|
client_default_token: this.parameters.token,
|
||||||
|
|
||||||
client_server_password: this.parameters.targetPassword,
|
client_server_password: this.parameters.serverPassword,
|
||||||
|
|
||||||
client_input_hardware: this.connection.client.isMicrophoneDisabled(),
|
client_input_hardware: this.connection.client.isMicrophoneDisabled(),
|
||||||
client_output_hardware: this.connection.client.hasOutputHardware(),
|
client_output_hardware: this.connection.client.hasOutputHardware(),
|
||||||
|
|
|
@ -441,7 +441,7 @@ export class ChannelConversationManager extends AbstractChatManager<ChannelConve
|
||||||
}
|
}
|
||||||
|
|
||||||
queryUnreadFlags() {
|
queryUnreadFlags() {
|
||||||
const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.cached_password() }});
|
const commandData = this.connection.channelTree.channels.map(e => { return { cid: e.channelId, cpw: e.getCachedPasswordHash() }});
|
||||||
this.connection.serverConnection.send_command("conversationfetch", commandData).catch(error => {
|
this.connection.serverConnection.send_command("conversationfetch", commandData).catch(error => {
|
||||||
logWarn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error);
|
logWarn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error);
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,7 +13,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
priority: 10,
|
priority: 10,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
await i18n.initialize();
|
await i18n.initialize();
|
||||||
ipc.setup();
|
ipc.setupIpcHandler();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,570 +1,18 @@
|
||||||
import {LogCategory, logTrace} from "./log";
|
import {EventRegistryHooks, setEventRegistryHooks} from "tc-events";
|
||||||
import {guid} from "./crypto/uid";
|
import {LogCategory, logError, logTrace} from "tc-shared/log";
|
||||||
import {useEffect} from "react";
|
|
||||||
import {unstable_batchedUpdates} from "react-dom";
|
|
||||||
import * as React from "react";
|
|
||||||
|
|
||||||
/*
|
export * from "tc-events";
|
||||||
export type EventPayloadObject = {
|
|
||||||
[key: string]: EventPayload
|
|
||||||
} | {
|
|
||||||
[key: number]: EventPayload
|
|
||||||
};
|
|
||||||
|
|
||||||
export type EventPayload = string | number | bigint | null | undefined | EventPayloadObject;
|
setEventRegistryHooks(new class implements EventRegistryHooks {
|
||||||
*/
|
logAsyncInvokeError(error: any) {
|
||||||
export type EventPayloadObject = any;
|
logError(LogCategory.EVENT_REGISTRY, tr("Failed to invoke async callback:\n%o"), error);
|
||||||
|
|
||||||
export type EventMap<P> = {
|
|
||||||
[K in keyof P]: EventPayloadObject & {
|
|
||||||
/* prohibit the type attribute on the highest layer (used to identify the event type) */
|
|
||||||
type?: never
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Event<P extends EventMap<P>, T extends keyof P> = {
|
|
||||||
readonly type: T,
|
|
||||||
|
|
||||||
as<S extends T>(target: S) : Event<P, S>;
|
|
||||||
asUnchecked<S extends T>(target: S) : Event<P, S>;
|
|
||||||
asAnyUnchecked<S extends keyof P>(target: S) : Event<P, S>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return an object containing only the event payload specific key value pairs.
|
|
||||||
*/
|
|
||||||
extractPayload() : P[T];
|
|
||||||
} & P[T];
|
|
||||||
|
|
||||||
namespace EventHelper {
|
|
||||||
/**
|
|
||||||
* Turn the payload object into a bus event object
|
|
||||||
* @param payload
|
|
||||||
*/
|
|
||||||
/* May inline this somehow? A function call seems to be 3% slower */
|
|
||||||
export function createEvent<P extends EventMap<P>, T extends keyof P>(type: T, payload?: P[T]) : Event<P, T> {
|
|
||||||
if(payload) {
|
|
||||||
(payload as any).type = type;
|
|
||||||
let event = payload as any as Event<P, T>;
|
|
||||||
event.as = as;
|
|
||||||
event.asUnchecked = asUnchecked;
|
|
||||||
event.asAnyUnchecked = asUnchecked;
|
|
||||||
event.extractPayload = extractPayload;
|
|
||||||
return event;
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
type,
|
|
||||||
as,
|
|
||||||
asUnchecked,
|
|
||||||
asAnyUnchecked: asUnchecked,
|
|
||||||
extractPayload
|
|
||||||
} as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractPayload() {
|
|
||||||
const result = Object.assign({}, this);
|
|
||||||
delete result["as"];
|
|
||||||
delete result["asUnchecked"];
|
|
||||||
delete result["asAnyUnchecked"];
|
|
||||||
delete result["extractPayload"];
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function as(target) {
|
|
||||||
if(this.type !== target) {
|
|
||||||
throw "Mismatching event type. Expected: " + target + ", Got: " + this.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asUnchecked() {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EventSender<Events extends EventMap<Events> = EventMap<any>> {
|
|
||||||
fire<T extends keyof Events>(event_type: T, data?: Events[T], overrideTypeKey?: boolean);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire an event later by using setTimeout(..)
|
|
||||||
* @param event_type The target event to be fired
|
|
||||||
* @param data The payload of the event
|
|
||||||
* @param callback The callback will be called after the event has been successfully dispatched
|
|
||||||
*/
|
|
||||||
fire_later<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire an event, which will be delayed until the next animation frame.
|
|
||||||
* This ensures that all react components have been successfully mounted/unmounted.
|
|
||||||
* @param event_type The target event to be fired
|
|
||||||
* @param data The payload of the event
|
|
||||||
* @param callback The callback will be called after the event has been successfully dispatched
|
|
||||||
*/
|
|
||||||
fire_react<T extends keyof Events>(event_type: T, data?: Events[T], callback?: () => void);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventDispatchType = "sync" | "later" | "react";
|
|
||||||
|
|
||||||
export interface EventConsumer {
|
|
||||||
handleEvent(mode: EventDispatchType, type: string, data: any);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventHandlerRegisterData {
|
|
||||||
registeredHandler: {[key: string]: ((event) => void)[]}
|
|
||||||
}
|
|
||||||
|
|
||||||
const kEventAnnotationKey = guid();
|
|
||||||
export class Registry<Events extends EventMap<Events> = EventMap<any>> implements EventSender<Events> {
|
|
||||||
protected readonly registryUniqueId;
|
|
||||||
|
|
||||||
protected persistentEventHandler: { [key: string]: ((event) => void)[] } = {};
|
|
||||||
protected oneShotEventHandler: { [key: string]: ((event) => void)[] } = {};
|
|
||||||
protected genericEventHandler: ((event) => void)[] = [];
|
|
||||||
protected consumer: EventConsumer[] = [];
|
|
||||||
|
|
||||||
private ipcConsumer: IpcEventBridge;
|
|
||||||
|
|
||||||
private debugPrefix = undefined;
|
|
||||||
private warnUnhandledEvents = false;
|
|
||||||
|
|
||||||
private pendingAsyncCallbacks: { type: any, data: any, callback: () => void }[];
|
|
||||||
private pendingAsyncCallbacksTimeout: number = 0;
|
|
||||||
|
|
||||||
private pendingReactCallbacks: { type: any, data: any, callback: () => void }[];
|
|
||||||
private pendingReactCallbacksFrame: number = 0;
|
|
||||||
|
|
||||||
static fromIpcDescription<Events extends EventMap<Events> = EventMap<any>>(description: IpcRegistryDescription<Events>) : Registry<Events> {
|
|
||||||
const registry = new Registry<Events>();
|
|
||||||
registry.ipcConsumer = new IpcEventBridge(registry as any, description.ipcChannelId);
|
|
||||||
registry.registerConsumer(registry.ipcConsumer);
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.registryUniqueId = "evreg_data_" + guid();
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
Object.values(this.persistentEventHandler).forEach(handlers => handlers.splice(0, handlers.length));
|
|
||||||
Object.values(this.oneShotEventHandler).forEach(handlers => handlers.splice(0, handlers.length));
|
|
||||||
this.genericEventHandler.splice(0, this.genericEventHandler.length);
|
|
||||||
this.consumer.splice(0, this.consumer.length);
|
|
||||||
|
|
||||||
this.ipcConsumer?.destroy();
|
|
||||||
this.ipcConsumer = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
enableDebug(prefix: string) { this.debugPrefix = prefix || "---"; }
|
|
||||||
disableDebug() { this.debugPrefix = undefined; }
|
|
||||||
|
|
||||||
enableWarnUnhandledEvents() { this.warnUnhandledEvents = true; }
|
|
||||||
disableWarnUnhandledEvents() { this.warnUnhandledEvents = false; }
|
|
||||||
|
|
||||||
fire<T extends keyof Events>(eventType: T, data?: Events[T], overrideTypeKey?: boolean) {
|
|
||||||
if(this.debugPrefix) {
|
|
||||||
logTrace(LogCategory.EVENT_REGISTRY, "[%s] Trigger event: %s", this.debugPrefix, eventType);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(typeof data === "object" && 'type' in data && !overrideTypeKey) {
|
|
||||||
if((data as any).type !== eventType) {
|
|
||||||
debugger;
|
|
||||||
throw "The keyword 'type' is reserved for the event type and should not be passed as argument";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const consumer of this.consumer) {
|
|
||||||
consumer.handleEvent("sync", eventType as string, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.doInvokeEvent(EventHelper.createEvent(eventType, data));
|
|
||||||
}
|
|
||||||
|
|
||||||
fire_later<T extends keyof Events>(eventType: T, data?: Events[T], callback?: () => void) {
|
|
||||||
if(!this.pendingAsyncCallbacksTimeout) {
|
|
||||||
this.pendingAsyncCallbacksTimeout = setTimeout(() => this.invokeAsyncCallbacks());
|
|
||||||
this.pendingAsyncCallbacks = [];
|
|
||||||
}
|
|
||||||
this.pendingAsyncCallbacks.push({ type: eventType, data: data, callback: callback });
|
|
||||||
|
|
||||||
for(const consumer of this.consumer) {
|
|
||||||
consumer.handleEvent("later", eventType as string, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fire_react<T extends keyof Events>(eventType: T, data?: Events[T], callback?: () => void) {
|
|
||||||
if(!this.pendingReactCallbacks) {
|
|
||||||
this.pendingReactCallbacksFrame = requestAnimationFrame(() => this.invokeReactCallbacks());
|
|
||||||
this.pendingReactCallbacks = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingReactCallbacks.push({ type: eventType, data: data, callback: callback });
|
|
||||||
|
|
||||||
for(const consumer of this.consumer) {
|
|
||||||
consumer.handleEvent("react", eventType as string, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
on<T extends keyof Events>(event: T | T[], handler: (event: Event<Events, T>) => void) : () => void;
|
|
||||||
on(events, handler) : () => void {
|
|
||||||
if(!Array.isArray(events)) {
|
|
||||||
events = [events];
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const event of events as string[]) {
|
|
||||||
const persistentHandler = this.persistentEventHandler[event] || (this.persistentEventHandler[event] = []);
|
|
||||||
persistentHandler.push(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => this.off(events, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
one<T extends keyof Events>(event: T | T[], handler: (event: Event<Events, T>) => void) : () => void;
|
|
||||||
one(events, handler) : () => void {
|
|
||||||
if(!Array.isArray(events)) {
|
|
||||||
events = [events];
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const event of events as string[]) {
|
|
||||||
const persistentHandler = this.oneShotEventHandler[event] || (this.oneShotEventHandler[event] = []);
|
|
||||||
persistentHandler.push(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => this.off(events, handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
off(handler: (event: Event<Events, keyof Events>) => void);
|
|
||||||
off<T extends keyof Events>(events: T | T[], handler: (event: Event<Events, T>) => void);
|
|
||||||
off(handlerOrEvents, handler?) {
|
|
||||||
if(typeof handlerOrEvents === "function") {
|
|
||||||
this.offAll(handler);
|
|
||||||
} else if(typeof handlerOrEvents === "string") {
|
|
||||||
if(this.persistentEventHandler[handlerOrEvents]) {
|
|
||||||
this.persistentEventHandler[handlerOrEvents].remove(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(this.oneShotEventHandler[handlerOrEvents]) {
|
|
||||||
this.oneShotEventHandler[handlerOrEvents].remove(handler);
|
|
||||||
}
|
|
||||||
} else if(Array.isArray(handlerOrEvents)) {
|
|
||||||
handlerOrEvents.forEach(handler_or_event => this.off(handler_or_event, handler));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onAll(handler: (event: Event<Events, keyof Events>) => void): () => void {
|
|
||||||
this.genericEventHandler.push(handler);
|
|
||||||
return () => this.genericEventHandler.remove(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
offAll(handler: (event: Event<Events, keyof Events>) => void) {
|
|
||||||
Object.values(this.persistentEventHandler).forEach(persistentHandler => persistentHandler.remove(handler));
|
|
||||||
Object.values(this.oneShotEventHandler).forEach(oneShotHandler => oneShotHandler.remove(handler));
|
|
||||||
this.genericEventHandler.remove(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param event
|
|
||||||
* @param handler
|
|
||||||
* @param condition If a boolean the event handler will only be registered if the condition is true
|
|
||||||
* @param reactEffectDependencies
|
|
||||||
*/
|
|
||||||
reactUse<T extends keyof Events>(event: T | T[], handler: (event: Event<Events, T>) => void, condition?: boolean, reactEffectDependencies?: any[]);
|
|
||||||
reactUse(event, handler, condition?, reactEffectDependencies?) {
|
|
||||||
if(typeof condition === "boolean" && !condition) {
|
|
||||||
useEffect(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlers = this.persistentEventHandler[event as any] || (this.persistentEventHandler[event as any] = []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handlers.push(handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
const index = handlers.indexOf(handler);
|
|
||||||
if(index !== -1) {
|
|
||||||
handlers.splice(index, 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, reactEffectDependencies);
|
|
||||||
}
|
|
||||||
|
|
||||||
private doInvokeEvent(event: Event<Events, keyof Events>) {
|
|
||||||
const oneShotHandler = this.oneShotEventHandler[event.type];
|
|
||||||
if(oneShotHandler) {
|
|
||||||
delete this.oneShotEventHandler[event.type];
|
|
||||||
for(const handler of oneShotHandler) {
|
|
||||||
handler(event);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const handlers = [...(this.persistentEventHandler[event.type] || [])];
|
|
||||||
for(const handler of handlers) {
|
|
||||||
handler(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const handler of this.genericEventHandler) {
|
|
||||||
handler(event);
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
let invokeCount = 0;
|
|
||||||
if(this.warnUnhandledEvents && invokeCount === 0) {
|
|
||||||
logWarn(LogCategory.EVENT_REGISTRY, "Event handler (%s) triggered event %s which has no consumers.", this.debugPrefix, event.type);
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
private invokeAsyncCallbacks() {
|
|
||||||
const callbacks = this.pendingAsyncCallbacks;
|
|
||||||
this.pendingAsyncCallbacksTimeout = 0;
|
|
||||||
this.pendingAsyncCallbacks = undefined;
|
|
||||||
|
|
||||||
let index = 0;
|
|
||||||
while(index < callbacks.length) {
|
|
||||||
this.fire(callbacks[index].type, callbacks[index].data);
|
|
||||||
try {
|
|
||||||
if(callbacks[index].callback) {
|
|
||||||
callbacks[index].callback();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
/* TODO: Improve error logging? */
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private invokeReactCallbacks() {
|
|
||||||
const callbacks = this.pendingReactCallbacks;
|
|
||||||
this.pendingReactCallbacksFrame = 0;
|
|
||||||
this.pendingReactCallbacks = undefined;
|
|
||||||
|
|
||||||
/* run this after the requestAnimationFrame has been finished since else it might be fired instantly */
|
|
||||||
setTimeout(() => {
|
|
||||||
/* batch all react updates */
|
|
||||||
unstable_batchedUpdates(() => {
|
|
||||||
let index = 0;
|
|
||||||
while(index < callbacks.length) {
|
|
||||||
this.fire(callbacks[index].type, callbacks[index].data);
|
|
||||||
try {
|
|
||||||
if(callbacks[index].callback) {
|
|
||||||
callbacks[index].callback();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
/* TODO: Improve error logging? */
|
|
||||||
}
|
|
||||||
index++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
registerHandler(handler: any, parentClasses?: boolean) {
|
|
||||||
if(typeof handler !== "object") {
|
|
||||||
throw "event handler must be an object";
|
|
||||||
}
|
|
||||||
|
|
||||||
if(typeof handler[this.registryUniqueId] !== "undefined") {
|
|
||||||
throw "event handler already registered";
|
|
||||||
}
|
|
||||||
|
|
||||||
const prototype = Object.getPrototypeOf(handler);
|
|
||||||
if(typeof prototype !== "object") {
|
|
||||||
throw "event handler must have a prototype";
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = handler[this.registryUniqueId] = {
|
|
||||||
registeredHandler: {}
|
|
||||||
} as EventHandlerRegisterData;
|
|
||||||
|
|
||||||
let currentPrototype = prototype;
|
|
||||||
do {
|
|
||||||
Object.getOwnPropertyNames(currentPrototype).forEach(functionName => {
|
|
||||||
if(functionName === "constructor") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(typeof prototype[functionName] !== "function") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(typeof prototype[functionName][kEventAnnotationKey] !== "object") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventData = prototype[functionName][kEventAnnotationKey];
|
|
||||||
const eventHandler = event => prototype[functionName].call(handler, event);
|
|
||||||
for(const event of eventData.events) {
|
|
||||||
const registeredHandler = data.registeredHandler[event] || (data.registeredHandler[event] = []);
|
|
||||||
registeredHandler.push(eventHandler);
|
|
||||||
|
|
||||||
this.on(event, eventHandler);
|
logReactInvokeError(error: any) {
|
||||||
|
logError(LogCategory.EVENT_REGISTRY, tr("Failed to invoke react callback:\n%o"), error);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if(!parentClasses) {
|
logTrace(message: string, ...args: any[]) {
|
||||||
break;
|
logTrace(LogCategory.EVENT_REGISTRY, message, ...args);
|
||||||
}
|
|
||||||
} while ((currentPrototype = Object.getPrototypeOf(currentPrototype)));
|
|
||||||
}
|
|
||||||
|
|
||||||
unregisterHandler(handler: any) {
|
|
||||||
if(typeof handler !== "object") {
|
|
||||||
throw "event handler must be an object";
|
|
||||||
}
|
|
||||||
|
|
||||||
if(typeof handler[this.registryUniqueId] === "undefined") {
|
|
||||||
throw "event handler not registered";
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = handler[this.registryUniqueId] as EventHandlerRegisterData;
|
|
||||||
delete handler[this.registryUniqueId];
|
|
||||||
|
|
||||||
for(const event of Object.keys(data.registeredHandler)) {
|
|
||||||
for(const handler of data.registeredHandler[event]) {
|
|
||||||
this.off(event as any, handler);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
registerConsumer(consumer: EventConsumer) : () => void {
|
|
||||||
const allConsumer = this.consumer;
|
|
||||||
allConsumer.push(consumer);
|
|
||||||
|
|
||||||
return () => allConsumer.remove(consumer);
|
|
||||||
}
|
|
||||||
|
|
||||||
unregisterConsumer(consumer: EventConsumer) {
|
|
||||||
this.consumer.remove(consumer);
|
|
||||||
}
|
|
||||||
|
|
||||||
generateIpcDescription() : IpcRegistryDescription<Events> {
|
|
||||||
if(!this.ipcConsumer) {
|
|
||||||
this.ipcConsumer = new IpcEventBridge(this as any, undefined);
|
|
||||||
this.registerConsumer(this.ipcConsumer);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ipcChannelId: this.ipcConsumer.ipcChannelId
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RegistryMap = {[key: string]: any /* can't use Registry here since the template parameter is missing */ };
|
|
||||||
|
|
||||||
export function EventHandler<EventTypes>(events: (keyof EventTypes) | (keyof EventTypes)[]) {
|
|
||||||
return function (target: any,
|
|
||||||
propertyKey: string,
|
|
||||||
_descriptor: PropertyDescriptor) {
|
|
||||||
if(typeof target[propertyKey] !== "function")
|
|
||||||
throw "Invalid event handler annotation. Expected to be on a function type.";
|
|
||||||
|
|
||||||
target[propertyKey][kEventAnnotationKey] = {
|
|
||||||
events: Array.isArray(events) ? events : [events]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ReactEventHandler<ObjectClass = React.Component<any, any>, Events = any>(registry_callback: (object: ObjectClass) => Registry<Events>) {
|
|
||||||
return function (constructor: Function) {
|
|
||||||
if(!React.Component.prototype.isPrototypeOf(constructor.prototype))
|
|
||||||
throw "Class/object isn't an instance of React.Component";
|
|
||||||
|
|
||||||
const didMount = constructor.prototype.componentDidMount;
|
|
||||||
constructor.prototype.componentDidMount = function() {
|
|
||||||
const registry = registry_callback(this);
|
|
||||||
if(!registry) throw "Event registry returned for an event object is invalid";
|
|
||||||
registry.registerHandler(this);
|
|
||||||
|
|
||||||
if(typeof didMount === "function") {
|
|
||||||
didMount.call(this, arguments);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const willUnmount = constructor.prototype.componentWillUnmount;
|
|
||||||
constructor.prototype.componentWillUnmount = function () {
|
|
||||||
const registry = registry_callback(this);
|
|
||||||
if(!registry) throw "Event registry returned for an event object is invalid";
|
|
||||||
try {
|
|
||||||
registry.unregisterHandler(this);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Failed to unregister event handler: %o", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if(typeof willUnmount === "function") {
|
|
||||||
willUnmount.call(this, arguments);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IpcRegistryDescription<Events extends EventMap<Events> = EventMap<any>> = {
|
|
||||||
ipcChannelId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
class IpcEventBridge implements EventConsumer {
|
|
||||||
readonly registry: Registry;
|
|
||||||
readonly ipcChannelId: string;
|
|
||||||
private readonly ownBridgeId: string;
|
|
||||||
private broadcastChannel: BroadcastChannel;
|
|
||||||
|
|
||||||
constructor(registry: Registry, ipcChannelId: string | undefined) {
|
|
||||||
this.registry = registry;
|
|
||||||
this.ownBridgeId = guid();
|
|
||||||
|
|
||||||
this.ipcChannelId = ipcChannelId || ("teaspeak-ipc-events-" + guid());
|
|
||||||
this.broadcastChannel = new BroadcastChannel(this.ipcChannelId);
|
|
||||||
this.broadcastChannel.onmessage = event => this.handleIpcMessage(event.data, event.source, event.origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if(this.broadcastChannel) {
|
|
||||||
this.broadcastChannel.onmessage = undefined;
|
|
||||||
this.broadcastChannel.onmessageerror = undefined;
|
|
||||||
this.broadcastChannel.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.broadcastChannel = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleEvent(dispatchType: EventDispatchType, eventType: string, eventPayload: any) {
|
|
||||||
if(eventPayload && eventPayload[this.ownBridgeId]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.broadcastChannel.postMessage({
|
|
||||||
type: "event",
|
|
||||||
source: this.ownBridgeId,
|
|
||||||
|
|
||||||
dispatchType,
|
|
||||||
eventType,
|
|
||||||
eventPayload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleIpcMessage(message: any, _source: MessageEventSource | null, _origin: string) {
|
|
||||||
if(message.source === this.ownBridgeId) {
|
|
||||||
/* It's our own event */
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(message.type === "event") {
|
|
||||||
const payload = message.eventPayload || {};
|
|
||||||
payload[this.ownBridgeId] = true;
|
|
||||||
switch(message.dispatchType as EventDispatchType) {
|
|
||||||
case "sync":
|
|
||||||
this.registry.fire(message.eventType, payload);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "react":
|
|
||||||
this.registry.fire_react(message.eventType, payload);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "later":
|
|
||||||
this.registry.fire_later(message.eventType, payload);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
});
|
|
@ -352,7 +352,7 @@ class LocalAvatarManagerFactory extends AbstractAvatarManagerFactory {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, kIPCAvatarChannel);
|
this.ipcChannel = ipc.getIpcInstance().createChannel(kIPCAvatarChannel);
|
||||||
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
||||||
|
|
||||||
server_connections.events().on("notify_handler_created", event => this.handleHandlerCreated(event.handler));
|
server_connections.events().on("notify_handler_created", event => this.handleHandlerCreated(event.handler));
|
||||||
|
|
|
@ -68,7 +68,7 @@ class IconManager extends AbstractIconManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, kIPCIconChannel);
|
this.ipcChannel = ipc.getIpcInstance().createChannel(kIPCIconChannel);
|
||||||
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
||||||
|
|
||||||
server_connections.events().on("notify_handler_created", event => {
|
server_connections.events().on("notify_handler_created", event => {
|
||||||
|
|
|
@ -8,7 +8,7 @@ import {
|
||||||
kIPCAvatarChannel,
|
kIPCAvatarChannel,
|
||||||
setGlobalAvatarManagerFactory, uniqueId2AvatarId
|
setGlobalAvatarManagerFactory, uniqueId2AvatarId
|
||||||
} from "../file/Avatars";
|
} from "../file/Avatars";
|
||||||
import {IPCChannel} from "../ipc/BrowserIPC";
|
import {getIpcInstance, IPCChannel} from "../ipc/BrowserIPC";
|
||||||
import {AppParameters} from "../settings";
|
import {AppParameters} from "../settings";
|
||||||
import {ChannelMessage} from "../ipc/BrowserIPC";
|
import {ChannelMessage} from "../ipc/BrowserIPC";
|
||||||
import {guid} from "../crypto/uid";
|
import {guid} from "../crypto/uid";
|
||||||
|
@ -159,7 +159,7 @@ class RemoteAvatarManagerFactory extends AbstractAvatarManagerFactory {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.ipcChannel = ipc.getIpcInstance().createChannel(AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, "invalid"), kIPCAvatarChannel);
|
this.ipcChannel = ipc.getIpcInstance().createCoreControlChannel(kIPCAvatarChannel);
|
||||||
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import * as loader from "tc-loader";
|
||||||
import {Stage} from "tc-loader";
|
import {Stage} from "tc-loader";
|
||||||
import {ChannelMessage, IPCChannel} from "tc-shared/ipc/BrowserIPC";
|
import {ChannelMessage, IPCChannel} from "tc-shared/ipc/BrowserIPC";
|
||||||
import * as ipc from "tc-shared/ipc/BrowserIPC";
|
import * as ipc from "tc-shared/ipc/BrowserIPC";
|
||||||
import {AppParameters} from "tc-shared/settings";
|
|
||||||
import {LogCategory, logWarn} from "tc-shared/log";
|
import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
|
|
||||||
class RemoteRemoteIcon extends RemoteIcon {
|
class RemoteRemoteIcon extends RemoteIcon {
|
||||||
|
@ -33,7 +32,7 @@ class RemoteIconManager extends AbstractIconManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.ipcChannel = ipc.getIpcInstance().createChannel(AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, "invalid"), kIPCIconChannel);
|
this.ipcChannel = ipc.getIpcInstance().createCoreControlChannel(kIPCIconChannel);
|
||||||
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,150 +1,131 @@
|
||||||
import "broadcastchannel-polyfill";
|
import "broadcastchannel-polyfill";
|
||||||
import {LogCategory, logDebug, logError, logWarn} from "../log";
|
import {LogCategory, logDebug, logError, logTrace, logWarn} from "../log";
|
||||||
import {ConnectHandler} from "../ipc/ConnectHandler";
|
import {ConnectHandler} from "../ipc/ConnectHandler";
|
||||||
import {tr} from "tc-shared/i18n/localize";
|
import {tr} from "tc-shared/i18n/localize";
|
||||||
|
import {guid} from "tc-shared/crypto/uid";
|
||||||
|
import {AppParameters} from "tc-shared/settings";
|
||||||
|
|
||||||
export interface BroadcastMessage {
|
interface IpcRawMessage {
|
||||||
timestamp: number;
|
timestampSend: number,
|
||||||
receiver: string;
|
|
||||||
sender: string;
|
|
||||||
|
|
||||||
type: string;
|
sourcePeerId: string,
|
||||||
data: any;
|
targetPeerId: string,
|
||||||
}
|
|
||||||
|
|
||||||
function uuidv4() {
|
targetChannelId: string,
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
||||||
const r = Math.random() * 16 | 0;
|
|
||||||
const v = c == 'x' ? r : (r & 0x3 | 0x8);
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProcessQuery {
|
message: ChannelMessage
|
||||||
timestamp: number
|
|
||||||
query_id: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelMessage {
|
export interface ChannelMessage {
|
||||||
channel_id: string;
|
type: string,
|
||||||
type: string;
|
data: any
|
||||||
data: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessQueryResponse {
|
|
||||||
request_timestamp: number
|
|
||||||
request_query_id: string;
|
|
||||||
|
|
||||||
device_id: string;
|
|
||||||
protocol: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CertificateAcceptCallback {
|
|
||||||
request_id: string;
|
|
||||||
}
|
|
||||||
export interface CertificateAcceptSucceeded { }
|
|
||||||
|
|
||||||
export abstract class BasicIPCHandler {
|
export abstract class BasicIPCHandler {
|
||||||
protected static readonly BROADCAST_UNIQUE_ID = "00000000-0000-4000-0000-000000000000";
|
protected static readonly BROADCAST_UNIQUE_ID = "00000000-0000-4000-0000-000000000000";
|
||||||
protected static readonly PROTOCOL_VERSION = 1;
|
|
||||||
|
protected readonly applicationChannelId: string;
|
||||||
|
protected readonly localPeerId: string;
|
||||||
|
|
||||||
protected registeredChannels: IPCChannel[] = [];
|
protected registeredChannels: IPCChannel[] = [];
|
||||||
protected localUniqueId: string;
|
|
||||||
|
|
||||||
protected constructor() { }
|
protected constructor(applicationChannelId: string) {
|
||||||
|
this.applicationChannelId = applicationChannelId;
|
||||||
setup() {
|
this.localPeerId = guid();
|
||||||
this.localUniqueId = uuidv4();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getLocalAddress() : string { return this.localUniqueId; }
|
setup() { }
|
||||||
|
|
||||||
abstract sendMessage(type: string, data: any, target?: string);
|
getApplicationChannelId() : string { return this.applicationChannelId; }
|
||||||
|
|
||||||
protected handleMessage(message: BroadcastMessage) {
|
getLocalPeerId() : string { return this.localPeerId; }
|
||||||
//log.trace(LogCategory.IPC, tr("Received message %o"), message);
|
|
||||||
|
|
||||||
if(message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID) {
|
abstract sendMessage(message: IpcRawMessage);
|
||||||
if(message.type == "process-query") {
|
|
||||||
logDebug(LogCategory.IPC, tr("Received a device query from %s."), message.sender);
|
|
||||||
this.sendMessage("process-query-response", {
|
|
||||||
request_query_id: (<ProcessQuery>message.data).query_id,
|
|
||||||
request_timestamp: (<ProcessQuery>message.data).timestamp,
|
|
||||||
|
|
||||||
device_id: this.localUniqueId,
|
protected handleMessage(message: IpcRawMessage) {
|
||||||
protocol: BasicIPCHandler.PROTOCOL_VERSION
|
logTrace(LogCategory.IPC, tr("Received message %o"), message);
|
||||||
} as ProcessQueryResponse, message.sender);
|
|
||||||
|
if(message.targetPeerId !== this.localPeerId && message.targetPeerId !== BasicIPCHandler.BROADCAST_UNIQUE_ID) {
|
||||||
|
/* The message isn't for us */
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if(message.receiver === this.localUniqueId) {
|
|
||||||
if(message.type == "process-query-response") {
|
|
||||||
const response: ProcessQueryResponse = message.data;
|
|
||||||
if(this._query_results[response.request_query_id])
|
|
||||||
this._query_results[response.request_query_id].push(response);
|
|
||||||
else {
|
|
||||||
logWarn(LogCategory.IPC, tr("Received a query response for an unknown request."));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else if(message.type == "certificate-accept-callback") {
|
|
||||||
const data: CertificateAcceptCallback = message.data;
|
|
||||||
if(!this._cert_accept_callbacks[data.request_id]) {
|
|
||||||
logWarn(LogCategory.IPC, tr("Received certificate accept callback for an unknown request ID."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._cert_accept_callbacks[data.request_id]();
|
|
||||||
delete this._cert_accept_callbacks[data.request_id];
|
|
||||||
|
|
||||||
this.sendMessage("certificate-accept-succeeded", {
|
let channelInvokeCount = 0;
|
||||||
|
|
||||||
} as CertificateAcceptSucceeded, message.sender);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
else if(message.type == "certificate-accept-succeeded") {
|
|
||||||
if(!this._cert_accept_succeeded[message.sender]) {
|
|
||||||
logWarn(LogCategory.IPC, tr("Received certificate accept succeeded, but haven't a callback."));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._cert_accept_succeeded[message.sender]();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if(message.type === "channel") {
|
|
||||||
const data: ChannelMessage = message.data;
|
|
||||||
|
|
||||||
let channel_invoked = false;
|
|
||||||
for(const channel of this.registeredChannels) {
|
for(const channel of this.registeredChannels) {
|
||||||
if(channel.channelId === data.channel_id && (typeof(channel.targetClientId) === "undefined" || channel.targetClientId === message.sender)) {
|
if(channel.channelId !== message.targetChannelId) {
|
||||||
if(channel.messageHandler)
|
continue;
|
||||||
channel.messageHandler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data);
|
|
||||||
channel_invoked = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if(!channel_invoked) {
|
if(typeof channel.targetPeerId === "string" && channel.targetPeerId !== message.sourcePeerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(channel.messageHandler) {
|
||||||
|
channel.messageHandler(message.sourcePeerId, message.targetPeerId === BasicIPCHandler.BROADCAST_UNIQUE_ID, message.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
channelInvokeCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!channelInvokeCount) {
|
||||||
/* Seems like we're not the only web/teaclient instance */
|
/* Seems like we're not the only web/teaclient instance */
|
||||||
/* console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); */
|
/* console.warn(tr("Received channel message for unknown channel (%s)"), data.channelId); */
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createChannel(targetId?: string, channelId?: string) : IPCChannel {
|
/**
|
||||||
|
* @param channelId
|
||||||
|
* @param remotePeerId The peer to receive messages from. If empty messages will be broadcasted
|
||||||
|
*/
|
||||||
|
createChannel(channelId: string, remotePeerId?: string) : IPCChannel {
|
||||||
let channel: IPCChannel = {
|
let channel: IPCChannel = {
|
||||||
targetClientId: targetId,
|
channelId: channelId,
|
||||||
channelId: channelId || uuidv4(),
|
targetPeerId: remotePeerId,
|
||||||
messageHandler: undefined,
|
messageHandler: undefined,
|
||||||
sendMessage: (type: string, data: any, target?: string) => {
|
sendMessage: (type: string, data: any, remotePeerId?: string) => {
|
||||||
if(typeof target !== "undefined") {
|
if(typeof remotePeerId !== "undefined") {
|
||||||
if(typeof channel.targetClientId === "string" && target != channel.targetClientId) {
|
if(typeof channel.targetPeerId === "string" && remotePeerId != channel.targetPeerId) {
|
||||||
throw "target id does not match channel target";
|
throw "target id does not match channel target";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage("channel", {
|
remotePeerId = remotePeerId || channel.targetPeerId || BasicIPCHandler.BROADCAST_UNIQUE_ID;
|
||||||
|
this.sendMessage({
|
||||||
|
timestampSend: Date.now(),
|
||||||
|
|
||||||
|
sourcePeerId: this.localPeerId,
|
||||||
|
targetPeerId: remotePeerId,
|
||||||
|
|
||||||
|
targetChannelId: channelId,
|
||||||
|
|
||||||
|
message: {
|
||||||
|
data,
|
||||||
|
type,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(remotePeerId === this.localPeerId || remotePeerId === BasicIPCHandler.BROADCAST_UNIQUE_ID) {
|
||||||
|
for(const localChannel of this.registeredChannels) {
|
||||||
|
if(localChannel.channelId !== channel.channelId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(typeof localChannel.targetPeerId === "string" && localChannel.targetPeerId !== this.localPeerId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(localChannel === channel) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(localChannel.messageHandler) {
|
||||||
|
localChannel.messageHandler(this.localPeerId, remotePeerId === BasicIPCHandler.BROADCAST_UNIQUE_ID, {
|
||||||
type: type,
|
type: type,
|
||||||
data: data,
|
data: data,
|
||||||
channel_id: channel.channelId
|
});
|
||||||
} as ChannelMessage, target || channel.targetClientId || BasicIPCHandler.BROADCAST_UNIQUE_ID);
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -152,77 +133,42 @@ export abstract class BasicIPCHandler {
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a channel which only communicates with the TeaSpeak - Core.
|
||||||
|
* @param channelId
|
||||||
|
*/
|
||||||
|
createCoreControlChannel(channelId: string) : IPCChannel {
|
||||||
|
return this.createChannel(channelId, AppParameters.getValue(AppParameters.KEY_IPC_CORE_PEER_ADDRESS, this.localPeerId));
|
||||||
|
}
|
||||||
|
|
||||||
channels() : IPCChannel[] { return this.registeredChannels; }
|
channels() : IPCChannel[] { return this.registeredChannels; }
|
||||||
|
|
||||||
deleteChannel(channel: IPCChannel) {
|
deleteChannel(channel: IPCChannel) {
|
||||||
this.registeredChannels = this.registeredChannels.filter(e => e !== channel);
|
this.registeredChannels.remove(channel);
|
||||||
}
|
|
||||||
|
|
||||||
private _query_results: {[key: string]:ProcessQueryResponse[]} = {};
|
|
||||||
async queryProcesses(timeout?: number) : Promise<ProcessQueryResponse[]> {
|
|
||||||
const query_id = uuidv4();
|
|
||||||
this._query_results[query_id] = [];
|
|
||||||
|
|
||||||
this.sendMessage("process-query", {
|
|
||||||
query_id: query_id,
|
|
||||||
timestamp: Date.now()
|
|
||||||
} as ProcessQuery);
|
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, timeout || 250));
|
|
||||||
const result = this._query_results[query_id];
|
|
||||||
delete this._query_results[query_id];
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _cert_accept_callbacks: {[key: string]:(() => any)} = {};
|
|
||||||
register_certificate_accept_callback(callback: () => any) : string {
|
|
||||||
const id = uuidv4();
|
|
||||||
this._cert_accept_callbacks[id] = callback;
|
|
||||||
return this.localUniqueId + ":" + id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _cert_accept_succeeded: {[sender: string]:(() => any)} = {};
|
|
||||||
post_certificate_accpected(id: string, timeout?: number) : Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = id.split(":");
|
|
||||||
const timeout_id = setTimeout(() => {
|
|
||||||
delete this._cert_accept_succeeded[data[0]];
|
|
||||||
clearTimeout(timeout_id);
|
|
||||||
reject("timeout");
|
|
||||||
}, timeout || 250);
|
|
||||||
this._cert_accept_succeeded[data[0]] = () => {
|
|
||||||
delete this._cert_accept_succeeded[data[0]];
|
|
||||||
clearTimeout(timeout_id);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
this.sendMessage("certificate-accept-callback", {
|
|
||||||
request_id: data[1]
|
|
||||||
} as CertificateAcceptCallback, data[0]);
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPCChannel {
|
export interface IPCChannel {
|
||||||
|
/** Channel id */
|
||||||
readonly channelId: string;
|
readonly channelId: string;
|
||||||
targetClientId?: string;
|
/** Target peer id. If set only messages from that process will be processed */
|
||||||
|
targetPeerId?: string;
|
||||||
|
|
||||||
messageHandler: (remoteId: string, broadcast: boolean, message: ChannelMessage) => void;
|
messageHandler: (sourcePeerId: string, broadcast: boolean, message: ChannelMessage) => void;
|
||||||
sendMessage(type: string, message: any, target?: string);
|
sendMessage(type: string, data: any, remotePeerId?: string);
|
||||||
}
|
}
|
||||||
|
|
||||||
class BroadcastChannelIPC extends BasicIPCHandler {
|
class BroadcastChannelIPC extends BasicIPCHandler {
|
||||||
private static readonly CHANNEL_NAME = "TeaSpeak-Web";
|
|
||||||
|
|
||||||
private channel: BroadcastChannel;
|
private channel: BroadcastChannel;
|
||||||
|
|
||||||
constructor() {
|
constructor(applicationChannelId: string) {
|
||||||
super();
|
super(applicationChannelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
super.setup();
|
super.setup();
|
||||||
|
|
||||||
this.channel = new BroadcastChannel(BroadcastChannelIPC.CHANNEL_NAME);
|
this.channel = new BroadcastChannel(this.applicationChannelId);
|
||||||
this.channel.onmessage = this.onMessage.bind(this);
|
this.channel.onmessage = this.onMessage.bind(this);
|
||||||
this.channel.onmessageerror = this.onError.bind(this);
|
this.channel.onmessageerror = this.onError.bind(this);
|
||||||
}
|
}
|
||||||
|
@ -233,7 +179,7 @@ class BroadcastChannelIPC extends BasicIPCHandler {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let message: BroadcastMessage;
|
let message: IpcRawMessage;
|
||||||
try {
|
try {
|
||||||
message = JSON.parse(event.data);
|
message = JSON.parse(event.data);
|
||||||
} catch(error) {
|
} catch(error) {
|
||||||
|
@ -247,52 +193,31 @@ class BroadcastChannelIPC extends BasicIPCHandler {
|
||||||
logWarn(LogCategory.IPC, tr("Received error: %o"), event);
|
logWarn(LogCategory.IPC, tr("Received error: %o"), event);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(type: string, data: any, target?: string) {
|
sendMessage(message: IpcRawMessage) {
|
||||||
const message: BroadcastMessage = {} as any;
|
|
||||||
|
|
||||||
message.sender = this.localUniqueId;
|
|
||||||
message.receiver = target ? target : BasicIPCHandler.BROADCAST_UNIQUE_ID;
|
|
||||||
message.timestamp = Date.now();
|
|
||||||
message.type = type;
|
|
||||||
message.data = data;
|
|
||||||
|
|
||||||
if(message.receiver === this.localUniqueId) {
|
|
||||||
this.handleMessage(message);
|
|
||||||
} else {
|
|
||||||
this.channel.postMessage(JSON.stringify(message));
|
this.channel.postMessage(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let handler: BasicIPCHandler;
|
let handlerInstance: BasicIPCHandler;
|
||||||
let connect_handler: ConnectHandler;
|
let connectHandler: ConnectHandler;
|
||||||
|
|
||||||
export function setup() {
|
export function setupIpcHandler() {
|
||||||
if(!supported())
|
if(handlerInstance) {
|
||||||
return;
|
throw "IPC handler already initialized";
|
||||||
|
}
|
||||||
|
|
||||||
if(handler)
|
handlerInstance = new BroadcastChannelIPC(AppParameters.getValue(AppParameters.KEY_IPC_APP_ADDRESS, guid()));
|
||||||
throw "bipc already started";
|
handlerInstance.setup();
|
||||||
|
logDebug(LogCategory.IPC, tr("Application IPC started for %s. Local peer address: %s"), handlerInstance.getApplicationChannelId(), handlerInstance.getLocalPeerId());
|
||||||
|
|
||||||
handler = new BroadcastChannelIPC();
|
connectHandler = new ConnectHandler(handlerInstance);
|
||||||
handler.setup();
|
connectHandler.setup();
|
||||||
|
|
||||||
connect_handler = new ConnectHandler(handler);
|
|
||||||
connect_handler.setup();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getIpcInstance() {
|
export function getIpcInstance() {
|
||||||
return handler;
|
return handlerInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getInstanceConnectHandler() {
|
export function getInstanceConnectHandler() {
|
||||||
return connect_handler;
|
return connectHandler;
|
||||||
}
|
|
||||||
|
|
||||||
export function supported() {
|
|
||||||
/* we've a polyfill now */
|
|
||||||
return true;
|
|
||||||
|
|
||||||
/* ios does not support this */
|
|
||||||
return typeof(window.BroadcastChannel) !== "undefined";
|
|
||||||
}
|
}
|
|
@ -8,6 +8,7 @@ export type ConnectRequestData = {
|
||||||
|
|
||||||
profile?: string;
|
profile?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|
||||||
password?: {
|
password?: {
|
||||||
value: string;
|
value: string;
|
||||||
hashed: boolean;
|
hashed: boolean;
|
||||||
|
@ -75,7 +76,7 @@ export class ConnectHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
public setup() {
|
public setup() {
|
||||||
this.ipc_channel = this.ipc_handler.createChannel(undefined, ConnectHandler.CHANNEL_NAME);
|
this.ipc_channel = this.ipc_handler.createChannel(ConnectHandler.CHANNEL_NAME);
|
||||||
this.ipc_channel.messageHandler = this.onMessage.bind(this);
|
this.ipc_channel.messageHandler = this.onMessage.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,217 +0,0 @@
|
||||||
import {LogCategory, logDebug, logInfo, logWarn} from "../log";
|
|
||||||
import {BasicIPCHandler, ChannelMessage, IPCChannel} from "../ipc/BrowserIPC";
|
|
||||||
import {guid} from "../crypto/uid";
|
|
||||||
import {tr} from "tc-shared/i18n/localize";
|
|
||||||
|
|
||||||
export interface MethodProxyInvokeData {
|
|
||||||
method_name: string;
|
|
||||||
arguments: any[];
|
|
||||||
promise_id: string;
|
|
||||||
}
|
|
||||||
export interface MethodProxyResultData {
|
|
||||||
promise_id: string;
|
|
||||||
result: any;
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
export interface MethodProxyCallback {
|
|
||||||
promise: Promise<any>;
|
|
||||||
promise_id: string;
|
|
||||||
|
|
||||||
resolve: (object: any) => any;
|
|
||||||
reject: (object: any) => any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MethodProxyConnectParameters = {
|
|
||||||
channel_id: string;
|
|
||||||
client_id: string;
|
|
||||||
}
|
|
||||||
export abstract class MethodProxy {
|
|
||||||
readonly ipc_handler: BasicIPCHandler;
|
|
||||||
private _ipc_channel: IPCChannel;
|
|
||||||
private _ipc_parameters: MethodProxyConnectParameters;
|
|
||||||
|
|
||||||
private readonly _local: boolean;
|
|
||||||
private readonly _slave: boolean;
|
|
||||||
|
|
||||||
private _connected: boolean;
|
|
||||||
private _proxied_methods: {[key: string]:() => Promise<any>} = {};
|
|
||||||
private _proxied_callbacks: {[key: string]:MethodProxyCallback} = {};
|
|
||||||
|
|
||||||
protected constructor(ipc_handler: BasicIPCHandler, connect_params?: MethodProxyConnectParameters) {
|
|
||||||
this.ipc_handler = ipc_handler;
|
|
||||||
this._ipc_parameters = connect_params;
|
|
||||||
this._connected = false;
|
|
||||||
this._slave = typeof(connect_params) !== "undefined";
|
|
||||||
this._local = typeof(connect_params) !== "undefined" && connect_params.channel_id === "local" && connect_params.client_id === "local";
|
|
||||||
}
|
|
||||||
|
|
||||||
protected setup() {
|
|
||||||
if(this._local) {
|
|
||||||
this._connected = true;
|
|
||||||
this.on_connected();
|
|
||||||
} else {
|
|
||||||
if(this._slave)
|
|
||||||
this._ipc_channel = this.ipc_handler.createChannel(this._ipc_parameters.client_id, this._ipc_parameters.channel_id);
|
|
||||||
else
|
|
||||||
this._ipc_channel = this.ipc_handler.createChannel();
|
|
||||||
|
|
||||||
this._ipc_channel.messageHandler = this._handle_message.bind(this);
|
|
||||||
if(this._slave)
|
|
||||||
this._ipc_channel.sendMessage("initialize", {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected finalize() {
|
|
||||||
if(!this._local) {
|
|
||||||
if(this._connected)
|
|
||||||
this._ipc_channel.sendMessage("finalize", {});
|
|
||||||
|
|
||||||
this.ipc_handler.deleteChannel(this._ipc_channel);
|
|
||||||
this._ipc_channel = undefined;
|
|
||||||
}
|
|
||||||
for(const promise of Object.values(this._proxied_callbacks))
|
|
||||||
promise.reject("disconnected");
|
|
||||||
this._proxied_callbacks = {};
|
|
||||||
|
|
||||||
this._connected = false;
|
|
||||||
this.on_disconnected();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected register_method<R>(method: (...args: any[]) => Promise<R> | string) {
|
|
||||||
let method_name: string;
|
|
||||||
if(typeof method === "function") {
|
|
||||||
logDebug(LogCategory.IPC, tr("Registering method proxy for %s"), method.name);
|
|
||||||
method_name = method.name;
|
|
||||||
} else {
|
|
||||||
logDebug(LogCategory.IPC, tr("Registering method proxy for %s"), method);
|
|
||||||
method_name = method;
|
|
||||||
}
|
|
||||||
|
|
||||||
if(!this[method_name])
|
|
||||||
throw "method is missing in current object";
|
|
||||||
|
|
||||||
this._proxied_methods[method_name] = this[method_name];
|
|
||||||
if(!this._local) {
|
|
||||||
this[method_name] = (...args: any[]) => {
|
|
||||||
if(!this._connected)
|
|
||||||
return Promise.reject("not connected");
|
|
||||||
|
|
||||||
const proxy_callback = {
|
|
||||||
promise_id: guid()
|
|
||||||
} as MethodProxyCallback;
|
|
||||||
this._proxied_callbacks[proxy_callback.promise_id] = proxy_callback;
|
|
||||||
proxy_callback.promise = new Promise((resolve, reject) => {
|
|
||||||
proxy_callback.resolve = resolve;
|
|
||||||
proxy_callback.reject = reject;
|
|
||||||
});
|
|
||||||
|
|
||||||
this._ipc_channel.sendMessage("invoke", {
|
|
||||||
promise_id: proxy_callback.promise_id,
|
|
||||||
arguments: [...args],
|
|
||||||
method_name: method_name
|
|
||||||
} as MethodProxyInvokeData);
|
|
||||||
return proxy_callback.promise;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handle_message(remote_id: string, boradcast: boolean, message: ChannelMessage) {
|
|
||||||
if(message.type === "finalize") {
|
|
||||||
this._handle_finalize();
|
|
||||||
} else if(message.type === "initialize") {
|
|
||||||
this._handle_remote_callback(remote_id);
|
|
||||||
} else if(message.type === "invoke") {
|
|
||||||
this._handle_invoke(message.data);
|
|
||||||
} else if(message.type === "result") {
|
|
||||||
this._handle_result(message.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handle_finalize() {
|
|
||||||
this.on_disconnected();
|
|
||||||
this.finalize();
|
|
||||||
this._connected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handle_remote_callback(remote_id: string) {
|
|
||||||
if(!this._ipc_channel.targetClientId) {
|
|
||||||
if(this._slave)
|
|
||||||
throw "initialize wrong state!";
|
|
||||||
|
|
||||||
this._ipc_channel.targetClientId = remote_id; /* now we're able to send messages */
|
|
||||||
this.on_connected();
|
|
||||||
this._ipc_channel.sendMessage("initialize", true);
|
|
||||||
} else {
|
|
||||||
if(!this._slave)
|
|
||||||
throw "initialize wrong state!";
|
|
||||||
|
|
||||||
this.on_connected();
|
|
||||||
}
|
|
||||||
this._connected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _send_result(promise_id: string, success: boolean, message: any) {
|
|
||||||
this._ipc_channel.sendMessage("result", {
|
|
||||||
promise_id: promise_id,
|
|
||||||
result: message,
|
|
||||||
success: success
|
|
||||||
} as MethodProxyResultData);
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handle_invoke(data: MethodProxyInvokeData) {
|
|
||||||
if(this._proxied_methods[data.method_name])
|
|
||||||
throw "we could not invoke a local proxied method!";
|
|
||||||
|
|
||||||
if(!this[data.method_name]) {
|
|
||||||
this._send_result(data.promise_id, false, "missing method");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
logInfo(LogCategory.IPC, tr("Invoking method %s with arguments: %o"), data.method_name, data.arguments);
|
|
||||||
|
|
||||||
const promise = this[data.method_name](...data.arguments);
|
|
||||||
promise.then(result => {
|
|
||||||
logInfo(LogCategory.IPC, tr("Result: %o"), result);
|
|
||||||
this._send_result(data.promise_id, true, result);
|
|
||||||
}).catch(error => {
|
|
||||||
this._send_result(data.promise_id, false, error);
|
|
||||||
});
|
|
||||||
} catch(error) {
|
|
||||||
this._send_result(data.promise_id, false, error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handle_result(data: MethodProxyResultData) {
|
|
||||||
if(!this._proxied_callbacks[data.promise_id]) {
|
|
||||||
logWarn(LogCategory.IPC, tr("Received proxy method result for unknown promise"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const callback = this._proxied_callbacks[data.promise_id];
|
|
||||||
delete this._proxied_callbacks[data.promise_id];
|
|
||||||
|
|
||||||
if(data.success)
|
|
||||||
callback.resolve(data.result);
|
|
||||||
else
|
|
||||||
callback.reject(data.result);
|
|
||||||
}
|
|
||||||
|
|
||||||
generate_connect_parameters() : MethodProxyConnectParameters {
|
|
||||||
if(this._slave)
|
|
||||||
throw "only masters can generate connect parameters!";
|
|
||||||
if(!this._ipc_channel)
|
|
||||||
throw "please call setup() before";
|
|
||||||
|
|
||||||
return {
|
|
||||||
channel_id: this._ipc_channel.channelId,
|
|
||||||
client_id: this.ipc_handler.getLocalAddress()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
is_slave() { return this._local || this._slave; } /* the popout modal */
|
|
||||||
is_master() { return this._local || !this._slave; } /* the host (teaweb application) */
|
|
||||||
|
|
||||||
protected abstract on_connected();
|
|
||||||
protected abstract on_disconnected();
|
|
||||||
}
|
|
|
@ -1,18 +1,18 @@
|
||||||
import * as loader from "tc-loader";
|
import * as loader from "tc-loader";
|
||||||
|
import {Stage} 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 {tra} from "./i18n/localize";
|
||||||
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";
|
||||||
import * as global_ev_handler from "./events/ClientGlobalControlHandler";
|
import * as global_ev_handler from "./events/ClientGlobalControlHandler";
|
||||||
import {Stage} from "tc-loader";
|
import {AppParameters, settings, Settings, UrlParameterBuilder, UrlParameterParser} from "tc-shared/settings";
|
||||||
import {AppParameters, settings, Settings} from "tc-shared/settings";
|
import {LogCategory, logDebug, logError, logInfo, logWarn} from "tc-shared/log";
|
||||||
import {LogCategory, logError, logInfo} from "tc-shared/log";
|
|
||||||
import {tra} from "./i18n/localize";
|
|
||||||
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
import {createInfoModal} from "tc-shared/ui/elements/Modal";
|
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
|
||||||
import {defaultRecorder, RecorderProfile, setDefaultRecorder} from "tc-shared/voice/RecorderProfile";
|
import {RecorderProfile, setDefaultRecorder} from "tc-shared/voice/RecorderProfile";
|
||||||
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
|
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
|
||||||
import {formatMessage} from "tc-shared/ui/frames/chat";
|
import {formatMessage} from "tc-shared/ui/frames/chat";
|
||||||
import {openModalNewcomer} from "tc-shared/ui/modal/ModalNewcomer";
|
import {openModalNewcomer} from "tc-shared/ui/modal/ModalNewcomer";
|
||||||
|
@ -25,6 +25,8 @@ import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler";
|
||||||
import {defaultConnectProfile, findConnectProfile} from "tc-shared/profiles/ConnectionProfile";
|
import {defaultConnectProfile, findConnectProfile} from "tc-shared/profiles/ConnectionProfile";
|
||||||
import {server_connections} from "tc-shared/ConnectionManager";
|
import {server_connections} from "tc-shared/ConnectionManager";
|
||||||
import {spawnConnectModalNew} from "tc-shared/ui/modal/connect/Controller";
|
import {spawnConnectModalNew} from "tc-shared/ui/modal/connect/Controller";
|
||||||
|
import {initializeKeyControl} from "./KeyControl";
|
||||||
|
import {assertMainApplication} from "tc-shared/ui/utils";
|
||||||
|
|
||||||
/* required import for init */
|
/* required import for init */
|
||||||
import "svg-sprites/client-icons";
|
import "svg-sprites/client-icons";
|
||||||
|
@ -46,8 +48,9 @@ import "./ui/modal/connect/Controller";
|
||||||
import "./ui/elements/ContextDivider";
|
import "./ui/elements/ContextDivider";
|
||||||
import "./ui/elements/Tab";
|
import "./ui/elements/Tab";
|
||||||
import "./clientservice";
|
import "./clientservice";
|
||||||
import {initializeKeyControl} from "./KeyControl";
|
import "./text/bbcode/InviteController";
|
||||||
import {assertMainApplication} from "tc-shared/ui/utils";
|
import {clientServiceInvite} from "tc-shared/clientservice";
|
||||||
|
import {ActionResult} from "tc-services";
|
||||||
|
|
||||||
assertMainApplication();
|
assertMainApplication();
|
||||||
|
|
||||||
|
@ -61,7 +64,7 @@ async function initialize() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bipc.setup();
|
bipc.setupIpcHandler();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
|
@ -96,43 +99,214 @@ async function initializeApp() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Used by the native client... We can't refactor this yet */
|
/* The native client has received a connect request. */
|
||||||
export function handle_connect_request(properties: ConnectRequestData, connection: ConnectionHandler) {
|
export function handleNativeConnectRequest(url: URL) {
|
||||||
const profile = findConnectProfile(properties.profile) || defaultConnectProfile();
|
let serverAddress = url.host;
|
||||||
const username = properties.username || profile.connectUsername();
|
if(url.searchParams.has("port")) {
|
||||||
|
if(serverAddress.indexOf(':') !== -1) {
|
||||||
const password = properties.password ? properties.password.value : "";
|
logWarn(LogCategory.GENERAL, tr("Received connect request which specified the port twice (via parameter and host). Using host port."));
|
||||||
const password_hashed = properties.password ? properties.password.hashed : false;
|
} else if(serverAddress.indexOf(":") === -1) {
|
||||||
|
serverAddress += ":" + url.searchParams.get("port");
|
||||||
if(profile && profile.valid()) {
|
|
||||||
settings.setValue(Settings.KEY_USER_IS_NEW, false);
|
|
||||||
|
|
||||||
if(!aplayer.initialized()) {
|
|
||||||
/* Trick the client into clicking somewhere on the site */
|
|
||||||
spawnYesNo(tra("Connect to {}", properties.address), tra("Would you like to connect to {}?", properties.address), result => {
|
|
||||||
if(result) {
|
|
||||||
aplayer.on_ready(() => handle_connect_request(properties, connection));
|
|
||||||
} else {
|
} else {
|
||||||
/* Well... the client don't want to... */
|
serverAddress = `[${serverAddress}]:${url.searchParams.get("port")}`;
|
||||||
}
|
}
|
||||||
}).open();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connection.startConnection(properties.address, profile, true, {
|
handleConnectRequest(serverAddress, undefined, new UrlParameterParser(url)).then(undefined);
|
||||||
nickname: username,
|
}
|
||||||
password: password.length > 0 ? {
|
|
||||||
password: password,
|
export async function handleConnectRequest(serverAddress: string, serverUniqueId: string | undefined, parameters: UrlParameterParser) {
|
||||||
hashed: password_hashed
|
const inviteLinkId = parameters.getValue(AppParameters.KEY_CONNECT_INVITE_REFERENCE, undefined);
|
||||||
} : undefined
|
logDebug(LogCategory.STATISTICS, tr("Executing connect request with invite key reference: %o"), inviteLinkId);
|
||||||
|
|
||||||
|
if(inviteLinkId) {
|
||||||
|
clientServiceInvite.logAction(inviteLinkId, "ConnectAttempt").then(result => {
|
||||||
|
if(result.status !== "success") {
|
||||||
|
logWarn(LogCategory.STATISTICS, tr("Failed to register connect attempt: %o"), result.result);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
server_connections.setActiveConnectionHandler(connection);
|
}
|
||||||
|
|
||||||
|
const result = await doHandleConnectRequest(serverAddress, serverUniqueId, parameters);
|
||||||
|
if(inviteLinkId) {
|
||||||
|
let promise: Promise<ActionResult<void>>;
|
||||||
|
switch (result.status) {
|
||||||
|
case "success":
|
||||||
|
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectSuccess");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "channel-already-joined":
|
||||||
|
case "server-already-joined":
|
||||||
|
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectNoAction", { reason: result.status });
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
promise = clientServiceInvite.logAction(inviteLinkId, "ConnectFailure", { reason: result.status });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
promise.then(result => {
|
||||||
|
if(result.status !== "success") {
|
||||||
|
logWarn(LogCategory.STATISTICS, tr("Failed to register connect result: %o"), result.result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConnectRequestResult = {
|
||||||
|
status:
|
||||||
|
"success" |
|
||||||
|
"profile-invalid" |
|
||||||
|
"client-aborted" |
|
||||||
|
"server-join-failed" |
|
||||||
|
"server-already-joined" |
|
||||||
|
"channel-already-joined" |
|
||||||
|
"channel-not-visible" |
|
||||||
|
"channel-join-failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param serverAddress The target address to connect to
|
||||||
|
* @param serverUniqueId If given a server unique id. If any of our current connections matches it, such connection will be used
|
||||||
|
* @param parameters General connect parameters from the connect URL
|
||||||
|
*/
|
||||||
|
async function doHandleConnectRequest(serverAddress: string, serverUniqueId: string | undefined, parameters: UrlParameterParser) : Promise<ConnectRequestResult> {
|
||||||
|
|
||||||
|
let targetServerConnection: ConnectionHandler;
|
||||||
|
let isCurrentServerConnection: boolean;
|
||||||
|
|
||||||
|
if(serverUniqueId) {
|
||||||
|
if(server_connections.getActiveConnectionHandler()?.getCurrentServerUniqueId() === serverUniqueId) {
|
||||||
|
targetServerConnection = server_connections.getActiveConnectionHandler();
|
||||||
|
isCurrentServerConnection = true;
|
||||||
} else {
|
} else {
|
||||||
|
targetServerConnection = server_connections.getAllConnectionHandlers().find(connection => connection.getCurrentServerUniqueId() === serverUniqueId);
|
||||||
|
isCurrentServerConnection = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileId = parameters.getValue(AppParameters.KEY_CONNECT_PROFILE, undefined);
|
||||||
|
const profile = findConnectProfile(profileId) || targetServerConnection?.serverConnection.handshake_handler()?.parameters.profile || defaultConnectProfile();
|
||||||
|
|
||||||
|
if(!profile || !profile.valid()) {
|
||||||
spawnConnectModalNew({
|
spawnConnectModalNew({
|
||||||
selectedAddress: properties.address,
|
selectedAddress: serverAddress,
|
||||||
selectedProfile: profile
|
selectedProfile: profile
|
||||||
});
|
});
|
||||||
|
return { status: "profile-invalid" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(!aplayer.initialized()) {
|
||||||
|
/* Trick the client into clicking somewhere on the site to initialize audio */
|
||||||
|
const resultPromise = new Promise<boolean>(resolve => {
|
||||||
|
spawnYesNo(tra("Connect to {}", serverAddress), tra("Would you like to connect to {}?", serverAddress), resolve).open();
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!(await resultPromise)) {
|
||||||
|
/* Well... the client don't want to... */
|
||||||
|
return { status: "client-aborted" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => aplayer.on_ready(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientNickname = parameters.getValue(AppParameters.KEY_CONNECT_NICKNAME, undefined);
|
||||||
|
|
||||||
|
const serverPassword = parameters.getValue(AppParameters.KEY_CONNECT_SERVER_PASSWORD, undefined);
|
||||||
|
const passwordsHashed = parameters.getValue(AppParameters.KEY_CONNECT_PASSWORDS_HASHED);
|
||||||
|
|
||||||
|
const channel = parameters.getValue(AppParameters.KEY_CONNECT_CHANNEL, undefined);
|
||||||
|
const channelPassword = parameters.getValue(AppParameters.KEY_CONNECT_CHANNEL_PASSWORD, undefined);
|
||||||
|
|
||||||
|
if(!targetServerConnection) {
|
||||||
|
targetServerConnection = server_connections.getActiveConnectionHandler();
|
||||||
|
if(targetServerConnection.connected) {
|
||||||
|
targetServerConnection = server_connections.spawnConnectionHandler();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server_connections.setActiveConnectionHandler(targetServerConnection);
|
||||||
|
if(targetServerConnection.getCurrentServerUniqueId() === serverUniqueId) {
|
||||||
|
/* Just join the new channel and may use the token (before) */
|
||||||
|
|
||||||
|
/* TODO: Use the token! */
|
||||||
|
let containsToken = false;
|
||||||
|
|
||||||
|
if(!channel) {
|
||||||
|
/* No need to join any channel */
|
||||||
|
if(!containsToken) {
|
||||||
|
createInfoModal(tr("Already connected"), tr("You're already connected to the target server.")).open();
|
||||||
|
} else {
|
||||||
|
/* Don't show a message since a token has been used */
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: "server-already-joined" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetChannel = targetServerConnection.channelTree.resolveChannelPath(channel);
|
||||||
|
if(!targetChannel) {
|
||||||
|
createErrorModal(tr("Missing target channel"), tr("Failed to join channel since it is not visible.")).open();
|
||||||
|
return { status: "channel-not-visible" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if(targetServerConnection.getClient().currentChannel() === targetChannel) {
|
||||||
|
createErrorModal(tr("Channel already joined"), tr("You already joined the channel.")).open();
|
||||||
|
return { status: "channel-already-joined" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if(targetChannel.getCachedPasswordHash()) {
|
||||||
|
const succeeded = await targetChannel.joinChannel();
|
||||||
|
if(succeeded) {
|
||||||
|
/* Successfully joined channel with a password we already knew */
|
||||||
|
return { status: "success" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetChannel.setCachedHashedPassword(channelPassword);
|
||||||
|
if(await targetChannel.joinChannel()) {
|
||||||
|
return { status: "success" };
|
||||||
|
} else {
|
||||||
|
/* TODO: More detail? */
|
||||||
|
return { status: "channel-join-failed" };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await targetServerConnection.startConnectionNew({
|
||||||
|
targetAddress: serverAddress,
|
||||||
|
|
||||||
|
nickname: clientNickname,
|
||||||
|
nicknameSpecified: false,
|
||||||
|
|
||||||
|
profile: profile,
|
||||||
|
token: undefined,
|
||||||
|
|
||||||
|
serverPassword: serverPassword,
|
||||||
|
serverPasswordHashed: passwordsHashed,
|
||||||
|
|
||||||
|
defaultChannel: channel,
|
||||||
|
defaultChannelPassword: channelPassword,
|
||||||
|
defaultChannelPasswordHashed: passwordsHashed
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
if(targetServerConnection.connected) {
|
||||||
|
return { status: "success" };
|
||||||
|
} else {
|
||||||
|
/* TODO: More detail? */
|
||||||
|
return { status: "server-join-failed" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Used by the old native clients (an within the multi instance handler). Delete it later */
|
||||||
|
export function handle_connect_request(properties: ConnectRequestData, _connection: ConnectionHandler) {
|
||||||
|
const urlBuilder = new UrlParameterBuilder();
|
||||||
|
urlBuilder.setValue(AppParameters.KEY_CONNECT_PROFILE, properties.profile);
|
||||||
|
urlBuilder.setValue(AppParameters.KEY_CONNECT_NICKNAME, properties.username);
|
||||||
|
|
||||||
|
urlBuilder.setValue(AppParameters.KEY_CONNECT_SERVER_PASSWORD, properties.password?.value);
|
||||||
|
urlBuilder.setValue(AppParameters.KEY_CONNECT_PASSWORDS_HASHED, properties.password?.hashed);
|
||||||
|
|
||||||
|
const url = new URL(`https://localhost/?${urlBuilder.build()}`);
|
||||||
|
handleConnectRequest(properties.address, undefined, new UrlParameterParser(url));
|
||||||
}
|
}
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
|
@ -235,7 +409,7 @@ const task_connect_handler: loader.Task = {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FIXME: All additional parameters! */
|
/* FIXME: All additional connect parameters! */
|
||||||
const connectData = {
|
const connectData = {
|
||||||
address: address,
|
address: address,
|
||||||
|
|
||||||
|
@ -293,7 +467,7 @@ const task_connect_handler: loader.Task = {
|
||||||
preventWelcomeUI = true;
|
preventWelcomeUI = true;
|
||||||
loader.register_task(loader.Stage.LOADED, {
|
loader.register_task(loader.Stage.LOADED, {
|
||||||
priority: 0,
|
priority: 0,
|
||||||
function: async () => handle_connect_request(connectData, server_connections.getActiveConnectionHandler() || server_connections.spawnConnectionHandler()),
|
function: async () => handleConnectRequest(address, undefined, AppParameters.Instance),
|
||||||
name: tr("default url connect")
|
name: tr("default url connect")
|
||||||
});
|
});
|
||||||
loader.register_task(loader.Stage.LOADED, task_teaweb_starter);
|
loader.register_task(loader.Stage.LOADED, task_teaweb_starter);
|
||||||
|
|
|
@ -76,10 +76,11 @@ function encodeValueToString<T extends RegistryValueType>(input: T) : string {
|
||||||
|
|
||||||
function resolveKey<ValueType extends RegistryValueType, DefaultType>(
|
function resolveKey<ValueType extends RegistryValueType, DefaultType>(
|
||||||
key: RegistryKey<ValueType>,
|
key: RegistryKey<ValueType>,
|
||||||
resolver: (key: string) => string | undefined,
|
resolver: (key: string) => string | undefined | null,
|
||||||
defaultValue: DefaultType
|
defaultValue: DefaultType
|
||||||
) : ValueType | DefaultType {
|
) : ValueType | DefaultType {
|
||||||
let value = resolver(key.key);
|
let value = resolver(key.key);
|
||||||
|
|
||||||
if(typeof value === "string") {
|
if(typeof value === "string") {
|
||||||
return decodeValueFromString(value, key.valueType);
|
return decodeValueFromString(value, key.valueType);
|
||||||
}
|
}
|
||||||
|
@ -104,41 +105,71 @@ function resolveKey<ValueType extends RegistryValueType, DefaultType>(
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UrlParameterParser {
|
||||||
|
private readonly url: URL;
|
||||||
|
|
||||||
|
constructor(url: URL) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getParameter(key: string) : string | undefined {
|
||||||
|
const value = this.url.searchParams.get(key);
|
||||||
|
if(value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
getValue<V extends RegistryValueType, DV>(key: RegistryKey<V>, defaultValue: DV) : V | DV;
|
||||||
|
getValue<V extends RegistryValueType>(key: ValuedRegistryKey<V>, defaultValue?: V) : V;
|
||||||
|
getValue<V extends RegistryValueType, DV>(key: RegistryKey<V> | ValuedRegistryKey<V>, defaultValue: DV) : V | DV {
|
||||||
|
if(arguments.length > 1) {
|
||||||
|
return resolveKey(key, key => this.getParameter(key), defaultValue);
|
||||||
|
} else if("defaultValue" in key) {
|
||||||
|
return resolveKey(key, key => this.getParameter(key), key.defaultValue);
|
||||||
|
} else {
|
||||||
|
throw tr("missing value");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UrlParameterBuilder {
|
||||||
|
private parameters = {};
|
||||||
|
|
||||||
|
setValue<V extends RegistryValueType>(key: RegistryKey<V>, value: V) {
|
||||||
|
if(value === undefined) {
|
||||||
|
delete this.parameters[key.key];
|
||||||
|
} else {
|
||||||
|
this.parameters[key.key] = encodeURIComponent(encodeValueToString(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
build() : string {
|
||||||
|
return Object.keys(this.parameters).map(key => `${key}=${this.parameters[key]}`).join("&");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switched appended to the application via the URL.
|
* Switched appended to the application via the URL.
|
||||||
* TODO: Passing native client switches
|
* TODO: Passing native client switches
|
||||||
*/
|
*/
|
||||||
export namespace AppParameters {
|
export namespace AppParameters {
|
||||||
const parameters = {};
|
export const Instance = new UrlParameterParser(new URL(window.location.href));
|
||||||
|
|
||||||
function parseParameters() {
|
|
||||||
let search;
|
|
||||||
if(window.opener && window.opener !== window) {
|
|
||||||
search = new URL(window.location.href).search;
|
|
||||||
} else {
|
|
||||||
search = location.search;
|
|
||||||
}
|
|
||||||
|
|
||||||
search.substr(1).split("&").forEach(part => {
|
|
||||||
let item = part.split("=");
|
|
||||||
parameters[item[0]] = decodeURIComponent(item[1]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getValue<V extends RegistryValueType, DV>(key: RegistryKey<V>, defaultValue: DV) : V | DV;
|
export function getValue<V extends RegistryValueType, DV>(key: RegistryKey<V>, defaultValue: DV) : V | DV;
|
||||||
export function getValue<V extends RegistryValueType>(key: ValuedRegistryKey<V>, defaultValue?: V) : V;
|
export function getValue<V extends RegistryValueType>(key: ValuedRegistryKey<V>, defaultValue?: V) : V;
|
||||||
export function getValue<V extends RegistryValueType, DV>(key: RegistryKey<V> | ValuedRegistryKey<V>, defaultValue: DV) : V | DV {
|
export function getValue<V extends RegistryValueType, DV>(key: RegistryKey<V> | ValuedRegistryKey<V>, defaultValue: DV) : V | DV {
|
||||||
if(arguments.length > 1) {
|
if(arguments.length > 1) {
|
||||||
return resolveKey(key, key => parameters[key], defaultValue);
|
return Instance.getValue(key, defaultValue);
|
||||||
} else if("defaultValue" in key) {
|
} else if("defaultValue" in key) {
|
||||||
return resolveKey(key, key => parameters[key], key.defaultValue);
|
return Instance.getValue(key);
|
||||||
} else {
|
} else {
|
||||||
throw tr("missing value");
|
throw tr("missing value");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parseParameters();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
(window as any).AppParameters = AppParameters;
|
(window as any).AppParameters = AppParameters;
|
||||||
|
|
||||||
export namespace AppParameters {
|
export namespace AppParameters {
|
||||||
|
@ -149,6 +180,12 @@ export namespace AppParameters {
|
||||||
description: "A target address to automatically connect to."
|
description: "A target address to automatically connect to."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const KEY_CONNECT_INVITE_REFERENCE: RegistryKey<string> = {
|
||||||
|
key: "cir",
|
||||||
|
fallbackKeys: ["connect-invite-reference"],
|
||||||
|
valueType: "string",
|
||||||
|
description: "The invite link used to generate the connect parameters"
|
||||||
|
};
|
||||||
|
|
||||||
export const KEY_CONNECT_NO_SINGLE_INSTANCE: ValuedRegistryKey<boolean> = {
|
export const KEY_CONNECT_NO_SINGLE_INSTANCE: ValuedRegistryKey<boolean> = {
|
||||||
key: "cnsi",
|
key: "cnsi",
|
||||||
|
@ -167,13 +204,13 @@ export namespace AppParameters {
|
||||||
|
|
||||||
export const KEY_CONNECT_NICKNAME: RegistryKey<string> = {
|
export const KEY_CONNECT_NICKNAME: RegistryKey<string> = {
|
||||||
key: "cn",
|
key: "cn",
|
||||||
fallbackKeys: ["connect_username"],
|
fallbackKeys: ["connect_username", "nickname"],
|
||||||
valueType: "string"
|
valueType: "string"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KEY_CONNECT_TOKEN: RegistryKey<string> = {
|
export const KEY_CONNECT_TOKEN: RegistryKey<string> = {
|
||||||
key: "ctk",
|
key: "ctk",
|
||||||
fallbackKeys: ["connect_token"],
|
fallbackKeys: ["connect_token", "connect-token", "token"],
|
||||||
valueType: "string",
|
valueType: "string",
|
||||||
description: "Token which will be used by default if the connection attempt succeeded."
|
description: "Token which will be used by default if the connection attempt succeeded."
|
||||||
};
|
};
|
||||||
|
@ -187,9 +224,17 @@ export namespace AppParameters {
|
||||||
|
|
||||||
export const KEY_CONNECT_SERVER_PASSWORD: RegistryKey<string> = {
|
export const KEY_CONNECT_SERVER_PASSWORD: RegistryKey<string> = {
|
||||||
key: "csp",
|
key: "csp",
|
||||||
fallbackKeys: ["connect_server_password"],
|
fallbackKeys: ["connect_server_password", "server-password"],
|
||||||
valueType: "string",
|
valueType: "string",
|
||||||
description: "The password (hashed) for the auto connect attempt."
|
description: "The password for the auto connect attempt."
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KEY_CONNECT_PASSWORDS_HASHED: ValuedRegistryKey<boolean> = {
|
||||||
|
key: "cph",
|
||||||
|
fallbackKeys: ["connect_passwords_hashed", "passwords-hashed"],
|
||||||
|
valueType: "boolean",
|
||||||
|
description: "Indicate whatever all passwords are hashed or not",
|
||||||
|
defaultValue: false
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KEY_CONNECT_CHANNEL: RegistryKey<string> = {
|
export const KEY_CONNECT_CHANNEL: RegistryKey<string> = {
|
||||||
|
@ -201,22 +246,28 @@ export namespace AppParameters {
|
||||||
|
|
||||||
export const KEY_CONNECT_CHANNEL_PASSWORD: RegistryKey<string> = {
|
export const KEY_CONNECT_CHANNEL_PASSWORD: RegistryKey<string> = {
|
||||||
key: "ccp",
|
key: "ccp",
|
||||||
fallbackKeys: ["connect_channel_password"],
|
fallbackKeys: ["connect_channel_password", "channel-password"],
|
||||||
valueType: "string",
|
valueType: "string",
|
||||||
description: "The target channel password (hashed) for the connect attempt."
|
description: "The target channel password (hashed) for the connect attempt."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const KEY_IPC_REMOTE_ADDRESS: RegistryKey<string> = {
|
export const KEY_IPC_APP_ADDRESS: RegistryKey<string> = {
|
||||||
key: "ipc-address",
|
key: "ipc-address",
|
||||||
valueType: "string",
|
valueType: "string",
|
||||||
description: "Address of the owner for IPC communication."
|
description: "Address of the apps IPC channel"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KEY_IPC_REMOTE_POPOUT_CHANNEL: RegistryKey<string> = {
|
export const KEY_IPC_CORE_PEER_ADDRESS: RegistryKey<string> = {
|
||||||
key: "ipc-channel",
|
key: "ipc-core-peer",
|
||||||
valueType: "string",
|
valueType: "string",
|
||||||
description: "The channel name of the popout channel communication id"
|
description: "Peer address of the apps core",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const KEY_MODAL_IDENTITY_CODE: RegistryKey<string> = {
|
||||||
|
key: "modal-identify",
|
||||||
|
valueType: "string",
|
||||||
|
description: "An authentication code used to register the new process as the modal"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const KEY_MODAL_TARGET: RegistryKey<string> = {
|
export const KEY_MODAL_TARGET: RegistryKey<string> = {
|
||||||
|
@ -708,6 +759,20 @@ export class Settings {
|
||||||
valueType: "boolean",
|
valueType: "boolean",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static readonly KEY_INVITE_SHORT_URL: ValuedRegistryKey<boolean> = {
|
||||||
|
key: "invite_short_url",
|
||||||
|
defaultValue: true,
|
||||||
|
description: "Enable/disable the short url for the invite menu",
|
||||||
|
valueType: "boolean",
|
||||||
|
};
|
||||||
|
|
||||||
|
static readonly KEY_INVITE_ADVANCED_ENABLED: ValuedRegistryKey<boolean> = {
|
||||||
|
key: "invite_advanced_enabled",
|
||||||
|
defaultValue: false,
|
||||||
|
description: "Enable/disable the advanced menu for the invite menu",
|
||||||
|
valueType: "boolean",
|
||||||
|
};
|
||||||
|
|
||||||
static readonly FN_LOG_ENABLED: (category: string) => RegistryKey<boolean> = category => {
|
static readonly FN_LOG_ENABLED: (category: string) => RegistryKey<boolean> = category => {
|
||||||
return {
|
return {
|
||||||
key: "log." + category.toLowerCase() + ".enabled",
|
key: "log." + category.toLowerCase() + ".enabled",
|
||||||
|
|
|
@ -0,0 +1,196 @@
|
||||||
|
import * as loader from "tc-loader";
|
||||||
|
import {ChannelMessage, getIpcInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC";
|
||||||
|
import {UrlParameterParser} from "tc-shared/settings";
|
||||||
|
import {IpcInviteInfo} from "tc-shared/text/bbcode/InviteDefinitions";
|
||||||
|
import {LogCategory, logError} from "tc-shared/log";
|
||||||
|
import {clientServiceInvite, clientServices} from "tc-shared/clientservice";
|
||||||
|
import {handleConnectRequest} from "tc-shared/main";
|
||||||
|
|
||||||
|
let ipcChannel: IPCChannel;
|
||||||
|
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
|
name: "Invite controller init",
|
||||||
|
function: async () => {
|
||||||
|
ipcChannel = getIpcInstance().createChannel("invite-info");
|
||||||
|
ipcChannel.messageHandler = handleIpcMessage;
|
||||||
|
},
|
||||||
|
priority: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
type QueryCacheEntry = { result, finished: boolean, timeout: number };
|
||||||
|
let queryCache: {[key: string]: QueryCacheEntry} = {};
|
||||||
|
|
||||||
|
let executingPendingInvites = false;
|
||||||
|
const pendingInviteQueries: (() => Promise<void>)[] = [];
|
||||||
|
|
||||||
|
function handleIpcMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
||||||
|
if(message.type === "query") {
|
||||||
|
const linkId = message.data["linkId"];
|
||||||
|
|
||||||
|
if(queryCache[linkId]) {
|
||||||
|
if(queryCache[linkId].finished) {
|
||||||
|
ipcChannel?.sendMessage("query-result", { linkId, result: queryCache[linkId].result }, remoteId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Query already enqueued. */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = queryCache[linkId] = {
|
||||||
|
finished: false,
|
||||||
|
result: undefined,
|
||||||
|
timeout: 0
|
||||||
|
} as QueryCacheEntry;
|
||||||
|
|
||||||
|
entry.timeout = setTimeout(() => {
|
||||||
|
if(queryCache[linkId] === entry) {
|
||||||
|
delete queryCache[linkId];
|
||||||
|
}
|
||||||
|
}, 30 * 60 * 1000);
|
||||||
|
|
||||||
|
pendingInviteQueries.push(() => queryInviteLink(linkId));
|
||||||
|
invokeLinkQueries();
|
||||||
|
} else if(message.type === "connect") {
|
||||||
|
const connectParameterString = message.data.connectParameters;
|
||||||
|
const serverAddress = message.data.serverAddress;
|
||||||
|
const serverUniqueId = message.data.serverUniqueId;
|
||||||
|
|
||||||
|
handleConnectRequest(serverAddress, serverUniqueId, new UrlParameterParser(new URL(`https://localhost/?${connectParameterString}`))).then(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invokeLinkQueries() {
|
||||||
|
if(executingPendingInvites) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
executingPendingInvites = true;
|
||||||
|
executePendingInvites().catch(error => {
|
||||||
|
logError(LogCategory.GENERAL, tr("Failed to execute pending invite queries: %o"), error);
|
||||||
|
executingPendingInvites = false;
|
||||||
|
if(pendingInviteQueries.length > 0) {
|
||||||
|
invokeLinkQueries();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executePendingInvites() {
|
||||||
|
while(pendingInviteQueries.length > 0) {
|
||||||
|
const invite = pendingInviteQueries.pop_front();
|
||||||
|
await invite();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
executingPendingInvites = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryInviteLink(linkId: string) {
|
||||||
|
let result: IpcInviteInfo;
|
||||||
|
try {
|
||||||
|
result = await doQueryInviteLink(linkId);
|
||||||
|
} catch (error) {
|
||||||
|
logError(LogCategory.GENERAL, tr("Failed to query invite link info: %o"), error);
|
||||||
|
result = {
|
||||||
|
status: "error",
|
||||||
|
message: tr("lookup the console for details")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if(queryCache[linkId]) {
|
||||||
|
queryCache[linkId].finished = true;
|
||||||
|
queryCache[linkId].result = result;
|
||||||
|
} else {
|
||||||
|
const entry = queryCache[linkId] = {
|
||||||
|
finished: true,
|
||||||
|
result: result,
|
||||||
|
timeout: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
entry.timeout = setTimeout(() => {
|
||||||
|
if(queryCache[linkId] === entry) {
|
||||||
|
delete queryCache[linkId];
|
||||||
|
}
|
||||||
|
}, 30 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcChannel?.sendMessage("query-result", { linkId, result });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doQueryInviteLink(linkId: string) : Promise<IpcInviteInfo> {
|
||||||
|
if(!clientServices.isSessionInitialized()) {
|
||||||
|
const connectAwait = new Promise(resolve => {
|
||||||
|
clientServices.awaitSession().then(() => resolve(true));
|
||||||
|
setTimeout(() => resolve(false), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
if(!await connectAwait) {
|
||||||
|
return { status: "error", message: tr("Client service not connected") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: Cache if the client has ever seen the view! */
|
||||||
|
const result = await clientServiceInvite.queryInviteLink(linkId, true);
|
||||||
|
if(result.status === "error") {
|
||||||
|
switch (result.result.type) {
|
||||||
|
case "InviteKeyExpired":
|
||||||
|
return { status: "expired" };
|
||||||
|
|
||||||
|
case "InviteKeyNotFound":
|
||||||
|
return { status: "not-found" };
|
||||||
|
|
||||||
|
default:
|
||||||
|
logError(LogCategory.GENERAL, tr("Failed to query invite link info for %s: %o"), linkId, result.result);
|
||||||
|
return { status: "error", message: tra("Server query error ({})", result.result.type) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteInfo = result.unwrap();
|
||||||
|
|
||||||
|
const serverName = inviteInfo.propertiesInfo["server-name"];
|
||||||
|
if(typeof serverName !== "string") {
|
||||||
|
return { status: "error", message: tr("Missing server name") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUniqueId = inviteInfo.propertiesInfo["server-unique-id"];
|
||||||
|
if(typeof serverUniqueId !== "string") {
|
||||||
|
return { status: "error", message: tr("Missing server unique id") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverAddress = inviteInfo.propertiesConnect["server-address"];
|
||||||
|
if(typeof serverAddress !== "string") {
|
||||||
|
return { status: "error", message: tr("Missing server address") };
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParameters = {};
|
||||||
|
{
|
||||||
|
urlParameters["cir"] = linkId;
|
||||||
|
|
||||||
|
urlParameters["cn"] = inviteInfo.propertiesConnect["nickname"];
|
||||||
|
urlParameters["ctk"] = inviteInfo.propertiesConnect["token"];
|
||||||
|
urlParameters["cc"] = inviteInfo.propertiesConnect["channel"];
|
||||||
|
|
||||||
|
urlParameters["cph"] = inviteInfo.propertiesConnect["passwords-hashed"];
|
||||||
|
urlParameters["csp"] = inviteInfo.propertiesConnect["server-password"];
|
||||||
|
urlParameters["ccp"] = inviteInfo.propertiesConnect["channel-password"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlParameterString = Object.keys(urlParameters)
|
||||||
|
.filter(key => typeof urlParameters[key] === "string" && urlParameters[key].length > 0)
|
||||||
|
.map(key => `${key}=${encodeURIComponent(urlParameters[key])}`)
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
return {
|
||||||
|
linkId: linkId,
|
||||||
|
|
||||||
|
status: "success",
|
||||||
|
expireTimestamp: inviteInfo.timestampExpired,
|
||||||
|
|
||||||
|
serverUniqueId: serverUniqueId,
|
||||||
|
serverName: serverName,
|
||||||
|
serverAddress: serverAddress,
|
||||||
|
|
||||||
|
channelName: inviteInfo.propertiesInfo["channel-name"],
|
||||||
|
|
||||||
|
connectParameters: urlParameterString,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
|
||||||
|
export type IpcInviteInfoLoaded = {
|
||||||
|
linkId: string,
|
||||||
|
|
||||||
|
serverAddress: string,
|
||||||
|
serverUniqueId: string,
|
||||||
|
serverName: string,
|
||||||
|
|
||||||
|
connectParameters: string,
|
||||||
|
|
||||||
|
channelId?: number,
|
||||||
|
channelName?: string,
|
||||||
|
|
||||||
|
expireTimestamp: number | 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IpcInviteInfo = (
|
||||||
|
{
|
||||||
|
status: "success",
|
||||||
|
} & IpcInviteInfoLoaded
|
||||||
|
) | {
|
||||||
|
status: "error",
|
||||||
|
message: string
|
||||||
|
} | {
|
||||||
|
status: "not-found" | "expired"
|
||||||
|
}
|
|
@ -0,0 +1,140 @@
|
||||||
|
@import "../../../css/static/mixin";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
margin-top: .25em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
height: 3em;
|
||||||
|
|
||||||
|
background: #454545;
|
||||||
|
border-radius: .2em;
|
||||||
|
|
||||||
|
padding: .2em .3em;
|
||||||
|
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.info, &.loading {
|
||||||
|
.left, .right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
margin-left: 1em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-grow: 0;
|
||||||
|
width: 6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
|
||||||
|
height: 2.4em;
|
||||||
|
align-self: center;
|
||||||
|
|
||||||
|
min-width: 1em;
|
||||||
|
max-width: 20em;
|
||||||
|
|
||||||
|
line-height: 1.2em;
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.joinServer {
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-grow: 0;
|
||||||
|
|
||||||
|
min-height: 0;
|
||||||
|
height: 1em;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channelName {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
color: #b3b3b3;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
max-height: 1em;
|
||||||
|
|
||||||
|
.name {
|
||||||
|
margin-left: .25em;
|
||||||
|
align-self: center;
|
||||||
|
@include text-dotdotdot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.serverName {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #b3b3b3;
|
||||||
|
|
||||||
|
&.large {
|
||||||
|
max-height: 2.4em;
|
||||||
|
overflow: hidden;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.short {
|
||||||
|
max-height: 1.2em;
|
||||||
|
@include text-dotdotdot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.containerError {
|
||||||
|
height: 2.4em;
|
||||||
|
color: #cf1717;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
|
||||||
|
&.noTitle {
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.title {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-grow: 0;
|
||||||
|
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
max-height: 2.4em;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
line-height: 1.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,227 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {IpcInviteInfo, IpcInviteInfoLoaded} from "tc-shared/text/bbcode/InviteDefinitions";
|
||||||
|
import {ChannelMessage, getIpcInstance, IPCChannel} from "tc-shared/ipc/BrowserIPC";
|
||||||
|
import * as loader from "tc-loader";
|
||||||
|
import {AppParameters} from "tc-shared/settings";
|
||||||
|
import {useEffect, useState} from "react";
|
||||||
|
import _ = require("lodash");
|
||||||
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
|
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||||
|
import {SimpleUrlRenderer} from "tc-shared/text/bbcode/url";
|
||||||
|
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||||
|
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
|
||||||
|
import {ClientIcon} from "svg-sprites/client-icons";
|
||||||
|
|
||||||
|
const cssStyle = require("./InviteRenderer.scss");
|
||||||
|
const kInviteUrlRegex = /^(https:\/\/)?(teaspeak.de\/|join.teaspeak.de\/(invite\/)?)([a-zA-Z0-9]{4})$/gm;
|
||||||
|
|
||||||
|
export function isInviteLink(url: string) : boolean {
|
||||||
|
kInviteUrlRegex.lastIndex = 0;
|
||||||
|
return !!url.match(kInviteUrlRegex);
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalInviteInfo = IpcInviteInfo | { status: "loading" };
|
||||||
|
type InviteCacheEntry = { status: LocalInviteInfo, timeout: number };
|
||||||
|
|
||||||
|
const localInviteCache: { [key: string]: InviteCacheEntry } = {};
|
||||||
|
const localInviteCallbacks: { [key: string]: (() => void)[] } = {};
|
||||||
|
|
||||||
|
const useInviteLink = (linkId: string): LocalInviteInfo => {
|
||||||
|
if(!localInviteCache[linkId]) {
|
||||||
|
localInviteCache[linkId] = { status: { status: "loading" }, timeout: setTimeout(() => delete localInviteCache[linkId], 60 * 1000) };
|
||||||
|
ipcChannel?.sendMessage("query", { linkId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ value, setValue ] = useState(localInviteCache[linkId].status);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if(typeof localInviteCache[linkId]?.status === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!_.isEqual(value, localInviteCache[linkId].status)) {
|
||||||
|
setValue(localInviteCache[linkId].status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callback = () => setValue(localInviteCache[linkId].status);
|
||||||
|
(localInviteCallbacks[linkId] || (localInviteCallbacks[linkId] = [])).push(callback);
|
||||||
|
return () => localInviteCallbacks[linkId]?.remove(callback);
|
||||||
|
}, [linkId]);
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadedInviteRenderer = React.memo((props: { info: IpcInviteInfoLoaded }) => {
|
||||||
|
let joinButton = (
|
||||||
|
<div className={cssStyle.right}>
|
||||||
|
<Button
|
||||||
|
color={"green"}
|
||||||
|
type={"small"}
|
||||||
|
onClick={() => {
|
||||||
|
ipcChannel?.sendMessage("connect", {
|
||||||
|
connectParameters: props.info.connectParameters,
|
||||||
|
serverAddress: props.info.serverAddress,
|
||||||
|
serverUniqueId: props.info.serverUniqueId,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Translatable>Join Now!</Translatable>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [, setRevision ] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if(props.info.expireTimestamp === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = props.info.expireTimestamp - (Date.now() / 1000);
|
||||||
|
if(timeout <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => setRevision(Date.now()));
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if(props.info.expireTimestamp > 0 && Date.now() / 1000 >= props.info.expireTimestamp) {
|
||||||
|
return (
|
||||||
|
<InviteErrorRenderer noTitle={true} key={"expired"}>
|
||||||
|
<Translatable>Link expired</Translatable>
|
||||||
|
</InviteErrorRenderer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(props.info.channelName) {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.container + " " + cssStyle.info} key={"with-channel"}>
|
||||||
|
<div className={cssStyle.left}>
|
||||||
|
<div className={cssStyle.channelName} title={props.info.channelName}>
|
||||||
|
<ClientIconRenderer icon={ClientIcon.ChannelGreenSubscribed} />
|
||||||
|
<div className={cssStyle.name}>{props.info.channelName}</div>
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.serverName + " " + cssStyle.short} title={props.info.serverName}>{props.info.serverName}</div>
|
||||||
|
</div>
|
||||||
|
{joinButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.container + " " + cssStyle.info} key={"without-channel"}>
|
||||||
|
<div className={cssStyle.left}>
|
||||||
|
<div className={cssStyle.joinServer}><Translatable>Join server</Translatable></div>
|
||||||
|
<div className={cssStyle.serverName + " " + cssStyle.large} title={props.info.serverName}>{props.info.serverName}</div>
|
||||||
|
</div>
|
||||||
|
{joinButton}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const InviteErrorRenderer = (props: { children, noTitle?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.container + " " + cssStyle.error}>
|
||||||
|
<div className={cssStyle.containerError + " " + (props.noTitle ? cssStyle.noTitle : "")}>
|
||||||
|
<div className={cssStyle.title}>
|
||||||
|
<Translatable>Failed to load invite key:</Translatable>
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.message}>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const InviteLoadingRenderer = () => {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.container + " " + cssStyle.loading}>
|
||||||
|
<div className={cssStyle.left}>
|
||||||
|
<div className={cssStyle.loading}>
|
||||||
|
<Translatable>Loading,<br /> please wait</Translatable> <LoadingDots />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.right}>
|
||||||
|
<Button
|
||||||
|
color={"green"}
|
||||||
|
type={"small"}
|
||||||
|
disabled={true}
|
||||||
|
>
|
||||||
|
<Translatable>Join now!</Translatable>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InviteLinkRenderer = (props: { url: string, handlerId: string }) => {
|
||||||
|
kInviteUrlRegex.lastIndex = 0;
|
||||||
|
const inviteLinkId = kInviteUrlRegex.exec(props.url)[4];
|
||||||
|
|
||||||
|
const linkInfo = useInviteLink(inviteLinkId);
|
||||||
|
|
||||||
|
let body;
|
||||||
|
switch (linkInfo.status) {
|
||||||
|
case "success":
|
||||||
|
body = <LoadedInviteRenderer info={linkInfo} key={"loaded"} />;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "loading":
|
||||||
|
body = <InviteLoadingRenderer key={"loading"} />;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
body = (
|
||||||
|
<InviteErrorRenderer key={"error"}>
|
||||||
|
{linkInfo.message}
|
||||||
|
</InviteErrorRenderer>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "expired":
|
||||||
|
body = (
|
||||||
|
<InviteErrorRenderer key={"expired"} noTitle={true}>
|
||||||
|
<Translatable>Invite link expired</Translatable>
|
||||||
|
</InviteErrorRenderer>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "not-found":
|
||||||
|
body = (
|
||||||
|
<InviteErrorRenderer key={"expired"} noTitle={true}>
|
||||||
|
<Translatable>Unknown invite link</Translatable>
|
||||||
|
</InviteErrorRenderer>
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<SimpleUrlRenderer target={props.url}>{props.url}</SimpleUrlRenderer>
|
||||||
|
{body}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let ipcChannel: IPCChannel;
|
||||||
|
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
|
name: "Invite controller init",
|
||||||
|
function: async () => {
|
||||||
|
ipcChannel = getIpcInstance().createCoreControlChannel("invite-info");
|
||||||
|
ipcChannel.messageHandler = handleIpcMessage;
|
||||||
|
},
|
||||||
|
priority: 10
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function handleIpcMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
||||||
|
if(message.type === "query-result") {
|
||||||
|
if(!localInviteCache[message.data.linkId]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
localInviteCache[message.data.linkId].status = message.data.result;
|
||||||
|
localInviteCallbacks[message.data.linkId]?.forEach(callback => callback());
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,11 +13,13 @@ import "./highlight";
|
||||||
import "./youtube";
|
import "./youtube";
|
||||||
import "./url";
|
import "./url";
|
||||||
import "./image";
|
import "./image";
|
||||||
|
import {ElementRenderer, Renderer} from "vendor/xbbcode/renderer/base";
|
||||||
|
import {TextElement} from "vendor/xbbcode/elements";
|
||||||
|
|
||||||
export let BBCodeHandlerContext: Context<string>;
|
export let BBCodeHandlerContext: Context<string>;
|
||||||
|
|
||||||
export const rendererText = new TextRenderer();
|
export const rendererText = new TextRenderer();
|
||||||
export const rendererReact = new ReactRenderer();
|
export const rendererReact = new ReactRenderer(true);
|
||||||
export const rendererHTML = new HTMLRenderer(rendererReact);
|
export const rendererHTML = new HTMLRenderer(rendererReact);
|
||||||
|
|
||||||
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
|
@ -8,6 +8,7 @@ import ReactRenderer from "vendor/xbbcode/renderer/react";
|
||||||
import {rendererReact, rendererText, BBCodeHandlerContext} from "tc-shared/text/bbcode/renderer";
|
import {rendererReact, rendererText, BBCodeHandlerContext} from "tc-shared/text/bbcode/renderer";
|
||||||
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
|
import {ClientTag} from "tc-shared/ui/tree/EntryTags";
|
||||||
import {isYoutubeLink, YoutubeRenderer} from "tc-shared/text/bbcode/youtube";
|
import {isYoutubeLink, YoutubeRenderer} from "tc-shared/text/bbcode/youtube";
|
||||||
|
import {InviteLinkRenderer, isInviteLink} from "tc-shared/text/bbcode/InviteRenderer";
|
||||||
|
|
||||||
function spawnUrlContextMenu(pageX: number, pageY: number, target: string) {
|
function spawnUrlContextMenu(pageX: number, pageY: number, target: string) {
|
||||||
contextmenu.spawn_context_menu(pageX, pageY, {
|
contextmenu.spawn_context_menu(pageX, pageY, {
|
||||||
|
@ -35,6 +36,17 @@ function spawnUrlContextMenu(pageX: number, pageY: number, target: string) {
|
||||||
|
|
||||||
const ClientUrlRegex = /client:\/\/([0-9]+)\/([-A-Za-z0-9+/=]+)~/g;
|
const ClientUrlRegex = /client:\/\/([0-9]+)\/([-A-Za-z0-9+/=]+)~/g;
|
||||||
|
|
||||||
|
export const SimpleUrlRenderer = (props: { target: string, children }) => {
|
||||||
|
return (
|
||||||
|
<a className={"xbbcode xbbcode-tag-url"} href={props.target} target={"_blank"} onContextMenu={event => {
|
||||||
|
event.preventDefault();
|
||||||
|
spawnUrlContextMenu(event.pageX, event.pageY, props.target);
|
||||||
|
}}>
|
||||||
|
{props.children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
|
loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
name: "XBBCode code tag init",
|
name: "XBBCode code tag init",
|
||||||
function: async () => {
|
function: async () => {
|
||||||
|
@ -65,23 +77,26 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
const clientDatabaseId = parseInt(clientData[1]);
|
const clientDatabaseId = parseInt(clientData[1]);
|
||||||
const clientUniqueId = clientDatabaseId[2];
|
const clientUniqueId = clientDatabaseId[2];
|
||||||
|
|
||||||
return <ClientTag
|
return (
|
||||||
|
<ClientTag
|
||||||
key={"er-" + ++reactId}
|
key={"er-" + ++reactId}
|
||||||
clientName={rendererText.renderContent(element).join("")}
|
clientName={rendererText.renderContent(element).join("")}
|
||||||
clientUniqueId={clientUniqueId}
|
clientUniqueId={clientUniqueId}
|
||||||
clientDatabaseId={clientDatabaseId > 0 ? clientDatabaseId : undefined}
|
clientDatabaseId={clientDatabaseId > 0 ? clientDatabaseId : undefined}
|
||||||
handlerId={handlerId}
|
handlerId={handlerId}
|
||||||
/>;
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(isInviteLink(target)) {
|
||||||
|
return <InviteLinkRenderer key={"er-" + ++reactId} handlerId={handlerId} url={target} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<a key={"er-" + ++reactId} className={"xbbcode xbbcode-tag-url"} href={target} target={"_blank"} onContextMenu={event => {
|
<SimpleUrlRenderer key={"er-" + ++reactId} target={target}>
|
||||||
event.preventDefault();
|
|
||||||
spawnUrlContextMenu(event.pageX, event.pageY, target);
|
|
||||||
}}>
|
|
||||||
{renderer.renderContent(element)}
|
{renderer.renderContent(element)}
|
||||||
</a>
|
</SimpleUrlRenderer>
|
||||||
);
|
);
|
||||||
|
|
||||||
if(isYoutubeLink(target)) {
|
if(isYoutubeLink(target)) {
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {ClientIcon} from "svg-sprites/client-icons";
|
||||||
import { tr } from "tc-shared/i18n/localize";
|
import { tr } from "tc-shared/i18n/localize";
|
||||||
import {EventChannelData} from "tc-shared/connectionlog/Definitions";
|
import {EventChannelData} from "tc-shared/connectionlog/Definitions";
|
||||||
import {spawnChannelEditNew} from "tc-shared/ui/modal/channel-edit/Controller";
|
import {spawnChannelEditNew} from "tc-shared/ui/modal/channel-edit/Controller";
|
||||||
|
import {spawnInviteGenerator} from "tc-shared/ui/modal/invite/Controller";
|
||||||
|
|
||||||
export enum ChannelType {
|
export enum ChannelType {
|
||||||
PERMANENT,
|
PERMANENT,
|
||||||
|
@ -456,7 +457,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
name: bold(tr("Switch to channel")),
|
name: bold(tr("Switch to channel")),
|
||||||
callback: () => this.joinChannel(),
|
callback: () => this.joinChannel(),
|
||||||
visible: this !== this.channelTree.client.getClient()?.currentChannel()
|
visible: this !== this.channelTree.client.getClient()?.currentChannel()
|
||||||
},{
|
}, {
|
||||||
type: contextmenu.MenuEntryType.ENTRY,
|
type: contextmenu.MenuEntryType.ENTRY,
|
||||||
icon_class: "client-filetransfer",
|
icon_class: "client-filetransfer",
|
||||||
name: bold(tr("Open channel file browser")),
|
name: bold(tr("Open channel file browser")),
|
||||||
|
@ -482,6 +483,11 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
openChannelInfo(this);
|
openChannelInfo(this);
|
||||||
},
|
},
|
||||||
icon_class: "client-about"
|
icon_class: "client-about"
|
||||||
|
}, {
|
||||||
|
type: contextmenu.MenuEntryType.ENTRY,
|
||||||
|
name: tr("Invite People"),
|
||||||
|
callback: () => spawnInviteGenerator(this),
|
||||||
|
icon_class: ClientIcon.InviteBuddy
|
||||||
},
|
},
|
||||||
...(() => {
|
...(() => {
|
||||||
const local_client = this.channelTree.client.getClient();
|
const local_client = this.channelTree.client.getClient();
|
||||||
|
@ -687,42 +693,51 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
return ChannelType.TEMPORARY;
|
return ChannelType.TEMPORARY;
|
||||||
}
|
}
|
||||||
|
|
||||||
joinChannel(ignorePasswordFlag?: boolean) {
|
async joinChannel(ignorePasswordFlag?: boolean) : Promise<boolean> {
|
||||||
if(this.channelTree.client.getClient().currentChannel() === this)
|
if(this.channelTree.client.getClient().currentChannel() === this) {
|
||||||
return;
|
return true;
|
||||||
|
|
||||||
if(this.properties.channel_flag_password === true && !this.cachedPasswordHash && !ignorePasswordFlag) {
|
|
||||||
this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD).then(() => {
|
|
||||||
this.joinChannel(true);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.channelTree.client.serverConnection.send_command("clientmove", {
|
if(this.properties.channel_flag_password === true && !this.cachedPasswordHash && !ignorePasswordFlag) {
|
||||||
|
const password = await this.requestChannelPassword(PermissionType.B_CHANNEL_JOIN_IGNORE_PASSWORD);
|
||||||
|
if(typeof password === "undefined") {
|
||||||
|
/* aborted */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.channelTree.client.serverConnection.send_command("clientmove", {
|
||||||
"clid": this.channelTree.client.getClientId(),
|
"clid": this.channelTree.client.getClientId(),
|
||||||
"cid": this.getChannelId(),
|
"cid": this.getChannelId(),
|
||||||
"cpw": this.cachedPasswordHash || ""
|
"cpw": this.cachedPasswordHash || ""
|
||||||
}).then(() => {
|
});
|
||||||
this.channelTree.client.sound.play(Sound.CHANNEL_JOINED);
|
this.channelTree.client.sound.play(Sound.CHANNEL_JOINED);
|
||||||
}).catch(error => {
|
return true;
|
||||||
|
} catch (error) {
|
||||||
if(error instanceof CommandResult) {
|
if(error instanceof CommandResult) {
|
||||||
if(error.id == ErrorCode.CHANNEL_INVALID_PASSWORD) { //Invalid password
|
if(error.id == ErrorCode.CHANNEL_INVALID_PASSWORD) { //Invalid password
|
||||||
this.invalidateCachedPassword();
|
this.invalidateCachedPassword();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestChannelPassword(ignorePermission: PermissionType) : Promise<{ hash: string } | undefined> {
|
async requestChannelPassword(ignorePermission: PermissionType) : Promise<{ hash: string } | undefined> {
|
||||||
if(this.cachedPasswordHash)
|
if(this.cachedPasswordHash) {
|
||||||
return { hash: this.cachedPasswordHash };
|
return { hash: this.cachedPasswordHash };
|
||||||
|
}
|
||||||
|
|
||||||
if(this.channelTree.client.permissions.neededPermission(ignorePermission).granted(1))
|
if(this.channelTree.client.permissions.neededPermission(ignorePermission).granted(1)) {
|
||||||
return { hash: "having ignore permission" };
|
return { hash: "having ignore permission" };
|
||||||
|
}
|
||||||
|
|
||||||
const password = await new Promise(resolve => createInputModal(tr("Channel password"), tr("Channel password:"), () => true, resolve).open())
|
const password = await new Promise(resolve => createInputModal(tr("Channel password"), tr("Channel password:"), () => true, resolve).open())
|
||||||
if(typeof(password) !== "string" || !password)
|
if(typeof(password) !== "string" || !password) {
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hash = await hashPassword(password);
|
const hash = await hashPassword(password);
|
||||||
this.cachedPasswordHash = hash;
|
this.cachedPasswordHash = hash;
|
||||||
|
@ -735,7 +750,11 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" });
|
this.events.fire("notify_cached_password_updated", { reason: "password-miss-match" });
|
||||||
}
|
}
|
||||||
|
|
||||||
cached_password() { return this.cachedPasswordHash; }
|
setCachedHashedPassword(passwordHash: string) {
|
||||||
|
this.cachedPasswordHash = passwordHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCachedPasswordHash() { return this.cachedPasswordHash; }
|
||||||
|
|
||||||
async updateSubscribeMode() {
|
async updateSubscribeMode() {
|
||||||
let shouldBeSubscribed = false;
|
let shouldBeSubscribed = false;
|
||||||
|
@ -839,7 +858,7 @@ export class ChannelEntry extends ChannelTreeEntry<ChannelEvents> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribed = this.isSubscribed();
|
const subscribed = this.isSubscribed();
|
||||||
if (this.properties.channel_flag_password === true && !this.cached_password()) {
|
if (this.properties.channel_flag_password === true && !this.getCachedPasswordHash()) {
|
||||||
return subscribed ? ClientIcon.ChannelYellowSubscribed : ClientIcon.ChannelYellow;
|
return subscribed ? ClientIcon.ChannelYellowSubscribed : ClientIcon.ChannelYellow;
|
||||||
} else if (!this.properties.channel_flag_maxclients_unlimited && this.clients().length >= this.properties.channel_maxclients) {
|
} else if (!this.properties.channel_flag_maxclients_unlimited && this.clients().length >= this.properties.channel_maxclients) {
|
||||||
return subscribed ? ClientIcon.ChannelRedSubscribed : ClientIcon.ChannelRed;
|
return subscribed ? ClientIcon.ChannelRedSubscribed : ClientIcon.ChannelRed;
|
||||||
|
|
|
@ -270,6 +270,19 @@ export class ChannelTree {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a channel by its path
|
||||||
|
*/
|
||||||
|
resolveChannelPath(target: string) : ChannelEntry | undefined {
|
||||||
|
if(target.match(/^\/[0-9]+$/)) {
|
||||||
|
const channelId = parseInt(target.substring(1));
|
||||||
|
return this.findChannel(channelId);
|
||||||
|
} else {
|
||||||
|
/* TODO: Resolve the whole channel path */
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
find_channel_by_name(name: string, parent?: ChannelEntry, force_parent: boolean = true) : ChannelEntry | undefined {
|
find_channel_by_name(name: string, parent?: ChannelEntry, force_parent: boolean = true) : ChannelEntry | undefined {
|
||||||
for(let index = 0; index < this.channels.length; index++)
|
for(let index = 0; index < this.channels.length; index++)
|
||||||
if(this.channels[index].channelName() == name && (!force_parent || parent == this.channels[index].parent))
|
if(this.channels[index].channelName() == name && (!force_parent || parent == this.channels[index].parent))
|
||||||
|
|
|
@ -95,7 +95,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
name: "entry tags",
|
name: "entry tags",
|
||||||
priority: 10,
|
priority: 10,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
const channel = getIpcInstance().createChannel(undefined, kIpcChannel);
|
const channel = getIpcInstance().createChannel(kIpcChannel);
|
||||||
channel.messageHandler = (_remoteId, _broadcast, message) => handleIpcMessage(message.type, message.data);
|
channel.messageHandler = (_remoteId, _broadcast, message) => handleIpcMessage(message.type, message.data);
|
||||||
}
|
}
|
||||||
});
|
});
|
|
@ -13,6 +13,7 @@ import {spawnAvatarList} from "../ui/modal/ModalAvatarList";
|
||||||
import {Registry} from "../events";
|
import {Registry} from "../events";
|
||||||
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "./ChannelTreeEntry";
|
import {ChannelTreeEntry, ChannelTreeEntryEvents} from "./ChannelTreeEntry";
|
||||||
import { tr } from "tc-shared/i18n/localize";
|
import { tr } from "tc-shared/i18n/localize";
|
||||||
|
import {spawnInviteGenerator} from "tc-shared/ui/modal/invite/Controller";
|
||||||
|
|
||||||
export class ServerProperties {
|
export class ServerProperties {
|
||||||
virtualserver_host: string = "";
|
virtualserver_host: string = "";
|
||||||
|
@ -209,7 +210,7 @@ export class ServerEntry extends ChannelTreeEntry<ServerEvents> {
|
||||||
type: contextmenu.MenuEntryType.ENTRY,
|
type: contextmenu.MenuEntryType.ENTRY,
|
||||||
icon_class: "client-invite_buddy",
|
icon_class: "client-invite_buddy",
|
||||||
name: tr("Invite buddy"),
|
name: tr("Invite buddy"),
|
||||||
callback: () => spawnInviteEditor(this.channelTree.client)
|
callback: () => spawnInviteGenerator(this)
|
||||||
}, {
|
}, {
|
||||||
type: contextmenu.MenuEntryType.HR,
|
type: contextmenu.MenuEntryType.HR,
|
||||||
name: ''
|
name: ''
|
||||||
|
|
|
@ -432,7 +432,7 @@ const ServerGroupRenderer = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfoBlock clientIcon={ClientIcon.PermissionChannel} valueClass={cssStyle.groups}>
|
<InfoBlock clientIcon={ClientIcon.PermissionChannel} valueClass={cssStyle.groups}>
|
||||||
<Translatable>Channel group</Translatable>
|
<Translatable>Server groups</Translatable>
|
||||||
<>{body}</>
|
<>{body}</>
|
||||||
</InfoBlock>
|
</InfoBlock>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {Registry, RegistryMap} from "tc-shared/events";
|
import {IpcRegistryDescription, Registry} from "tc-shared/events";
|
||||||
import {AbstractConversationUiEvents} from "./AbstractConversationDefinitions";
|
import {AbstractConversationUiEvents} from "./AbstractConversationDefinitions";
|
||||||
import {ConversationPanel} from "./AbstractConversationRenderer";
|
import {ConversationPanel} from "./AbstractConversationRenderer";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
@ -8,11 +8,11 @@ class PopoutConversationRenderer extends AbstractModal {
|
||||||
private readonly events: Registry<AbstractConversationUiEvents>;
|
private readonly events: Registry<AbstractConversationUiEvents>;
|
||||||
private readonly userData: any;
|
private readonly userData: any;
|
||||||
|
|
||||||
constructor(registryMap: RegistryMap, userData: any) {
|
constructor(events: IpcRegistryDescription<AbstractConversationUiEvents>, userData: any) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.userData = userData;
|
this.userData = userData;
|
||||||
this.events = registryMap["default"] as any;
|
this.events = Registry.fromIpcDescription(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderBody() {
|
renderBody() {
|
||||||
|
|
|
@ -26,8 +26,8 @@ const kRegexDomain = /^(localhost|((([a-zA-Z0-9_-]{0,63}\.){0,253})?[a-zA-Z0-9_-
|
||||||
|
|
||||||
export type ConnectParameters = {
|
export type ConnectParameters = {
|
||||||
targetAddress: string,
|
targetAddress: string,
|
||||||
targetPassword?: string,
|
serverPassword?: string,
|
||||||
targetPasswordHashed?: boolean,
|
serverPasswordHashed?: boolean,
|
||||||
|
|
||||||
nickname: string,
|
nickname: string,
|
||||||
nicknameSpecified: boolean,
|
nicknameSpecified: boolean,
|
||||||
|
@ -38,6 +38,7 @@ export type ConnectParameters = {
|
||||||
|
|
||||||
defaultChannel?: string | number,
|
defaultChannel?: string | number,
|
||||||
defaultChannelPassword?: string,
|
defaultChannelPassword?: string,
|
||||||
|
defaultChannelPasswordHashed?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
class ConnectController {
|
class ConnectController {
|
||||||
|
@ -272,8 +273,8 @@ class ConnectController {
|
||||||
|
|
||||||
profile: this.currentProfile,
|
profile: this.currentProfile,
|
||||||
|
|
||||||
targetPassword: this.currentPassword,
|
serverPassword: this.currentPassword,
|
||||||
targetPasswordHashed: this.currentPasswordHashed
|
serverPasswordHashed: this.currentPasswordHashed
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,336 @@
|
||||||
|
import {ChannelEntry} from "tc-shared/tree/Channel";
|
||||||
|
import {ServerAddress, ServerEntry} from "tc-shared/tree/Server";
|
||||||
|
import {Registry} from "tc-events";
|
||||||
|
import {InviteChannel, InviteUiEvents, InviteUiVariables} from "tc-shared/ui/modal/invite/Definitions";
|
||||||
|
import {createIpcUiVariableProvider, IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
|
||||||
|
import {spawnModal} from "tc-shared/ui/react-elements/modal";
|
||||||
|
import {ConnectionHandler} from "tc-shared/ConnectionHandler";
|
||||||
|
import {hashPassword} from "tc-shared/utils/helpers";
|
||||||
|
import {LogCategory, logError} from "tc-shared/log";
|
||||||
|
import {clientServiceInvite, clientServices} from "tc-shared/clientservice";
|
||||||
|
import {Settings, settings} from "tc-shared/settings";
|
||||||
|
|
||||||
|
class InviteController {
|
||||||
|
readonly connection: ConnectionHandler;
|
||||||
|
readonly events: Registry<InviteUiEvents>;
|
||||||
|
readonly variables: IpcUiVariableProvider<InviteUiVariables>;
|
||||||
|
|
||||||
|
private registeredEvents: (() => void)[] = [];
|
||||||
|
|
||||||
|
private readonly targetAddress: string;
|
||||||
|
private readonly targetServerPassword: string | undefined;
|
||||||
|
|
||||||
|
private readonly fallbackWebClientUrlBase: string;
|
||||||
|
|
||||||
|
private targetChannelId: number;
|
||||||
|
private targetChannelName: string;
|
||||||
|
private targetChannelPasswordHashed: string | undefined;
|
||||||
|
private targetChannelPasswordRaw: string | undefined;
|
||||||
|
|
||||||
|
private useToken: string;
|
||||||
|
private linkExpiresAfter: number | 0;
|
||||||
|
|
||||||
|
private inviteLinkError: string;
|
||||||
|
private inviteLinkShort: string;
|
||||||
|
private inviteLinkLong: string;
|
||||||
|
private inviteLinkExpireDate: number;
|
||||||
|
|
||||||
|
private showShortInviteLink: boolean;
|
||||||
|
private showAdvancedSettings: boolean;
|
||||||
|
private webClientUrlBase: string;
|
||||||
|
|
||||||
|
private inviteLinkUpdateExecuting: boolean;
|
||||||
|
private inviteLinkUpdatePending: boolean;
|
||||||
|
|
||||||
|
private linkAdminToken: string;
|
||||||
|
|
||||||
|
constructor(connection: ConnectionHandler, targetAddress: string, targetHashedServerPassword: string | undefined) {
|
||||||
|
this.connection = connection;
|
||||||
|
this.events = new Registry<InviteUiEvents>();
|
||||||
|
this.variables = createIpcUiVariableProvider();
|
||||||
|
this.registeredEvents = [];
|
||||||
|
|
||||||
|
if (document.location.protocol !== 'https:') {
|
||||||
|
/*
|
||||||
|
* Seems to be a test environment or the TeaClient for localhost where we dont have to use https.
|
||||||
|
*/
|
||||||
|
this.fallbackWebClientUrlBase = "https://web.teaspeak.de/";
|
||||||
|
} else if (document.location.hostname === "localhost" || document.location.host.startsWith("127.")) {
|
||||||
|
this.fallbackWebClientUrlBase = "https://web.teaspeak.de/";
|
||||||
|
} else {
|
||||||
|
this.fallbackWebClientUrlBase = document.location.origin + document.location.pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetAddress = targetAddress;
|
||||||
|
this.targetServerPassword = targetHashedServerPassword;
|
||||||
|
|
||||||
|
this.targetChannelId = 0;
|
||||||
|
|
||||||
|
this.linkExpiresAfter = 0;
|
||||||
|
|
||||||
|
this.showShortInviteLink = settings.getValue(Settings.KEY_INVITE_SHORT_URL);
|
||||||
|
this.showAdvancedSettings = settings.getValue(Settings.KEY_INVITE_ADVANCED_ENABLED);
|
||||||
|
|
||||||
|
this.inviteLinkUpdateExecuting = false;
|
||||||
|
this.inviteLinkUpdatePending = false;
|
||||||
|
|
||||||
|
this.variables.setVariableProvider("generatedLink", () => {
|
||||||
|
if(typeof this.inviteLinkError === "string") {
|
||||||
|
return { status: "error", message: this.inviteLinkError };
|
||||||
|
} else if(typeof this.inviteLinkLong === "string") {
|
||||||
|
return { status: "success", shortUrl: this.inviteLinkShort, longUrl: this.inviteLinkLong, expireDate: this.inviteLinkExpireDate };
|
||||||
|
} else {
|
||||||
|
return { status: "generating" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.variables.setVariableProvider("availableChannels", () => {
|
||||||
|
const result: InviteChannel[] = [];
|
||||||
|
const walkChannel = (channel: ChannelEntry, depth: number) => {
|
||||||
|
result.push({ channelId: channel.channelId, channelName: channel.properties.channel_name, depth });
|
||||||
|
|
||||||
|
channel = channel.child_channel_head;
|
||||||
|
while(channel) {
|
||||||
|
walkChannel(channel, depth + 1);
|
||||||
|
channel = channel.channel_next;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.connection.channelTree.rootChannel().forEach(channel => walkChannel(channel, 0));
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.variables.setVariableProvider("selectedChannel", () => this.targetChannelId);
|
||||||
|
this.variables.setVariableEditor("selectedChannel", newValue => {
|
||||||
|
const channel = this.connection.channelTree.findChannel(newValue);
|
||||||
|
if(!channel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectChannel(channel);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.variables.setVariableProvider("channelPassword", () => ({
|
||||||
|
hashed: this.targetChannelPasswordHashed,
|
||||||
|
raw: this.targetChannelPasswordRaw
|
||||||
|
}));
|
||||||
|
this.variables.setVariableEditorAsync("channelPassword", async newValue => {
|
||||||
|
this.targetChannelPasswordRaw = newValue.raw;
|
||||||
|
this.targetChannelPasswordHashed = await hashPassword(newValue.raw);
|
||||||
|
this.updateInviteLink();
|
||||||
|
|
||||||
|
return {
|
||||||
|
hashed: this.targetChannelPasswordHashed,
|
||||||
|
raw: this.targetChannelPasswordRaw
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
this.registeredEvents.push(this.connection.channelTree.events.on(["notify_channel_list_received", "notify_channel_created"], () => {
|
||||||
|
this.variables.sendVariable("availableChannels");
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.registeredEvents.push(this.connection.channelTree.events.on("notify_channel_deleted", event => {
|
||||||
|
if(this.targetChannelId === event.channel.channelId) {
|
||||||
|
this.selectChannel(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.variables.sendVariable("availableChannels");
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.variables.setVariableProvider("shortLink", () => this.showShortInviteLink);
|
||||||
|
this.variables.setVariableEditor("shortLink", newValue => {
|
||||||
|
this.showShortInviteLink = newValue;
|
||||||
|
settings.setValue(Settings.KEY_INVITE_SHORT_URL, newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.variables.setVariableProvider("advancedSettings", () => this.showAdvancedSettings);
|
||||||
|
this.variables.setVariableEditor("advancedSettings", newValue => {
|
||||||
|
this.showAdvancedSettings = newValue;
|
||||||
|
settings.setValue(Settings.KEY_INVITE_ADVANCED_ENABLED, newValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.variables.setVariableProvider("token", () => this.useToken);
|
||||||
|
this.variables.setVariableEditor("token", newValue => {
|
||||||
|
this.useToken = newValue;
|
||||||
|
this.updateInviteLink();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.variables.setVariableProvider("expiresAfter", () => this.linkExpiresAfter);
|
||||||
|
this.variables.setVariableEditor("expiresAfter", newValue => {
|
||||||
|
this.linkExpiresAfter = newValue;
|
||||||
|
this.updateInviteLink();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.variables.setVariableProvider("webClientUrlBase", () => ({ fallback: this.fallbackWebClientUrlBase, override: this.webClientUrlBase }));
|
||||||
|
this.variables.setVariableEditor("webClientUrlBase", newValue => {
|
||||||
|
this.webClientUrlBase = newValue.override;
|
||||||
|
this.updateInviteLink();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.events.destroy();
|
||||||
|
this.variables.destroy();
|
||||||
|
|
||||||
|
this.registeredEvents?.forEach(callback => callback());
|
||||||
|
this.registeredEvents = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectChannel(channel: ChannelEntry | undefined) {
|
||||||
|
if(channel) {
|
||||||
|
if(this.targetChannelId === channel.channelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.targetChannelId = channel.channelId;
|
||||||
|
this.targetChannelName = channel.channelName();
|
||||||
|
this.targetChannelPasswordHashed = channel.getCachedPasswordHash();
|
||||||
|
this.targetChannelPasswordRaw = undefined;
|
||||||
|
} else if(this.targetChannelId === 0) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this.targetChannelId = 0;
|
||||||
|
this.targetChannelPasswordHashed = undefined;
|
||||||
|
this.targetChannelPasswordRaw = undefined;
|
||||||
|
}
|
||||||
|
this.updateInviteLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInviteLink() {
|
||||||
|
if(this.inviteLinkUpdateExecuting) {
|
||||||
|
this.inviteLinkUpdatePending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.inviteLinkUpdateExecuting = true;
|
||||||
|
this.inviteLinkUpdatePending = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
this.inviteLinkError = undefined;
|
||||||
|
this.inviteLinkShort = undefined;
|
||||||
|
this.inviteLinkLong = undefined;
|
||||||
|
this.variables.sendVariable("generatedLink");
|
||||||
|
|
||||||
|
while(this.inviteLinkUpdatePending) {
|
||||||
|
this.inviteLinkUpdatePending = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.doUpdateInviteLink();
|
||||||
|
} catch (error) {
|
||||||
|
logError(LogCategory.GENERAL, tr("Failed to update invite link: %o"), error);
|
||||||
|
this.inviteLinkError = tr("Unknown error occurred");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.variables.sendVariable("generatedLink");
|
||||||
|
this.inviteLinkUpdateExecuting = false;
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doUpdateInviteLink() {
|
||||||
|
this.inviteLinkError = undefined;
|
||||||
|
this.inviteLinkShort = undefined;
|
||||||
|
this.inviteLinkLong = undefined;
|
||||||
|
|
||||||
|
if(!clientServices.isSessionInitialized()) {
|
||||||
|
this.inviteLinkError = tr("Client services not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = this.connection.channelTree.server;
|
||||||
|
try { await server.updateProperties(); } catch (_) {}
|
||||||
|
|
||||||
|
const propertiesInfo = {};
|
||||||
|
const propertiesConnect = {};
|
||||||
|
|
||||||
|
{
|
||||||
|
propertiesInfo["server-name"] = server.properties.virtualserver_name;
|
||||||
|
propertiesInfo["server-unique-id"] = server.properties.virtualserver_unique_identifier;
|
||||||
|
propertiesInfo["slots-used"] = server.properties.virtualserver_clientsonline.toString();
|
||||||
|
propertiesInfo["slots-max"] = server.properties.virtualserver_maxclients.toString();
|
||||||
|
|
||||||
|
propertiesConnect["server-address"] = this.targetAddress;
|
||||||
|
if(this.targetServerPassword) {
|
||||||
|
propertiesConnect["server-password"] = this.targetServerPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.targetChannelId > 0) {
|
||||||
|
propertiesConnect["channel"] = `/${this.targetChannelId}`;
|
||||||
|
propertiesInfo["channel-name"] = this.targetChannelName;
|
||||||
|
|
||||||
|
if(this.targetChannelPasswordHashed) {
|
||||||
|
propertiesConnect["channel-password"] = this.targetChannelPasswordHashed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.targetChannelPasswordHashed || this.targetServerPassword) {
|
||||||
|
propertiesConnect["passwords-hashed"] = "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlBase = this.webClientUrlBase || this.fallbackWebClientUrlBase;
|
||||||
|
if(new URL(urlBase).hostname !== "web.teaspeak.de") {
|
||||||
|
propertiesConnect["webclient-host"] = urlBase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await clientServiceInvite.createInviteLink(propertiesConnect, propertiesInfo, typeof this.linkAdminToken === "undefined", this.linkExpiresAfter);
|
||||||
|
if(result.status !== "success") {
|
||||||
|
logError(LogCategory.GENERAL, tr("Failed to register invite link: %o"), result.result);
|
||||||
|
this.inviteLinkError = tr("Server error") + " (" + result.result.type + ")";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inviteLink = result.unwrap();
|
||||||
|
this.linkAdminToken = inviteLink.adminToken;
|
||||||
|
this.inviteLinkShort = `https://teaspeak.de/${inviteLink.linkId}`;
|
||||||
|
this.inviteLinkLong = `https://join.teaspeak.de/${inviteLink.linkId}`;
|
||||||
|
this.inviteLinkExpireDate = this.linkExpiresAfter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnInviteGenerator(target: ChannelEntry | ServerEntry) {
|
||||||
|
let targetAddress: string, targetHashedServerPassword: string | undefined, serverName: string;
|
||||||
|
|
||||||
|
{
|
||||||
|
let address: ServerAddress;
|
||||||
|
if(target instanceof ServerEntry) {
|
||||||
|
address = target.remote_address;
|
||||||
|
serverName = target.properties.virtualserver_name;
|
||||||
|
} else if(target instanceof ChannelEntry) {
|
||||||
|
address = target.channelTree.server.remote_address;
|
||||||
|
serverName = target.channelTree.server.properties.virtualserver_name;
|
||||||
|
} else {
|
||||||
|
throw tr("invalid target");
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = target.channelTree.client;
|
||||||
|
const connectParameters = connection.getServerConnection().handshake_handler().parameters;
|
||||||
|
if(connectParameters.serverPassword) {
|
||||||
|
if(!connectParameters.serverPasswordHashed) {
|
||||||
|
throw tr("expected the target server password to be hashed");
|
||||||
|
}
|
||||||
|
targetHashedServerPassword = connectParameters.serverPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!address) {
|
||||||
|
throw tr("missing target address");
|
||||||
|
}
|
||||||
|
|
||||||
|
if(address.host.indexOf(':') === -1) {
|
||||||
|
targetAddress = `${address.host}:${address.port}`;
|
||||||
|
} else {
|
||||||
|
targetAddress = `[${address.host}]:${address.port}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new InviteController(target.channelTree.client, targetAddress, targetHashedServerPassword);
|
||||||
|
if(target instanceof ChannelEntry) {
|
||||||
|
/* will implicitly update the invite link */
|
||||||
|
controller.selectChannel(target);
|
||||||
|
} else {
|
||||||
|
controller.updateInviteLink();
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = spawnModal("modal-invite", [ controller.events.generateIpcDescription(), controller.variables.generateConsumerDescription(), serverName ]);
|
||||||
|
controller.events.one("action_close", () => modal.destroy());
|
||||||
|
modal.getEvents().on("destroy", () => controller.destroy());
|
||||||
|
modal.show().then(undefined);
|
||||||
|
}
|
|
@ -0,0 +1,39 @@
|
||||||
|
|
||||||
|
export type InviteChannel = {
|
||||||
|
channelId: number,
|
||||||
|
channelName: string,
|
||||||
|
depth: number
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface InviteUiVariables {
|
||||||
|
shortLink: boolean,
|
||||||
|
advancedSettings: boolean,
|
||||||
|
|
||||||
|
selectedChannel: number | 0,
|
||||||
|
channelPassword: {
|
||||||
|
raw: string | undefined,
|
||||||
|
hashed: string | undefined
|
||||||
|
},
|
||||||
|
|
||||||
|
token: string | undefined,
|
||||||
|
expiresAfter: number | 0,
|
||||||
|
|
||||||
|
webClientUrlBase: { override: string | undefined, fallback: string },
|
||||||
|
|
||||||
|
readonly availableChannels: InviteChannel[],
|
||||||
|
|
||||||
|
readonly generatedLink: {
|
||||||
|
status: "generating"
|
||||||
|
} | {
|
||||||
|
status: "error", message: string
|
||||||
|
} | {
|
||||||
|
status: "success",
|
||||||
|
longUrl: string,
|
||||||
|
shortUrl: string,
|
||||||
|
expireDate: number | 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteUiEvents {
|
||||||
|
action_close: {}
|
||||||
|
}
|
|
@ -0,0 +1,215 @@
|
||||||
|
@import "../../../../css/static/mixin";
|
||||||
|
@import "../../../../css/static/properties";
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
width: 30em;
|
||||||
|
padding: 1em;
|
||||||
|
|
||||||
|
@include user-select(none);
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #557edc;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerOptions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
margin-bottom: .5em;
|
||||||
|
|
||||||
|
.generalOptions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
.general, .channel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: stretch;
|
||||||
|
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.advancedOptions {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
.optionTitle {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionValue {
|
||||||
|
height: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerOptionsAdvanced {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerButtons {
|
||||||
|
margin-top: 1em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.containerLink {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
|
||||||
|
.output {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
color: #999999;
|
||||||
|
background-color: #28292b;
|
||||||
|
|
||||||
|
border: 1px #161616 solid;
|
||||||
|
border-radius: .2em;
|
||||||
|
|
||||||
|
padding: .5em;
|
||||||
|
padding-right: 1.5em;
|
||||||
|
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-shrink: 1;
|
||||||
|
|
||||||
|
a {
|
||||||
|
@include text-dotdotdot();
|
||||||
|
}
|
||||||
|
|
||||||
|
&.generating {
|
||||||
|
a {
|
||||||
|
color: #606060;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.errored {
|
||||||
|
a {
|
||||||
|
color: #e62222;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.success, &.errored {
|
||||||
|
@include user-select(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkExpire {
|
||||||
|
font-size: .8em;
|
||||||
|
text-align: left;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: -1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.containerCopy {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
right: .5em;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-size: 1.3em;
|
||||||
|
padding: .1em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: .115em;
|
||||||
|
|
||||||
|
transition: background-color .25s ease-in-out;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #ffffff10;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 1em;
|
||||||
|
width: 1em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$copied-color: #222224;
|
||||||
|
.copied {
|
||||||
|
opacity: 0;
|
||||||
|
box-shadow: 0 8px 16px rgba(0,0,0,0.24);
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
width: 4em;
|
||||||
|
height: 1.5em;
|
||||||
|
|
||||||
|
background: $copied-color;
|
||||||
|
|
||||||
|
top: 100%;
|
||||||
|
left: 50%;
|
||||||
|
|
||||||
|
border-radius: .1em;
|
||||||
|
margin-left: -2em;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
transition: opacity .1s ease-in-out;
|
||||||
|
|
||||||
|
&.shown {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #389738;
|
||||||
|
z-index: 1;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
$width: .5em;
|
||||||
|
&::before {
|
||||||
|
content: ' ';
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
margin-left: -$width / 2;
|
||||||
|
margin-top: -$width / 2;
|
||||||
|
|
||||||
|
transform: rotate(45deg);
|
||||||
|
|
||||||
|
width: $width;
|
||||||
|
height: $width;
|
||||||
|
|
||||||
|
background: $copied-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,416 @@
|
||||||
|
import * as React from "react";
|
||||||
|
import {useContext, useEffect, useState} from "react";
|
||||||
|
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
|
||||||
|
import {Translatable} from "tc-shared/ui/react-elements/i18n";
|
||||||
|
import {IpcRegistryDescription, Registry} from "tc-events";
|
||||||
|
import {InviteUiEvents, InviteUiVariables} from "tc-shared/ui/modal/invite/Definitions";
|
||||||
|
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
|
||||||
|
import {Button} from "tc-shared/ui/react-elements/Button";
|
||||||
|
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
|
||||||
|
import {ClientIconRenderer} from "tc-shared/ui/react-elements/Icons";
|
||||||
|
import {ClientIcon} from "svg-sprites/client-icons";
|
||||||
|
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
|
||||||
|
import {copyToClipboard} from "tc-shared/utils/helpers";
|
||||||
|
import {ControlledBoxedInputField, ControlledSelect} from "tc-shared/ui/react-elements/InputField";
|
||||||
|
import {useTr} from "tc-shared/ui/react-elements/Helper";
|
||||||
|
import {Checkbox} from "tc-shared/ui/react-elements/Checkbox";
|
||||||
|
import * as moment from 'moment';
|
||||||
|
|
||||||
|
const cssStyle = require("./Renderer.scss");
|
||||||
|
|
||||||
|
const EventsContext = React.createContext<Registry<InviteUiEvents>>(undefined);
|
||||||
|
const VariablesContext = React.createContext<UiVariableConsumer<InviteUiVariables>>(undefined);
|
||||||
|
|
||||||
|
const OptionChannel = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const availableChannels = variables.useReadOnly("availableChannels", undefined, []);
|
||||||
|
const selectedChannel = variables.useVariable("selectedChannel", undefined, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.option}>
|
||||||
|
<div className={cssStyle.optionTitle}>
|
||||||
|
<Translatable>Automatically join channel</Translatable>
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.optionValue}>
|
||||||
|
<ControlledSelect
|
||||||
|
value={selectedChannel.localValue.toString()}
|
||||||
|
type={"boxed"}
|
||||||
|
className={cssStyle.optionsChannel}
|
||||||
|
onChange={event => {
|
||||||
|
const value = parseInt(event.target.value);
|
||||||
|
if(isNaN(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedChannel.setValue(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option key={"no-channel"} value={"0"}>{useTr("No specific channel")}</option>
|
||||||
|
<option key={"server-channels"} disabled>{useTr("Available channels:")}</option>
|
||||||
|
{availableChannels.map(channel => (
|
||||||
|
<option key={"channel-" + channel.channelId} value={channel.channelId + ""}>{channel.channelName}</option>
|
||||||
|
)) as any}
|
||||||
|
</ControlledSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const OptionChannelPassword = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const selectedChannel = variables.useReadOnly("selectedChannel", undefined, 0);
|
||||||
|
const channelPassword = variables.useVariable("channelPassword", undefined, { raw: undefined, hashed: undefined });
|
||||||
|
|
||||||
|
let body;
|
||||||
|
if(selectedChannel === 0) {
|
||||||
|
body = (
|
||||||
|
<ControlledBoxedInputField className={cssStyle.optionChannelPassword} value={""} key={"no-password"} placeholder={tr("No channel selected")} editable={false} onChange={() => {}} />
|
||||||
|
);
|
||||||
|
} else if(channelPassword.localValue.hashed && !channelPassword.localValue.raw) {
|
||||||
|
body = (
|
||||||
|
<ControlledBoxedInputField
|
||||||
|
className={cssStyle.optionChannelPassword}
|
||||||
|
value={""}
|
||||||
|
placeholder={tr("Using cached password")}
|
||||||
|
editable={true}
|
||||||
|
onChange={newValue => channelPassword.setValue({ hashed: channelPassword.localValue.hashed, raw: newValue }, true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<ControlledBoxedInputField
|
||||||
|
className={cssStyle.optionChannelPassword}
|
||||||
|
value={channelPassword.localValue.raw}
|
||||||
|
placeholder={tr("Don't use a password")}
|
||||||
|
editable={true}
|
||||||
|
onChange={newValue => channelPassword.setValue({ hashed: channelPassword.localValue.hashed, raw: newValue }, true)}
|
||||||
|
onBlur={() => channelPassword.setValue(channelPassword.localValue, false)}
|
||||||
|
finishOnEnter={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.option}>
|
||||||
|
<div className={cssStyle.optionTitle}><Translatable>Channel password</Translatable></div>
|
||||||
|
<div className={cssStyle.optionValue}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
const OptionGeneralShortLink = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const showShortUrl = variables.useVariable("shortLink", undefined, true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.option}>
|
||||||
|
<Checkbox
|
||||||
|
onChange={newValue => showShortUrl.setValue(newValue)}
|
||||||
|
value={showShortUrl.localValue}
|
||||||
|
label={<Translatable>Use short URL</Translatable>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const OptionGeneralShowAdvanced = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const showShortUrl = variables.useVariable("advancedSettings", undefined, false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.option}>
|
||||||
|
<Checkbox
|
||||||
|
onChange={newValue => showShortUrl.setValue(newValue)}
|
||||||
|
value={showShortUrl.localValue}
|
||||||
|
label={<Translatable>Advanced settings</Translatable>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const OptionAdvancedToken = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const currentToken = variables.useVariable("token", undefined, "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.option}>
|
||||||
|
<div className={cssStyle.optionTitle}><Translatable>Token</Translatable></div>
|
||||||
|
<div className={cssStyle.optionValue}>
|
||||||
|
<ControlledBoxedInputField
|
||||||
|
className={cssStyle.optionChannelPassword}
|
||||||
|
value={currentToken.localValue}
|
||||||
|
placeholder={tr("Don't use a token")}
|
||||||
|
editable={true}
|
||||||
|
onChange={newValue => currentToken.setValue(newValue, true)}
|
||||||
|
onBlur={() => currentToken.setValue(currentToken.localValue, false)}
|
||||||
|
finishOnEnter={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const OptionAdvancedWebUrlBase = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const currentUrl = variables.useVariable("webClientUrlBase", undefined, { override: undefined, fallback: undefined });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.option}>
|
||||||
|
<div className={cssStyle.optionTitle}><Translatable>WebClient URL</Translatable></div>
|
||||||
|
<div className={cssStyle.optionValue}>
|
||||||
|
<ControlledBoxedInputField
|
||||||
|
className={cssStyle.optionChannelPassword}
|
||||||
|
value={currentUrl.localValue.override || ""}
|
||||||
|
placeholder={currentUrl.localValue.fallback || tr("loading...")}
|
||||||
|
editable={true}
|
||||||
|
onChange={newValue => currentUrl.setValue({ fallback: currentUrl.localValue.fallback, override: newValue }, true)}
|
||||||
|
onBlur={() => currentUrl.setValue(currentUrl.localValue, false)}
|
||||||
|
finishOnEnter={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type ExpirePreset = {
|
||||||
|
name: () => string,
|
||||||
|
seconds: number
|
||||||
|
};
|
||||||
|
|
||||||
|
const ExpirePresets: ExpirePreset[] = [
|
||||||
|
{ name: () => tr("5 Minutes"), seconds: 5 * 60 },
|
||||||
|
{ name: () => tr("1 hour"), seconds: 60 * 60 },
|
||||||
|
{ name: () => tr("24 hours"), seconds: 24 * 60 * 60 },
|
||||||
|
{ name: () => tr("1 Week"), seconds: 7 * 24 * 60 * 60 },
|
||||||
|
{ name: () => tr("1 Month"), seconds: 31 * 24 * 60 * 60 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const OptionAdvancedExpires = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const expiresAfter = variables.useVariable("expiresAfter", undefined, 0);
|
||||||
|
|
||||||
|
let presetSelected = -2;
|
||||||
|
if(expiresAfter.localValue === 0) {
|
||||||
|
presetSelected = -1;
|
||||||
|
} else {
|
||||||
|
const difference = expiresAfter.localValue - Date.now() / 1000;
|
||||||
|
if(difference > 0) {
|
||||||
|
for(let index = 0; index < ExpirePresets.length; index++) {
|
||||||
|
if(Math.abs(difference - ExpirePresets[index].seconds) <= 60 * 60) {
|
||||||
|
presetSelected = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.option}>
|
||||||
|
<div className={cssStyle.optionTitle}><Translatable>Link expire time</Translatable></div>
|
||||||
|
<div className={cssStyle.optionValue}>
|
||||||
|
<ControlledSelect
|
||||||
|
type={"boxed"}
|
||||||
|
value={presetSelected + ""}
|
||||||
|
onChange={event => {
|
||||||
|
const value = parseInt(event.target.value);
|
||||||
|
if(isNaN(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(value === -1) {
|
||||||
|
expiresAfter.setValue(0);
|
||||||
|
} else if(value >= 0) {
|
||||||
|
expiresAfter.setValue(Math.floor(Date.now() / 1000 + ExpirePresets[value].seconds));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option key={"unknown"} value={"-2"} style={{ display: "none" }}>{useTr("Unknown")}</option>
|
||||||
|
<option key={"never"} value={"-1"}>{useTr("never")}</option>
|
||||||
|
{
|
||||||
|
ExpirePresets.map((preset, index) => (
|
||||||
|
<option key={"p-" + index} value={index.toString()}>{preset.name()}</option>
|
||||||
|
)) as any
|
||||||
|
}
|
||||||
|
</ControlledSelect>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const OptionsAdvanced = React.memo(() => {
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.containerOptionsAdvanced}>
|
||||||
|
<div className={cssStyle.title}><Translatable>Advanced options</Translatable></div>
|
||||||
|
<OptionAdvancedToken />
|
||||||
|
<OptionAdvancedWebUrlBase />
|
||||||
|
<OptionAdvancedExpires />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const Options = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const showAdvanced = variables.useReadOnly("advancedSettings", undefined, false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.containerOptions}>
|
||||||
|
<div className={cssStyle.generalOptions}>
|
||||||
|
<div className={cssStyle.general}>
|
||||||
|
<div className={cssStyle.title}><Translatable>General</Translatable></div>
|
||||||
|
<OptionGeneralShortLink />
|
||||||
|
<OptionGeneralShowAdvanced />
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.channel}>
|
||||||
|
<div className={cssStyle.title}><Translatable>Channel</Translatable></div>
|
||||||
|
<OptionChannel />
|
||||||
|
<OptionChannelPassword />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showAdvanced ? <OptionsAdvanced key={"advanced"} /> : undefined}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ButtonCopy = React.memo((props: { onCopy: () => void, disabled: boolean }) => {
|
||||||
|
const [ showTimeout, setShowTimeout ] = useState(0);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
useEffect(() => {
|
||||||
|
if(now >= showTimeout) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => setShowTimeout(0), showTimeout - now);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.containerCopy}>
|
||||||
|
<div className={cssStyle.button} onClick={() => {
|
||||||
|
if(props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
props.onCopy();
|
||||||
|
setShowTimeout(Date.now() + 1750);
|
||||||
|
}}>
|
||||||
|
<ClientIconRenderer icon={ClientIcon.CopyUrl} />
|
||||||
|
</div>
|
||||||
|
<div className={cssStyle.copied + " " + (now < showTimeout ? cssStyle.shown : "")}>
|
||||||
|
<a>Copied!</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const LinkExpire = (props: { date: number | 0 | -1 }) => {
|
||||||
|
let value;
|
||||||
|
if(props.date === -1) {
|
||||||
|
value = <React.Fragment key={"unset"}> </React.Fragment>;
|
||||||
|
} else if(props.date === 0) {
|
||||||
|
value = <React.Fragment key={"never"}><Translatable>Link expires never</Translatable></React.Fragment>;
|
||||||
|
} else {
|
||||||
|
value = <React.Fragment key={"never"}><Translatable>Link expires at</Translatable> {moment(props.date * 1000).format('LLLL')}</React.Fragment>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.linkExpire}>{value}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Link = React.memo(() => {
|
||||||
|
const variables = useContext(VariablesContext);
|
||||||
|
const shortLink = variables.useReadOnly("shortLink", undefined, true);
|
||||||
|
const link = variables.useReadOnly("generatedLink", undefined, { status: "generating" });
|
||||||
|
|
||||||
|
let className, value, copyValue;
|
||||||
|
switch (link.status) {
|
||||||
|
case "generating":
|
||||||
|
className = cssStyle.generating;
|
||||||
|
value = <React.Fragment key={"loading"}><Translatable>Generating link</Translatable> <LoadingDots /></React.Fragment>;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
className = cssStyle.errored;
|
||||||
|
copyValue = link.message;
|
||||||
|
value = link.message;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "success":
|
||||||
|
className = cssStyle.success;
|
||||||
|
copyValue = shortLink ? link.shortUrl : link.longUrl;
|
||||||
|
value = copyValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.containerLink}>
|
||||||
|
<div className={cssStyle.title}><Translatable>Link</Translatable></div>
|
||||||
|
<div className={cssStyle.output + " " + className}>
|
||||||
|
<a>{value}</a>
|
||||||
|
<ButtonCopy disabled={link.status === "generating"} onCopy={() => {
|
||||||
|
if(copyValue) {
|
||||||
|
copyToClipboard(copyValue);
|
||||||
|
}
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<LinkExpire date={link.status === "success" ? link.expireDate : -1} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const Buttons = () => {
|
||||||
|
const events = useContext(EventsContext);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssStyle.containerButtons}>
|
||||||
|
<Button type={"small"} color={"green"} onClick={() => events.fire("action_close")}>
|
||||||
|
<Translatable>Close</Translatable>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class ModalInvite extends AbstractModal {
|
||||||
|
private readonly events: Registry<InviteUiEvents>;
|
||||||
|
private readonly variables: UiVariableConsumer<InviteUiVariables>;
|
||||||
|
private readonly serverName: string;
|
||||||
|
|
||||||
|
constructor(events: IpcRegistryDescription<InviteUiEvents>, variables: IpcVariableDescriptor<InviteUiVariables>, serverName: string) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.events = Registry.fromIpcDescription(events);
|
||||||
|
this.variables = createIpcUiVariableConsumer(variables);
|
||||||
|
this.serverName = serverName;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBody(): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<EventsContext.Provider value={this.events}>
|
||||||
|
<VariablesContext.Provider value={this.variables}>
|
||||||
|
<div className={cssStyle.container}>
|
||||||
|
<Options />
|
||||||
|
<Link />
|
||||||
|
<Buttons />
|
||||||
|
</div>
|
||||||
|
</VariablesContext.Provider>
|
||||||
|
</EventsContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTitle(): string | React.ReactElement {
|
||||||
|
return <><Translatable>Invite People to</Translatable> {this.serverName}</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export = ModalInvite;
|
||||||
|
|
||||||
|
/*
|
||||||
|
const modal = spawnModal("global-settings-editor", [ events.generateIpcDescription() ], { popoutable: true, popedOut: false });
|
||||||
|
modal.show();
|
||||||
|
modal.getEvents().on("destroy", () => {
|
||||||
|
events.fire("notify_destroy");
|
||||||
|
events.destroy();
|
||||||
|
});
|
||||||
|
*/
|
|
@ -533,7 +533,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
return {
|
return {
|
||||||
path: e.info.path,
|
path: e.info.path,
|
||||||
cid: e.info.channelId,
|
cid: e.info.channelId,
|
||||||
cpw: e.info.channel?.cached_password(),
|
cpw: e.info.channel?.getCachedPasswordHash(),
|
||||||
name: e.name
|
name: e.name
|
||||||
}
|
}
|
||||||
})).then(async result => {
|
})).then(async result => {
|
||||||
|
@ -647,7 +647,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
//ftcreatedir cid=4 cpw dirname=\/TestDir return_code=1:17
|
//ftcreatedir cid=4 cpw dirname=\/TestDir return_code=1:17
|
||||||
connection.serverConnection.send_command("ftcreatedir", {
|
connection.serverConnection.send_command("ftcreatedir", {
|
||||||
cid: path.channelId,
|
cid: path.channelId,
|
||||||
cpw: path.channel.cached_password(),
|
cpw: path.channel.getCachedPasswordHash(),
|
||||||
dirname: path.path + event.name
|
dirname: path.path + event.name
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
events.fire("action_create_directory_result", {path: event.path, name: event.name, status: "success"});
|
events.fire("action_create_directory_result", {path: event.path, name: event.name, status: "success"});
|
||||||
|
@ -709,7 +709,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
channel: info.channelId,
|
channel: info.channelId,
|
||||||
path: info.type === "channel" ? info.path : "",
|
path: info.type === "channel" ? info.path : "",
|
||||||
name: info.type === "channel" ? file.name : "/" + file.name,
|
name: info.type === "channel" ? file.name : "/" + file.name,
|
||||||
channelPassword: info.channel?.cached_password(),
|
channelPassword: info.channel?.getCachedPasswordHash(),
|
||||||
targetSupplier: targetSupplier
|
targetSupplier: targetSupplier
|
||||||
});
|
});
|
||||||
transfer.awaitFinished().then(() => {
|
transfer.awaitFinished().then(() => {
|
||||||
|
@ -752,7 +752,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand
|
||||||
const fileName = file.name;
|
const fileName = file.name;
|
||||||
const transfer = connection.fileManager.initializeFileUpload({
|
const transfer = connection.fileManager.initializeFileUpload({
|
||||||
channel: pathInfo.channelId,
|
channel: pathInfo.channelId,
|
||||||
channelPassword: pathInfo.channel?.cached_password(),
|
channelPassword: pathInfo.channel?.getCachedPasswordHash(),
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: pathInfo.path,
|
path: pathInfo.path,
|
||||||
source: async () => TransferProvider.provider().createBrowserFileSource(file)
|
source: async () => TransferProvider.provider().createBrowserFileSource(file)
|
||||||
|
|
|
@ -4,6 +4,89 @@ import {joinClassList} from "tc-shared/ui/react-elements/Helper";
|
||||||
|
|
||||||
const cssStyle = require("./InputField.scss");
|
const cssStyle = require("./InputField.scss");
|
||||||
|
|
||||||
|
export const ControlledBoxedInputField = (props: {
|
||||||
|
prefix?: string;
|
||||||
|
suffix?: string;
|
||||||
|
|
||||||
|
placeholder?: string;
|
||||||
|
|
||||||
|
disabled?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
|
||||||
|
value?: string;
|
||||||
|
|
||||||
|
rightIcon?: () => ReactElement;
|
||||||
|
leftIcon?: () => ReactElement;
|
||||||
|
inputBox?: () => ReactElement; /* if set the onChange and onInput will not work anymore! */
|
||||||
|
|
||||||
|
isInvalid?: boolean;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
maxLength?: number,
|
||||||
|
|
||||||
|
size?: "normal" | "large" | "small";
|
||||||
|
type?: "text" | "password" | "number";
|
||||||
|
|
||||||
|
onChange: (newValue?: string) => void,
|
||||||
|
onEnter?: () => void,
|
||||||
|
|
||||||
|
onFocus?: () => void,
|
||||||
|
onBlur?: () => void,
|
||||||
|
|
||||||
|
finishOnEnter?: boolean,
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
draggable={false}
|
||||||
|
className={
|
||||||
|
cssStyle.containerBoxed + " " +
|
||||||
|
cssStyle["size-" + (props.size || "normal")] + " " +
|
||||||
|
(props.disabled ? cssStyle.disabled : "") + " " +
|
||||||
|
(props.isInvalid ? cssStyle.isInvalid : "") + " " +
|
||||||
|
(typeof props.editable !== "boolean" || props.editable ? cssStyle.editable : "") + " " +
|
||||||
|
(props.leftIcon ? "" : cssStyle.noLeftIcon) + " " +
|
||||||
|
(props.rightIcon ? "" : cssStyle.noRightIcon) + " " +
|
||||||
|
props.className
|
||||||
|
}
|
||||||
|
onFocus={props.onFocus}
|
||||||
|
onBlur={() => props.onBlur()}
|
||||||
|
>
|
||||||
|
{props.leftIcon ? props.leftIcon() : ""}
|
||||||
|
{props.prefix ? <a key={"prefix"} className={cssStyle.prefix}>{props.prefix}</a> : undefined}
|
||||||
|
{props.inputBox ?
|
||||||
|
<span key={"custom-input"}
|
||||||
|
className={cssStyle.inputBox + " " + (props.editable ? cssStyle.editable : "")}
|
||||||
|
onClick={props.onFocus}>{props.inputBox()}</span> :
|
||||||
|
|
||||||
|
<input key={"input"}
|
||||||
|
|
||||||
|
value={props.value || ""}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
|
||||||
|
readOnly={typeof props.editable === "boolean" ? !props.editable : false}
|
||||||
|
disabled={typeof props.disabled === "boolean" ? props.disabled : false}
|
||||||
|
|
||||||
|
onChange={event => props.onChange(event.currentTarget.value)}
|
||||||
|
onKeyPress={event => {
|
||||||
|
if(event.key === "Enter") {
|
||||||
|
if(props.finishOnEnter) {
|
||||||
|
event.currentTarget.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
if(props.onEnter) {
|
||||||
|
props.onEnter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{props.suffix ? <a key={"suffix"} className={cssStyle.suffix}>{props.suffix}</a> : undefined}
|
||||||
|
{props.rightIcon ? props.rightIcon() : ""}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface BoxedInputFieldProperties {
|
export interface BoxedInputFieldProperties {
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
|
@ -33,6 +116,8 @@ export interface BoxedInputFieldProperties {
|
||||||
|
|
||||||
onChange?: (newValue: string) => void;
|
onChange?: (newValue: string) => void;
|
||||||
onInput?: (newValue: string) => void;
|
onInput?: (newValue: string) => void;
|
||||||
|
|
||||||
|
finishOnEnter?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoxedInputFieldState {
|
export interface BoxedInputFieldState {
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
import {LogCategory, logDebug, logTrace, logWarn} from "../../../log";
|
import {LogCategory, logDebug, logTrace} from "../../../log";
|
||||||
import * as ipc from "../../../ipc/BrowserIPC";
|
import * as ipc from "../../../ipc/BrowserIPC";
|
||||||
import {ChannelMessage} from "../../../ipc/BrowserIPC";
|
import {ChannelMessage} from "../../../ipc/BrowserIPC";
|
||||||
import {Registry} from "../../../events";
|
import {Registry} from "tc-events";
|
||||||
import {
|
import {
|
||||||
EventControllerBase,
|
EventControllerBase,
|
||||||
|
kPopoutIPCChannelId,
|
||||||
Popout2ControllerMessages,
|
Popout2ControllerMessages,
|
||||||
PopoutIPCMessage
|
PopoutIPCMessage
|
||||||
} from "../../../ui/react-elements/external-modal/IPCMessage";
|
} from "../../../ui/react-elements/external-modal/IPCMessage";
|
||||||
import {ModalController, ModalEvents, ModalOptions, ModalState} from "../../../ui/react-elements/ModalDefinitions";
|
import {ModalController, ModalEvents, ModalOptions, ModalState} from "../../../ui/react-elements/ModalDefinitions";
|
||||||
|
import {guid} from "tc-shared/crypto/uid";
|
||||||
|
|
||||||
export abstract class AbstractExternalModalController extends EventControllerBase<"controller"> implements ModalController {
|
export abstract class AbstractExternalModalController extends EventControllerBase<"controller"> implements ModalController {
|
||||||
public readonly modalType: string;
|
public readonly modalType: string;
|
||||||
|
@ -20,13 +22,13 @@ export abstract class AbstractExternalModalController extends EventControllerBas
|
||||||
private callbackWindowInitialized: (error?: string) => void;
|
private callbackWindowInitialized: (error?: string) => void;
|
||||||
|
|
||||||
protected constructor(modalType: string, constructorArguments: any[]) {
|
protected constructor(modalType: string, constructorArguments: any[]) {
|
||||||
super();
|
super(guid());
|
||||||
this.modalType = modalType;
|
this.modalType = modalType;
|
||||||
this.constructorArguments = constructorArguments;
|
this.constructorArguments = constructorArguments;
|
||||||
|
|
||||||
this.modalEvents = new Registry<ModalEvents>();
|
this.modalEvents = new Registry<ModalEvents>();
|
||||||
|
|
||||||
this.ipcChannel = ipc.getIpcInstance().createChannel();
|
this.ipcChannel = ipc.getIpcInstance().createChannel(kPopoutIPCChannelId);
|
||||||
this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this);
|
this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this);
|
||||||
|
|
||||||
this.documentUnloadListener = () => this.destroy();
|
this.documentUnloadListener = () => this.destroy();
|
||||||
|
@ -120,27 +122,32 @@ export abstract class AbstractExternalModalController extends EventControllerBas
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
||||||
if(broadcast)
|
if(!broadcast && remoteId !== this.ipcRemotePeerId) {
|
||||||
|
logDebug(LogCategory.IPC, tr("Received direct IPC message for popout controller from unknown source: %s"), remoteId);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if(this.ipcRemoteId === undefined) {
|
|
||||||
logDebug(LogCategory.IPC, tr("Remote window connected with id %s"), remoteId);
|
|
||||||
this.ipcRemoteId = remoteId;
|
|
||||||
} else if(this.ipcRemoteId !== remoteId) {
|
|
||||||
this.ipcRemoteId = remoteId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
super.handleIPCMessage(remoteId, broadcast, message);
|
this.handleTypedIPCMessage(remoteId, broadcast, message.type as any, message.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleTypedIPCMessage<T extends Popout2ControllerMessages>(type: T, payload: PopoutIPCMessage[T]) {
|
protected handleTypedIPCMessage<T extends Popout2ControllerMessages>(remoteId: string, isBroadcast: boolean, type: T, payload: PopoutIPCMessage[T]) {
|
||||||
super.handleTypedIPCMessage(type, payload);
|
super.handleTypedIPCMessage(remoteId, isBroadcast, type, payload);
|
||||||
|
|
||||||
switch (type) {
|
if(type === "hello-popout") {
|
||||||
case "hello-popout": {
|
const messageHello = payload as PopoutIPCMessage["hello-popout"];
|
||||||
const tpayload = payload as PopoutIPCMessage["hello-popout"];
|
if(messageHello.authenticationCode !== this.ipcAuthenticationCode) {
|
||||||
logTrace(LogCategory.IPC, "Received Hello World from popup with version %s (expected %s).", tpayload.version, __build.version);
|
/* most likely not for us */
|
||||||
if(tpayload.version !== __build.version) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(this.ipcRemotePeerId) {
|
||||||
|
logTrace(LogCategory.IPC, tr("Modal popout slave changed from %s to %s. Side reload?"), this.ipcRemotePeerId, remoteId);
|
||||||
|
/* TODO: Send a good by to the old modal */
|
||||||
|
}
|
||||||
|
this.ipcRemotePeerId = remoteId;
|
||||||
|
|
||||||
|
logTrace(LogCategory.IPC, "Received Hello World from popup (peer id %s) with version %s (expected %s).", remoteId, messageHello.version, __build.version);
|
||||||
|
if(messageHello.version !== __build.version) {
|
||||||
this.sendIPCMessage("hello-controller", { accepted: false, message: tr("version miss match") });
|
this.sendIPCMessage("hello-controller", { accepted: false, message: tr("version miss match") });
|
||||||
if(this.callbackWindowInitialized) {
|
if(this.callbackWindowInitialized) {
|
||||||
this.callbackWindowInitialized(tr("version miss match"));
|
this.callbackWindowInitialized(tr("version miss match"));
|
||||||
|
@ -155,16 +162,6 @@ export abstract class AbstractExternalModalController extends EventControllerBas
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendIPCMessage("hello-controller", { accepted: true, constructorArguments: this.constructorArguments });
|
this.sendIPCMessage("hello-controller", { accepted: true, constructorArguments: this.constructorArguments });
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "invoke-modal-action":
|
|
||||||
/* must be handled by the underlying handler */
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
logWarn(LogCategory.IPC, "Received unknown message type from popup window: %s", type);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,7 +1,9 @@
|
||||||
import {ChannelMessage, IPCChannel} from "../../../ipc/BrowserIPC";
|
import {IPCChannel} from "../../../ipc/BrowserIPC";
|
||||||
|
|
||||||
|
export const kPopoutIPCChannelId = "popout-channel";
|
||||||
|
|
||||||
export interface PopoutIPCMessage {
|
export interface PopoutIPCMessage {
|
||||||
"hello-popout": { version: string },
|
"hello-popout": { version: string, authenticationCode: string },
|
||||||
"hello-controller": { accepted: boolean, message?: string, constructorArguments?: any[] },
|
"hello-controller": { accepted: boolean, message?: string, constructorArguments?: any[] },
|
||||||
"invoke-modal-action": {
|
"invoke-modal-action": {
|
||||||
action: "close" | "minimize"
|
action: "close" | "minimize"
|
||||||
|
@ -22,28 +24,24 @@ export interface ReceivedIPCMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class EventControllerBase<Type extends "controller" | "popout"> {
|
export abstract class EventControllerBase<Type extends "controller" | "popout"> {
|
||||||
|
protected readonly ipcAuthenticationCode: string;
|
||||||
|
protected ipcRemotePeerId: string;
|
||||||
protected ipcChannel: IPCChannel;
|
protected ipcChannel: IPCChannel;
|
||||||
protected ipcRemoteId: string;
|
|
||||||
|
|
||||||
protected constructor() { }
|
protected constructor(ipcAuthenticationCode: string) {
|
||||||
|
this.ipcAuthenticationCode = ipcAuthenticationCode;
|
||||||
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
|
||||||
if(this.ipcRemoteId !== remoteId) {
|
|
||||||
console.warn("Received message from unknown end: %s. Expected: %s", remoteId, this.ipcRemoteId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.handleTypedIPCMessage(message.type as any, message.data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected sendIPCMessage<T extends SendIPCMessage[Type]>(type: T, payload: PopoutIPCMessage[T]) {
|
protected sendIPCMessage<T extends SendIPCMessage[Type]>(type: T, payload: PopoutIPCMessage[T]) {
|
||||||
this.ipcChannel.sendMessage(type, payload, this.ipcRemoteId);
|
this.ipcChannel.sendMessage(type, payload, this.ipcRemotePeerId);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleTypedIPCMessage<T extends ReceivedIPCMessage[Type]>(type: T, payload: PopoutIPCMessage[T]) {}
|
protected handleTypedIPCMessage<T extends ReceivedIPCMessage[Type]>(remoteId: string, isBroadcast: boolean, type: T, payload: PopoutIPCMessage[T]) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
protected destroyIPC() {
|
protected destroyIPC() {
|
||||||
this.ipcChannel = undefined;
|
this.ipcChannel = undefined;
|
||||||
this.ipcRemoteId = undefined;
|
this.ipcRemotePeerId = undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,8 +2,8 @@ import {getIpcInstance as getIPCInstance} from "../../../ipc/BrowserIPC";
|
||||||
import {AppParameters} from "../../../settings";
|
import {AppParameters} from "../../../settings";
|
||||||
import {
|
import {
|
||||||
Controller2PopoutMessages,
|
Controller2PopoutMessages,
|
||||||
EventControllerBase,
|
EventControllerBase, kPopoutIPCChannelId,
|
||||||
PopoutIPCMessage
|
PopoutIPCMessage,
|
||||||
} from "../../../ui/react-elements/external-modal/IPCMessage";
|
} from "../../../ui/react-elements/external-modal/IPCMessage";
|
||||||
|
|
||||||
let controller: PopoutController;
|
let controller: PopoutController;
|
||||||
|
@ -21,11 +21,12 @@ class PopoutController extends EventControllerBase<"popout"> {
|
||||||
private callbackControllerHello: (accepted: boolean | string) => void;
|
private callbackControllerHello: (accepted: boolean | string) => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super(AppParameters.getValue(AppParameters.KEY_MODAL_IDENTITY_CODE, "invalid"));
|
||||||
this.ipcRemoteId = AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, "invalid");
|
|
||||||
|
|
||||||
this.ipcChannel = getIPCInstance().createChannel(this.ipcRemoteId, AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_POPOUT_CHANNEL, "invalid"));
|
this.ipcChannel = getIPCInstance().createChannel(kPopoutIPCChannelId);
|
||||||
this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this);
|
this.ipcChannel.messageHandler = (sourcePeerId, broadcast, message) => {
|
||||||
|
this.handleTypedIPCMessage(sourcePeerId, broadcast, message.type as any, message.data);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getConstructorArguments() : any[] {
|
getConstructorArguments() : any[] {
|
||||||
|
@ -33,7 +34,7 @@ class PopoutController extends EventControllerBase<"popout"> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize() {
|
async initialize() {
|
||||||
this.sendIPCMessage("hello-popout", { version: __build.version });
|
this.sendIPCMessage("hello-popout", { version: __build.version, authenticationCode: this.ipcAuthenticationCode });
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
|
@ -55,13 +56,14 @@ class PopoutController extends EventControllerBase<"popout"> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleTypedIPCMessage<T extends Controller2PopoutMessages>(type: T, payload: PopoutIPCMessage[T]) {
|
protected handleTypedIPCMessage<T extends Controller2PopoutMessages>(remoteId: string, isBroadcast: boolean, type: T, payload: PopoutIPCMessage[T]) {
|
||||||
super.handleTypedIPCMessage(type, payload);
|
super.handleTypedIPCMessage(remoteId, isBroadcast, type, payload);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "hello-controller": {
|
case "hello-controller": {
|
||||||
const tpayload = payload as PopoutIPCMessage["hello-controller"];
|
const tpayload = payload as PopoutIPCMessage["hello-controller"];
|
||||||
console.log("Received Hello World from controller. Window instance accpected: %o", tpayload.accepted);
|
this.ipcRemotePeerId = remoteId;
|
||||||
|
console.log("Received Hello World from controller (peer id %s). Window instance accepted: %o", this.ipcRemotePeerId, tpayload.accepted);
|
||||||
if(!this.callbackControllerHello) {
|
if(!this.callbackControllerHello) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
function: async () => {
|
function: async () => {
|
||||||
await import("tc-shared/proto");
|
await import("tc-shared/proto");
|
||||||
await i18n.initialize();
|
await i18n.initialize();
|
||||||
ipc.setup();
|
ipc.setupIpcHandler();
|
||||||
|
|
||||||
setupJSRender();
|
setupJSRender();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
import {IpcRegistryDescription, Registry} from "tc-shared/events";
|
import {IpcRegistryDescription, Registry} from "tc-shared/events";
|
||||||
import {VideoViewerEvents} from "tc-shared/video-viewer/Definitions";
|
import {VideoViewerEvents} from "tc-shared/video-viewer/Definitions";
|
||||||
import {ReactElement} from "react";
|
|
||||||
import * as React from "react";
|
|
||||||
import {ChannelEditEvents} from "tc-shared/ui/modal/channel-edit/Definitions";
|
import {ChannelEditEvents} from "tc-shared/ui/modal/channel-edit/Definitions";
|
||||||
import {EchoTestEvents} from "tc-shared/ui/modal/echo-test/Definitions";
|
import {EchoTestEvents} from "tc-shared/ui/modal/echo-test/Definitions";
|
||||||
import {ModalGlobalSettingsEditorEvents} from "tc-shared/ui/modal/global-settings-editor/Definitions";
|
import {ModalGlobalSettingsEditorEvents} from "tc-shared/ui/modal/global-settings-editor/Definitions";
|
||||||
|
import {InviteUiEvents, InviteUiVariables} from "tc-shared/ui/modal/invite/Definitions";
|
||||||
|
|
||||||
|
import {ReactElement} from "react";
|
||||||
|
import * as React from "react";
|
||||||
|
import {IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
|
||||||
|
|
||||||
export type ModalType = "error" | "warning" | "info" | "none";
|
export type ModalType = "error" | "warning" | "info" | "none";
|
||||||
export type ModalRenderType = "page" | "dialog";
|
export type ModalRenderType = "page" | "dialog";
|
||||||
|
@ -124,5 +127,10 @@ export interface ModalConstructorArguments {
|
||||||
"conversation": any,
|
"conversation": any,
|
||||||
"css-editor": any,
|
"css-editor": any,
|
||||||
"channel-tree": any,
|
"channel-tree": any,
|
||||||
"modal-connect": any
|
"modal-connect": any,
|
||||||
|
"modal-invite": [
|
||||||
|
/* events */ IpcRegistryDescription<InviteUiEvents>,
|
||||||
|
/* variables */ IpcVariableDescriptor<InviteUiVariables>,
|
||||||
|
/* serverName */ string
|
||||||
|
]
|
||||||
}
|
}
|
|
@ -66,3 +66,10 @@ registerModal({
|
||||||
classLoader: async () => await import("tc-shared/ui/modal/connect/Renderer"),
|
classLoader: async () => await import("tc-shared/ui/modal/connect/Renderer"),
|
||||||
popoutSupported: true
|
popoutSupported: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerModal({
|
||||||
|
modalId: "modal-invite",
|
||||||
|
classLoader: async () => await import("tc-shared/ui/modal/invite/Renderer"),
|
||||||
|
popoutSupported: true
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
@ -84,7 +84,6 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
|
||||||
name: "entry tags",
|
name: "entry tags",
|
||||||
priority: 10,
|
priority: 10,
|
||||||
function: async () => {
|
function: async () => {
|
||||||
const ipc = getIpcInstance();
|
ipcChannel = getIpcInstance().createCoreControlChannel(kIpcChannel);
|
||||||
ipcChannel = ipc.createChannel(AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, ipc.getLocalAddress()), kIpcChannel);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
|
@ -2,7 +2,7 @@ import {UiVariableConsumer, UiVariableMap, UiVariableProvider} from "tc-shared/u
|
||||||
import {guid} from "tc-shared/crypto/uid";
|
import {guid} from "tc-shared/crypto/uid";
|
||||||
import {LogCategory, logWarn} from "tc-shared/log";
|
import {LogCategory, logWarn} from "tc-shared/log";
|
||||||
|
|
||||||
class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVariableProvider<Variables> {
|
export class IpcUiVariableProvider<Variables extends UiVariableMap> extends UiVariableProvider<Variables> {
|
||||||
readonly ipcChannelId: string;
|
readonly ipcChannelId: string;
|
||||||
private broadcastChannel: BroadcastChannel;
|
private broadcastChannel: BroadcastChannel;
|
||||||
|
|
||||||
|
@ -146,7 +146,6 @@ class IpcUiVariableConsumer<Variables extends UiVariableMap> extends UiVariableC
|
||||||
|
|
||||||
private handleIpcMessage(message: any, _source: MessageEventSource | null) {
|
private handleIpcMessage(message: any, _source: MessageEventSource | null) {
|
||||||
if(message.type === "notify") {
|
if(message.type === "notify") {
|
||||||
console.error("Received notify %s", message.variable);
|
|
||||||
this.notifyRemoteVariable(message.variable, message.customData, message.value);
|
this.notifyRemoteVariable(message.variable, message.customData, message.value);
|
||||||
} else if(message.type === "edit-result") {
|
} else if(message.type === "edit-result") {
|
||||||
const payload = this.editListener[message.token];
|
const payload = this.editListener[message.token];
|
||||||
|
|
|
@ -45,6 +45,10 @@ export abstract class UiVariableProvider<Variables extends UiVariableMap> {
|
||||||
this.variableProvider[variable as any] = provider;
|
this.variableProvider[variable as any] = provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param variable
|
||||||
|
* @param editor If the editor returns `false` or a new variable, such variable will be used
|
||||||
|
*/
|
||||||
setVariableEditor<T extends keyof Variables>(variable: T, editor: UiVariableEditor<Variables, T>) {
|
setVariableEditor<T extends keyof Variables>(variable: T, editor: UiVariableEditor<Variables, T>) {
|
||||||
this.variableEditor[variable as any] = editor;
|
this.variableEditor[variable as any] = editor;
|
||||||
}
|
}
|
||||||
|
@ -247,7 +251,7 @@ export abstract class UiVariableConsumer<Variables extends UiVariableMap> {
|
||||||
/* Variable constructor */
|
/* Variable constructor */
|
||||||
cacheEntry.useCount++;
|
cacheEntry.useCount++;
|
||||||
|
|
||||||
if(cacheEntry.status === "loading") {
|
if(cacheEntry.status === "loaded") {
|
||||||
return {
|
return {
|
||||||
status: "set",
|
status: "set",
|
||||||
value: cacheEntry.currentValue
|
value: cacheEntry.currentValue
|
||||||
|
|
|
@ -13,7 +13,9 @@
|
||||||
"tc-backend/*": ["shared/backend.d/*"],
|
"tc-backend/*": ["shared/backend.d/*"],
|
||||||
"tc-loader": ["loader/exports/loader.d.ts"],
|
"tc-loader": ["loader/exports/loader.d.ts"],
|
||||||
"svg-sprites/*": ["shared/svg-sprites/*"],
|
"svg-sprites/*": ["shared/svg-sprites/*"],
|
||||||
"vendor/xbbcode/*": ["vendor/xbbcode/src/*"]
|
"vendor/xbbcode/*": ["vendor/xbbcode/src/*"],
|
||||||
|
"tc-events": ["vendor/TeaEventBus/src/index.ts"],
|
||||||
|
"tc-services": ["vendor/TeaClientServices/src/index.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
|
@ -24,6 +26,8 @@
|
||||||
"../js/main.tsx",
|
"../js/main.tsx",
|
||||||
"../backend.d",
|
"../backend.d",
|
||||||
"../js/**/*.ts",
|
"../js/**/*.ts",
|
||||||
"../../webpack/build-definitions.d.ts"
|
"../../webpack/build-definitions.d.ts",
|
||||||
|
"../../vendor/TeaEventBus/src/**/*.ts",
|
||||||
|
"../../vendor/TeaClientServices/src/**/*.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -15,6 +15,8 @@
|
||||||
"tc-backend/web/*": ["web/app/*"], /* specific web part */
|
"tc-backend/web/*": ["web/app/*"], /* specific web part */
|
||||||
"tc-backend/*": ["shared/backend.d/*"],
|
"tc-backend/*": ["shared/backend.d/*"],
|
||||||
"tc-loader": ["loader/exports/loader.d.ts"],
|
"tc-loader": ["loader/exports/loader.d.ts"],
|
||||||
|
"tc-events": ["vendor/TeaEventBus/src/index.ts"],
|
||||||
|
"tc-services": ["vendor/TeaClientServices/src/index.ts"],
|
||||||
|
|
||||||
"svg-sprites/*": ["shared/svg-sprites/*"],
|
"svg-sprites/*": ["shared/svg-sprites/*"],
|
||||||
"vendor/xbbcode/*": ["vendor/xbbcode/src/*"]
|
"vendor/xbbcode/*": ["vendor/xbbcode/src/*"]
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit f9267daa208f7f97a7bc56d52b89dac7cc0004e7
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 8310382d8a851b2e7095b400807141065811da53
|
|
@ -1 +1 @@
|
||||||
Subproject commit 336077435bbb09bb25f6efdcdac36956288fd3ca
|
Subproject commit d1a1b51f61c0dce71ebd856208964581ba6fecc7
|
|
@ -1,11 +1,13 @@
|
||||||
import {AbstractExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller";
|
import {AbstractExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller";
|
||||||
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
|
import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo";
|
||||||
import * as ipc from "tc-shared/ipc/BrowserIPC";
|
import {ChannelMessage, getIpcInstance} from "tc-shared/ipc/BrowserIPC";
|
||||||
import {ChannelMessage} from "tc-shared/ipc/BrowserIPC";
|
|
||||||
import {LogCategory, logDebug, logWarn} from "tc-shared/log";
|
import {LogCategory, logDebug, logWarn} from "tc-shared/log";
|
||||||
import {Popout2ControllerMessages, PopoutIPCMessage} from "tc-shared/ui/react-elements/external-modal/IPCMessage";
|
import {Popout2ControllerMessages, PopoutIPCMessage} from "tc-shared/ui/react-elements/external-modal/IPCMessage";
|
||||||
import {tr, tra} from "tc-shared/i18n/localize";
|
import {tr, tra} from "tc-shared/i18n/localize";
|
||||||
import {ModalOptions} from "tc-shared/ui/react-elements/modal/Definitions";
|
import {ModalOptions} from "tc-shared/ui/react-elements/modal/Definitions";
|
||||||
|
import {assertMainApplication} from "tc-shared/ui/utils";
|
||||||
|
|
||||||
|
assertMainApplication();
|
||||||
|
|
||||||
export class ExternalModalController extends AbstractExternalModalController {
|
export class ExternalModalController extends AbstractExternalModalController {
|
||||||
private readonly options: ModalOptions;
|
private readonly options: ModalOptions;
|
||||||
|
@ -87,8 +89,9 @@ export class ExternalModalController extends AbstractExternalModalController {
|
||||||
"loader-target": "manifest",
|
"loader-target": "manifest",
|
||||||
"chunk": "modal-external",
|
"chunk": "modal-external",
|
||||||
"modal-target": this.modalType,
|
"modal-target": this.modalType,
|
||||||
"ipc-channel": this.ipcChannel.channelId,
|
"modal-identify": this.ipcAuthenticationCode,
|
||||||
"ipc-address": ipc.getIpcInstance().getLocalAddress(),
|
"ipc-address": getIpcInstance().getApplicationChannelId(),
|
||||||
|
"ipc-core-peer": getIpcInstance().getLocalPeerId(),
|
||||||
"disableGlobalContextMenu": __build.mode === "debug" ? 1 : 0,
|
"disableGlobalContextMenu": __build.mode === "debug" ? 1 : 0,
|
||||||
"loader-abort": __build.mode === "debug" ? 1 : 0,
|
"loader-abort": __build.mode === "debug" ? 1 : 0,
|
||||||
};
|
};
|
||||||
|
@ -113,7 +116,7 @@ export class ExternalModalController extends AbstractExternalModalController {
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
protected handleIPCMessage(remoteId: string, broadcast: boolean, message: ChannelMessage) {
|
||||||
if(!broadcast && this.ipcRemoteId !== remoteId) {
|
if(!broadcast && this.ipcRemotePeerId !== remoteId) {
|
||||||
if(this.windowClosedTestInterval > 0) {
|
if(this.windowClosedTestInterval > 0) {
|
||||||
clearInterval(this.windowClosedTestInterval);
|
clearInterval(this.windowClosedTestInterval);
|
||||||
this.windowClosedTestInterval = 0;
|
this.windowClosedTestInterval = 0;
|
||||||
|
@ -127,8 +130,12 @@ export class ExternalModalController extends AbstractExternalModalController {
|
||||||
super.handleIPCMessage(remoteId, broadcast, message);
|
super.handleIPCMessage(remoteId, broadcast, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected handleTypedIPCMessage<T extends Popout2ControllerMessages>(type: T, payload: PopoutIPCMessage[T]) {
|
protected handleTypedIPCMessage<T extends Popout2ControllerMessages>(remoteId: string, isBroadcast: boolean, type: T, payload: PopoutIPCMessage[T]) {
|
||||||
super.handleTypedIPCMessage(type, payload);
|
super.handleTypedIPCMessage(remoteId, isBroadcast, type, payload);
|
||||||
|
|
||||||
|
if(isBroadcast) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case "invoke-modal-action":
|
case "invoke-modal-action":
|
||||||
|
|
|
@ -26,7 +26,7 @@ class IPCContextMenu implements ContextMenuFactory {
|
||||||
private closeCallback: () => void;
|
private closeCallback: () => void;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, kIPCContextMenuChannel);
|
this.ipcChannel = ipc.getIpcInstance().createChannel(kIPCContextMenuChannel);
|
||||||
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this);
|
||||||
|
|
||||||
/* if we're just created we're the focused window ;) */
|
/* if we're just created we're the focused window ;) */
|
||||||
|
|
|
@ -230,7 +230,9 @@ export const config = async (target: "web" | "client"): Promise<Configuration> =
|
||||||
resolve: {
|
resolve: {
|
||||||
extensions: ['.tsx', '.ts', '.js', ".scss", ".css", ".wasm"],
|
extensions: ['.tsx', '.ts', '.js', ".scss", ".css", ".wasm"],
|
||||||
alias: {
|
alias: {
|
||||||
"vendor/xbbcode": path.resolve(__dirname, "vendor/xbbcode/src")
|
"vendor/xbbcode": path.resolve(__dirname, "vendor/xbbcode/src"),
|
||||||
|
"tc-events": path.resolve(__dirname, "vendor/TeaEventBus/src/index.ts"),
|
||||||
|
"tc-services": path.resolve(__dirname, "vendor/TeaClientServices/src/index.ts"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
externals: [
|
externals: [
|
||||||
|
|
Loading…
Reference in New Issue