diff --git a/docs/JSON-RPC.md b/docs/JSON-RPC.md index 3501fbdc9d..14aa9e4001 100644 --- a/docs/JSON-RPC.md +++ b/docs/JSON-RPC.md @@ -129,6 +129,42 @@ Results: | result.version | string | The Jamulus version. | +### jamulusclient/connect + +Connects the client to a server. Any current connection is terminated first. The connection is established asynchronously: subscribe to the jamulusclient/connecting, jamulusclient/connected, jamulusclient/connectingFailed and jamulusclient/connectionStateChanged notifications to follow its progress. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.address | string | Socket address of the server (host:port). | +| params.serverName | string | Optional human readable server name used for display purposes. Defaults to the address. | +| params.directory | string | Optional socket address of a directory to hole-punch through before connecting (host:port). Use for a server behind a cloud firewall/NAT that is registered with that directory; address is connected to verbatim and need not be listed by the directory. Example: anygenre1.jamulus.io:22124 | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | "ok" once the connection attempt has been initiated. | + + +### jamulusclient/disconnect + +Disconnects the client from the current server. Does nothing if the client is not connected. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result | string | Always "ok". | + + ### jamulusclient/getChannelInfo Returns the client's profile information. @@ -188,6 +224,24 @@ Results: | result.clients | array | The client list. See jamulusclient/clientListReceived for the format. | +### jamulusclient/getConnectionState + +Returns the current connection state. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params | object | No parameters (empty object). | + +Results: + +| Name | Type | Description | +| --- | --- | --- | +| result.state | string | The connection state (disconnected, connecting, or connected). | +| result.serverName | string | The human readable name of the current server (empty if disconnected). | + + ### jamulusclient/getCurrentDirectory Returns the currently selected directory socket address. @@ -656,6 +710,39 @@ Parameters: | params.id | number | The channel ID assigned to the client. | +### jamulusclient/connecting + +Emitted when a connection to a server has been requested but is not yet established. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.serverName | string | The human readable server name (or the address if no name is known). | + + +### jamulusclient/connectingFailed + +Emitted when a connection attempt failed before it could be requested from the server. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.error | string | The error message. | + + +### jamulusclient/connectionStateChanged + +Emitted whenever the connection state changes. + +Parameters: + +| Name | Type | Description | +| --- | --- | --- | +| params.state | string | The new connection state (disconnected, connecting, or connected). | + + ### jamulusclient/disconnected Emitted when the client is disconnected from the server. diff --git a/src/client.cpp b/src/client.cpp index aa36cacb3f..9a00cc8620 100644 --- a/src/client.cpp +++ b/src/client.cpp @@ -1162,13 +1162,54 @@ void CClient::Disconnect() /// @method /// @brief Connects to strServerAddress. If a connection is currently requested /// or established, that connection is terminated first. +/// +/// If strDirectoryAddress is given, the server is assumed to be behind a +/// firewall/NAT that requires directory-assisted UDP hole punching (the +/// same mechanism the GUI directory list uses). We first ask that +/// directory to poke its registered servers towards our socket, wait +/// briefly for their replies to open the firewall, and only then connect. +/// strServerAddress is always connected to verbatim and need not appear +/// in the directory's server list. /// @emit Connecting (strServerName) if SetServerAddr was valid. emit happens through Start(). /// Use to set CClientDlg to show being connected /// @emit ConnectingFailed (error) if an error occurred /// Use to display error message in CClientDlg /// @param strServerAddress - the server address to connect to /// @param strServerName - the human readable server name passed to Connecting() -void CClient::Connect ( const QString& strServerAddress, const QString& strServerName ) +/// @param strDirectoryAddress - optional directory to hole-punch through first +void CClient::Connect ( const QString& strServerAddress, const QString& strServerName, const QString& strDirectoryAddress ) +{ + if ( strDirectoryAddress.isEmpty() ) + { + // no hole punching requested: connect straight away + connectToServer ( strServerAddress, strServerName ); + return; + } + + CHostAddress haDirectoryAddress; + + // directories are queried over IPv4 only (same as jamulusclient/pollServerList) + if ( !NetworkUtil::ParseNetworkAddress ( strDirectoryAddress, haDirectoryAddress, false ) ) + { + emit ConnectingFailed ( tr ( "Received invalid directory address. Please check for typos in the provided directory address." ) ); + return; + } + + // Ask the directory for its server list. As a side effect the directory + // tells each registered server to send us an "empty message", which opens + // the server's firewall for our socket (UDP hole punching). + CreateCLReqServerListMes ( haDirectoryAddress ); + + // Defer the actual connect so the servers' replies have time to arrive and + // open the firewall before we send our first packet. + QTimer::singleShot ( HOLE_PUNCH_CONNECT_DELAY_MS, this, [this, strServerAddress, strServerName]() { + connectToServer ( strServerAddress, strServerName ); + } ); +} + +/// @brief Performs the actual connect. See Connect(), which either calls this +/// directly or after a directory hole-punch delay. +void CClient::connectToServer ( const QString& strServerAddress, const QString& strServerName ) { try { diff --git a/src/client.h b/src/client.h index 1c4c4e5428..96ee8e45b9 100644 --- a/src/client.h +++ b/src/client.h @@ -170,7 +170,7 @@ class CClient : public QObject virtual ~CClient(); void Disconnect(); - void Connect ( const QString& strServerAddress, const QString& strServerName ); + void Connect ( const QString& strServerAddress, const QString& strServerName, const QString& strDirectoryAddress = "" ); // The ConnectedServerName is emitted by Connecting() to update the UI with a human readable server name void SetConnectedServerName ( const QString& strServerName ) { strConnectedServerName = strServerName; }; @@ -383,6 +383,10 @@ class CClient : public QObject void SetConnectionState ( const EConnectionState eNewConnectionState ); + // performs the actual connect; Connect() calls this directly, or after a + // directory hole-punch delay when a directory address was supplied + void connectToServer ( const QString& strServerAddress, const QString& strServerName ); + // only one channel is needed for client application CChannel Channel; CProtocol ConnLessProtocol; diff --git a/src/clientrpc.cpp b/src/clientrpc.cpp index 0f376d10b4..3fccdfb6fa 100644 --- a/src/clientrpc.cpp +++ b/src/clientrpc.cpp @@ -47,6 +47,21 @@ #include "clientrpc.h" +static QString ConnectionStateToString ( const EConnectionState eState ) +{ + switch ( eState ) + { + case CS_CONNECTING: + return "connecting"; + + case CS_CONNECTED: + return "connected"; + + default: + return "disconnected"; + } +} + CClientRpc::CClientRpc ( CClient* pClient, CClientSettings* pSettings, CRpcServer* pRpcServer, QObject* parent ) : QObject ( parent ), m_pSettings ( pSettings ) @@ -168,6 +183,36 @@ CClientRpc::CClientRpc ( CClient* pClient, CClientSettings* pSettings, CRpcServe /// @param {object} params - No parameters (empty object). connect ( pClient, &CClient::Disconnected, [=]() { pRpcServer->BroadcastNotification ( "jamulusclient/disconnected", QJsonObject{} ); } ); + /// @rpc_notification jamulusclient/connecting + /// @brief Emitted when a connection to a server has been requested but is not yet established. + /// @param {string} params.serverName - The human readable server name (or the address if no name is known). + connect ( pClient, &CClient::Connecting, [=] ( QString strServerName ) { + pRpcServer->BroadcastNotification ( "jamulusclient/connecting", + QJsonObject{ + { "serverName", strServerName }, + } ); + } ); + + /// @rpc_notification jamulusclient/connectingFailed + /// @brief Emitted when a connection attempt failed before it could be requested from the server. + /// @param {string} params.error - The error message. + connect ( pClient, &CClient::ConnectingFailed, [=] ( QString strError ) { + pRpcServer->BroadcastNotification ( "jamulusclient/connectingFailed", + QJsonObject{ + { "error", strError }, + } ); + } ); + + /// @rpc_notification jamulusclient/connectionStateChanged + /// @brief Emitted whenever the connection state changes. + /// @param {string} params.state - The new connection state (disconnected, connecting, or connected). + connect ( pClient, &CClient::ConnectionStateChanged, [=] ( EConnectionState eState ) { + pRpcServer->BroadcastNotification ( "jamulusclient/connectionStateChanged", + QJsonObject{ + { "state", ConnectionStateToString ( eState ) }, + } ); + } ); + /// @rpc_notification jamulusclient/recorderState /// @brief Emitted when the client is connected to a server whose recorder state changes. /// @param {number} params.state - The recorder state. @@ -212,6 +257,70 @@ CClientRpc::CClientRpc ( CClient* pClient, CClientSettings* pSettings, CRpcServe Q_UNUSED ( params ); } ); + /// @rpc_method jamulusclient/connect + /// @brief Connects the client to a server. Any current connection is terminated first. + /// The connection is established asynchronously: subscribe to the jamulusclient/connecting, + /// jamulusclient/connected, jamulusclient/connectingFailed and jamulusclient/connectionStateChanged + /// notifications to follow its progress. + /// @param {string} params.address - Socket address of the server (host:port). + /// @param {string} params.serverName - Optional human readable server name used for display purposes. Defaults to the address. + /// @param {string} params.directory - Optional socket address of a directory to hole-punch through before + /// connecting (host:port). Use for a server behind a cloud firewall/NAT that is registered with that + /// directory; address is connected to verbatim and need not be listed by the directory. Example: + /// anygenre1.jamulus.io:22124 + /// @result {string} result - "ok" once the connection attempt has been initiated. + pRpcServer->HandleMethod ( "jamulusclient/connect", [=] ( const QJsonObject& params, QJsonObject& response ) { + auto jsonAddress = params["address"]; + if ( !jsonAddress.isString() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, "Invalid params: address is not a string" ); + return; + } + + auto jsonDirectory = params["directory"]; + if ( !jsonDirectory.isUndefined() && !jsonDirectory.isNull() && !jsonDirectory.isString() ) + { + response["error"] = CRpcServer::CreateJsonRpcError ( CRpcServer::iErrInvalidParams, "Invalid params: directory is not a string" ); + return; + } + + auto jsonServerName = params["serverName"]; + const QString strAddress = NetworkUtil::FixAddress ( jsonAddress.toString() ); + const QString strServerName = jsonServerName.isString() ? jsonServerName.toString() : strAddress; + const QString strDirectory = jsonDirectory.isString() ? NetworkUtil::FixAddress ( jsonDirectory.toString() ) : QString(); + + pClient->Connect ( strAddress, strServerName, strDirectory ); + + response["result"] = "ok"; + } ); + + /// @rpc_method jamulusclient/disconnect + /// @brief Disconnects the client from the current server. Does nothing if the client is not connected. + /// @param {object} params - No parameters (empty object). + /// @result {string} result - Always "ok". + pRpcServer->HandleMethod ( "jamulusclient/disconnect", [=] ( const QJsonObject& params, QJsonObject& response ) { + pClient->Disconnect(); + + response["result"] = "ok"; + Q_UNUSED ( params ); + } ); + + /// @rpc_method jamulusclient/getConnectionState + /// @brief Returns the current connection state. + /// @param {object} params - No parameters (empty object). + /// @result {string} result.state - The connection state (disconnected, connecting, or connected). + /// @result {string} result.serverName - The human readable name of the current server (empty if disconnected). + pRpcServer->HandleMethod ( "jamulusclient/getConnectionState", [=] ( const QJsonObject& params, QJsonObject& response ) { + const EConnectionState eState = pClient->GetConnectionState(); + + QJsonObject result{ + { "state", ConnectionStateToString ( eState ) }, + { "serverName", eState == CS_DISCONNECTED ? QString() : pClient->GetConnectedServerName() }, + }; + response["result"] = result; + Q_UNUSED ( params ); + } ); + /// @rpc_method jamulus/getMode /// @brief Returns the current mode, i.e. whether Jamulus is running as a server or client. /// @param {object} params - No parameters (empty object). diff --git a/src/global.h b/src/global.h index 5414a58c41..39c2d04b93 100644 --- a/src/global.h +++ b/src/global.h @@ -123,6 +123,11 @@ LED bar: lbr // specify an invalid port to disable the server #define INVALID_PORT -1 +// when connecting via a directory (hole punching), wait this long after asking +// the directory to poke its registered servers before connecting, so the +// servers' replies have time to open their firewall for our socket +#define HOLE_PUNCH_CONNECT_DELAY_MS 400 + // servers to check for new versions #define UPDATECHECK1_ADDRESS "updatecheck1.jamulus.app" #define UPDATECHECK2_ADDRESS "updatecheck2.jamulus.app" diff --git a/src/main.cpp b/src/main.cpp index f74148cc64..16d5d9bd32 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -128,6 +128,7 @@ int main ( int argc, char** argv ) quint16 iQosNumber = DEFAULT_QOS_NUMBER; ELicenceType eLicenceType = LT_NO_LICENCE; QString strConnOnStartupAddress = ""; + QString strConnectDirectory = ""; QString strIniFileName = ""; QString strLoggingFileName = ""; QString strRecordingDirName = ""; @@ -538,6 +539,16 @@ int main ( int argc, char** argv ) continue; } + // Directory to hole-punch through for connect on startup -------------- + if ( GetStringArgument ( argc, argv, i, "--connectdirectory", "--connectdirectory", strArgument ) ) + { + strConnectDirectory = NetworkUtil::FixAddress ( strArgument ); + qInfo() << qUtf8Printable ( QString ( "- connect on startup via directory: %1" ).arg ( strConnectDirectory ) ); + CommandLineOptions << "--connectdirectory"; + ClientOnlyOptions << "--connectdirectory"; + continue; + } + // Disabling auto Jack connections ------------------------------------- if ( GetFlagArgument ( argv, i, "-j", "--nojackconnect" ) ) { @@ -1015,7 +1026,7 @@ int main ( int argc, char** argv ) // Connect on startup if requested if ( !strConnOnStartupAddress.isEmpty() ) { - Client.Connect ( strConnOnStartupAddress, strConnOnStartupAddress ); + Client.Connect ( strConnOnStartupAddress, strConnOnStartupAddress, strConnectDirectory ); } pApp->exec(); @@ -1029,7 +1040,7 @@ int main ( int argc, char** argv ) // Connect on startup if requested if ( !strConnOnStartupAddress.isEmpty() ) { - Client.Connect ( strConnOnStartupAddress, strConnOnStartupAddress ); + Client.Connect ( strConnOnStartupAddress, strConnOnStartupAddress, strConnectDirectory ); } pApp->exec(); @@ -1195,6 +1206,7 @@ QString UsageArguments ( char** argv ) "\n" "Client only:\n" " -c, --connect connect to given Server address on startup\n" + " --connectdirectory directory to hole-punch through for --connect (host:port)\n" " -j, --nojackconnect disable auto JACK connections\n" " -M, --mutestream prevent others on a server from hearing what I play\n" " --mutemyown prevent me from hearing what I play in the server mix (headless only)\n"