diff --git a/.gitmodules b/.gitmodules index a6464e98..8bd60322 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,9 @@ [submodule "web/native-codec/libraries/opus"] path = web/native-codec/libraries/opus 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 diff --git a/ChangeLog.md b/ChangeLog.md index 7347a177..7165537d 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,4 +1,8 @@ # Changelog: +* **20.02.21** + - Improved the browser IPC module + - Added support for client invite links + * **15.02.21** - Fixed critical bug within the event registry class - Added a dropdown for the microphone control button to quickly change microphones diff --git a/client-api/api.php b/client-api/api.php index e9e7f01f..fc05893c 100644 --- a/client-api/api.php +++ b/client-api/api.php @@ -1,23 +1,23 @@ false, - "msg" => $message - ])); - } + function errorExit($message) { + http_response_code(400); + die(json_encode([ + "success" => false, + "msg" => $message + ])); + } - function verifyPostSecret() { + function verifyPostSecret() { if(!isset($_POST["secret"])) { errorExit("Missing required information!"); } @@ -36,159 +36,159 @@ } } - function handleRequest() { - if(isset($_GET) && isset($_GET["type"])) { - if ($_GET["type"] == "update-info") { - global $CLIENT_BASE_PATH; - $raw_versions = file_get_contents($CLIENT_BASE_PATH . "/version.json"); - if($raw_versions === false) { + function handleRequest() { + if(isset($_GET) && isset($_GET["type"])) { + if ($_GET["type"] == "update-info") { + global $CLIENT_BASE_PATH; + $raw_versions = file_get_contents($CLIENT_BASE_PATH . "/version.json"); + if($raw_versions === false) { errorExit("Missing file!"); } - $versions = json_decode($raw_versions, true); - $versions["success"] = true; + $versions = json_decode($raw_versions, true); + $versions["success"] = true; - die(json_encode($versions)); - } - else if ($_GET["type"] == "update-download") { - global $CLIENT_BASE_PATH; + die(json_encode($versions)); + } + else if ($_GET["type"] == "update-download") { + global $CLIENT_BASE_PATH; - $path = $CLIENT_BASE_PATH . $_GET["channel"] . DIRECTORY_SEPARATOR . $_GET["version"] . DIRECTORY_SEPARATOR; - $raw_release_info = file_get_contents($path . "info.json"); - if($raw_release_info === false) { + $path = $CLIENT_BASE_PATH . $_GET["channel"] . DIRECTORY_SEPARATOR . $_GET["version"] . DIRECTORY_SEPARATOR; + $raw_release_info = file_get_contents($path . "info.json"); + if($raw_release_info === false) { errorExit("missing info file (version and/or channel missing. Path was " . $path . ")"); } - $release_info = json_decode($raw_release_info); + $release_info = json_decode($raw_release_info); - foreach($release_info as $platform) { - if($platform->platform != $_GET["platform"]) continue; - if($platform->arch != $_GET["arch"]) continue; + foreach($release_info as $platform) { + if($platform->platform != $_GET["platform"]) continue; + if($platform->arch != $_GET["arch"]) continue; - http_response_code(200); - header("Cache-Control: public"); // needed for internet explorer - header("Content-Type: application/binary"); - header("Content-Transfer-Encoding: Binary"); - header("Content-Length:".filesize($path . $platform->update)); - header("Content-Disposition: attachment; filename=update.tar.gz"); - header("info-version: 1"); - readfile($path . $platform->update); - die(); - } - errorExit("Missing platform, arch or file"); - } - else if ($_GET["type"] == "ui-info") { - global $UI_BASE_PATH; + http_response_code(200); + header("Cache-Control: public"); // needed for internet explorer + header("Content-Type: application/binary"); + header("Content-Transfer-Encoding: Binary"); + header("Content-Length:".filesize($path . $platform->update)); + header("Content-Disposition: attachment; filename=update.tar.gz"); + header("info-version: 1"); + readfile($path . $platform->update); + die(); + } + errorExit("Missing platform, arch or file"); + } + else if ($_GET["type"] == "ui-info") { + global $UI_BASE_PATH; - $version_info = file_get_contents($UI_BASE_PATH . "info.json"); - if($version_info === false) $version_info = array(); - else $version_info = json_decode($version_info, true); + $version_info = file_get_contents($UI_BASE_PATH . "info.json"); + if($version_info === false) $version_info = array(); + else $version_info = json_decode($version_info, true); - $info = array(); - $info["success"] = true; - $info["versions"] = array(); + $info = array(); + $info["success"] = true; + $info["versions"] = array(); - foreach($version_info as $channel => $data) { - if(!isset($data["latest"])) continue; + foreach($version_info as $channel => $data) { + if(!isset($data["latest"])) continue; - $channel_info = [ - "timestamp" => $data["latest"]["timestamp"], - "version" => $data["latest"]["version"], - "git-ref" => $data["latest"]["git-ref"], - "channel" => $channel, - "required_client" => $data["latest"]["required_client"] - ]; - array_push($info["versions"], $channel_info); - } - - die(json_encode($info)); - } else if ($_GET["type"] == "ui-download") { - global $UI_BASE_PATH; + $channel_info = [ + "timestamp" => $data["latest"]["timestamp"], + "version" => $data["latest"]["version"], + "git-ref" => $data["latest"]["git-ref"], + "channel" => $channel, + "required_client" => $data["latest"]["required_client"] + ]; + array_push($info["versions"], $channel_info); + } + + die(json_encode($info)); + } else if ($_GET["type"] == "ui-download") { + global $UI_BASE_PATH; - if(!isset($_GET["channel"]) || !isset($_GET["version"])) - errorExit("missing required parameters"); + if(!isset($_GET["channel"]) || !isset($_GET["version"])) + errorExit("missing required parameters"); - if($_GET["version"] !== "latest" && !isset($_GET["git-ref"])) - errorExit("missing required parameters"); + if($_GET["version"] !== "latest" && !isset($_GET["git-ref"])) + errorExit("missing required parameters"); - $version_info = file_get_contents($UI_BASE_PATH . "info.json"); - if($version_info === false) $version_info = array(); - else $version_info = json_decode($version_info, true); + $version_info = file_get_contents($UI_BASE_PATH . "info.json"); + if($version_info === false) $version_info = array(); + else $version_info = json_decode($version_info, true); - $channel_data = $version_info[$_GET["channel"]]; - if(!isset($channel_data)) - errorExit("channel unknown"); + $channel_data = $version_info[$_GET["channel"]]; + if(!isset($channel_data)) + errorExit("channel unknown"); - $ui_pack = false; - if($_GET["version"] === "latest") { - $ui_pack = $channel_data["latest"]; - } else { - foreach ($channel_data["history"] as $entry) { - if($entry["version"] == $_GET["version"] && $entry["git-ref"] == $_GET["git-ref"]) { - $ui_pack = $entry; - break; - } - } - } - if($ui_pack === false) - errorExit("missing version"); + $ui_pack = false; + if($_GET["version"] === "latest") { + $ui_pack = $channel_data["latest"]; + } else { + foreach ($channel_data["history"] as $entry) { + if($entry["version"] == $_GET["version"] && $entry["git-ref"] == $_GET["git-ref"]) { + $ui_pack = $entry; + break; + } + } + } + if($ui_pack === false) + errorExit("missing version"); - header("Cache-Control: public"); // needed for internet explorer - header("Content-Type: application/binary"); - header("Content-Transfer-Encoding: Binary"); - header("Content-Disposition: attachment; filename=ui.tar.gz"); - header("info-version: 1"); + header("Cache-Control: public"); // needed for internet explorer + header("Content-Type: application/binary"); + header("Content-Transfer-Encoding: Binary"); + header("Content-Disposition: attachment; filename=ui.tar.gz"); + header("info-version: 1"); - header("x-ui-timestamp: " . $ui_pack["timestamp"]); - header("x-ui-version: " . $ui_pack["version"]); - header("x-ui-git-ref: " . $ui_pack["git-ref"]); - header("x-ui-required_client: " . $ui_pack["required_client"]); + header("x-ui-timestamp: " . $ui_pack["timestamp"]); + header("x-ui-version: " . $ui_pack["version"]); + header("x-ui-git-ref: " . $ui_pack["git-ref"]); + header("x-ui-required_client: " . $ui_pack["required_client"]); - $read = readfile($ui_pack["file"]); - header("Content-Length:" . $read); + $read = readfile($ui_pack["file"]); + header("Content-Length:" . $read); - if($read === false) errorExit("internal error: Failed to read file!"); - die(); - } - } - else if($_POST["type"] == "deploy-build") { - global $CLIENT_BASE_PATH; + if($read === false) errorExit("internal error: Failed to read file!"); + die(); + } + } + else if($_POST["type"] == "deploy-build") { + global $CLIENT_BASE_PATH; - if(!isset($_POST["version"]) || !isset($_POST["platform"]) || !isset($_POST["arch"]) || !isset($_POST["update_suffix"]) || !isset($_POST["installer_suffix"])) { + if(!isset($_POST["version"]) || !isset($_POST["platform"]) || !isset($_POST["arch"]) || !isset($_POST["update_suffix"]) || !isset($_POST["installer_suffix"])) { errorExit("Missing required information!"); - } + } verifyPostSecret(); - if(!isset($_FILES["update"])) { + if(!isset($_FILES["update"])) { errorExit("Missing update file"); } - if($_FILES["update"]["error"] !== UPLOAD_ERR_OK) { + if($_FILES["update"]["error"] !== UPLOAD_ERR_OK) { errorExit("Upload for update failed!"); } - if(!isset($_FILES["installer"])) { + if(!isset($_FILES["installer"])) { errorExit("Missing installer file"); } - if($_FILES["installer"]["error"] !== UPLOAD_ERR_OK) { + if($_FILES["installer"]["error"] !== UPLOAD_ERR_OK) { errorExit("Upload for installer failed!"); } - $json_version = json_decode($_POST["version"], true); - $version = $json_version["major"] . "." . $json_version["minor"] . "." . $json_version["patch"] . ($json_version["build"] > 0 ? "-" . $json_version["build"] : ""); - $path = $CLIENT_BASE_PATH . DIRECTORY_SEPARATOR . $_POST["channel"] . DIRECTORY_SEPARATOR . $version . DIRECTORY_SEPARATOR; - exec("mkdir -p " . $path); - //mkdir($path, 777, true); + $json_version = json_decode($_POST["version"], true); + $version = $json_version["major"] . "." . $json_version["minor"] . "." . $json_version["patch"] . ($json_version["build"] > 0 ? "-" . $json_version["build"] : ""); + $path = $CLIENT_BASE_PATH . DIRECTORY_SEPARATOR . $_POST["channel"] . DIRECTORY_SEPARATOR . $version . DIRECTORY_SEPARATOR; + exec("mkdir -p " . $path); + //mkdir($path, 777, true); - $filename_update = "TeaClient-" . $_POST["platform"] . "_" . $_POST["arch"] . "." . $_POST["update_suffix"]; - $filename_install = "TeaClient-" . $_POST["platform"] . "_" . $_POST["arch"] . "." . $_POST["installer_suffix"]; + $filename_update = "TeaClient-" . $_POST["platform"] . "_" . $_POST["arch"] . "." . $_POST["update_suffix"]; + $filename_install = "TeaClient-" . $_POST["platform"] . "_" . $_POST["arch"] . "." . $_POST["installer_suffix"]; - { - $version_info = file_get_contents($path . "info.json"); - if($version_info === false) { + { + $version_info = file_get_contents($path . "info.json"); + if($version_info === false) { $version_info = array(); } else { $version_info = json_decode($version_info, true); @@ -197,26 +197,26 @@ } } - for($index = 0; $index < count($version_info); $index++) { - if($version_info[$index]["platform"] == $_POST["platform"] && $version_info[$index]["arch"] == $_POST["arch"]) { - array_splice($version_info, $index, 1); - break; - } - } + for($index = 0; $index < count($version_info); $index++) { + if($version_info[$index]["platform"] == $_POST["platform"] && $version_info[$index]["arch"] == $_POST["arch"]) { + array_splice($version_info, $index, 1); + break; + } + } - $info = array(); - $info["platform"] = $_POST["platform"]; - $info["arch"] = $_POST["arch"]; - $info["update"] = $filename_update; - $info["install"] = $filename_install; - array_push($version_info, $info); - file_put_contents($path . "info.json", json_encode($version_info)); - } + $info = array(); + $info["platform"] = $_POST["platform"]; + $info["arch"] = $_POST["arch"]; + $info["update"] = $filename_update; + $info["install"] = $filename_install; + array_push($version_info, $info); + file_put_contents($path . "info.json", json_encode($version_info)); + } - { - $filename = $CLIENT_BASE_PATH . DIRECTORY_SEPARATOR . "version.json"; - $indexes = file_get_contents($filename); - if($indexes === false) { + { + $filename = $CLIENT_BASE_PATH . DIRECTORY_SEPARATOR . "version.json"; + $indexes = file_get_contents($filename); + if($indexes === false) { $indexes = array(); } else { $indexes = json_decode($indexes, true); @@ -225,52 +225,52 @@ } } - $index = &$indexes[$_POST["channel"]]; - if(!isset($index)) { + $index = &$indexes[$_POST["channel"]]; + if(!isset($index)) { $index = array(); - } + } - for($idx = 0; $idx < count($index); $idx++) { - if($index[$idx]["platform"] == $_POST["platform"] && $index[$idx]["arch"] == $_POST["arch"]) { - array_splice($index, $idx, 1); - break; - } - } + for($idx = 0; $idx < count($index); $idx++) { + if($index[$idx]["platform"] == $_POST["platform"] && $index[$idx]["arch"] == $_POST["arch"]) { + array_splice($index, $idx, 1); + break; + } + } - $info = array(); - $info["platform"] = $_POST["platform"]; - $info["arch"] = $_POST["arch"]; - $info["version"] = $json_version; - array_push($index, $info); + $info = array(); + $info["platform"] = $_POST["platform"]; + $info["arch"] = $_POST["arch"]; + $info["version"] = $json_version; + array_push($index, $info); - file_put_contents($filename, json_encode($indexes)); - } + file_put_contents($filename, json_encode($indexes)); + } - move_uploaded_file($_FILES["installer"]["tmp_name"],$path . $filename_install); - move_uploaded_file($_FILES["update"]["tmp_name"],$path . $filename_update); + move_uploaded_file($_FILES["installer"]["tmp_name"],$path . $filename_install); + move_uploaded_file($_FILES["update"]["tmp_name"],$path . $filename_update); - die(json_encode([ - "success" => true - ])); - } - else if($_POST["type"] == "deploy-ui-build") { - global $UI_BASE_PATH; + die(json_encode([ + "success" => true + ])); + } + else if($_POST["type"] == "deploy-ui-build") { + global $UI_BASE_PATH; - if(!isset($_POST["channel"]) || !isset($_POST["version"]) || !isset($_POST["git_ref"]) || !isset($_POST["required_client"])) { + if(!isset($_POST["channel"]) || !isset($_POST["version"]) || !isset($_POST["git_ref"]) || !isset($_POST["required_client"])) { errorExit("Missing required information!"); - } + } verifyPostSecret(); - $path = $UI_BASE_PATH . DIRECTORY_SEPARATOR; - $channeled_path = $UI_BASE_PATH . DIRECTORY_SEPARATOR . $_POST["channel"]; - $filename = "TeaClientUI-" . $_POST["version"] . "_" . $_POST["git_ref"] . ".tar.gz"; - exec("mkdir -p " . $path); - exec("mkdir -p " . $channeled_path); + $path = $UI_BASE_PATH . DIRECTORY_SEPARATOR; + $channeled_path = $UI_BASE_PATH . DIRECTORY_SEPARATOR . $_POST["channel"]; + $filename = "TeaClientUI-" . $_POST["version"] . "_" . $_POST["git_ref"] . ".tar.gz"; + exec("mkdir -p " . $path); + exec("mkdir -p " . $channeled_path); - { - $info = file_get_contents($path . "info.json"); - if($info === false) { + { + $info = file_get_contents($path . "info.json"); + if($info === false) { $info = array(); } else { $info = json_decode($info, true); @@ -279,36 +279,36 @@ } } - $channel_info = &$info[$_POST["channel"]]; - if(!$channel_info) { + $channel_info = &$info[$_POST["channel"]]; + if(!$channel_info) { $channel_info = array(); } - $entry = [ - "timestamp" => time(), - "file" => $channeled_path . DIRECTORY_SEPARATOR . $filename, - "version" => $_POST["version"], - "git-ref" => $_POST["git_ref"], - "required_client" => $_POST["required_client"] - ]; + $entry = [ + "timestamp" => time(), + "file" => $channeled_path . DIRECTORY_SEPARATOR . $filename, + "version" => $_POST["version"], + "git-ref" => $_POST["git_ref"], + "required_client" => $_POST["required_client"] + ]; - $channel_info["latest"] = $entry; - if(!$channel_info["history"]) $channel_info["history"] = array(); - array_push($channel_info["history"], $entry); + $channel_info["latest"] = $entry; + if(!$channel_info["history"]) $channel_info["history"] = array(); + array_push($channel_info["history"], $entry); - file_put_contents($path . "info.json", json_encode($info)); - } + file_put_contents($path . "info.json", json_encode($info)); + } - move_uploaded_file($_FILES["file"]["tmp_name"],$channeled_path . DIRECTORY_SEPARATOR . $filename); - die(json_encode([ - "success" => true - ])); - } - else die(json_encode([ - "success" => false, - "error" => "invalid action!" - ])); - } + move_uploaded_file($_FILES["file"]["tmp_name"],$channeled_path . DIRECTORY_SEPARATOR . $filename); + die(json_encode([ + "success" => true + ])); + } + else die(json_encode([ + "success" => false, + "error" => "invalid action!" + ])); + } - handleRequest(); + handleRequest(); diff --git a/shared/js/ConnectionHandler.ts b/shared/js/ConnectionHandler.ts index 19bd34cd..5902f14c 100644 --- a/shared/js/ConnectionHandler.ts +++ b/shared/js/ConnectionHandler.ts @@ -281,17 +281,32 @@ export class ConnectionHandler { client_nickname: parameters.nickname }); - this.channelTree.initialiseHead(parameters.targetAddress, resolvedAddress); + this.channelTree.initialiseHead(parameters.targetAddress, parsedAddress); /* hash the password if not already hashed */ - if(parameters.targetPassword && !parameters.targetPasswordHashed) { + if(parameters.serverPassword && !parameters.serverPasswordHashed) { try { - parameters.targetPassword = await hashPassword(parameters.targetPassword); - parameters.targetPasswordHashed = true; + parameters.serverPassword = await hashPassword(parameters.serverPassword); + parameters.serverPasswordHashed = true; } catch(error) { logError(LogCategory.CLIENT, tr("Failed to hash connect password: %o"), error); createErrorModal(tr("Error while hashing password"), tr("Failed to hash server password!
") + error).open(); + /* FIXME: Abort connection attempt */ + } + if(this.connectAttemptId !== localConnectionAttemptId) { + /* Our attempt has been aborted */ + return; + } + } + + if(parameters.defaultChannelPassword && !parameters.defaultChannelPasswordHashed) { + try { + parameters.defaultChannelPassword = await hashPassword(parameters.defaultChannelPassword); + parameters.defaultChannelPasswordHashed = true; + } catch(error) { + logError(LogCategory.CLIENT, tr("Failed to hash channel password: %o"), error); + createErrorModal(tr("Error while hashing password"), tr("Failed to hash channel password!
") + error).open(); /* FIXME: Abort connection attempt */ } @@ -332,6 +347,7 @@ export class ConnectionHandler { } this.handleDisconnect(DisconnectReason.DNS_FAILED, error); + return; } } else { this.handleDisconnect(DisconnectReason.DNS_FAILED, tr("Unable to resolve hostname")); @@ -343,7 +359,7 @@ export class ConnectionHandler { } else { this.currentConnectId = await connectionHistory.logConnectionAttempt({ 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, }); } @@ -359,8 +375,8 @@ export class ConnectionHandler { nickname: parameters.nickname, nicknameSpecified: true, - targetPassword: parameters.password?.password, - targetPasswordHashed: parameters.password?.hashed, + serverPassword: parameters.password?.password, + serverPasswordHashed: parameters.password?.hashed, defaultChannel: parameters?.channel?.target, defaultChannelPassword: parameters?.channel?.password, @@ -968,11 +984,11 @@ export class ConnectionHandler { const connectParameters = this.serverConnection.handshake_handler().parameters; return { - channel: targetChannel ? {target: "/" + targetChannel.channelId, password: targetChannel.cached_password()} : undefined, + channel: targetChannel ? {target: "/" + targetChannel.channelId, password: targetChannel.getCachedPasswordHash()} : undefined, nickname: name, - password: connectParameters.targetPassword ? { - password: connectParameters.targetPassword, - hashed: connectParameters.targetPasswordHashed + password: connectParameters.serverPassword ? { + password: connectParameters.serverPassword, + hashed: connectParameters.serverPasswordHashed } : undefined } } diff --git a/shared/js/clientservice/GeoLocation.ts b/shared/js/clientservice/GeoLocation.ts deleted file mode 100644 index 8a877f2c..00000000 --- a/shared/js/clientservice/GeoLocation.ts +++ /dev/null @@ -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; -} - -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; - - 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 { - return await new Promise(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 { - 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 { - 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 { - 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" -}); \ No newline at end of file diff --git a/shared/js/clientservice/Messages.d.ts b/shared/js/clientservice/Messages.d.ts deleted file mode 100644 index cd3504e1..00000000 --- a/shared/js/clientservice/Messages.d.ts +++ /dev/null @@ -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 }; \ No newline at end of file diff --git a/shared/js/clientservice/index.ts b/shared/js/clientservice/index.ts index 23104b84..220b33db 100644 --- a/shared/js/clientservice/index.ts +++ b/shared/js/clientservice/index.ts @@ -1,443 +1,70 @@ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; -import {LogCategory, logDebug, logError, logInfo, logTrace, logWarn} from "tc-shared/log"; -import {Registry} from "tc-shared/events"; -import { - CommandSessionInitializeAgent, CommandSessionUpdateLocale, - Message, - MessageCommand, - MessageCommandResult, - MessageNotify, - NotifyClientsOnline -} from "./Messages"; -import {config, tr} from "tc-shared/i18n/localize"; -import {geoLocationProvider} from "tc-shared/clientservice/GeoLocation"; -import translation_config = config.translation_config; +import {config} from "tc-shared/i18n/localize"; import {getBackend} from "tc-shared/backend"; +import {ClientServiceConfig, ClientServiceInvite, ClientServices, ClientSessionType, LocalAgent} from "tc-services"; -const kApiVersion = 1; -const kVerbose = true; - -type ConnectionState = "disconnected" | "connecting" | "connected" | "reconnect-pending"; -type PendingCommand = { - resolve: (result: MessageCommandResult) => void, - timeout: number -}; - -interface ClientServiceConnectionEvents { - notify_state_changed: { oldState: ConnectionState, newState: ConnectionState }, - notify_notify_received: { notify: MessageNotify } -} - -let tokenIndex = 0; -class ClientServiceConnection { - readonly events: Registry; - 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(); - 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 { - 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 { - while(true) { - const result = await this.connection.executeCommand(command); - switch (result.type) { - case "ServerInternalError": - case "CommandEnqueueError": - case "ClientSessionUninitialized": - const shouldRetry = await new Promise(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); - } -} +import translation_config = config.translation_config; export let clientServices: ClientServices; +export let clientServiceInvite: ClientServiceInvite; loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { priority: 30, function: async () => { - clientServices = new ClientServices(); - clientServices.start(); + clientServices = new ClientServices(new class implements ClientServiceConfig { + 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; + + clientServiceInvite = new ClientServiceInvite(clientServices); + (window as any).clientServiceInvite = clientServiceInvite; }, name: "client services" }); \ No newline at end of file diff --git a/shared/js/connection/HandshakeHandler.ts b/shared/js/connection/HandshakeHandler.ts index 42b55be8..819f7be2 100644 --- a/shared/js/connection/HandshakeHandler.ts +++ b/shared/js/connection/HandshakeHandler.ts @@ -87,7 +87,7 @@ export class HandshakeHandler { client_default_channel_password: this.parameters.defaultChannelPassword || "", 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_output_hardware: this.connection.client.hasOutputHardware(), diff --git a/shared/js/conversations/ChannelConversationManager.ts b/shared/js/conversations/ChannelConversationManager.ts index 3ad71554..23b8f99c 100644 --- a/shared/js/conversations/ChannelConversationManager.ts +++ b/shared/js/conversations/ChannelConversationManager.ts @@ -441,7 +441,7 @@ export class ChannelConversationManager extends AbstractChatManager { 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 => { logWarn(LogCategory.CHAT, tr("Failed to query conversation indexes: %o"), error); }); diff --git a/shared/js/devel_main.ts b/shared/js/devel_main.ts index c90f9ba2..38190583 100644 --- a/shared/js/devel_main.ts +++ b/shared/js/devel_main.ts @@ -13,7 +13,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { priority: 10, function: async () => { await i18n.initialize(); - ipc.setup(); + ipc.setupIpcHandler(); } }); diff --git a/shared/js/events.ts b/shared/js/events.ts index cc0cf62e..afb87d21 100644 --- a/shared/js/events.ts +++ b/shared/js/events.ts @@ -1,570 +1,18 @@ -import {LogCategory, logTrace} from "./log"; -import {guid} from "./crypto/uid"; -import {useEffect} from "react"; -import {unstable_batchedUpdates} from "react-dom"; -import * as React from "react"; +import {EventRegistryHooks, setEventRegistryHooks} from "tc-events"; +import {LogCategory, logError, logTrace} from "tc-shared/log"; -/* -export type EventPayloadObject = { - [key: string]: EventPayload -} | { - [key: number]: EventPayload -}; +export * from "tc-events"; -export type EventPayload = string | number | bigint | null | undefined | EventPayloadObject; -*/ -export type EventPayloadObject = any; - -export type EventMap

= { - [K in keyof P]: EventPayloadObject & { - /* prohibit the type attribute on the highest layer (used to identify the event type) */ - type?: never - } -}; - -export type Event

, T extends keyof P> = { - readonly type: T, - - as(target: S) : Event; - asUnchecked(target: S) : Event; - asAnyUnchecked(target: S) : Event; - - /** - * 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

, T extends keyof P>(type: T, payload?: P[T]) : Event { - if(payload) { - (payload as any).type = type; - let event = payload as any as Event; - event.as = as; - event.asUnchecked = asUnchecked; - event.asAnyUnchecked = asUnchecked; - event.extractPayload = extractPayload; - return event; - } else { - return { - type, - as, - asUnchecked, - asAnyUnchecked: asUnchecked, - extractPayload - } as any; - } +setEventRegistryHooks(new class implements EventRegistryHooks { + logAsyncInvokeError(error: any) { + logError(LogCategory.EVENT_REGISTRY, tr("Failed to invoke async callback:\n%o"), error); } - function extractPayload() { - const result = Object.assign({}, this); - delete result["as"]; - delete result["asUnchecked"]; - delete result["asAnyUnchecked"]; - delete result["extractPayload"]; - return result; + logReactInvokeError(error: any) { + logError(LogCategory.EVENT_REGISTRY, tr("Failed to invoke react callback:\n%o"), error); } - function as(target) { - if(this.type !== target) { - throw "Mismatching event type. Expected: " + target + ", Got: " + this.type; - } - - return this; + logTrace(message: string, ...args: any[]) { + logTrace(LogCategory.EVENT_REGISTRY, message, ...args); } - - function asUnchecked() { - return this; - } -} - -export interface EventSender = EventMap> { - fire(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(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(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 = EventMap> implements EventSender { - 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 = EventMap>(description: IpcRegistryDescription) : Registry { - const registry = new Registry(); - 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(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(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(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(event: T | T[], handler: (event: Event) => 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(event: T | T[], handler: (event: Event) => 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) => void); - off(events: T | T[], handler: (event: Event) => 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) => void): () => void { - this.genericEventHandler.push(handler); - return () => this.genericEventHandler.remove(handler); - } - - offAll(handler: (event: Event) => 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(event: T | T[], handler: (event: Event) => 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) { - 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); - } - }); - - if(!parentClasses) { - break; - } - } 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 { - 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(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, Events = any>(registry_callback: (object: ObjectClass) => Registry) { - 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 = EventMap> = { - 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; - } - } - } -} \ No newline at end of file +}); \ No newline at end of file diff --git a/shared/js/file/LocalAvatars.ts b/shared/js/file/LocalAvatars.ts index b8cddc2e..cca9d616 100644 --- a/shared/js/file/LocalAvatars.ts +++ b/shared/js/file/LocalAvatars.ts @@ -352,7 +352,7 @@ class LocalAvatarManagerFactory extends AbstractAvatarManagerFactory { constructor() { super(); - this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, kIPCAvatarChannel); + this.ipcChannel = ipc.getIpcInstance().createChannel(kIPCAvatarChannel); this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); server_connections.events().on("notify_handler_created", event => this.handleHandlerCreated(event.handler)); diff --git a/shared/js/file/LocalIcons.ts b/shared/js/file/LocalIcons.ts index dc3fb820..ede65e7e 100644 --- a/shared/js/file/LocalIcons.ts +++ b/shared/js/file/LocalIcons.ts @@ -68,7 +68,7 @@ class IconManager extends AbstractIconManager { constructor() { super(); - this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, kIPCIconChannel); + this.ipcChannel = ipc.getIpcInstance().createChannel(kIPCIconChannel); this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); server_connections.events().on("notify_handler_created", event => { diff --git a/shared/js/file/RemoteAvatars.ts b/shared/js/file/RemoteAvatars.ts index 8d8fd771..1dc63aff 100644 --- a/shared/js/file/RemoteAvatars.ts +++ b/shared/js/file/RemoteAvatars.ts @@ -8,7 +8,7 @@ import { kIPCAvatarChannel, setGlobalAvatarManagerFactory, uniqueId2AvatarId } from "../file/Avatars"; -import {IPCChannel} from "../ipc/BrowserIPC"; +import {getIpcInstance, IPCChannel} from "../ipc/BrowserIPC"; import {AppParameters} from "../settings"; import {ChannelMessage} from "../ipc/BrowserIPC"; import {guid} from "../crypto/uid"; @@ -159,7 +159,7 @@ class RemoteAvatarManagerFactory extends AbstractAvatarManagerFactory { constructor() { 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); } diff --git a/shared/js/file/RemoteIcons.ts b/shared/js/file/RemoteIcons.ts index 7aef5860..82ff40e8 100644 --- a/shared/js/file/RemoteIcons.ts +++ b/shared/js/file/RemoteIcons.ts @@ -3,7 +3,6 @@ import * as loader from "tc-loader"; import {Stage} from "tc-loader"; import {ChannelMessage, IPCChannel} 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"; class RemoteRemoteIcon extends RemoteIcon { @@ -33,7 +32,7 @@ class RemoteIconManager extends AbstractIconManager { constructor() { 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); } diff --git a/shared/js/ipc/BrowserIPC.ts b/shared/js/ipc/BrowserIPC.ts index 6d8b928b..845e70fb 100644 --- a/shared/js/ipc/BrowserIPC.ts +++ b/shared/js/ipc/BrowserIPC.ts @@ -1,150 +1,131 @@ import "broadcastchannel-polyfill"; -import {LogCategory, logDebug, logError, logWarn} from "../log"; +import {LogCategory, logDebug, logError, logTrace, logWarn} from "../log"; import {ConnectHandler} from "../ipc/ConnectHandler"; import {tr} from "tc-shared/i18n/localize"; +import {guid} from "tc-shared/crypto/uid"; +import {AppParameters} from "tc-shared/settings"; -export interface BroadcastMessage { - timestamp: number; - receiver: string; - sender: string; +interface IpcRawMessage { + timestampSend: number, - type: string; - data: any; -} + sourcePeerId: string, + targetPeerId: string, -function uuidv4() { - 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); - }); -} + targetChannelId: string, -interface ProcessQuery { - timestamp: number - query_id: string; + message: ChannelMessage } export interface ChannelMessage { - channel_id: string; - type: string; - data: any; + type: string, + 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 { 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 localUniqueId: string; - protected constructor() { } - - setup() { - this.localUniqueId = uuidv4(); + protected constructor(applicationChannelId: string) { + this.applicationChannelId = applicationChannelId; + this.localPeerId = guid(); } - getLocalAddress() : string { return this.localUniqueId; } + setup() { } - abstract sendMessage(type: string, data: any, target?: string); + getApplicationChannelId() : string { return this.applicationChannelId; } - protected handleMessage(message: BroadcastMessage) { - //log.trace(LogCategory.IPC, tr("Received message %o"), message); + getLocalPeerId() : string { return this.localPeerId; } - if(message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID) { - 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: (message.data).query_id, - request_timestamp: (message.data).timestamp, + abstract sendMessage(message: IpcRawMessage); - device_id: this.localUniqueId, - protocol: BasicIPCHandler.PROTOCOL_VERSION - } as ProcessQueryResponse, message.sender); - 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]; + protected handleMessage(message: IpcRawMessage) { + logTrace(LogCategory.IPC, tr("Received message %o"), message); - this.sendMessage("certificate-accept-succeeded", { - - } 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.targetPeerId !== this.localPeerId && message.targetPeerId !== BasicIPCHandler.BROADCAST_UNIQUE_ID) { + /* The message isn't for us */ + return; } - if(message.type === "channel") { - const data: ChannelMessage = message.data; - let channel_invoked = false; - for(const channel of this.registeredChannels) { - if(channel.channelId === data.channel_id && (typeof(channel.targetClientId) === "undefined" || channel.targetClientId === message.sender)) { - if(channel.messageHandler) - channel.messageHandler(message.sender, message.receiver === BasicIPCHandler.BROADCAST_UNIQUE_ID, data); - channel_invoked = true; - } + let channelInvokeCount = 0; + for(const channel of this.registeredChannels) { + if(channel.channelId !== message.targetChannelId) { + continue; } - if(!channel_invoked) { - /* Seems like we're not the only web/teaclient instance */ - /* console.warn(tr("Received channel message for unknown channel (%s)"), data.channel_id); */ + 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 */ + /* 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 = { - targetClientId: targetId, - channelId: channelId || uuidv4(), + channelId: channelId, + targetPeerId: remotePeerId, messageHandler: undefined, - sendMessage: (type: string, data: any, target?: string) => { - if(typeof target !== "undefined") { - if(typeof channel.targetClientId === "string" && target != channel.targetClientId) { + sendMessage: (type: string, data: any, remotePeerId?: string) => { + if(typeof remotePeerId !== "undefined") { + if(typeof channel.targetPeerId === "string" && remotePeerId != channel.targetPeerId) { throw "target id does not match channel target"; } } - this.sendMessage("channel", { - type: type, - data: data, - channel_id: channel.channelId - } as ChannelMessage, target || channel.targetClientId || BasicIPCHandler.BROADCAST_UNIQUE_ID); + 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, + data: data, + }); + } + } + } } }; @@ -152,77 +133,42 @@ export abstract class BasicIPCHandler { 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; } deleteChannel(channel: IPCChannel) { - this.registeredChannels = this.registeredChannels.filter(e => e !== channel); - } - - private _query_results: {[key: string]:ProcessQueryResponse[]} = {}; - async queryProcesses(timeout?: number) : Promise { - 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 { - 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]); - }) + this.registeredChannels.remove(channel); } } export interface IPCChannel { + /** Channel id */ 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; - sendMessage(type: string, message: any, target?: string); + messageHandler: (sourcePeerId: string, broadcast: boolean, message: ChannelMessage) => void; + sendMessage(type: string, data: any, remotePeerId?: string); } class BroadcastChannelIPC extends BasicIPCHandler { - private static readonly CHANNEL_NAME = "TeaSpeak-Web"; - private channel: BroadcastChannel; - constructor() { - super(); + constructor(applicationChannelId: string) { + super(applicationChannelId); } 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.onmessageerror = this.onError.bind(this); } @@ -233,7 +179,7 @@ class BroadcastChannelIPC extends BasicIPCHandler { return; } - let message: BroadcastMessage; + let message: IpcRawMessage; try { message = JSON.parse(event.data); } catch(error) { @@ -247,52 +193,31 @@ class BroadcastChannelIPC extends BasicIPCHandler { logWarn(LogCategory.IPC, tr("Received error: %o"), event); } - sendMessage(type: string, data: any, target?: string) { - 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)); - } + sendMessage(message: IpcRawMessage) { + this.channel.postMessage(JSON.stringify(message)); } } -let handler: BasicIPCHandler; -let connect_handler: ConnectHandler; +let handlerInstance: BasicIPCHandler; +let connectHandler: ConnectHandler; -export function setup() { - if(!supported()) - return; +export function setupIpcHandler() { + if(handlerInstance) { + throw "IPC handler already initialized"; + } - if(handler) - throw "bipc already started"; + handlerInstance = new BroadcastChannelIPC(AppParameters.getValue(AppParameters.KEY_IPC_APP_ADDRESS, guid())); + handlerInstance.setup(); + logDebug(LogCategory.IPC, tr("Application IPC started for %s. Local peer address: %s"), handlerInstance.getApplicationChannelId(), handlerInstance.getLocalPeerId()); - handler = new BroadcastChannelIPC(); - handler.setup(); - - connect_handler = new ConnectHandler(handler); - connect_handler.setup(); + connectHandler = new ConnectHandler(handlerInstance); + connectHandler.setup(); } export function getIpcInstance() { - return handler; + return handlerInstance; } export function getInstanceConnectHandler() { - return connect_handler; -} - -export function supported() { - /* we've a polyfill now */ - return true; - - /* ios does not support this */ - return typeof(window.BroadcastChannel) !== "undefined"; + return connectHandler; } \ No newline at end of file diff --git a/shared/js/ipc/ConnectHandler.ts b/shared/js/ipc/ConnectHandler.ts index c731f83e..df66e0ff 100644 --- a/shared/js/ipc/ConnectHandler.ts +++ b/shared/js/ipc/ConnectHandler.ts @@ -8,6 +8,7 @@ export type ConnectRequestData = { profile?: string; username?: string; + password?: { value: string; hashed: boolean; @@ -75,7 +76,7 @@ export class ConnectHandler { } 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); } diff --git a/shared/js/ipc/MethodProxy.ts b/shared/js/ipc/MethodProxy.ts deleted file mode 100644 index 1c3a5606..00000000 --- a/shared/js/ipc/MethodProxy.ts +++ /dev/null @@ -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; - 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} = {}; - 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(method: (...args: any[]) => Promise | 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(); -} \ No newline at end of file diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 20521489..28f7c129 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -1,18 +1,18 @@ import * as loader from "tc-loader"; +import {Stage} from "tc-loader"; import * as bipc from "./ipc/BrowserIPC"; import * as sound from "./sound/Sounds"; import * as i18n from "./i18n/localize"; +import {tra} from "./i18n/localize"; import * as fidentity from "./profiles/identities/TeaForumIdentity"; import * as aplayer from "tc-backend/audio/player"; import * as ppt from "tc-backend/ppt"; import * as global_ev_handler from "./events/ClientGlobalControlHandler"; -import {Stage} from "tc-loader"; -import {AppParameters, settings, Settings} from "tc-shared/settings"; -import {LogCategory, logError, logInfo} from "tc-shared/log"; -import {tra} from "./i18n/localize"; +import {AppParameters, settings, Settings, UrlParameterBuilder, UrlParameterParser} from "tc-shared/settings"; +import {LogCategory, logDebug, logError, logInfo, logWarn} from "tc-shared/log"; import {ConnectionHandler} from "tc-shared/ConnectionHandler"; -import {createInfoModal} from "tc-shared/ui/elements/Modal"; -import {defaultRecorder, RecorderProfile, setDefaultRecorder} from "tc-shared/voice/RecorderProfile"; +import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal"; +import {RecorderProfile, setDefaultRecorder} from "tc-shared/voice/RecorderProfile"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; import {formatMessage} from "tc-shared/ui/frames/chat"; 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 {server_connections} from "tc-shared/ConnectionManager"; import {spawnConnectModalNew} from "tc-shared/ui/modal/connect/Controller"; +import {initializeKeyControl} from "./KeyControl"; +import {assertMainApplication} from "tc-shared/ui/utils"; /* required import for init */ import "svg-sprites/client-icons"; @@ -46,8 +48,9 @@ import "./ui/modal/connect/Controller"; import "./ui/elements/ContextDivider"; import "./ui/elements/Tab"; import "./clientservice"; -import {initializeKeyControl} from "./KeyControl"; -import {assertMainApplication} from "tc-shared/ui/utils"; +import "./text/bbcode/InviteController"; +import {clientServiceInvite} from "tc-shared/clientservice"; +import {ActionResult} from "tc-services"; assertMainApplication(); @@ -61,7 +64,7 @@ async function initialize() { return; } - bipc.setup(); + bipc.setupIpcHandler(); } async function initializeApp() { @@ -96,43 +99,214 @@ async function initializeApp() { } } -/* Used by the native client... We can't refactor this yet */ -export function handle_connect_request(properties: ConnectRequestData, connection: ConnectionHandler) { - const profile = findConnectProfile(properties.profile) || defaultConnectProfile(); - const username = properties.username || profile.connectUsername(); - - const password = properties.password ? properties.password.value : ""; - const password_hashed = properties.password ? properties.password.hashed : false; - - 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 { - /* Well... the client don't want to... */ - } - }).open(); - return; +/* The native client has received a connect request. */ +export function handleNativeConnectRequest(url: URL) { + let serverAddress = url.host; + if(url.searchParams.has("port")) { + if(serverAddress.indexOf(':') !== -1) { + logWarn(LogCategory.GENERAL, tr("Received connect request which specified the port twice (via parameter and host). Using host port.")); + } else if(serverAddress.indexOf(":") === -1) { + serverAddress += ":" + url.searchParams.get("port"); + } else { + serverAddress = `[${serverAddress}]:${url.searchParams.get("port")}`; } + } - connection.startConnection(properties.address, profile, true, { - nickname: username, - password: password.length > 0 ? { - password: password, - hashed: password_hashed - } : undefined - }); - server_connections.setActiveConnectionHandler(connection); - } else { - spawnConnectModalNew({ - selectedAddress: properties.address, - selectedProfile: profile + handleConnectRequest(serverAddress, undefined, new UrlParameterParser(url)).then(undefined); +} + +export async function handleConnectRequest(serverAddress: string, serverUniqueId: string | undefined, parameters: UrlParameterParser) { + const inviteLinkId = parameters.getValue(AppParameters.KEY_CONNECT_INVITE_REFERENCE, 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); + } }); } + + const result = await doHandleConnectRequest(serverAddress, serverUniqueId, parameters); + if(inviteLinkId) { + let promise: Promise>; + 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 { + + let targetServerConnection: ConnectionHandler; + let isCurrentServerConnection: boolean; + + if(serverUniqueId) { + if(server_connections.getActiveConnectionHandler()?.getCurrentServerUniqueId() === serverUniqueId) { + targetServerConnection = server_connections.getActiveConnectionHandler(); + isCurrentServerConnection = true; + } 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({ + selectedAddress: serverAddress, + 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(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() { @@ -235,7 +409,7 @@ const task_connect_handler: loader.Task = { return; } - /* FIXME: All additional parameters! */ + /* FIXME: All additional connect parameters! */ const connectData = { address: address, @@ -293,7 +467,7 @@ const task_connect_handler: loader.Task = { preventWelcomeUI = true; loader.register_task(loader.Stage.LOADED, { 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") }); loader.register_task(loader.Stage.LOADED, task_teaweb_starter); diff --git a/shared/js/settings.ts b/shared/js/settings.ts index 5288ff3c..df36f6ef 100644 --- a/shared/js/settings.ts +++ b/shared/js/settings.ts @@ -76,10 +76,11 @@ function encodeValueToString(input: T) : string { function resolveKey( key: RegistryKey, - resolver: (key: string) => string | undefined, + resolver: (key: string) => string | undefined | null, defaultValue: DefaultType ) : ValueType | DefaultType { let value = resolver(key.key); + if(typeof value === "string") { return decodeValueFromString(value, key.valueType); } @@ -104,41 +105,71 @@ function resolveKey( 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(key: RegistryKey, defaultValue: DV) : V | DV; + getValue(key: ValuedRegistryKey, defaultValue?: V) : V; + getValue(key: RegistryKey | ValuedRegistryKey, 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(key: RegistryKey, 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. * TODO: Passing native client switches */ export namespace AppParameters { - const parameters = {}; - - 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 const Instance = new UrlParameterParser(new URL(window.location.href)); export function getValue(key: RegistryKey, defaultValue: DV) : V | DV; export function getValue(key: ValuedRegistryKey, defaultValue?: V) : V; export function getValue(key: RegistryKey | ValuedRegistryKey, defaultValue: DV) : V | DV { if(arguments.length > 1) { - return resolveKey(key, key => parameters[key], defaultValue); + return Instance.getValue(key, defaultValue); } else if("defaultValue" in key) { - return resolveKey(key, key => parameters[key], key.defaultValue); + return Instance.getValue(key); } else { throw tr("missing value"); } } - - parseParameters(); } + (window as any).AppParameters = AppParameters; export namespace AppParameters { @@ -149,6 +180,12 @@ export namespace AppParameters { description: "A target address to automatically connect to." }; + export const KEY_CONNECT_INVITE_REFERENCE: RegistryKey = { + 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 = { key: "cnsi", @@ -167,13 +204,13 @@ export namespace AppParameters { export const KEY_CONNECT_NICKNAME: RegistryKey = { key: "cn", - fallbackKeys: ["connect_username"], + fallbackKeys: ["connect_username", "nickname"], valueType: "string" }; export const KEY_CONNECT_TOKEN: RegistryKey = { key: "ctk", - fallbackKeys: ["connect_token"], + fallbackKeys: ["connect_token", "connect-token", "token"], valueType: "string", 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 = { key: "csp", - fallbackKeys: ["connect_server_password"], + fallbackKeys: ["connect_server_password", "server-password"], 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 = { + 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 = { @@ -201,22 +246,28 @@ export namespace AppParameters { export const KEY_CONNECT_CHANNEL_PASSWORD: RegistryKey = { key: "ccp", - fallbackKeys: ["connect_channel_password"], + fallbackKeys: ["connect_channel_password", "channel-password"], valueType: "string", description: "The target channel password (hashed) for the connect attempt." }; - export const KEY_IPC_REMOTE_ADDRESS: RegistryKey = { + export const KEY_IPC_APP_ADDRESS: RegistryKey = { key: "ipc-address", 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 = { - key: "ipc-channel", + export const KEY_IPC_CORE_PEER_ADDRESS: RegistryKey = { + key: "ipc-core-peer", 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 = { + key: "modal-identify", + valueType: "string", + description: "An authentication code used to register the new process as the modal" }; export const KEY_MODAL_TARGET: RegistryKey = { @@ -708,6 +759,20 @@ export class Settings { valueType: "boolean", }; + static readonly KEY_INVITE_SHORT_URL: ValuedRegistryKey = { + 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 = { + 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 = category => { return { key: "log." + category.toLowerCase() + ".enabled", diff --git a/shared/js/text/bbcode/InviteController.ts b/shared/js/text/bbcode/InviteController.ts new file mode 100644 index 00000000..3482c7f0 --- /dev/null +++ b/shared/js/text/bbcode/InviteController.ts @@ -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)[] = []; + +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 { + 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, + }; +} \ No newline at end of file diff --git a/shared/js/text/bbcode/InviteDefinitions.ts b/shared/js/text/bbcode/InviteDefinitions.ts new file mode 100644 index 00000000..0135d0ee --- /dev/null +++ b/shared/js/text/bbcode/InviteDefinitions.ts @@ -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" +} \ No newline at end of file diff --git a/shared/js/text/bbcode/InviteRenderer.scss b/shared/js/text/bbcode/InviteRenderer.scss new file mode 100644 index 00000000..a2a4b764 --- /dev/null +++ b/shared/js/text/bbcode/InviteRenderer.scss @@ -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; + } + } + } +} \ No newline at end of file diff --git a/shared/js/text/bbcode/InviteRenderer.tsx b/shared/js/text/bbcode/InviteRenderer.tsx new file mode 100644 index 00000000..df5f20f1 --- /dev/null +++ b/shared/js/text/bbcode/InviteRenderer.tsx @@ -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 = ( +

+ +
+ ); + + 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 ( + + Link expired + + ); + } + + if(props.info.channelName) { + return ( +
+
+
+ +
{props.info.channelName}
+
+
{props.info.serverName}
+
+ {joinButton} +
+ ); + } else { + return ( +
+
+
Join server
+
{props.info.serverName}
+
+ {joinButton} +
+ ); + } +}); + +const InviteErrorRenderer = (props: { children, noTitle?: boolean }) => { + return ( +
+
+
+ Failed to load invite key: +
+
+ {props.children} +
+
+
+ ); +} + +const InviteLoadingRenderer = () => { + return ( +
+
+
+ Loading,
please wait
+
+
+
+ +
+
+ ); +} + +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 = ; + break; + + case "loading": + body = ; + break; + + case "error": + body = ( + + {linkInfo.message} + + ); + break; + + case "expired": + body = ( + + Invite link expired + + ); + break; + + case "not-found": + body = ( + + Unknown invite link + + ); + break; + } + + return ( + + {props.url} + {body} + + ); +} + +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()); + } +} \ No newline at end of file diff --git a/shared/js/text/bbcode/renderer.ts b/shared/js/text/bbcode/renderer.tsx similarity index 81% rename from shared/js/text/bbcode/renderer.ts rename to shared/js/text/bbcode/renderer.tsx index 9dc1cc98..5ad5f070 100644 --- a/shared/js/text/bbcode/renderer.ts +++ b/shared/js/text/bbcode/renderer.tsx @@ -13,11 +13,13 @@ import "./highlight"; import "./youtube"; import "./url"; import "./image"; +import {ElementRenderer, Renderer} from "vendor/xbbcode/renderer/base"; +import {TextElement} from "vendor/xbbcode/elements"; export let BBCodeHandlerContext: Context; export const rendererText = new TextRenderer(); -export const rendererReact = new ReactRenderer(); +export const rendererReact = new ReactRenderer(true); export const rendererHTML = new HTMLRenderer(rendererReact); loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { diff --git a/shared/js/text/bbcode/url.tsx b/shared/js/text/bbcode/url.tsx index 9987652d..cf34849e 100644 --- a/shared/js/text/bbcode/url.tsx +++ b/shared/js/text/bbcode/url.tsx @@ -8,6 +8,7 @@ import ReactRenderer from "vendor/xbbcode/renderer/react"; import {rendererReact, rendererText, BBCodeHandlerContext} from "tc-shared/text/bbcode/renderer"; import {ClientTag} from "tc-shared/ui/tree/EntryTags"; 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) { 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; +export const SimpleUrlRenderer = (props: { target: string, children }) => { + return ( + { + event.preventDefault(); + spawnUrlContextMenu(event.pageX, event.pageY, props.target); + }}> + {props.children} + + ); +} + loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { name: "XBBCode code tag init", function: async () => { @@ -65,23 +77,26 @@ loader.register_task(loader.Stage.JAVASCRIPT_INITIALIZING, { const clientDatabaseId = parseInt(clientData[1]); const clientUniqueId = clientDatabaseId[2]; - return 0 ? clientDatabaseId : undefined} - handlerId={handlerId} - />; + return ( + 0 ? clientDatabaseId : undefined} + handlerId={handlerId} + /> + ); + } + + if(isInviteLink(target)) { + return ; } } const body = ( - { - event.preventDefault(); - spawnUrlContextMenu(event.pageX, event.pageY, target); - }}> + {renderer.renderContent(element)} - + ); if(isYoutubeLink(target)) { diff --git a/shared/js/tree/Channel.ts b/shared/js/tree/Channel.ts index bfd39a13..67d3d41e 100644 --- a/shared/js/tree/Channel.ts +++ b/shared/js/tree/Channel.ts @@ -22,6 +22,7 @@ import {ClientIcon} from "svg-sprites/client-icons"; import { tr } from "tc-shared/i18n/localize"; import {EventChannelData} from "tc-shared/connectionlog/Definitions"; import {spawnChannelEditNew} from "tc-shared/ui/modal/channel-edit/Controller"; +import {spawnInviteGenerator} from "tc-shared/ui/modal/invite/Controller"; export enum ChannelType { PERMANENT, @@ -456,7 +457,7 @@ export class ChannelEntry extends ChannelTreeEntry { name: bold(tr("Switch to channel")), callback: () => this.joinChannel(), visible: this !== this.channelTree.client.getClient()?.currentChannel() - },{ + }, { type: contextmenu.MenuEntryType.ENTRY, icon_class: "client-filetransfer", name: bold(tr("Open channel file browser")), @@ -482,6 +483,11 @@ export class ChannelEntry extends ChannelTreeEntry { openChannelInfo(this); }, 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(); @@ -687,42 +693,51 @@ export class ChannelEntry extends ChannelTreeEntry { return ChannelType.TEMPORARY; } - joinChannel(ignorePasswordFlag?: boolean) { - if(this.channelTree.client.getClient().currentChannel() === this) - return; + async joinChannel(ignorePasswordFlag?: boolean) : Promise { + if(this.channelTree.client.getClient().currentChannel() === this) { + 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", { - "clid": this.channelTree.client.getClientId(), - "cid": this.getChannelId(), - "cpw": this.cachedPasswordHash || "" - }).then(() => { + 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(), + "cid": this.getChannelId(), + "cpw": this.cachedPasswordHash || "" + }); this.channelTree.client.sound.play(Sound.CHANNEL_JOINED); - }).catch(error => { + return true; + } catch (error) { if(error instanceof CommandResult) { if(error.id == ErrorCode.CHANNEL_INVALID_PASSWORD) { //Invalid password this.invalidateCachedPassword(); } } - }); + return false; + } } async requestChannelPassword(ignorePermission: PermissionType) : Promise<{ hash: string } | undefined> { - if(this.cachedPasswordHash) + if(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" }; + } 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; + } const hash = await hashPassword(password); this.cachedPasswordHash = hash; @@ -735,7 +750,11 @@ export class ChannelEntry extends ChannelTreeEntry { 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() { let shouldBeSubscribed = false; @@ -839,7 +858,7 @@ export class ChannelEntry extends ChannelTreeEntry { } 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; } else if (!this.properties.channel_flag_maxclients_unlimited && this.clients().length >= this.properties.channel_maxclients) { return subscribed ? ClientIcon.ChannelRedSubscribed : ClientIcon.ChannelRed; diff --git a/shared/js/tree/ChannelTree.tsx b/shared/js/tree/ChannelTree.tsx index 50879272..f1ca0bb1 100644 --- a/shared/js/tree/ChannelTree.tsx +++ b/shared/js/tree/ChannelTree.tsx @@ -270,6 +270,19 @@ export class ChannelTree { 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 { for(let index = 0; index < this.channels.length; index++) if(this.channels[index].channelName() == name && (!force_parent || parent == this.channels[index].parent)) diff --git a/shared/js/tree/EntryTagsHandler.ts b/shared/js/tree/EntryTagsHandler.ts index 54f26673..c66fa09d 100644 --- a/shared/js/tree/EntryTagsHandler.ts +++ b/shared/js/tree/EntryTagsHandler.ts @@ -95,7 +95,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { name: "entry tags", priority: 10, function: async () => { - const channel = getIpcInstance().createChannel(undefined, kIpcChannel); + const channel = getIpcInstance().createChannel(kIpcChannel); channel.messageHandler = (_remoteId, _broadcast, message) => handleIpcMessage(message.type, message.data); } }); \ No newline at end of file diff --git a/shared/js/tree/Server.ts b/shared/js/tree/Server.ts index c7023684..25bd29c8 100644 --- a/shared/js/tree/Server.ts +++ b/shared/js/tree/Server.ts @@ -13,6 +13,7 @@ import {spawnAvatarList} from "../ui/modal/ModalAvatarList"; import {Registry} from "../events"; import {ChannelTreeEntry, ChannelTreeEntryEvents} from "./ChannelTreeEntry"; import { tr } from "tc-shared/i18n/localize"; +import {spawnInviteGenerator} from "tc-shared/ui/modal/invite/Controller"; export class ServerProperties { virtualserver_host: string = ""; @@ -209,7 +210,7 @@ export class ServerEntry extends ChannelTreeEntry { type: contextmenu.MenuEntryType.ENTRY, icon_class: "client-invite_buddy", name: tr("Invite buddy"), - callback: () => spawnInviteEditor(this.channelTree.client) + callback: () => spawnInviteGenerator(this) }, { type: contextmenu.MenuEntryType.HR, name: '' diff --git a/shared/js/ui/frames/side/ClientInfoRenderer.tsx b/shared/js/ui/frames/side/ClientInfoRenderer.tsx index 92748b03..44035223 100644 --- a/shared/js/ui/frames/side/ClientInfoRenderer.tsx +++ b/shared/js/ui/frames/side/ClientInfoRenderer.tsx @@ -432,7 +432,7 @@ const ServerGroupRenderer = () => { return ( - Channel group + Server groups <>{body} ); diff --git a/shared/js/ui/frames/side/PopoutConversationRenderer.tsx b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx index fe17eca0..b98b92ef 100644 --- a/shared/js/ui/frames/side/PopoutConversationRenderer.tsx +++ b/shared/js/ui/frames/side/PopoutConversationRenderer.tsx @@ -1,4 +1,4 @@ -import {Registry, RegistryMap} from "tc-shared/events"; +import {IpcRegistryDescription, Registry} from "tc-shared/events"; import {AbstractConversationUiEvents} from "./AbstractConversationDefinitions"; import {ConversationPanel} from "./AbstractConversationRenderer"; import * as React from "react"; @@ -8,11 +8,11 @@ class PopoutConversationRenderer extends AbstractModal { private readonly events: Registry; private readonly userData: any; - constructor(registryMap: RegistryMap, userData: any) { + constructor(events: IpcRegistryDescription, userData: any) { super(); this.userData = userData; - this.events = registryMap["default"] as any; + this.events = Registry.fromIpcDescription(events); } renderBody() { diff --git a/shared/js/ui/modal/connect/Controller.ts b/shared/js/ui/modal/connect/Controller.ts index 45c2c08e..5ce74de1 100644 --- a/shared/js/ui/modal/connect/Controller.ts +++ b/shared/js/ui/modal/connect/Controller.ts @@ -26,8 +26,8 @@ const kRegexDomain = /^(localhost|((([a-zA-Z0-9_-]{0,63}\.){0,253})?[a-zA-Z0-9_- export type ConnectParameters = { targetAddress: string, - targetPassword?: string, - targetPasswordHashed?: boolean, + serverPassword?: string, + serverPasswordHashed?: boolean, nickname: string, nicknameSpecified: boolean, @@ -38,6 +38,7 @@ export type ConnectParameters = { defaultChannel?: string | number, defaultChannelPassword?: string, + defaultChannelPasswordHashed?: boolean, } class ConnectController { @@ -272,8 +273,8 @@ class ConnectController { profile: this.currentProfile, - targetPassword: this.currentPassword, - targetPasswordHashed: this.currentPasswordHashed + serverPassword: this.currentPassword, + serverPasswordHashed: this.currentPasswordHashed }; } diff --git a/shared/js/ui/modal/invite/Controller.ts b/shared/js/ui/modal/invite/Controller.ts new file mode 100644 index 00000000..550b65b9 --- /dev/null +++ b/shared/js/ui/modal/invite/Controller.ts @@ -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; + readonly variables: IpcUiVariableProvider; + + 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(); + 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); +} \ No newline at end of file diff --git a/shared/js/ui/modal/invite/Definitions.ts b/shared/js/ui/modal/invite/Definitions.ts new file mode 100644 index 00000000..bf01742d --- /dev/null +++ b/shared/js/ui/modal/invite/Definitions.ts @@ -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: {} +} \ No newline at end of file diff --git a/shared/js/ui/modal/invite/Renderer.scss b/shared/js/ui/modal/invite/Renderer.scss new file mode 100644 index 00000000..6c3152cc --- /dev/null +++ b/shared/js/ui/modal/invite/Renderer.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/invite/Renderer.tsx b/shared/js/ui/modal/invite/Renderer.tsx new file mode 100644 index 00000000..35302b5d --- /dev/null +++ b/shared/js/ui/modal/invite/Renderer.tsx @@ -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>(undefined); +const VariablesContext = React.createContext>(undefined); + +const OptionChannel = React.memo(() => { + const variables = useContext(VariablesContext); + const availableChannels = variables.useReadOnly("availableChannels", undefined, []); + const selectedChannel = variables.useVariable("selectedChannel", undefined, 0); + + return ( +
+
+ Automatically join channel +
+
+ { + const value = parseInt(event.target.value); + if(isNaN(value)) { + return; + } + + selectedChannel.setValue(value); + }} + > + + + {availableChannels.map(channel => ( + + )) as any} + +
+
+ ); +}); + +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 = ( + {}} /> + ); + } else if(channelPassword.localValue.hashed && !channelPassword.localValue.raw) { + body = ( + channelPassword.setValue({ hashed: channelPassword.localValue.hashed, raw: newValue }, true)} + /> + ); + } else { + body = ( + channelPassword.setValue({ hashed: channelPassword.localValue.hashed, raw: newValue }, true)} + onBlur={() => channelPassword.setValue(channelPassword.localValue, false)} + finishOnEnter={true} + /> + ); + } + + return ( +
+
Channel password
+
+ {body} +
+
+ ); +}) + +const OptionGeneralShortLink = React.memo(() => { + const variables = useContext(VariablesContext); + const showShortUrl = variables.useVariable("shortLink", undefined, true); + + return ( +
+ showShortUrl.setValue(newValue)} + value={showShortUrl.localValue} + label={Use short URL} + /> +
+ ) +}) + +const OptionGeneralShowAdvanced = React.memo(() => { + const variables = useContext(VariablesContext); + const showShortUrl = variables.useVariable("advancedSettings", undefined, false); + + return ( +
+ showShortUrl.setValue(newValue)} + value={showShortUrl.localValue} + label={Advanced settings} + /> +
+ ) +}) + +const OptionAdvancedToken = React.memo(() => { + const variables = useContext(VariablesContext); + const currentToken = variables.useVariable("token", undefined, ""); + + return ( +
+
Token
+
+ currentToken.setValue(newValue, true)} + onBlur={() => currentToken.setValue(currentToken.localValue, false)} + finishOnEnter={true} + /> +
+
+ ); +}); + +const OptionAdvancedWebUrlBase = React.memo(() => { + const variables = useContext(VariablesContext); + const currentUrl = variables.useVariable("webClientUrlBase", undefined, { override: undefined, fallback: undefined }); + + return ( +
+
WebClient URL
+
+ currentUrl.setValue({ fallback: currentUrl.localValue.fallback, override: newValue }, true)} + onBlur={() => currentUrl.setValue(currentUrl.localValue, false)} + finishOnEnter={true} + /> +
+
+ ); +}); + +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 ( +
+
Link expire time
+
+ { + 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)); + } + }} + > + + + { + ExpirePresets.map((preset, index) => ( + + )) as any + } + +
+
+ ); +}); + +const OptionsAdvanced = React.memo(() => { + return ( +
+
Advanced options
+ + + +
+ ) +}); + +const Options = React.memo(() => { + const variables = useContext(VariablesContext); + const showAdvanced = variables.useReadOnly("advancedSettings", undefined, false); + + return ( +
+
+
+
General
+ + +
+
+
Channel
+ + +
+
+ {showAdvanced ? : undefined} +
+ ); +}); + +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 ( +
+
{ + if(props.disabled) { + return; + } + + props.onCopy(); + setShowTimeout(Date.now() + 1750); + }}> + +
+
+ Copied! +
+
+ ); +}); + +const LinkExpire = (props: { date: number | 0 | -1 }) => { + let value; + if(props.date === -1) { + value =  ; + } else if(props.date === 0) { + value = Link expires never; + } else { + value = Link expires at {moment(props.date * 1000).format('LLLL')}; + } + + return ( +
{value}
+ ); +} + +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 = Generating link ; + 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 ( +
+
Link
+
+ {value} + { + if(copyValue) { + copyToClipboard(copyValue); + } + }} /> +
+ +
+ ); +}); + +const Buttons = () => { + const events = useContext(EventsContext); + + return ( +
+ +
+ ) +} + +class ModalInvite extends AbstractModal { + private readonly events: Registry; + private readonly variables: UiVariableConsumer; + private readonly serverName: string; + + constructor(events: IpcRegistryDescription, variables: IpcVariableDescriptor, serverName: string) { + super(); + + this.events = Registry.fromIpcDescription(events); + this.variables = createIpcUiVariableConsumer(variables); + this.serverName = serverName; + } + + renderBody(): React.ReactElement { + return ( + + +
+ + + +
+
+
+ ); + } + + renderTitle(): string | React.ReactElement { + return <>Invite People to {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(); +}); + */ \ No newline at end of file diff --git a/shared/js/ui/modal/transfer/FileBrowserControllerRemote.ts b/shared/js/ui/modal/transfer/FileBrowserControllerRemote.ts index 33c4d364..a3739daa 100644 --- a/shared/js/ui/modal/transfer/FileBrowserControllerRemote.ts +++ b/shared/js/ui/modal/transfer/FileBrowserControllerRemote.ts @@ -533,7 +533,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand return { path: e.info.path, cid: e.info.channelId, - cpw: e.info.channel?.cached_password(), + cpw: e.info.channel?.getCachedPasswordHash(), name: e.name } })).then(async result => { @@ -647,7 +647,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand //ftcreatedir cid=4 cpw dirname=\/TestDir return_code=1:17 connection.serverConnection.send_command("ftcreatedir", { cid: path.channelId, - cpw: path.channel.cached_password(), + cpw: path.channel.getCachedPasswordHash(), dirname: path.path + event.name }).then(() => { 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, path: info.type === "channel" ? info.path : "", name: info.type === "channel" ? file.name : "/" + file.name, - channelPassword: info.channel?.cached_password(), + channelPassword: info.channel?.getCachedPasswordHash(), targetSupplier: targetSupplier }); transfer.awaitFinished().then(() => { @@ -752,7 +752,7 @@ export function initializeRemoteFileBrowserController(connection: ConnectionHand const fileName = file.name; const transfer = connection.fileManager.initializeFileUpload({ channel: pathInfo.channelId, - channelPassword: pathInfo.channel?.cached_password(), + channelPassword: pathInfo.channel?.getCachedPasswordHash(), name: file.name, path: pathInfo.path, source: async () => TransferProvider.provider().createBrowserFileSource(file) diff --git a/shared/js/ui/react-elements/InputField.tsx b/shared/js/ui/react-elements/InputField.tsx index a1bae6f1..aa3d7a6f 100644 --- a/shared/js/ui/react-elements/InputField.tsx +++ b/shared/js/ui/react-elements/InputField.tsx @@ -4,6 +4,89 @@ import {joinClassList} from "tc-shared/ui/react-elements/Helper"; 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 ( +
props.onBlur()} + > + {props.leftIcon ? props.leftIcon() : ""} + {props.prefix ? {props.prefix} : undefined} + {props.inputBox ? + {props.inputBox()} : + + props.onChange(event.currentTarget.value)} + onKeyPress={event => { + if(event.key === "Enter") { + if(props.finishOnEnter) { + event.currentTarget.blur(); + } + + if(props.onEnter) { + props.onEnter(); + } + } + }} + /> + } + {props.suffix ? {props.suffix} : undefined} + {props.rightIcon ? props.rightIcon() : ""} +
+ ); +} + export interface BoxedInputFieldProperties { prefix?: string; suffix?: string; @@ -33,6 +116,8 @@ export interface BoxedInputFieldProperties { onChange?: (newValue: string) => void; onInput?: (newValue: string) => void; + + finishOnEnter?: boolean, } export interface BoxedInputFieldState { diff --git a/shared/js/ui/react-elements/external-modal/Controller.ts b/shared/js/ui/react-elements/external-modal/Controller.ts index 2732aa7f..57e58af2 100644 --- a/shared/js/ui/react-elements/external-modal/Controller.ts +++ b/shared/js/ui/react-elements/external-modal/Controller.ts @@ -1,13 +1,15 @@ -import {LogCategory, logDebug, logTrace, logWarn} from "../../../log"; +import {LogCategory, logDebug, logTrace} from "../../../log"; import * as ipc from "../../../ipc/BrowserIPC"; import {ChannelMessage} from "../../../ipc/BrowserIPC"; -import {Registry} from "../../../events"; +import {Registry} from "tc-events"; import { EventControllerBase, + kPopoutIPCChannelId, Popout2ControllerMessages, PopoutIPCMessage } from "../../../ui/react-elements/external-modal/IPCMessage"; 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 { public readonly modalType: string; @@ -20,13 +22,13 @@ export abstract class AbstractExternalModalController extends EventControllerBas private callbackWindowInitialized: (error?: string) => void; protected constructor(modalType: string, constructorArguments: any[]) { - super(); + super(guid()); this.modalType = modalType; this.constructorArguments = constructorArguments; this.modalEvents = new Registry(); - this.ipcChannel = ipc.getIpcInstance().createChannel(); + this.ipcChannel = ipc.getIpcInstance().createChannel(kPopoutIPCChannelId); this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this); this.documentUnloadListener = () => this.destroy(); @@ -120,51 +122,46 @@ export abstract class AbstractExternalModalController extends EventControllerBas } 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; - - 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(type: T, payload: PopoutIPCMessage[T]) { - super.handleTypedIPCMessage(type, payload); + protected handleTypedIPCMessage(remoteId: string, isBroadcast: boolean, type: T, payload: PopoutIPCMessage[T]) { + super.handleTypedIPCMessage(remoteId, isBroadcast, type, payload); - switch (type) { - case "hello-popout": { - const tpayload = payload as PopoutIPCMessage["hello-popout"]; - logTrace(LogCategory.IPC, "Received Hello World from popup with version %s (expected %s).", tpayload.version, __build.version); - if(tpayload.version !== __build.version) { - this.sendIPCMessage("hello-controller", { accepted: false, message: tr("version miss match") }); - if(this.callbackWindowInitialized) { - this.callbackWindowInitialized(tr("version miss match")); - this.callbackWindowInitialized = undefined; - } - return; - } - - if(this.callbackWindowInitialized) { - this.callbackWindowInitialized(); - this.callbackWindowInitialized = undefined; - } - - this.sendIPCMessage("hello-controller", { accepted: true, constructorArguments: this.constructorArguments }); - break; + if(type === "hello-popout") { + const messageHello = payload as PopoutIPCMessage["hello-popout"]; + if(messageHello.authenticationCode !== this.ipcAuthenticationCode) { + /* most likely not for us */ + return; } - case "invoke-modal-action": - /* must be handled by the underlying handler */ - break; + 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; - default: - logWarn(LogCategory.IPC, "Received unknown message type from popup window: %s", type); + 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") }); + if(this.callbackWindowInitialized) { + this.callbackWindowInitialized(tr("version miss match")); + this.callbackWindowInitialized = undefined; + } return; + } + + if(this.callbackWindowInitialized) { + this.callbackWindowInitialized(); + this.callbackWindowInitialized = undefined; + } + + this.sendIPCMessage("hello-controller", { accepted: true, constructorArguments: this.constructorArguments }); } } } \ No newline at end of file diff --git a/shared/js/ui/react-elements/external-modal/IPCMessage.ts b/shared/js/ui/react-elements/external-modal/IPCMessage.ts index 282293e4..c3f80c21 100644 --- a/shared/js/ui/react-elements/external-modal/IPCMessage.ts +++ b/shared/js/ui/react-elements/external-modal/IPCMessage.ts @@ -1,7 +1,9 @@ -import {ChannelMessage, IPCChannel} from "../../../ipc/BrowserIPC"; +import {IPCChannel} from "../../../ipc/BrowserIPC"; + +export const kPopoutIPCChannelId = "popout-channel"; export interface PopoutIPCMessage { - "hello-popout": { version: string }, + "hello-popout": { version: string, authenticationCode: string }, "hello-controller": { accepted: boolean, message?: string, constructorArguments?: any[] }, "invoke-modal-action": { action: "close" | "minimize" @@ -22,28 +24,24 @@ export interface ReceivedIPCMessage { } export abstract class EventControllerBase { + protected readonly ipcAuthenticationCode: string; + protected ipcRemotePeerId: string; protected ipcChannel: IPCChannel; - protected ipcRemoteId: string; - protected constructor() { } - - 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 constructor(ipcAuthenticationCode: string) { + this.ipcAuthenticationCode = ipcAuthenticationCode; } protected sendIPCMessage(type: T, payload: PopoutIPCMessage[T]) { - this.ipcChannel.sendMessage(type, payload, this.ipcRemoteId); + this.ipcChannel.sendMessage(type, payload, this.ipcRemotePeerId); } - protected handleTypedIPCMessage(type: T, payload: PopoutIPCMessage[T]) {} + protected handleTypedIPCMessage(remoteId: string, isBroadcast: boolean, type: T, payload: PopoutIPCMessage[T]) { + + } protected destroyIPC() { this.ipcChannel = undefined; - this.ipcRemoteId = undefined; + this.ipcRemotePeerId = undefined; } } \ No newline at end of file diff --git a/shared/js/ui/react-elements/external-modal/PopoutController.ts b/shared/js/ui/react-elements/external-modal/PopoutController.ts index 6c528555..8f5e0580 100644 --- a/shared/js/ui/react-elements/external-modal/PopoutController.ts +++ b/shared/js/ui/react-elements/external-modal/PopoutController.ts @@ -2,8 +2,8 @@ import {getIpcInstance as getIPCInstance} from "../../../ipc/BrowserIPC"; import {AppParameters} from "../../../settings"; import { Controller2PopoutMessages, - EventControllerBase, - PopoutIPCMessage + EventControllerBase, kPopoutIPCChannelId, + PopoutIPCMessage, } from "../../../ui/react-elements/external-modal/IPCMessage"; let controller: PopoutController; @@ -21,11 +21,12 @@ class PopoutController extends EventControllerBase<"popout"> { private callbackControllerHello: (accepted: boolean | string) => void; constructor() { - super(); - this.ipcRemoteId = AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, "invalid"); + super(AppParameters.getValue(AppParameters.KEY_MODAL_IDENTITY_CODE, "invalid")); - this.ipcChannel = getIPCInstance().createChannel(this.ipcRemoteId, AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_POPOUT_CHANNEL, "invalid")); - this.ipcChannel.messageHandler = this.handleIPCMessage.bind(this); + this.ipcChannel = getIPCInstance().createChannel(kPopoutIPCChannelId); + this.ipcChannel.messageHandler = (sourcePeerId, broadcast, message) => { + this.handleTypedIPCMessage(sourcePeerId, broadcast, message.type as any, message.data); + }; } getConstructorArguments() : any[] { @@ -33,7 +34,7 @@ class PopoutController extends EventControllerBase<"popout"> { } async initialize() { - this.sendIPCMessage("hello-popout", { version: __build.version }); + this.sendIPCMessage("hello-popout", { version: __build.version, authenticationCode: this.ipcAuthenticationCode }); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { @@ -55,13 +56,14 @@ class PopoutController extends EventControllerBase<"popout"> { }); } - protected handleTypedIPCMessage(type: T, payload: PopoutIPCMessage[T]) { - super.handleTypedIPCMessage(type, payload); + protected handleTypedIPCMessage(remoteId: string, isBroadcast: boolean, type: T, payload: PopoutIPCMessage[T]) { + super.handleTypedIPCMessage(remoteId, isBroadcast, type, payload); switch (type) { case "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) { return; } diff --git a/shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts b/shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts index 0954cfd5..07955647 100644 --- a/shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts +++ b/shared/js/ui/react-elements/external-modal/PopoutEntrypoint.ts @@ -27,7 +27,7 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { function: async () => { await import("tc-shared/proto"); await i18n.initialize(); - ipc.setup(); + ipc.setupIpcHandler(); setupJSRender(); } diff --git a/shared/js/ui/react-elements/modal/Definitions.ts b/shared/js/ui/react-elements/modal/Definitions.ts index 78b3da2c..f5336015 100644 --- a/shared/js/ui/react-elements/modal/Definitions.ts +++ b/shared/js/ui/react-elements/modal/Definitions.ts @@ -1,10 +1,13 @@ import {IpcRegistryDescription, Registry} from "tc-shared/events"; 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 {EchoTestEvents} from "tc-shared/ui/modal/echo-test/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 ModalRenderType = "page" | "dialog"; @@ -124,5 +127,10 @@ export interface ModalConstructorArguments { "conversation": any, "css-editor": any, "channel-tree": any, - "modal-connect": any + "modal-connect": any, + "modal-invite": [ + /* events */ IpcRegistryDescription, + /* variables */ IpcVariableDescriptor, + /* serverName */ string + ] } \ No newline at end of file diff --git a/shared/js/ui/react-elements/modal/Registry.ts b/shared/js/ui/react-elements/modal/Registry.ts index 29d39fae..833294ed 100644 --- a/shared/js/ui/react-elements/modal/Registry.ts +++ b/shared/js/ui/react-elements/modal/Registry.ts @@ -66,3 +66,10 @@ registerModal({ classLoader: async () => await import("tc-shared/ui/modal/connect/Renderer"), popoutSupported: true }); + +registerModal({ + modalId: "modal-invite", + classLoader: async () => await import("tc-shared/ui/modal/invite/Renderer"), + popoutSupported: true +}); + diff --git a/shared/js/ui/tree/EntryTags.tsx b/shared/js/ui/tree/EntryTags.tsx index c0a7ab59..68036eb0 100644 --- a/shared/js/ui/tree/EntryTags.tsx +++ b/shared/js/ui/tree/EntryTags.tsx @@ -84,7 +84,6 @@ loader.register_task(Stage.JAVASCRIPT_INITIALIZING, { name: "entry tags", priority: 10, function: async () => { - const ipc = getIpcInstance(); - ipcChannel = ipc.createChannel(AppParameters.getValue(AppParameters.KEY_IPC_REMOTE_ADDRESS, ipc.getLocalAddress()), kIpcChannel); + ipcChannel = getIpcInstance().createCoreControlChannel(kIpcChannel); } }); \ No newline at end of file diff --git a/shared/js/ui/utils/IpcVariable.ts b/shared/js/ui/utils/IpcVariable.ts index 1bf82315..1ca545d6 100644 --- a/shared/js/ui/utils/IpcVariable.ts +++ b/shared/js/ui/utils/IpcVariable.ts @@ -2,7 +2,7 @@ import {UiVariableConsumer, UiVariableMap, UiVariableProvider} from "tc-shared/u import {guid} from "tc-shared/crypto/uid"; import {LogCategory, logWarn} from "tc-shared/log"; -class IpcUiVariableProvider extends UiVariableProvider { +export class IpcUiVariableProvider extends UiVariableProvider { readonly ipcChannelId: string; private broadcastChannel: BroadcastChannel; @@ -146,7 +146,6 @@ class IpcUiVariableConsumer extends UiVariableC private handleIpcMessage(message: any, _source: MessageEventSource | null) { if(message.type === "notify") { - console.error("Received notify %s", message.variable); this.notifyRemoteVariable(message.variable, message.customData, message.value); } else if(message.type === "edit-result") { const payload = this.editListener[message.token]; diff --git a/shared/js/ui/utils/Variable.ts b/shared/js/ui/utils/Variable.ts index b3fa3fa7..656e740c 100644 --- a/shared/js/ui/utils/Variable.ts +++ b/shared/js/ui/utils/Variable.ts @@ -45,6 +45,10 @@ export abstract class UiVariableProvider { 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(variable: T, editor: UiVariableEditor) { this.variableEditor[variable as any] = editor; } @@ -247,7 +251,7 @@ export abstract class UiVariableConsumer { /* Variable constructor */ cacheEntry.useCount++; - if(cacheEntry.status === "loading") { + if(cacheEntry.status === "loaded") { return { status: "set", value: cacheEntry.currentValue diff --git a/shared/tsconfig/tsconfig.declarations.json b/shared/tsconfig/tsconfig.declarations.json index 8b42e72e..38ad8aa7 100644 --- a/shared/tsconfig/tsconfig.declarations.json +++ b/shared/tsconfig/tsconfig.declarations.json @@ -13,7 +13,9 @@ "tc-backend/*": ["shared/backend.d/*"], "tc-loader": ["loader/exports/loader.d.ts"], "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": [ @@ -24,6 +26,8 @@ "../js/main.tsx", "../backend.d", "../js/**/*.ts", - "../../webpack/build-definitions.d.ts" + "../../webpack/build-definitions.d.ts", + "../../vendor/TeaEventBus/src/**/*.ts", + "../../vendor/TeaClientServices/src/**/*.ts" ] } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 1d263934..1389a012 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,8 @@ "tc-backend/web/*": ["web/app/*"], /* specific web part */ "tc-backend/*": ["shared/backend.d/*"], "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/*"], "vendor/xbbcode/*": ["vendor/xbbcode/src/*"] diff --git a/vendor/TeaClientServices b/vendor/TeaClientServices new file mode 160000 index 00000000..f9267daa --- /dev/null +++ b/vendor/TeaClientServices @@ -0,0 +1 @@ +Subproject commit f9267daa208f7f97a7bc56d52b89dac7cc0004e7 diff --git a/vendor/TeaEventBus b/vendor/TeaEventBus new file mode 160000 index 00000000..8310382d --- /dev/null +++ b/vendor/TeaEventBus @@ -0,0 +1 @@ +Subproject commit 8310382d8a851b2e7095b400807141065811da53 diff --git a/vendor/xbbcode b/vendor/xbbcode index 33607743..d1a1b51f 160000 --- a/vendor/xbbcode +++ b/vendor/xbbcode @@ -1 +1 @@ -Subproject commit 336077435bbb09bb25f6efdcdac36956288fd3ca +Subproject commit d1a1b51f61c0dce71ebd856208964581ba6fecc7 diff --git a/web/app/ExternalModalFactory.ts b/web/app/ExternalModalFactory.ts index 1b3417b3..f7873d89 100644 --- a/web/app/ExternalModalFactory.ts +++ b/web/app/ExternalModalFactory.ts @@ -1,11 +1,13 @@ import {AbstractExternalModalController} from "tc-shared/ui/react-elements/external-modal/Controller"; import {spawnYesNo} from "tc-shared/ui/modal/ModalYesNo"; -import * as ipc from "tc-shared/ipc/BrowserIPC"; -import {ChannelMessage} from "tc-shared/ipc/BrowserIPC"; +import {ChannelMessage, getIpcInstance} from "tc-shared/ipc/BrowserIPC"; import {LogCategory, logDebug, logWarn} from "tc-shared/log"; import {Popout2ControllerMessages, PopoutIPCMessage} from "tc-shared/ui/react-elements/external-modal/IPCMessage"; import {tr, tra} from "tc-shared/i18n/localize"; import {ModalOptions} from "tc-shared/ui/react-elements/modal/Definitions"; +import {assertMainApplication} from "tc-shared/ui/utils"; + +assertMainApplication(); export class ExternalModalController extends AbstractExternalModalController { private readonly options: ModalOptions; @@ -87,8 +89,9 @@ export class ExternalModalController extends AbstractExternalModalController { "loader-target": "manifest", "chunk": "modal-external", "modal-target": this.modalType, - "ipc-channel": this.ipcChannel.channelId, - "ipc-address": ipc.getIpcInstance().getLocalAddress(), + "modal-identify": this.ipcAuthenticationCode, + "ipc-address": getIpcInstance().getApplicationChannelId(), + "ipc-core-peer": getIpcInstance().getLocalPeerId(), "disableGlobalContextMenu": __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) { - if(!broadcast && this.ipcRemoteId !== remoteId) { + if(!broadcast && this.ipcRemotePeerId !== remoteId) { if(this.windowClosedTestInterval > 0) { clearInterval(this.windowClosedTestInterval); this.windowClosedTestInterval = 0; @@ -127,8 +130,12 @@ export class ExternalModalController extends AbstractExternalModalController { super.handleIPCMessage(remoteId, broadcast, message); } - protected handleTypedIPCMessage(type: T, payload: PopoutIPCMessage[T]) { - super.handleTypedIPCMessage(type, payload); + protected handleTypedIPCMessage(remoteId: string, isBroadcast: boolean, type: T, payload: PopoutIPCMessage[T]) { + super.handleTypedIPCMessage(remoteId, isBroadcast, type, payload); + + if(isBroadcast) { + return; + } switch (type) { case "invoke-modal-action": diff --git a/web/app/ui/context-menu/Ipc.ts b/web/app/ui/context-menu/Ipc.ts index 379f5350..a921ada5 100644 --- a/web/app/ui/context-menu/Ipc.ts +++ b/web/app/ui/context-menu/Ipc.ts @@ -26,7 +26,7 @@ class IPCContextMenu implements ContextMenuFactory { private closeCallback: () => void; constructor() { - this.ipcChannel = ipc.getIpcInstance().createChannel(undefined, kIPCContextMenuChannel); + this.ipcChannel = ipc.getIpcInstance().createChannel(kIPCContextMenuChannel); this.ipcChannel.messageHandler = this.handleIpcMessage.bind(this); /* if we're just created we're the focused window ;) */ diff --git a/webpack.config.ts b/webpack.config.ts index 87ef945a..6b817a7a 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -230,7 +230,9 @@ export const config = async (target: "web" | "client"): Promise = resolve: { extensions: ['.tsx', '.ts', '.js', ".scss", ".css", ".wasm"], 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: [