mirror of https://github.com/qt/qtgrpc.git
405 lines
13 KiB
C++
405 lines
13 KiB
C++
// Copyright (C) 2024 The Qt Company Ltd.
|
|
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
|
|
|
|
#include "clientworker.h"
|
|
#include "chatmessages.qpb.h"
|
|
|
|
#include <QtQml/QQmlFile>
|
|
|
|
#include <QtGrpc/QGrpcCallOptions>
|
|
#include <QtGrpc/QGrpcChannelOptions>
|
|
#include <QtGrpc/QGrpcHttp2Channel>
|
|
|
|
#include <QtNetwork/QSslCertificate>
|
|
#include <QtNetwork/QSslConfiguration>
|
|
|
|
#include <QtCore/QFile>
|
|
#include <QtCore/QFileInfo>
|
|
#include <QtCore/QMimeDatabase>
|
|
#include <QtCore/QStandardPaths>
|
|
#include <QtCore/QUrl>
|
|
#include <QtCore/QTimer>
|
|
|
|
#include <chrono>
|
|
|
|
using namespace std::chrono_literals;
|
|
using namespace Backend;
|
|
|
|
ClientWorker::ClientWorker(QObject *parent) : QObject(parent)
|
|
{
|
|
}
|
|
|
|
ClientWorker::~ClientWorker() = default;
|
|
|
|
std::optional<QDir> ClientWorker::getValidDownloadDir() const
|
|
{
|
|
if (!m_client || !m_chatStream || m_userCredentials.name().isEmpty())
|
|
return {};
|
|
const auto path = QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
|
|
if (path.isEmpty()) {
|
|
qWarning("No download location found for standard path!");
|
|
return {};
|
|
}
|
|
QDir downloadDir(path + "/QtGrpcChat/" + m_userCredentials.name());
|
|
if (!downloadDir.exists())
|
|
downloadDir.mkpath(".");
|
|
return downloadDir;
|
|
}
|
|
|
|
std::optional<chat::ChatMessage> ClientWorker::createMessage() const
|
|
{
|
|
if (m_userCredentials.name().isEmpty())
|
|
return {};
|
|
chat::ChatMessage message;
|
|
message.setTimestamp(QDateTime::currentDateTimeUtc().toMSecsSinceEpoch());
|
|
message.setUsername(m_userCredentials.name());
|
|
return message;
|
|
}
|
|
|
|
void ClientWorker::setHostUri(const QUrl &hostUri)
|
|
{
|
|
const auto scheme = hostUri.scheme();
|
|
if (scheme != "http" && scheme != "https") {
|
|
emit chatError(tr("Specified scheme '%1' is not supported. Use 'http' or 'https'.")
|
|
.arg(scheme));
|
|
return;
|
|
}
|
|
if (m_hostUri == hostUri)
|
|
return;
|
|
m_hostUri = hostUri;
|
|
m_hostUriDirty = true;
|
|
}
|
|
|
|
//! [client-4b]
|
|
void ClientWorker::registerUser(const chat::Credentials &credentials)
|
|
{
|
|
if (credentials.name().isEmpty() || credentials.password().isEmpty()) {
|
|
emit chatError(tr("Invalid credentials for registration"));
|
|
return;
|
|
}
|
|
|
|
if ((!m_client || m_hostUriDirty) && !initializeClient()) {
|
|
emit chatError(tr("Failed registration: unabled to initialize client"));
|
|
return;
|
|
}
|
|
|
|
auto reply = m_client->Register(credentials, QGrpcCallOptions{}.setDeadlineTimeout(5s));
|
|
const auto *replyPtr = reply.get();
|
|
connect(
|
|
replyPtr, &QGrpcCallReply::finished, this,
|
|
[this, reply = std::move(reply)](const QGrpcStatus &status) {
|
|
emit registerFinished(status);
|
|
},
|
|
Qt::SingleShotConnection);
|
|
}
|
|
//! [client-4b]
|
|
|
|
//! [client-5a]
|
|
void ClientWorker::login(const chat::Credentials &credentials)
|
|
{
|
|
if (credentials.name().isEmpty() || credentials.password().isEmpty()) {
|
|
emit chatError(tr("Invalid credentials for login"));
|
|
return;
|
|
}
|
|
//! [client-5a]
|
|
if ((!m_client || m_hostUriDirty) && !initializeClient()) {
|
|
emit chatError(tr("Failed login: unabled to initialize client"));
|
|
return;
|
|
}
|
|
|
|
if (m_chatStream) {
|
|
emit chatError(tr("Already logged in to an active session."));
|
|
return;
|
|
}
|
|
m_userCredentials = credentials;
|
|
|
|
//! [client-5b]
|
|
QGrpcCallOptions opts;
|
|
opts.setMetadata({
|
|
{ "user-name", credentials.name().toUtf8() },
|
|
{ "user-password", credentials.password().toUtf8() },
|
|
});
|
|
connectStream(opts);
|
|
}
|
|
//! [client-5b]
|
|
|
|
void ClientWorker::logout()
|
|
{
|
|
if (!m_chatStream || m_chatState == ChatState::Disconnected
|
|
|| m_chatState == ChatState::Disconnecting) {
|
|
return;
|
|
}
|
|
setState(ChatState::Disconnecting);
|
|
m_chatStream->writesDone();
|
|
}
|
|
|
|
void ClientWorker::sendFile(const QUrl &url)
|
|
{
|
|
// By default gRPC accepts a maximum message size of 4 MiB
|
|
constexpr auto MaxMessageSize = quint64(3.9 * 1024 * 1024);
|
|
|
|
if (m_chatState == ChatState::Connecting) {
|
|
m_fileRequestBuffer.push_back(url);
|
|
return;
|
|
}
|
|
|
|
if (!m_chatStream || m_chatState != ChatState::Connected) {
|
|
emit chatError("Unable to send file: no active chat session");
|
|
return;
|
|
}
|
|
|
|
const auto fileUrl = QQmlFile::urlToLocalFileOrQrc(url);
|
|
const auto utf8Url = url.toString().toUtf8();
|
|
const auto fileName = QFileInfo(fileUrl).fileName();
|
|
|
|
if (!QFile::exists(fileUrl)) {
|
|
emit chatError(tr("The file \"%1\" could not be found").arg(fileName));
|
|
return;
|
|
}
|
|
|
|
static QMimeDatabase mimeDb;
|
|
|
|
QFile file(fileUrl);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
emit chatError(tr("The file \"%1\" could not be opened").arg(fileName));
|
|
return;
|
|
}
|
|
|
|
chat::FileMessage fileMessage;
|
|
fileMessage.setName(fileName);
|
|
fileMessage.setSize(uint64_t(file.size()));
|
|
|
|
QMimeType mime = mimeDb.mimeTypeForFile(fileUrl);
|
|
const auto mimeName = mime.name();
|
|
if (mimeName.contains("image"))
|
|
fileMessage.setType(chat::FileMessage::Type::IMAGE);
|
|
else if (mimeName.contains("audio"))
|
|
fileMessage.setType(chat::FileMessage::Type::AUDIO);
|
|
else if (mimeName.contains("video"))
|
|
fileMessage.setType(chat::FileMessage::Type::VIDEO);
|
|
else if (mime.inherits("text/plain"))
|
|
fileMessage.setType(chat::FileMessage::Type::TEXT);
|
|
else
|
|
fileMessage.setType(chat::FileMessage::Type::UNKNOWN);
|
|
|
|
// Send the file as chunks when the maximum supported message size is exceeded.
|
|
if (auto count = (quint64(file.size()) + MaxMessageSize - 1) / MaxMessageSize; count > 1) {
|
|
chat::FileMessage::Continuation continuation;
|
|
continuation.setIndex(0);
|
|
continuation.setCount(count);
|
|
continuation.setUuid(QUuid::createUuid());
|
|
fileMessage.setContinuation(std::move(continuation));
|
|
}
|
|
|
|
auto message = createMessage();
|
|
if (!message)
|
|
return;
|
|
|
|
while (!file.atEnd()) {
|
|
QByteArray content = file.read(MaxMessageSize);
|
|
|
|
message->setFile(fileMessage);
|
|
message->file().setContent(std::move(content));
|
|
m_chatStream->writeMessage(*message);
|
|
|
|
// We only need the content for writing it to the disk. The visual
|
|
// representation only needs the url.
|
|
message->file().setContent(utf8Url);
|
|
emit fileMessageSent(*message);
|
|
|
|
if (fileMessage.hasContinuation()) {
|
|
auto c = fileMessage.continuation();
|
|
c.setIndex(c.index() + 1);
|
|
fileMessage.setContinuation(std::move(c));
|
|
}
|
|
}
|
|
}
|
|
|
|
void ClientWorker::sendFiles(const QList<QUrl> &urls)
|
|
{
|
|
for (const auto &url : urls)
|
|
sendFile(url);
|
|
}
|
|
|
|
//! [client-7b]
|
|
void ClientWorker::sendMessage(const chat::ChatMessage &message)
|
|
{
|
|
if (!m_chatStream || m_chatState != ChatState::Connected) {
|
|
emit chatError(tr("Unable to send message"));
|
|
return;
|
|
}
|
|
m_chatStream->writeMessage(message);
|
|
}
|
|
//! [client-7b]
|
|
|
|
void ClientWorker::setState(ChatState state)
|
|
{
|
|
if (m_chatState == state)
|
|
return;
|
|
m_chatState = state;
|
|
emit chatStateChanged(state);
|
|
}
|
|
|
|
//! [client-6a]
|
|
void ClientWorker::connectStream(const QGrpcCallOptions &opts)
|
|
{
|
|
//! [client-6a]
|
|
auto initialMessage = createMessage();
|
|
if (!initialMessage)
|
|
return;
|
|
|
|
//! [client-6b]
|
|
m_chatStream = m_client->ChatRoom(*initialMessage, opts);
|
|
//! [client-6b]
|
|
|
|
if (!m_chatStream) {
|
|
m_userCredentials = {};
|
|
emit chatError(tr("initiating bidirectional stream failed"));
|
|
return;
|
|
}
|
|
|
|
//! [client-6c]
|
|
connect(m_chatStream.get(), &QGrpcBidiStream::finished, this,
|
|
[this, opts](const QGrpcStatus &status) {
|
|
if (m_chatState == ChatState::Connected) {
|
|
// If we're connected retry again in 250 ms, no matter the error.
|
|
QTimer::singleShot(250, [this, opts]() { connectStream(opts); });
|
|
} else {
|
|
setState(ChatState::Disconnected);
|
|
m_chatResponse = {};
|
|
m_userCredentials = {};
|
|
m_chatStream.reset();
|
|
emit chatStreamFinished(status);
|
|
}
|
|
});
|
|
//! [client-6c]
|
|
|
|
//! [client-6d]
|
|
connect(m_chatStream.get(), &QGrpcBidiStream::messageReceived, this, [this] {
|
|
//! [client-6d]
|
|
if (!m_chatStream->read(&m_chatResponse)) {
|
|
qWarning("Failed to deserialize!");
|
|
return;
|
|
}
|
|
|
|
//! [client-6e]
|
|
switch (m_chatResponse.contentField()) {
|
|
case chat::ChatMessage::ContentFields::UninitializedField:
|
|
qDebug("Received uninitialized message");
|
|
return;
|
|
case chat::ChatMessage::ContentFields::Text:
|
|
if (m_chatResponse.text().content().isEmpty())
|
|
return;
|
|
break;
|
|
case chat::ChatMessage::ContentFields::File:
|
|
// Download any file messages and store the downloaded URL in the
|
|
// content, allowing the model to reference it from there.
|
|
m_chatResponse.file()
|
|
.setContent(saveFileRequest(m_chatResponse.file()).toString().toUtf8());
|
|
break;
|
|
//! [client-6e]
|
|
case chat::ChatMessage::ContentFields::UserStatus:
|
|
if (m_chatResponse.userStatus().type() == chat::UserStatus::Type::LOGIN
|
|
&& m_chatResponse.username() == m_userCredentials.name()) {
|
|
setState(ChatState::Connected); // Login successful
|
|
for (const auto &m : m_fileRequestBuffer) // Send potentially buffered messages
|
|
sendFile(m);
|
|
m_fileRequestBuffer.clear();
|
|
}
|
|
}
|
|
//! [client-6f]
|
|
emit chatStreamMessageReceived(m_chatResponse);
|
|
});
|
|
setState(Backend::ChatState::Connecting);
|
|
}
|
|
//! [client-6f]
|
|
|
|
bool ClientWorker::initializeClient()
|
|
{
|
|
QGrpcChannelOptions opts;
|
|
#if QT_CONFIG(ssl)
|
|
//! [client-ssl]
|
|
if (m_hostUri.scheme() == "https") {
|
|
if (!QSslSocket::supportsSsl()) {
|
|
emit chatError(tr("The device doesn't support SSL. Please use the 'http' scheme."));
|
|
return false;
|
|
}
|
|
QFile crtFile(":/res/root.crt");
|
|
if (!crtFile.open(QFile::ReadOnly)) {
|
|
qFatal("Unable to load root certificate");
|
|
return false;
|
|
}
|
|
|
|
QSslConfiguration sslConfig;
|
|
QSslCertificate crt(crtFile.readAll());
|
|
sslConfig.addCaCertificate(crt);
|
|
sslConfig.setProtocol(QSsl::TlsV1_2OrLater);
|
|
sslConfig.setAllowedNextProtocols({ "h2" }); // Allow HTTP/2
|
|
|
|
// Disable hostname verification to allow connections from any local IP.
|
|
// Acceptable for development but avoid in production for security.
|
|
sslConfig.setPeerVerifyMode(QSslSocket::VerifyNone);
|
|
opts.setSslConfiguration(sslConfig);
|
|
}
|
|
//! [client-ssl]
|
|
#endif
|
|
|
|
m_client = std::make_unique<chat::QtGrpcChat::Client>();
|
|
if (!m_client->attachChannel(std::make_shared<QGrpcHttp2Channel>(m_hostUri, opts)))
|
|
return false;
|
|
m_hostUriDirty = false;
|
|
return true;
|
|
}
|
|
|
|
QString ClientWorker::toUniqueFilePath(const QString &filePath)
|
|
{
|
|
qsizetype count = 0;
|
|
QFileInfo fileInfo(filePath);
|
|
QString newFilePath = filePath;
|
|
|
|
while (QFile::exists(newFilePath)) {
|
|
newFilePath = QString("%1/%2_%3.%4")
|
|
.arg(fileInfo.absolutePath(), fileInfo.completeBaseName())
|
|
.arg(count)
|
|
.arg(fileInfo.suffix());
|
|
++count;
|
|
}
|
|
return newFilePath;
|
|
}
|
|
|
|
QUrl ClientWorker::saveFileRequest(const chat::FileMessage &fileRequest) const
|
|
{
|
|
const auto downloadDir = getValidDownloadDir();
|
|
if (!downloadDir)
|
|
return {};
|
|
|
|
QString savedFile = downloadDir->path() + QDir::separator();
|
|
if (fileRequest.hasContinuation()) {
|
|
savedFile += fileRequest.continuation().uuid().toString();
|
|
} else {
|
|
// Since this is the only transmission it must be locally unique already
|
|
savedFile = toUniqueFilePath(savedFile += fileRequest.name());
|
|
}
|
|
|
|
QFile writer(savedFile);
|
|
if (!writer.open(QIODevice::WriteOnly | QIODevice::Append)) {
|
|
qWarning() << "cannot open" << writer.fileName();
|
|
return {};
|
|
}
|
|
if (writer.write(fileRequest.content()) < 0) {
|
|
qDebug() << "Failed to write content to file" << writer.fileName();
|
|
return {};
|
|
}
|
|
|
|
if (fileRequest.hasContinuation()
|
|
&& fileRequest.continuation().index() == fileRequest.continuation().count() - 1) {
|
|
savedFile = toUniqueFilePath(downloadDir->path() + '/' + fileRequest.name());
|
|
if (!writer.rename(savedFile))
|
|
return {};
|
|
} else if (fileRequest.hasContinuation()) {
|
|
return {}; // Don't return the path to incomplete downloads
|
|
}
|
|
|
|
return QUrl::fromLocalFile(savedFile);
|
|
}
|