mirror of
https://github.com/tomahawk-player/tomahawk.git
synced 2025-04-17 22:43:21 +02:00
Update Hatchet account type to current protocol workflow.
This commit is contained in:
parent
37ec3466ab
commit
1e178ec5bc
@ -86,7 +86,7 @@ HatchetAccount::HatchetAccount( const QString& accountId )
|
||||
setAccountServiceName( "Hatchet" );
|
||||
// We're connecting peers.
|
||||
setTypes( SipType );
|
||||
|
||||
/*
|
||||
QFile pemFile( ":/hatchet-account/mandella.pem" );
|
||||
pemFile.open( QIODevice::ReadOnly );
|
||||
tDebug( LOGVERBOSE ) << Q_FUNC_INFO << "certs/mandella.pem: " << pemFile.readAll();
|
||||
@ -100,6 +100,7 @@ HatchetAccount::HatchetAccount( const QString& accountId )
|
||||
return;
|
||||
}
|
||||
m_publicKey = new QCA::PublicKey( publicKey );
|
||||
*/
|
||||
}
|
||||
|
||||
|
||||
@ -223,66 +224,128 @@ uint
|
||||
HatchetAccount::refreshTokenExpiration() const
|
||||
{
|
||||
bool ok;
|
||||
return credentials().value( "expiration" ).toUInt( &ok );
|
||||
return credentials().value( "refresh_token_expiration" ).toUInt( &ok );
|
||||
}
|
||||
|
||||
|
||||
QByteArray
|
||||
HatchetAccount::mandellaAccessToken() const
|
||||
{
|
||||
return credentials().value( "mandella_access_token" ).toByteArray();
|
||||
}
|
||||
|
||||
|
||||
uint
|
||||
HatchetAccount::mandellaAccessTokenExpiration() const
|
||||
{
|
||||
bool ok;
|
||||
return credentials().value( "mandella_access_token_expiration" ).toUInt( &ok );
|
||||
}
|
||||
|
||||
|
||||
QByteArray
|
||||
HatchetAccount::mandellaTokenType() const
|
||||
{
|
||||
return credentials().value( "mandella_token_type" ).toByteArray();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
HatchetAccount::loginWithPassword( const QString& username, const QString& password, const QString &otp )
|
||||
{
|
||||
if ( username.isEmpty() || password.isEmpty() || !m_publicKey )
|
||||
//if ( username.isEmpty() || password.isEmpty() || !m_publicKey )
|
||||
if ( username.isEmpty() || password.isEmpty() )
|
||||
{
|
||||
tLog() << "No tomahawk account username or pw or public key, not logging in";
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
m_uuid = QUuid::createUuid().toString();
|
||||
QCA::SecureArray sa( m_uuid.toLatin1() );
|
||||
QCA::SecureArray result = m_publicKey->encrypt( sa, QCA::EME_PKCS1_OAEP );
|
||||
|
||||
QVariantMap params;
|
||||
params[ "password" ] = password;
|
||||
params[ "username" ] = username;
|
||||
if ( !otp.isEmpty() )
|
||||
params[ "otp" ] = otp;
|
||||
params[ "client" ] = "Tomahawk (" + QHostInfo::localHostName() + ")";
|
||||
params[ "nonce" ] = QString( result.toByteArray().toBase64() );
|
||||
*/
|
||||
|
||||
QJson::Serializer s;
|
||||
const QByteArray msgJson = s.serialize( params );
|
||||
QNetworkRequest req( QUrl( c_loginServer + "/authentication/password") );
|
||||
req.setHeader( QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded" );
|
||||
|
||||
QUrl params;
|
||||
params.addQueryItem( "username", username );
|
||||
params.addQueryItem( "password", password );
|
||||
params.addQueryItem( "grant_type", "password" );
|
||||
if ( !otp.isEmpty() )
|
||||
params.addQueryItem( "otp", otp );
|
||||
|
||||
QNetworkRequest req( QUrl( c_loginServer + "/auth/credentials") );
|
||||
req.setHeader( QNetworkRequest::ContentTypeHeader, "application/json; charset=utf-8" );
|
||||
QNetworkReply* reply = Tomahawk::Utils::nam()->post( req, msgJson );
|
||||
QByteArray data = params.encodedQuery();
|
||||
|
||||
QNetworkReply* reply = Tomahawk::Utils::nam()->post( req, data );
|
||||
|
||||
NewClosure( reply, SIGNAL( finished() ), this, SLOT( onPasswordLoginFinished( QNetworkReply*, const QString& ) ), reply, username );
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
HatchetAccount::fetchAccessTokens( const QString& type )
|
||||
HatchetAccount::fetchAccessToken( const QString& type )
|
||||
{
|
||||
if ( username().isEmpty() || refreshToken().isEmpty() )
|
||||
if ( username().isEmpty() )
|
||||
{
|
||||
tLog() << "No refresh token, not logging in";
|
||||
tLog() << "No username, not logging in";
|
||||
return;
|
||||
}
|
||||
if ( mandellaAccessToken().isEmpty() ||
|
||||
(mandellaAccessTokenExpiration() < QDateTime::currentDateTime().toTime_t() &&
|
||||
(refreshToken().isEmpty() ||
|
||||
(refreshTokenExpiration() != 0 && refreshTokenExpiration() < QDateTime::currentDateTime().toTime_t()))) )
|
||||
{
|
||||
tLog() << "No valid combination of access/refresh tokens, not logging in";
|
||||
tLog() << "Mandella access token expiration:" << mandellaAccessTokenExpiration() << ", refresh token expiration:" << refreshTokenExpiration();
|
||||
emit authError( "No valid credentials are stored locally, please log in again.", 401, QVariantMap() );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( refreshTokenExpiration() < ( QDateTime::currentMSecsSinceEpoch() / 1000 ) )
|
||||
tLog() << "Refresh token has expired, but may still be valid on the server";
|
||||
uint matExpiration = mandellaAccessTokenExpiration();
|
||||
bool interceptionNeeded = false;
|
||||
|
||||
tLog() << "Fetching access tokens";
|
||||
QNetworkRequest req( QUrl( c_accessTokenServer + "/tokens/" + type + "?username=" + username() + "&refresh_token=" + refreshToken() ) );
|
||||
if ( matExpiration < QDateTime::currentDateTime().toTime_t() )
|
||||
{
|
||||
interceptionNeeded = true;
|
||||
tLog() << "Mandella access token has expired, fetching new ones first";
|
||||
}
|
||||
else
|
||||
{
|
||||
tLog() << "Fetching access tokens of type" << type;
|
||||
}
|
||||
|
||||
QNetworkReply* reply = Tomahawk::Utils::nam()->get( req );
|
||||
QNetworkRequest req( QUrl( c_accessTokenServer + "/tokens/" + (interceptionNeeded ? "refresh/" + QString::fromUtf8(mandellaTokenType()).toLower() : "fetch/" + type) ) );
|
||||
QNetworkReply* reply;
|
||||
|
||||
connect( reply, SIGNAL( finished() ), this, SLOT( onFetchAccessTokensFinished() ) );
|
||||
if ( interceptionNeeded )
|
||||
{
|
||||
tLog() << "Intercepting; new mandella access token needed";
|
||||
req.setHeader( QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded" );
|
||||
QUrl params;
|
||||
params.addQueryItem( "grant_type", "refresh_token" );
|
||||
params.addQueryItem( "refresh_token", refreshToken() );
|
||||
QByteArray data = params.encodedQuery();
|
||||
reply = Tomahawk::Utils::nam()->post( req, data );
|
||||
reply->setProperty( "originalType", type );
|
||||
}
|
||||
else
|
||||
{
|
||||
tLog() << "Fetching token of type" << type;
|
||||
req.setRawHeader( "Authorization", QString( mandellaTokenType() + " " + mandellaAccessToken()).toUtf8() );
|
||||
reply = Tomahawk::Utils::nam()->get( req );
|
||||
}
|
||||
|
||||
NewClosure( reply, SIGNAL( finished() ), this, SLOT( onFetchAccessTokenFinished( QNetworkReply*, const QString& ) ), reply, type );
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
HatchetAccount::onPasswordLoginFinished( QNetworkReply* reply, const QString& username )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO;
|
||||
Q_ASSERT( reply );
|
||||
bool ok;
|
||||
int statusCode = reply->attribute( QNetworkRequest::HttpStatusCodeAttribute ).toInt( &ok );
|
||||
@ -307,12 +370,13 @@ HatchetAccount::onPasswordLoginFinished( QNetworkReply* reply, const QString& us
|
||||
}
|
||||
if ( statusCode >= 400 )
|
||||
{
|
||||
QString errString = resp.value( "result" ).toMap().value( "errorinfo" ).toMap().value( "description" ).toString();
|
||||
QString errString = resp.value( "error_description" ).toString();
|
||||
tLog() << Q_FUNC_INFO << "An error was returned from the authentication server: " << errString;
|
||||
emit authError( errString, statusCode, resp );
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
const QString nonce = resp.value( "result" ).toMap().value( "nonce" ).toString();
|
||||
if ( nonce != m_uuid )
|
||||
{
|
||||
@ -320,32 +384,59 @@ HatchetAccount::onPasswordLoginFinished( QNetworkReply* reply, const QString& us
|
||||
emit authError( "The nonce value was incorrect. YOUR ACCOUNT MAY BE COMPROMISED.", statusCode, resp );
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
const QByteArray refreshTokenBytes = resp.value( "result" ).toMap().value( "refresh_token" ).toByteArray();
|
||||
uint expiration = resp.value( "result" ).toMap().value( "expiration" ).toUInt( &ok );
|
||||
const QByteArray refreshTokenBytes = resp.value( "refresh_token" ).toByteArray();
|
||||
uint refreshTokenExpiration = resp.value( "refresh_token_expires_in" ).toUInt( &ok );
|
||||
if ( refreshTokenBytes.isEmpty() || !ok )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO << "Error reading refresh token or its expiration";
|
||||
emit authError( "An error encountered parsing the authentication server's response", 0, QVariantMap() );
|
||||
return;
|
||||
}
|
||||
const QByteArray accessTokenBytes = resp.value( "access_token" ).toByteArray();
|
||||
uint accessTokenExpiration = resp.value( "expires_in" ).toUInt( &ok );
|
||||
if ( accessTokenBytes.isEmpty() || !ok )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO << "Error reading access token or its expiration";
|
||||
emit authError( "An error encountered parsing the authentication server's response", 0, QVariantMap() );
|
||||
return;
|
||||
}
|
||||
const QByteArray tokenTypeBytes = resp.value( "token_type" ).toByteArray();
|
||||
if ( tokenTypeBytes.isEmpty() )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO << "Error reading access token type";
|
||||
emit authError( "An error encountered parsing the authentication server's response", 0, QVariantMap() );
|
||||
return;
|
||||
}
|
||||
|
||||
QVariantHash creds = credentials();
|
||||
creds[ "username" ] = username;
|
||||
creds[ "refresh_token" ] = refreshTokenBytes;
|
||||
creds[ "expiration" ] = expiration;
|
||||
creds[ "refresh_token_expiration" ] = refreshTokenExpiration == 0 ? 0 : QDateTime::currentDateTime().toTime_t() + refreshTokenExpiration;
|
||||
creds[ "mandella_access_token" ] = accessTokenBytes;
|
||||
creds[ "mandella_access_token_expiration" ] = QDateTime::currentDateTime().toTime_t() + accessTokenExpiration;
|
||||
creds[ "mandella_token_type" ] = tokenTypeBytes;
|
||||
setCredentials( creds );
|
||||
syncConfig();
|
||||
|
||||
if ( !refreshTokenBytes.isEmpty() )
|
||||
{
|
||||
if ( sipPlugin() )
|
||||
sipPlugin()->connectPlugin();
|
||||
}
|
||||
if ( sipPlugin() )
|
||||
sipPlugin()->connectPlugin();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
HatchetAccount::onFetchAccessTokensFinished()
|
||||
HatchetAccount::onFetchAccessTokenFinished( QNetworkReply* reply, const QString& type )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO;
|
||||
QNetworkReply* reply = qobject_cast< QNetworkReply* >( sender() );
|
||||
Q_ASSERT( reply );
|
||||
|
||||
QString originalType;
|
||||
if ( reply->property( "originalType" ).isValid() )
|
||||
{
|
||||
originalType = reply->property( "originalType" ).toString();
|
||||
}
|
||||
|
||||
bool ok;
|
||||
int statusCode = reply->attribute( QNetworkRequest::HttpStatusCodeAttribute ).toInt( &ok );
|
||||
if ( !ok )
|
||||
@ -369,36 +460,59 @@ HatchetAccount::onFetchAccessTokensFinished()
|
||||
}
|
||||
if ( statusCode >= 400 )
|
||||
{
|
||||
QString errString = resp.value( "result" ).toMap().value( "errorinfo" ).toMap().value( "description" ).toString();
|
||||
QString errString = resp.value( "error_description" ).toString();
|
||||
tLog() << Q_FUNC_INFO << "An error was returned from the authentication server: " << errString;
|
||||
emit authError( errString, statusCode, resp );
|
||||
return;
|
||||
}
|
||||
|
||||
QVariantHash creds = credentials();
|
||||
QStringList tokenTypesFound;
|
||||
|
||||
tDebug() << Q_FUNC_INFO << "resp: " << resp;
|
||||
|
||||
if ( resp[ "result" ].toMap().contains( "refresh_token_expiration" ) )
|
||||
if ( !originalType.isEmpty() )
|
||||
{
|
||||
bool ok;
|
||||
uint expiration = resp.value( "result" ).toMap().value( "refresh_token_expiration" ).toUInt( &ok );
|
||||
creds[ "expiration" ] = expiration;
|
||||
}
|
||||
|
||||
foreach( QVariant tokenVariant, resp[ "result" ].toMap()[ "tokens" ].toList() )
|
||||
{
|
||||
QVariantMap tokenMap = tokenVariant.toMap();
|
||||
QString tokenTypeName = tokenMap[ "type" ].toString() + "tokens";
|
||||
if ( !tokenTypesFound.contains( tokenTypeName ) )
|
||||
const QByteArray accessTokenBytes = resp.value( "access_token" ).toByteArray();
|
||||
uint accessTokenExpiration = resp.value( "expires_in" ).toUInt( &ok );
|
||||
if ( accessTokenBytes.isEmpty() || !ok )
|
||||
{
|
||||
creds[ tokenTypeName ] = QVariantList();
|
||||
tokenTypesFound.append( tokenTypeName );
|
||||
tLog() << Q_FUNC_INFO << "Error reading access token or its expiration";
|
||||
emit authError( "An error encountered parsing the authentication server's response", 0, QVariantMap() );
|
||||
return;
|
||||
}
|
||||
creds[ tokenTypeName ] = creds[ tokenTypeName ].toList() << tokenMap;
|
||||
const QByteArray tokenTypeBytes = resp.value( "token_type" ).toByteArray();
|
||||
if ( tokenTypeBytes.isEmpty() )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO << "Error reading access token type";
|
||||
emit authError( "An error encountered parsing the authentication server's response", 0, QVariantMap() );
|
||||
return;
|
||||
}
|
||||
creds[ "mandella_access_token" ] = accessTokenBytes;
|
||||
creds[ "mandella_access_token_expiration" ] = QDateTime::currentDateTime().toTime_t() + accessTokenExpiration;
|
||||
creds[ "mandella_token_type" ] = tokenTypeBytes;
|
||||
setCredentials( creds );
|
||||
syncConfig();
|
||||
|
||||
fetchAccessToken( originalType );
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray accessTokenBytes = resp.value( "access_token" ).toByteArray();
|
||||
uint accessTokenExpiration = resp.value( "expires_in" ).toUInt( &ok );
|
||||
if ( accessTokenBytes.isEmpty() || !ok )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO << "Error reading access token or its expiration";
|
||||
emit authError( "An error encountered parsing the authentication server's response", 0, QVariantMap() );
|
||||
return;
|
||||
}
|
||||
const QByteArray tokenTypeBytes = resp.value( "token_type" ).toByteArray();
|
||||
if ( tokenTypeBytes.isEmpty() )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO << "Error reading access token type";
|
||||
emit authError( "An error encountered parsing the authentication server's response", 0, QVariantMap() );
|
||||
return;
|
||||
}
|
||||
|
||||
creds[ type + "_access_token" ] = accessTokenBytes;
|
||||
|
||||
tDebug() << Q_FUNC_INFO << "Creds: " << creds;
|
||||
|
||||
setCredentials( creds );
|
||||
@ -406,7 +520,7 @@ HatchetAccount::onFetchAccessTokensFinished()
|
||||
|
||||
tLog() << Q_FUNC_INFO << "Access tokens fetched successfully";
|
||||
|
||||
emit accessTokensFetched();
|
||||
emit accessTokenFetched();
|
||||
}
|
||||
|
||||
|
||||
|
@ -79,7 +79,6 @@ public:
|
||||
void setConnectionState( Account::ConnectionState connectionState );
|
||||
ConnectionState connectionState() const;
|
||||
|
||||
|
||||
virtual Tomahawk::InfoSystem::InfoPluginPtr infoPlugin() { return Tomahawk::InfoSystem::InfoPluginPtr(); }
|
||||
SipPlugin* sipPlugin( bool create = true );
|
||||
|
||||
@ -88,24 +87,29 @@ public:
|
||||
|
||||
QString username() const;
|
||||
|
||||
void fetchAccessTokens( const QString& type = "dreamcatcher" );
|
||||
void fetchAccessToken( const QString& type = "dreamcatcher" );
|
||||
|
||||
QString authUrlForService( const Service& service ) const;
|
||||
|
||||
signals:
|
||||
void authError( QString error, int statusCode, const QVariantMap );
|
||||
void deauthenticated();
|
||||
void accessTokensFetched();
|
||||
void accessTokenFetched();
|
||||
|
||||
private slots:
|
||||
void onPasswordLoginFinished( QNetworkReply*, const QString& username );
|
||||
void onFetchAccessTokensFinished();
|
||||
void onFetchAccessTokenFinished( QNetworkReply*, const QString& type );
|
||||
void authUrlDiscovered( Tomahawk::Accounts::HatchetAccount::Service service, const QString& authUrl );
|
||||
|
||||
private:
|
||||
QByteArray refreshToken() const;
|
||||
uint refreshTokenExpiration() const;
|
||||
|
||||
QByteArray mandellaAccessToken() const;
|
||||
uint mandellaAccessTokenExpiration() const;
|
||||
|
||||
QByteArray mandellaTokenType() const;
|
||||
|
||||
void loginWithPassword( const QString& username, const QString& password, const QString &otp );
|
||||
|
||||
QVariantMap parseReply( QNetworkReply* reply, bool& ok ) const;
|
||||
|
@ -56,7 +56,7 @@ HatchetAccountConfig::HatchetAccountConfig( HatchetAccount* account )
|
||||
|
||||
connect( m_account, SIGNAL( authError( QString, int, QVariantMap ) ), this, SLOT( authError( QString, int, QVariantMap ) ) );
|
||||
connect( m_account, SIGNAL( deauthenticated() ), this, SLOT( showLoggedOut() ) );
|
||||
connect( m_account, SIGNAL( accessTokensFetched() ), this, SLOT( accountInfoUpdated() ) );
|
||||
connect( m_account, SIGNAL( accessTokenFetched() ), this, SLOT( accountInfoUpdated() ) );
|
||||
|
||||
if ( !m_account->refreshToken().isEmpty() )
|
||||
accountInfoUpdated();
|
||||
@ -170,7 +170,7 @@ HatchetAccountConfig::accountInfoUpdated()
|
||||
void
|
||||
HatchetAccountConfig::authError( const QString &error, int statusCode, const QVariantMap& resp )
|
||||
{
|
||||
if ( statusCode == 401 && resp["result"].toMap()["errorinfo"].toMap().contains("missingotp") )
|
||||
if ( statusCode == 400 && error == "otp_needed" )
|
||||
{
|
||||
m_ui->usernameLabel->hide();
|
||||
m_ui->usernameEdit->hide();
|
||||
@ -182,7 +182,7 @@ HatchetAccountConfig::authError( const QString &error, int statusCode, const QVa
|
||||
return;
|
||||
}
|
||||
if ( statusCode == 401 )
|
||||
m_account->deauthenticate();
|
||||
m_account->deauthenticate();
|
||||
QMessageBox::critical( this, "An error was encountered:", error );
|
||||
}
|
||||
|
||||
|
@ -48,7 +48,7 @@ HatchetSipPlugin::HatchetSipPlugin( Tomahawk::Accounts::Account *account )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO;
|
||||
|
||||
connect( m_account, SIGNAL( accessTokensFetched() ), this, SLOT( connectWebSocket() ) );
|
||||
connect( m_account, SIGNAL( accessTokenFetched() ), this, SLOT( connectWebSocket() ) );
|
||||
connect( Servent::instance(), SIGNAL( dbSyncTriggered() ), this, SLOT( dbSyncTriggered() ));
|
||||
|
||||
QFile pemFile( ":/hatchet-account/dreamcatcher.pem" );
|
||||
@ -113,7 +113,7 @@ HatchetSipPlugin::connectPlugin()
|
||||
}
|
||||
|
||||
hatchetAccount()->setConnectionState( Tomahawk::Accounts::Account::Connecting );
|
||||
hatchetAccount()->fetchAccessTokens();
|
||||
hatchetAccount()->fetchAccessToken( "dreamcatcher" );
|
||||
}
|
||||
|
||||
|
||||
@ -156,42 +156,18 @@ HatchetSipPlugin::connectWebSocket()
|
||||
return;
|
||||
}
|
||||
|
||||
m_token.clear();
|
||||
m_token = m_account->credentials()[ "dreamcatcher_access_token" ].toString();
|
||||
|
||||
QVariantList tokensCreds = m_account->credentials()[ "dreamcatchertokens" ].toList();
|
||||
|
||||
//FIXME: Don't blindly pick the first one that matches? Most likely, cycle through if the first one fails
|
||||
QVariantMap connectVals;
|
||||
foreach ( QVariant credObj, tokensCreds )
|
||||
if ( m_token.isEmpty() )
|
||||
{
|
||||
QVariantMap creds = credObj.toMap();
|
||||
if ( creds.contains( "type" ) && creds[ "type" ].toString() == "dreamcatcher" )
|
||||
{
|
||||
connectVals = creds;
|
||||
m_token = creds[ "token" ].toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
QString url;
|
||||
if ( !connectVals.isEmpty() )
|
||||
{
|
||||
QString port = connectVals[ "port" ].toString();
|
||||
if ( port == "443" )
|
||||
url = "wss://";
|
||||
else
|
||||
url = "ws://";
|
||||
url += connectVals[ "host" ].toString() + ':' + connectVals[ "port" ].toString();
|
||||
}
|
||||
|
||||
if ( url.isEmpty() || m_token.isEmpty() )
|
||||
{
|
||||
tLog() << Q_FUNC_INFO << "Unable to find a proper connection endpoint; bailing";
|
||||
tLog() << Q_FUNC_INFO << "Unable to find an access token";
|
||||
disconnectPlugin();
|
||||
return;
|
||||
}
|
||||
else
|
||||
tLog() << Q_FUNC_INFO << "Connecting to Dreamcatcher endpoint at: " << url;
|
||||
|
||||
QString url( "wss://dreamcatcher.hatchet.is:443" );
|
||||
tLog() << Q_FUNC_INFO << "Connecting to Dreamcatcher endpoint at: " << url;
|
||||
|
||||
m_webSocketThreadController->setUrl( url );
|
||||
m_webSocketThreadController->start();
|
||||
|
Loading…
x
Reference in New Issue
Block a user