mirror of https://github.com/qt/qtgrpc.git
Backport the simple chat example
Backport the simple chat example. The application implements a simple messenger using Qt GRPC. It shows how to utilize the server-side streaming and use the call-based user/password credentials. Task-number: QTBUG-109598 Pick-to: 6.5 Change-Id: I4ca3695780a9cc9991c92c4423e3af9d8e0eaf35 Reviewed-by: Leena Miettinen <riitta-leena.miettinen@qt.io> Reviewed-by: Mårten Nordheim <marten.nordheim@qt.io>
This commit is contained in:
parent
2e3fdd5c05
commit
fdd1d0339f
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
# Copyright (C) 2023 The Qt Company Ltd.
|
||||
# SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
add_subdirectory(client)
|
||||
add_subdirectory(server)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
#include <QImage>
|
||||
#include <QBuffer>
|
||||
|
||||
#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<int, QByteArray> ChatMessageModel::roleNames() const
|
||||
{
|
||||
static QHash<int, QByteArray> 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<std::shared_ptr<qtgrpc::examples::chat::ChatMessage>> &messages)
|
||||
{
|
||||
if (messages.size() > 0) {
|
||||
beginInsertRows(QModelIndex(), m_container.size(),
|
||||
m_container.size() + messages.size() - 1);
|
||||
m_container.append(messages);
|
||||
endInsertRows();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
#ifndef CHATMESSAGEMODEL_H
|
||||
#define CHATMESSAGEMODEL_H
|
||||
|
||||
#include <QAbstractListModel>
|
||||
|
||||
#include <memory>
|
||||
|
||||
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<int, QByteArray> roleNames() const override;
|
||||
QVariant data(const QModelIndex &index, int role) const override;
|
||||
|
||||
void append(const QList<std::shared_ptr<qtgrpc::examples::chat::ChatMessage>> &messages);
|
||||
|
||||
private:
|
||||
QList<std::shared_ptr<qtgrpc::examples::chat::ChatMessage>> m_container;
|
||||
};
|
||||
|
||||
#endif // CHATMESSAGEMODEL_H
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
#include <QGuiApplication>
|
||||
#include <QQmlApplicationEngine>
|
||||
#include <QFontDatabase>
|
||||
#include <QIcon>
|
||||
#include <QFont>
|
||||
|
||||
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();
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -0,0 +1,172 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
#include "simplechatengine.h"
|
||||
|
||||
#include <QGrpcHttp2Channel>
|
||||
#include <QGrpcUserPasswordCredentials>
|
||||
#include <QGrpcInsecureChannelCredentials>
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QCryptographicHash>
|
||||
#include <QDateTime>
|
||||
#include <QClipboard>
|
||||
#include <QGuiApplication>
|
||||
#include <QMimeData>
|
||||
#include <QImage>
|
||||
#include <QByteArray>
|
||||
#include <QBuffer>
|
||||
|
||||
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<QAbstractGrpcChannel> 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<qtgrpc::examples::chat::ChatMessages>().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<QImage>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
#ifndef SIMPLECHATENGINE_H
|
||||
#define SIMPLECHATENGINE_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QQmlEngine>
|
||||
|
||||
#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
|
||||
|
|
@ -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]
|
||||
|
|
@ -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
|
||||
$<TARGET_FILE:WrapProtoc::WrapProtoc>
|
||||
ARGS
|
||||
--grpc_out "${out_dir}"
|
||||
--cpp_out "${out_dir}"
|
||||
"-I${CMAKE_CURRENT_LIST_DIR}/../proto/"
|
||||
--plugin=protoc-gen-grpc=$<TARGET_FILE:WrapgRPC::WrapgRPCPlugin>
|
||||
"${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}"
|
||||
)
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
// Copyright (C) 2023 The Qt Company Ltd.
|
||||
// Copyright (C) 2019 Alexey Edelev <semlanik@gmail.com>
|
||||
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
|
||||
|
||||
#include <iostream>
|
||||
#include <string_view>
|
||||
#include <list>
|
||||
#include <cassert>
|
||||
#include <optional>
|
||||
|
||||
#include <grpc++/grpc++.h>
|
||||
#include <simplechat.pb.h>
|
||||
#include <simplechat.grpc.pb.h>
|
||||
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<SimpleChat::Service>
|
||||
{
|
||||
public:
|
||||
SimpleChatService();
|
||||
void run(std::string_view address);
|
||||
|
||||
grpc::Status sendMessage(grpc::ServerContext *context, const ChatMessage *request,
|
||||
None *) override;
|
||||
|
||||
private:
|
||||
std::optional<std::string> checkUserCredentials(grpc::ServerContext *context);
|
||||
void addMessageHandler(MessageListHandler *userHandler);
|
||||
void sendMessageToClients(const ChatMessage *message);
|
||||
|
||||
Users m_usersDatabase;
|
||||
ChatMessages m_messages;
|
||||
std::list<MessageListHandler *> 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<qtgrpc::examples::chat::ChatMessages> 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<grpc::ServerCompletionQueue> cq = builder.AddCompletionQueue();
|
||||
std::unique_ptr<grpc::Server> 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<void **>(&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<std::string> 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");
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
|
|
@ -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.
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue