diff --git a/examples/grpc/CMakeLists.txt b/examples/grpc/CMakeLists.txt index 729bf62e..8b444d12 100644 --- a/examples/grpc/CMakeLists.txt +++ b/examples/grpc/CMakeLists.txt @@ -6,4 +6,5 @@ if(TARGET Qt6::Quick AND TARGET Qt6::qtprotobufgen AND TARGET Qt6::qtgrpcgen) qt_internal_add_example(magic8ball) + qt_internal_add_example(chat) endif() diff --git a/examples/grpc/chat/CMakeLists.txt b/examples/grpc/chat/CMakeLists.txt new file mode 100644 index 00000000..ba4c6f15 --- /dev/null +++ b/examples/grpc/chat/CMakeLists.txt @@ -0,0 +1,5 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +add_subdirectory(client) +add_subdirectory(server) diff --git a/examples/grpc/chat/client/CMakeLists.txt b/examples/grpc/chat/client/CMakeLists.txt new file mode 100644 index 00000000..d567656c --- /dev/null +++ b/examples/grpc/chat/client/CMakeLists.txt @@ -0,0 +1,61 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +cmake_minimum_required(VERSION 3.16) +project(GrpcChatClient LANGUAGES CXX) + +if(NOT DEFINED INSTALL_EXAMPLESDIR) + set(INSTALL_EXAMPLESDIR "examples") +endif() + +set(INSTALL_EXAMPLEDIR "${INSTALL_EXAMPLESDIR}/grpc/chat") + +find_package(Qt6 REQUIRED COMPONENTS Core Protobuf Grpc Quick) + +qt_standard_project_setup() + +qt_add_executable(grpcchatclient + main.cpp + simplechatengine.cpp simplechatengine.h + chatmessagemodel.cpp chatmessagemodel.h +) + +qt_add_qml_module(grpcchatclient + URI qtgrpc.examples.chat + VERSION 1.0 + AUTO_RESOURCE_PREFIX + QML_FILES + Main.qml + ChatInputField.qml + ChatView.qml +) + +qt_add_resources(grpcchatclient + "assets" + PREFIX "/" + FILES + "qt_logo_green_64x64px.png" +) + +qt_add_protobuf(grpcchatclient + PROTO_FILES + ../proto/simplechat.proto +) + +qt_add_grpc(grpcchatclient CLIENT + PROTO_FILES + ../proto/simplechat.proto +) + +target_link_libraries(grpcchatclient + PRIVATE + Qt::Gui + Qt::Quick + Qt::Grpc +) + +install(TARGETS grpcchatclient + RUNTIME DESTINATION "${INSTALL_EXAMPLEDIR}" + BUNDLE DESTINATION "${INSTALL_EXAMPLEDIR}" + LIBRARY DESTINATION "${INSTALL_EXAMPLEDIR}" +) diff --git a/examples/grpc/chat/client/ChatInputField.qml b/examples/grpc/chat/client/ChatInputField.qml new file mode 100644 index 00000000..7b4a517e --- /dev/null +++ b/examples/grpc/chat/client/ChatInputField.qml @@ -0,0 +1,23 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import QtQuick +import QtQuick.Controls + +TextField { + id: _inputField + width: 200 + color: "#cecfd5" + placeholderTextColor: "#9d9faa" + font.pointSize: 14 + padding: 10 + background: Rectangle { + radius: 5 + border { + width: 1 + color: _inputField.focus ? "#41cd52" : "#f3f3f4" + } + color: "#222840" + } +} diff --git a/examples/grpc/chat/client/ChatView.qml b/examples/grpc/chat/client/ChatView.qml new file mode 100644 index 00000000..7c2bc985 --- /dev/null +++ b/examples/grpc/chat/client/ChatView.qml @@ -0,0 +1,153 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import QtQuick + +import qtgrpc.examples.chat + +Rectangle { + id: root + anchors.fill: parent + color: "#09102b" + onVisibleChanged: { + if (visible) { + _inputField.forceActiveFocus() + } + } + + ListView { + id: messageListView + anchors.top: parent.top + anchors.bottom: _inputField.top + anchors.left: parent.left + anchors.right: parent.right + model: SimpleChatEngine.messages + clip: true + delegate: Item { + height: _imageWrapper.height + 10 + width: root.width + Item { + id: _imageWrapper + height: _messageColumn.height + 20 + width: parent.width/2 - 20 + property bool ownMessage: SimpleChatEngine.userName === model.from + anchors{ + right: _imageWrapper.ownMessage ? parent.right : undefined + left: _imageWrapper.ownMessage ? undefined : parent.left + rightMargin: _imageWrapper.ownMessage ? 10 : 0 + leftMargin: _imageWrapper.ownMessage ? 0 : 10 + verticalCenter: parent.verticalCenter + } + + Rectangle { + anchors.fill: parent + radius: 5 + color: _imageWrapper.ownMessage ? "#9d9faa" : "#53586b" + border.color: "#41cd52" + border.width: 1 + } + + Column { + id: _messageColumn + anchors { + left: parent.left + right: parent.right + leftMargin: 10 + rightMargin: 10 + verticalCenter: parent.verticalCenter + } + Text { + id: _userName + property string from: _imageWrapper.ownMessage ? qsTr("You") : model.from + anchors.left: parent.left + anchors.right: parent.right + font.pointSize: 12 + font.weight: Font.Bold + color: "#f3f3f4" + text: _userName.from + ": " + } + + Loader { + id: delegateLoader + anchors.left: parent.left + anchors.right: parent.right + height: item ? item.height : 0 + sourceComponent: model.type === SimpleChatEngine.Image ? imageDelegate : textDelegate + onItemChanged: { + if (item) { + item.content = model.content + } + } + } + } + } + } + onCountChanged: { + Qt.callLater( messageListView.positionViewAtEnd ) + } + } + + Component { + id: textDelegate + Item { + property alias content: innerText.text + height: childrenRect.height + Text { + id: innerText + anchors.left: parent.left + anchors.right: parent.right + height: implicitHeight + font.pointSize: 12 + color: "#f3f3f4" + wrapMode: Text.Wrap + } + } + } + Component { + id: imageDelegate + Item { + clip: true + property alias content: innerImage.source + height: childrenRect.height + Image { + id: innerImage + width: implicitWidth + height: implicitHeight + } + } + } + + ChatInputField { + id: _inputField + focus: true + anchors { + left: parent.left + right: parent.right + bottom: parent.bottom + margins: 20 + } + + placeholderText: qsTr("Start typing here or paste an image") + onAccepted: { + SimpleChatEngine.sendMessage(_inputField.text) + _inputField.text = "" + } + + Keys.onPressed: (event)=> { + if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) { + console.log("Ctrl + V") + switch (SimpleChatEngine.clipBoardContentType) { + case SimpleChatEngine.Text: + paste() + break + case SimpleChatEngine.Image: + SimpleChatEngine.sendImageFromClipboard() + break + } + + event.accepted = true + } + } + } +} diff --git a/examples/grpc/chat/client/Main.qml b/examples/grpc/chat/client/Main.qml new file mode 100644 index 00000000..012bfede --- /dev/null +++ b/examples/grpc/chat/client/Main.qml @@ -0,0 +1,151 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import QtQuick +import QtQuick.Controls + +import qtgrpc.examples.chat + +ApplicationWindow { + id: mainWindow + visible: true + width: 640 + height: 480 + minimumWidth: 640 + minimumHeight: 480 + title: qsTr("Simple Chat") + Rectangle { + id: background + anchors.fill: parent + color: "#09102b" + } + + Row { + spacing: 10 + anchors { + top: parent.top + topMargin: 10 + left: parent.left + leftMargin: 20 + } + Image { + source: "qrc:/qt_logo_green_64x64px.png" + width: implicitWidth + height: implicitHeight + } + Text { + anchors.verticalCenter: parent.verticalCenter + color: "#f3f3f4" + font.pointSize: 20 + text: qsTr("Chat") + } + } + + Column { + id: loginControl + spacing: 5 + visible: SimpleChatEngine.state !== SimpleChatEngine.Connected + anchors.centerIn: parent + enabled: SimpleChatEngine.state === SimpleChatEngine.Disconnected + ChatInputField { + id: loginField + width: 200 + placeholderText: qsTr("Login") + onAccepted: { + SimpleChatEngine.login(loginField.text, passwordField.text) + } + onVisibleChanged: { + if (visible) { + loginField.forceActiveFocus() + } + } + Component.onCompleted: { + if (visible) { + loginField.forceActiveFocus() + } + } + } + ChatInputField { + id: passwordField + echoMode: TextInput.Password + placeholderText: qsTr("Password") + onAccepted: { + SimpleChatEngine.login(loginField.text, passwordField.text) + } + } + Button { + id: loginButton + anchors.horizontalCenter: parent.horizontalCenter + width: enterText.implicitWidth + 20 + height: 40 + background: Rectangle { + radius: 5 + border { + width: 1 + color: loginButton.pressed ? "#41cd52" : "#f3f3f4" + } + color:"#53586b" + Text { + id: enterText + text : qsTr("Enter") + color: "#f3f3f4" + anchors.centerIn: parent + font.pointSize: 14 + } + } + + onClicked: { + SimpleChatEngine.login(loginField.text, passwordField.text) + } + } + } + + ChatView { + id: chatView + visible: SimpleChatEngine.state === SimpleChatEngine.Connected + } + + Text { + id: connectingText + visible: SimpleChatEngine.state === SimpleChatEngine.Connecting + anchors.top: loginControl.bottom + anchors.topMargin: 10 + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("Connecting...") + font.pointSize: 14 + color: "#f3f3f4" + } + + Text { + id: authFailedText + visible: false + anchors.top: loginControl.bottom + anchors.topMargin: 10 + anchors.horizontalCenter: parent.horizontalCenter + text: qsTr("This username with this password doesn't exists.") + font.pointSize: 14 + color: "#f3f3f4" + onVisibleChanged: { + if (authFailedText.visible) { + fadeOutTimer.restart() + } else { + fadeOutTimer.stop() + } + } + + Timer { + id: fadeOutTimer + onTriggered: { + authFailedText.visible = false + } + } + } + + Connections { + target: SimpleChatEngine + function onAuthFailed() { + authFailedText.visible = true; + } + } +} diff --git a/examples/grpc/chat/client/chatmessagemodel.cpp b/examples/grpc/chat/client/chatmessagemodel.cpp new file mode 100644 index 00000000..d00ce14d --- /dev/null +++ b/examples/grpc/chat/client/chatmessagemodel.cpp @@ -0,0 +1,90 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include +#include + +#include "chatmessagemodel.h" +#include "simplechat.qpb.h" + +namespace { +enum Role { + Content = Qt::UserRole + 1, + Type, + From, +}; + +QString getImage(const QByteArray &data) +{ + return QString("data:image/png;base64,") + data.toBase64(); +} + +QString getImageScaled(const QByteArray &data) +{ + QImage img = QImage::fromData(data, "PNG"); + img = img.scaled(300, 300, Qt::KeepAspectRatio); + QByteArray scaledData; + QBuffer buffer(&scaledData); + img.save(&buffer, "PNG"); + return getImage(scaledData); +} + +QString getText(const QByteArray &data) +{ + return QString::fromUtf8(data); +} +} // namespace + +ChatMessageModel::ChatMessageModel(QObject *parent) : QAbstractListModel(parent) { } + +QHash ChatMessageModel::roleNames() const +{ + static QHash s_roleNames; + if (s_roleNames.isEmpty()) { + s_roleNames.insert(Content, "content"); + s_roleNames.insert(Type, "type"); + s_roleNames.insert(From, "from"); + } + return s_roleNames; +} + +QVariant ChatMessageModel::data(const QModelIndex &index, int role) const +{ + int row = index.row(); + + if (row < 0 || row >= m_container.count() || !m_container.at(row)) + return QVariant(); + + auto dataPtr = m_container.at(row).get(); + + switch (role) { + case Content: + if (dataPtr->type() == qtgrpc::examples::chat::ChatMessage::Image) + return QVariant::fromValue(getImageScaled(dataPtr->content())); + else + return QVariant::fromValue(getText(dataPtr->content())); + case Type: + return QVariant::fromValue(dataPtr->type()); + case From: + return QVariant::fromValue(dataPtr->from()); + } + + return QVariant(); +} + +int ChatMessageModel::rowCount(const QModelIndex &) const +{ + return m_container.count(); +} + +void ChatMessageModel::append( + const QList> &messages) +{ + if (messages.size() > 0) { + beginInsertRows(QModelIndex(), m_container.size(), + m_container.size() + messages.size() - 1); + m_container.append(messages); + endInsertRows(); + } +} diff --git a/examples/grpc/chat/client/chatmessagemodel.h b/examples/grpc/chat/client/chatmessagemodel.h new file mode 100644 index 00000000..eb75bace --- /dev/null +++ b/examples/grpc/chat/client/chatmessagemodel.h @@ -0,0 +1,32 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#ifndef CHATMESSAGEMODEL_H +#define CHATMESSAGEMODEL_H + +#include + +#include + +namespace qtgrpc::examples::chat { +class ChatMessage; +} + +class ChatMessageModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit ChatMessageModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent) const override; + QHash roleNames() const override; + QVariant data(const QModelIndex &index, int role) const override; + + void append(const QList> &messages); + +private: + QList> m_container; +}; + +#endif // CHATMESSAGEMODEL_H diff --git a/examples/grpc/chat/client/main.cpp b/examples/grpc/chat/client/main.cpp new file mode 100644 index 00000000..748ff86c --- /dev/null +++ b/examples/grpc/chat/client/main.cpp @@ -0,0 +1,26 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include +#include +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + QGuiApplication::setWindowIcon(QIcon(":/qt_logo_green_64x64px.png")); + + QQmlApplicationEngine engine; + + QObject::connect(&engine, &QQmlApplicationEngine::objectCreationFailed, &app, [](){ + QCoreApplication::exit(-1); + }, Qt::QueuedConnection); + + engine.loadFromModule("qtgrpc.examples.chat", "Main"); + + return app.exec(); +} diff --git a/examples/grpc/chat/client/qt_logo_green_64x64px.png b/examples/grpc/chat/client/qt_logo_green_64x64px.png new file mode 100644 index 00000000..9cb2e01d Binary files /dev/null and b/examples/grpc/chat/client/qt_logo_green_64x64px.png differ diff --git a/examples/grpc/chat/client/simplechatengine.cpp b/examples/grpc/chat/client/simplechatengine.cpp new file mode 100644 index 00000000..a4e50fcd --- /dev/null +++ b/examples/grpc/chat/client/simplechatengine.cpp @@ -0,0 +1,172 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include "simplechatengine.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +SimpleChatEngine::SimpleChatEngine(QObject *parent) + : QObject(parent), + m_state(Disconnected), + m_client(new qtgrpc::examples::chat::SimpleChat::Client), + m_clipBoard(QGuiApplication::clipboard()) +{ + qRegisterProtobufTypes(); + if (m_clipBoard) + QObject::connect(m_clipBoard, &QClipboard::dataChanged, this, + &SimpleChatEngine::clipBoardContentTypeChanged); +} + +SimpleChatEngine::~SimpleChatEngine() +{ + delete m_client; +} + +void SimpleChatEngine::login(const QString &name, const QString &password) +{ + if (m_state != Disconnected) + return; + + setState(Connecting); + QUrl url("http://localhost:65002"); + + // ![0] + std::shared_ptr channel( + new QGrpcHttp2Channel(url, + QGrpcUserPasswordCredentials(name, password.toUtf8()) + | QGrpcInsecureChannelCredentials())); + // ![0] + + m_client->attachChannel(channel); + + // ![1] + auto stream = m_client->streamMessageList(qtgrpc::examples::chat::None()); + QObject::connect(stream.get(), &QGrpcStream::errorOccurred, this, + [this, stream](const QGrpcStatus &status) { + qCritical() + << "Stream error(" << status.code() << "):" << status.message(); + if (status.code() == QGrpcStatus::Unauthenticated) + emit authFailed(); + }); + + QObject::connect(stream.get(), &QGrpcStream::finished, this, + [this, stream]() { setState(Disconnected); }); + + QObject::connect(stream.get(), &QGrpcStream::messageReceived, this, [this, name, stream]() { + if (m_userName != name) { + m_userName = name; + emit userNameChanged(); + } + setState(Connected); + m_messages.append(stream->read().messages()); + }); + // ![1] +} + +void SimpleChatEngine::sendMessage(const QString &content) +{ + // ![2] + qtgrpc::examples::chat::ChatMessage msg; + msg.setContent(content.toUtf8()); + msg.setType(qtgrpc::examples::chat::ChatMessage::Text); + msg.setTimestamp(QDateTime::currentMSecsSinceEpoch()); + msg.setFrom(m_userName); + m_client->sendMessage(msg); + // ![2] +} + +SimpleChatEngine::ContentType SimpleChatEngine::clipBoardContentType() const +{ + if (m_clipBoard != nullptr) { + const QMimeData *mime = m_clipBoard->mimeData(); + if (mime != nullptr) { + if (mime->hasImage() || mime->hasUrls()) + return SimpleChatEngine::ContentType::Image; + else if (mime->hasText()) + return SimpleChatEngine::ContentType::Text; + } + } + return SimpleChatEngine::ContentType::Unknown; +} + +void SimpleChatEngine::sendImageFromClipboard() +{ + if (m_clipBoard == nullptr) + return; + + QByteArray imgData; + const QMimeData *mime = m_clipBoard->mimeData(); + if (mime != nullptr) { + if (mime->hasImage()) { + QImage img = mime->imageData().value(); + img = img.scaled(300, 300, Qt::KeepAspectRatio); + QBuffer buffer(&imgData); + buffer.open(QIODevice::WriteOnly); + img.save(&buffer, "PNG"); + buffer.close(); + } else if (mime->hasUrls()) { + QUrl imgUrl = mime->urls().at(0); + if (!imgUrl.isLocalFile()) { + qWarning() << "Only supports transfer of local images"; + return; + } + QImage img(imgUrl.toLocalFile()); + if (img.isNull()) { + qWarning() << "Invalid image format"; + return; + } + + QBuffer buffer(&imgData); + buffer.open(QIODevice::WriteOnly); + img.save(&buffer, "PNG"); + buffer.close(); + } + } + + if (imgData.isEmpty()) + return; + + qtgrpc::examples::chat::ChatMessage msg; + msg.setContent(imgData); + msg.setType(qtgrpc::examples::chat::ChatMessage::Image); + msg.setTimestamp(QDateTime::currentMSecsSinceEpoch()); + msg.setFrom(m_userName); + m_client->sendMessage(msg); +} + +ChatMessageModel *SimpleChatEngine::messages() +{ + return &m_messages; +} + +QString SimpleChatEngine::userName() const +{ + return m_userName; +} + +SimpleChatEngine::State SimpleChatEngine::state() const +{ + return m_state; +} + +void SimpleChatEngine::setState(SimpleChatEngine::State state) +{ + if (m_state != state) { + m_state = state; + emit stateChanged(); + } +} diff --git a/examples/grpc/chat/client/simplechatengine.h b/examples/grpc/chat/client/simplechatengine.h new file mode 100644 index 00000000..e223c5ab --- /dev/null +++ b/examples/grpc/chat/client/simplechatengine.h @@ -0,0 +1,75 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#ifndef SIMPLECHATENGINE_H +#define SIMPLECHATENGINE_H + +#include +#include + +#include "simplechat.qpb.h" +#include "simplechat_client.grpc.qpb.h" +#include "chatmessagemodel.h" + +QT_BEGIN_NAMESPACE +class QClipboard; +QT_END_NAMESPACE + +class SimpleChatEngine : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + Q_PROPERTY(QString userName READ userName NOTIFY userNameChanged) + Q_PROPERTY(ChatMessageModel *messages READ messages CONSTANT) + Q_PROPERTY(SimpleChatEngine::ContentType clipBoardContentType READ clipBoardContentType NOTIFY + clipBoardContentTypeChanged) + Q_PROPERTY(SimpleChatEngine::State state READ state NOTIFY stateChanged) +public: + enum ContentType { + Unknown = qtgrpc::examples::chat::ChatMessage::ContentType::Unknown, + Text = qtgrpc::examples::chat::ChatMessage::ContentType::Text, + Image = qtgrpc::examples::chat::ChatMessage::ContentType::Image, + }; + Q_ENUM(ContentType) + + enum State { + Disconnected = 0, + Connecting = 1, + Connected = 2, + }; + Q_ENUM(State) + + explicit SimpleChatEngine(QObject *parent = nullptr); + ~SimpleChatEngine() override; + Q_INVOKABLE void login(const QString &name, const QString &password); + Q_INVOKABLE void sendMessage(const QString &message); + + Q_INVOKABLE void sendImageFromClipboard(); + + QString userName() const; + ChatMessageModel *messages(); + SimpleChatEngine::ContentType clipBoardContentType() const; + + SimpleChatEngine::State state() const; + +Q_SIGNALS: + void authFailed(); + void clipBoardContentTypeChanged(); + void userNameChanged(); + void stateChanged(); + +private: + void setState(SimpleChatEngine::State state); + + SimpleChatEngine::State m_state; + ChatMessageModel m_messages; + qtgrpc::examples::chat::SimpleChat::Client *m_client; + QClipboard *m_clipBoard; + QString m_userName; +}; + +Q_DECLARE_METATYPE(ChatMessageModel *) + +#endif // SIMPLECHATENGINE_H diff --git a/examples/grpc/chat/proto/simplechat.proto b/examples/grpc/chat/proto/simplechat.proto new file mode 100644 index 00000000..4b11b744 --- /dev/null +++ b/examples/grpc/chat/proto/simplechat.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +//! [0] +package qtgrpc.examples.chat; + +message ChatMessage +{ + enum ContentType { + Unknown = 0; + Text = 1; + Image = 2; + }; + uint64 timestamp = 1; + bytes content = 2; + ContentType type = 3; + string from = 4; +} + +message ChatMessages +{ + repeated ChatMessage messages = 1; +} + +message User +{ + string name = 1; + string password = 2; +} + +message Users { + repeated User users = 1; +} + +message None { } + +service SimpleChat +{ + rpc messageList(None) returns (stream ChatMessages) { } + rpc sendMessage(ChatMessage) returns (None) { } +} +//! [0] diff --git a/examples/grpc/chat/server/CMakeLists.txt b/examples/grpc/chat/server/CMakeLists.txt new file mode 100644 index 00000000..1b538e56 --- /dev/null +++ b/examples/grpc/chat/server/CMakeLists.txt @@ -0,0 +1,58 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: BSD-3-Clause + +cmake_minimum_required(VERSION 3.16) + +project(GrpcChatServer LANGUAGES CXX) + +# Qt6::Grpc module is not used directly in this project. But this allows to find Qt6::Grpc's +# dependencies without setting extra cmake module paths. +find_package(Qt6 COMPONENTS Grpc) +find_package(WrapgRPCPlugin) +find_package(WrapgRPC) + +if(NOT TARGET WrapgRPC::WrapgRPCPlugin OR NOT TARGET WrapProtoc::WrapProtoc + OR NOT TARGET WrapgRPC::WrapLibgRPC) + message(WARNING "Dependencies of QtGrpc test server not found. Skipping.") + return() +endif() + +set(proto_files "${CMAKE_CURRENT_LIST_DIR}/../proto/simplechat.proto") +set(out_dir "${CMAKE_CURRENT_BINARY_DIR}") + +set(generated_files + "${out_dir}/simplechat.pb.h" "${out_dir}/simplechat.pb.cc" + "${out_dir}/simplechat.grpc.pb.h" "${out_dir}/simplechat.grpc.pb.cc") + +add_custom_command( + OUTPUT ${generated_files} + COMMAND + $ + ARGS + --grpc_out "${out_dir}" + --cpp_out "${out_dir}" + "-I${CMAKE_CURRENT_LIST_DIR}/../proto/" + --plugin=protoc-gen-grpc=$ + "${proto_files}" + WORKING_DIRECTORY ${out_dir} + DEPENDS "${proto_files}" + COMMENT "Generating gRPC ${target} sources..." + COMMAND_EXPAND_LISTS + VERBATIM +) + +set_source_files_properties(${generated_files} PROPERTIES GENERATED TRUE) + +add_executable(grpcchatserver + ${generated_files} + main.cpp +) + +target_include_directories(grpcchatserver PRIVATE ${out_dir}) +target_link_libraries(grpcchatserver PRIVATE WrapgRPC::WrapLibgRPC) + +install(TARGETS grpcchatserver + RUNTIME DESTINATION "${INSTALL_EXAMPLEDIR}" + BUNDLE DESTINATION "${INSTALL_EXAMPLEDIR}" + LIBRARY DESTINATION "${INSTALL_EXAMPLEDIR}" +) diff --git a/examples/grpc/chat/server/main.cpp b/examples/grpc/chat/server/main.cpp new file mode 100644 index 00000000..bb3492ca --- /dev/null +++ b/examples/grpc/chat/server/main.cpp @@ -0,0 +1,213 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// Copyright (C) 2019 Alexey Edelev +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +#include +#include +#include +#include +#include + +#include +#include +#include +using namespace qtgrpc::examples::chat; + +class MessageListHandler; +class UserListHandler; + +constexpr std::string_view nameHeader("user-name"); +constexpr std::string_view passwordHeader("user-password"); +class SimpleChatService final : public SimpleChat::WithAsyncMethod_messageList +{ +public: + SimpleChatService(); + void run(std::string_view address); + + grpc::Status sendMessage(grpc::ServerContext *context, const ChatMessage *request, + None *) override; + +private: + std::optional checkUserCredentials(grpc::ServerContext *context); + void addMessageHandler(MessageListHandler *userHandler); + void sendMessageToClients(const ChatMessage *message); + + Users m_usersDatabase; + ChatMessages m_messages; + std::list m_activeClients; +}; + +struct HandlerTag +{ + enum Type { Request = 1, Reply, Disconnect, Reject }; + + HandlerTag(HandlerTag::Type t, MessageListHandler *h) : tag(t), handler(h) { } + + HandlerTag::Type tag; + MessageListHandler *handler; +}; + +class MessageListHandler +{ +public: + MessageListHandler(SimpleChatService *service, grpc::ServerCompletionQueue *queue) + : writer(&ctx), cq(queue) + { + ctx.AsyncNotifyWhenDone(new HandlerTag(HandlerTag::Disconnect, this)); + service->RequestmessageList(&ctx, &request, &writer, cq, cq, + new HandlerTag(HandlerTag::Request, this)); + } + + None request; + grpc::ServerAsyncWriter writer; + grpc::ServerContext ctx; + grpc::ServerCompletionQueue *cq; +}; + +void SimpleChatService::run(std::string_view address) +{ + grpc::ServerBuilder builder; + builder.AddListeningPort(std::string(address), grpc::InsecureServerCredentials()); + builder.RegisterService(this); + std::unique_ptr cq = builder.AddCompletionQueue(); + std::unique_ptr server(builder.BuildAndStart()); + std::cout << "Server listening on " << address << '\n'; + + MessageListHandler *pending = new MessageListHandler(this, cq.get()); + while (true) { + HandlerTag *tag = nullptr; + bool ok = false; + cq->Next(reinterpret_cast(&tag), &ok); + + if (tag == nullptr) + continue; + + if (!ok) { + std::cout << "Unable to proccess tag from the completion queue\n"; + delete tag; + continue; + } + + switch (tag->tag) { + case HandlerTag::Request: { + std::cout << "New connection request received\n"; + std::string name; + if (!checkUserCredentials(&(pending->ctx))) { + std::cout << "Authentication failed\n"; + pending->writer.Finish(grpc::Status(grpc::StatusCode::UNAUTHENTICATED, + "User or login are invalid"), + new HandlerTag(HandlerTag::Reject, pending)); + } else { + std::cout << "User " << name << " connected to chat\n"; + addMessageHandler(pending); + pending->writer.Write(m_messages, nullptr); + } + pending = new MessageListHandler(this, cq.get()); + } break; + case HandlerTag::Disconnect: { + auto it = std::find(m_activeClients.begin(), m_activeClients.end(), tag->handler); + if (it != m_activeClients.end()) { + std::cout << "Client disconnected\n"; + m_activeClients.erase(it); + delete tag->handler; + } + } break; + case HandlerTag::Reject: + std::cout << "Connection rejected\n"; + m_activeClients.remove(tag->handler); + delete tag->handler; + break; + case HandlerTag::Reply: + std::cout << "Sending data to users\n"; + break; + } + delete tag; + } +} + +std::optional SimpleChatService::checkUserCredentials(grpc::ServerContext *context) +{ + assert(context != nullptr); + + std::string name; + std::string password; + for (const auto &[key, value] : std::as_const(context->client_metadata())) { + if (std::string(key.data(), key.size()) == nameHeader) { + name = std::string(value.data(), value.size()); + } + if (std::string(key.data(), key.size()) == passwordHeader) { + password = std::string(value.data(), value.size()); + } + } + + return std::find_if(m_usersDatabase.users().begin(), m_usersDatabase.users().end(), + [&name, &password](const auto &it) { + return it.name() == name && it.password() == password; + }) + != m_usersDatabase.users().end() + ? std::optional{ name } + : std::nullopt; +} + +SimpleChatService::SimpleChatService() +{ + // All passwords are 'qwerty' by default + User *newUser = m_usersDatabase.add_users(); + newUser->set_name("user1"); + newUser->set_password("qwerty"); + newUser = m_usersDatabase.add_users(); + newUser->set_name("user2"); + newUser->set_password("qwerty"); + newUser = m_usersDatabase.add_users(); + newUser->set_name("user3"); + newUser->set_password("qwerty"); + newUser = m_usersDatabase.add_users(); + newUser->set_name("user4"); + newUser->set_password("qwerty"); + newUser = m_usersDatabase.add_users(); + newUser->set_name("user5"); + newUser->set_password("qwerty"); +} + +void SimpleChatService::sendMessageToClients(const ChatMessage *message) +{ + assert(message != nullptr); + + // Send only new messages after users received all the messages for this session after stream + // call. + ChatMessages messages; + ChatMessage *msg = messages.add_messages(); + *msg = *message; + + for (auto client : m_activeClients) + client->writer.Write(messages, new HandlerTag(HandlerTag::Reply, client)); +} + +void SimpleChatService::addMessageHandler(MessageListHandler *handler) +{ + assert(handler != nullptr); + m_activeClients.push_back(handler); +} + +grpc::Status SimpleChatService::sendMessage(grpc::ServerContext *context, + const ChatMessage *request, None *) +{ + assert(context != nullptr); + assert(request != nullptr); + + auto name = checkUserCredentials(context); + if (!name) + return grpc::Status(grpc::StatusCode::UNAUTHENTICATED, "Login or password are invalid"); + + auto msg = m_messages.add_messages(); + *msg = *request; + msg->set_from(*name); + sendMessageToClients(msg); + return grpc::Status(); +} + +int main(int, char *[]) +{ + SimpleChatService srv; + srv.run("localhost:65002"); +} diff --git a/examples/grpc/doc/images/chatconversation.webp b/examples/grpc/doc/images/chatconversation.webp new file mode 100644 index 00000000..9e2ce4df Binary files /dev/null and b/examples/grpc/doc/images/chatconversation.webp differ diff --git a/examples/grpc/doc/images/chatlogin.webp b/examples/grpc/doc/images/chatlogin.webp new file mode 100644 index 00000000..6f0abf06 Binary files /dev/null and b/examples/grpc/doc/images/chatlogin.webp differ diff --git a/examples/grpc/doc/src/chat.qdoc b/examples/grpc/doc/src/chat.qdoc new file mode 100644 index 00000000..5a6d794c --- /dev/null +++ b/examples/grpc/doc/src/chat.qdoc @@ -0,0 +1,78 @@ +// Copyright (C) 2023 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GFDL-1.3-no-invariants-only + +/*! + \example chat + \ingroup qtgrpc-examples + \meta tag {network,protobuf,grpc,serialization,tcp} + \meta category {Networking} + \title Chat + + \brief Using the Qt GRPC client API in the user applications. + + \e Chat explains how to authenticate chat users and send and receive short + messages between chat clients. The application supports the following + message formats: + \list + \li Text - use message input field to send the message. + \li Image - copy the image buffer to the clipboard to send the message + using the \c{'Ctrl + V'} shortcut. + \endlist + + The chat client uses a simple RPC protocol described in the protobuf scheme: + \snippet chat/proto/simplechat.proto 0 + + On the login screen, enter user credentials: + \image chatlogin.webp + + \note The list of users is predefined on the server side and is constant. + The password for all users is \c qwerty. + + The chat client uses an authentication type called \c {call credentials}. + Each gRPC message includes the user credentials in the message header. + Credentials are passed to the \l QGrpcHttp2Channel once and reused + implicitily: + + \snippet chat/client/simplechatengine.cpp 0 + + The chat client starts the communication with the server using a + subscription to gRPC server streaming: + + \snippet chat/client/simplechatengine.cpp 1 + + The \l QGrpcStream handler provides the signals that the client application + should connect to. + + The \l QGrpcStream::errorOccurred signal indicates the error that occurred + either on the server side or in the communication channel. Typically, an + error results in the connection to the server being closed and + the \l QGrpcStream::finished signal being emitted. + + When the server sends new messages to the stream, \l QGrpcStream emits the + \l QGrpcStream::messageReceived signal. The slot connected to this signal + processes the chat message. Messages that are received from the + \c {SimpleChat/messageList} server stream are collected in the custom + \l QAbstractListModel model and displayed to the user. + + When the \l QGrpcStream::finished signal is emitted, there is nothing more + you can do with this stream instance, so you need to initiate a new + subscription. + + After a successful subscription, the chat client switches to the + conversation screen and allows you to see and send short messages: + + \image chatconversation.webp + + To send the message, use a unary RPC call \c {SimpleChat/sendMessage}. + The client application first sets fields of the \c ChatMessage protobuf + message and then calls the client method: + + \snippet chat/client/simplechatengine.cpp 2 + + Then, the gRPC server processes the client messages and broadcasts them to + all the connected clients through the \c {SimpleChat/messageList} stream. + + \note This example uses the reference gRPC C++ API in the server + implementation. +*/ +