From 12784e240797015f2c987e5438190913b9dec094 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 1 Jun 2026 19:04:46 -0400 Subject: [PATCH 1/8] feat(cloud): move account control to objects toolbar Replace the Cloud menu bar entry with a VS Code-style account button pinned to the bottom of the left objects toolbar, including a disabled signed-in user row in the popup menu and MainWindow unit tests. Co-authored-by: Cursor --- src/mainwindow.cpp | 113 ++++++++++++++++++++++++++++++++-------- src/mainwindow.h | 6 +++ src/mainwindow_test.cpp | 86 ++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 23 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 17c3cae73..217375a26 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -98,6 +98,7 @@ #include #include #include +#include #include #include #include @@ -1981,6 +1982,8 @@ void MainWindow::initToolBar() this, refreshTopoButtons); connect(EditorModeController::instance(), &EditorModeController::modeChanged, this, &MainWindow::updateToolRailForMode); + + setupCloudAccountStatusControl(); updateToolRailForMode(); connect(pAddCube, SIGNAL(triggered()),m_pPrimitivesWidget,SLOT(createCube())); @@ -2176,25 +2179,6 @@ void MainWindow::initToolBar() m_viewCubeController->setActiveWidget(mDockWidgetList.first()->getOgreWidget()); m_viewCubeController->setVisible(true); - QMenu* cloudMenu = menuBar()->addMenu(tr("&Cloud")); - cloudMenu->setObjectName(QStringLiteral("menuCloud")); - m_cloudSignInAction = cloudMenu->addAction(tr("Sign in to QtMesh Cloud...")); - m_cloudSignInAction->setObjectName(QStringLiteral("actionQtMeshCloudSignIn")); - connect(m_cloudSignInAction, &QAction::triggered, this, &MainWindow::signInToQtMeshCloud); - m_cloudSignOutAction = cloudMenu->addAction(tr("Sign out")); - m_cloudSignOutAction->setObjectName(QStringLiteral("actionQtMeshCloudSignOut")); - connect(m_cloudSignOutAction, &QAction::triggered, this, &MainWindow::signOutOfQtMeshCloud); - cloudMenu->addSeparator(); - m_cloudUploadFilesAction = cloudMenu->addAction(tr("Upload Files...")); - m_cloudUploadFilesAction->setObjectName(QStringLiteral("actionQtMeshCloudUploadFiles")); - connect(m_cloudUploadFilesAction, &QAction::triggered, this, &MainWindow::uploadFilesToQtMeshCloud); - m_cloudOpenDashboardAction = cloudMenu->addAction(tr("Open My Projects")); - m_cloudOpenDashboardAction->setObjectName(QStringLiteral("actionQtMeshCloudOpenProjects")); - connect(m_cloudOpenDashboardAction, &QAction::triggered, this, []() { - QDesktopServices::openUrl(QUrl(QStringLiteral(QTMESH_CLOUD_WEB_URL))); - }); - updateCloudAuthActions(); - // AI Settings menu QMenu* aiMenu = menuBar()->addMenu(tr("&AI")); aiMenu->setObjectName("menuAI"); @@ -2308,20 +2292,103 @@ const QPalette &MainWindow::darkPalette() return (*darkPalette); } +void MainWindow::setupCloudAccountStatusControl() +{ + m_cloudAccountMenu = new QMenu(this); + m_cloudAccountMenu->setObjectName(QStringLiteral("menuCloud")); + + m_cloudAccountInfoAction = m_cloudAccountMenu->addAction(tr("Signed in")); + m_cloudAccountInfoAction->setObjectName(QStringLiteral("actionQtMeshCloudAccountInfo")); + m_cloudAccountInfoAction->setEnabled(false); + m_cloudAccountInfoSeparator = m_cloudAccountMenu->addSeparator(); + + m_cloudSignInAction = m_cloudAccountMenu->addAction(tr("Sign in to QtMesh Cloud...")); + m_cloudSignInAction->setObjectName(QStringLiteral("actionQtMeshCloudSignIn")); + connect(m_cloudSignInAction, &QAction::triggered, this, &MainWindow::signInToQtMeshCloud); + + m_cloudSignOutAction = m_cloudAccountMenu->addAction(tr("Sign out")); + m_cloudSignOutAction->setObjectName(QStringLiteral("actionQtMeshCloudSignOut")); + connect(m_cloudSignOutAction, &QAction::triggered, this, &MainWindow::signOutOfQtMeshCloud); + + m_cloudAccountMenu->addSeparator(); + + m_cloudUploadFilesAction = m_cloudAccountMenu->addAction(tr("Upload Files...")); + m_cloudUploadFilesAction->setObjectName(QStringLiteral("actionQtMeshCloudUploadFiles")); + connect(m_cloudUploadFilesAction, &QAction::triggered, this, &MainWindow::uploadFilesToQtMeshCloud); + + m_cloudOpenDashboardAction = m_cloudAccountMenu->addAction(tr("Open My Projects")); + m_cloudOpenDashboardAction->setObjectName(QStringLiteral("actionQtMeshCloudOpenProjects")); + connect(m_cloudOpenDashboardAction, &QAction::triggered, this, []() { + QDesktopServices::openUrl(QUrl(QStringLiteral(QTMESH_CLOUD_WEB_URL))); + }); + + connect(m_cloudAccountMenu, &QMenu::aboutToShow, this, &MainWindow::updateCloudAuthActions); + + // Push the account control to the bottom of the left objects toolbar (VS Code-style). + QWidget* toolbarStretch = new QWidget(ui->objectsToolbar); + toolbarStretch->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding); + toolbarStretch->setMinimumSize(0, 0); + QAction* stretchAction = ui->objectsToolbar->addWidget(toolbarStretch); + stretchAction->setObjectName(QStringLiteral("modeAnyObjectsToolbarStretch")); + + m_cloudAccountButton = new QToolButton(ui->objectsToolbar); + m_cloudAccountButton->setObjectName(QStringLiteral("cloudAccountButton")); + m_cloudAccountButton->setAutoRaise(true); + m_cloudAccountButton->setPopupMode(QToolButton::InstantPopup); + m_cloudAccountButton->setToolButtonStyle(Qt::ToolButtonIconOnly); + m_cloudAccountButton->setMenu(m_cloudAccountMenu); + + QIcon cloudIcon = QIcon::fromTheme(QStringLiteral("internet-services")); + if (cloudIcon.isNull()) + cloudIcon = style()->standardIcon(QStyle::SP_DriveNetIcon); + m_cloudAccountButton->setIcon(cloudIcon); + m_cloudAccountButton->setStyleSheet( + QStringLiteral("QToolButton { padding: 4px; border: none; }" + "QToolButton:hover { background: palette(midlight); }" + "QToolButton:pressed { background: palette(mid); }")); + + QAction* cloudAction = ui->objectsToolbar->addWidget(m_cloudAccountButton); + cloudAction->setObjectName(QStringLiteral("modeAnyCloudAccountAction")); + updateCloudAuthActions(); +} + void MainWindow::updateCloudAuthActions() { QSettings settings; CloudCredentialStore::migrateLegacySettingsIfNeeded(); const bool signedIn = CloudCredentialStore::hasSession(); const QString display = storedCloudDisplayName(); + + if (m_cloudAccountButton) { + if (signedIn && !display.isEmpty()) { + m_cloudAccountButton->setToolTip( + tr("Signed in as %1. Click for QtMesh Cloud account options.").arg(display)); + } else { + m_cloudAccountButton->setToolTip(tr("QtMesh Cloud — click to sign in")); + } + } + + if (m_cloudAccountInfoAction) { + if (signedIn) { + m_cloudAccountInfoAction->setText(display.isEmpty() + ? tr("Signed in") + : tr("Signed in as %1").arg(display)); + m_cloudAccountInfoAction->setVisible(true); + } else { + m_cloudAccountInfoAction->setVisible(false); + } + } + if (m_cloudAccountInfoSeparator) + m_cloudAccountInfoSeparator->setVisible(signedIn); + if (m_cloudSignInAction) { + m_cloudSignInAction->setVisible(!signedIn); m_cloudSignInAction->setEnabled(!signedIn); - m_cloudSignInAction->setText(signedIn && !display.isEmpty() - ? tr("Signed in as %1").arg(display) - : tr("Sign in to QtMesh Cloud...")); } - if (m_cloudSignOutAction) + if (m_cloudSignOutAction) { + m_cloudSignOutAction->setVisible(signedIn); m_cloudSignOutAction->setEnabled(signedIn); + } if (m_cloudUploadFilesAction) m_cloudUploadFilesAction->setEnabled(true); if (m_cloudOpenDashboardAction) diff --git a/src/mainwindow.h b/src/mainwindow.h index 18cf8ff1e..7794529e1 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -26,6 +26,7 @@ class QPlainTextEdit; class QLabel; class QToolBar; class QAction; +class QToolButton; class OgreWidget; namespace Ui { @@ -190,6 +191,10 @@ public slots: QToolBar* m_modeBarShell = nullptr; QToolBar* m_topBarStretch = nullptr; QQuickWidget* m_modeBar = nullptr; + QToolButton* m_cloudAccountButton = nullptr; + QMenu* m_cloudAccountMenu = nullptr; + QAction* m_cloudAccountInfoAction = nullptr; + QAction* m_cloudAccountInfoSeparator = nullptr; QAction* m_cloudSignInAction = nullptr; QAction* m_cloudSignOutAction = nullptr; QAction* m_cloudUploadFilesAction = nullptr; @@ -224,6 +229,7 @@ public slots: void showBottomToolDock(QDockWidget* dock); void tabifyBottomToolDocks(); void updateToolRailForMode(); + void setupCloudAccountStatusControl(); void updateCloudAuthActions(); }; diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index 85f7e217e..d99e4bc8d 100644 --- a/src/mainwindow_test.cpp +++ b/src/mainwindow_test.cpp @@ -37,6 +37,8 @@ #include "EditorModeController.h" #include "MeshInfoOverlay.h" #include "MeshValidator.h" +#include "CloudCredentialStore.h" +#include "AppSettingsKeys.h" #include "TestHelpers.h" #include "EditorViewport.h" #include "ViewportSettingsKeys.h" @@ -352,6 +354,90 @@ TEST_F(MainWindowTest, ContextualToolRailAiChatPrecedesAddPrimitive) EXPECT_LT(aiIdx, primIdx) << "Open AI Chat should appear before Add Primitive on the rail"; } +TEST_F(MainWindowTest, CloudAccountControlLivesAtBottomOfObjectsToolbar) +{ + QAction* stretch = findActionByObjectName(QStringLiteral("modeAnyObjectsToolbarStretch")); + QAction* cloud = findActionByObjectName(QStringLiteral("modeAnyCloudAccountAction")); + ASSERT_NE(stretch, nullptr); + ASSERT_NE(cloud, nullptr); + + const QList actions = window->ui->objectsToolbar->actions(); + const int stretchIdx = actions.indexOf(stretch); + const int cloudIdx = actions.indexOf(cloud); + EXPECT_GE(stretchIdx, 0); + EXPECT_GE(cloudIdx, 0); + EXPECT_LT(stretchIdx, cloudIdx); + EXPECT_EQ(cloudIdx, actions.size() - 1) << "Cloud account should be the last rail item"; + + auto* cloudButton = qobject_cast(window->ui->objectsToolbar->widgetForAction(cloud)); + ASSERT_NE(cloudButton, nullptr); + EXPECT_EQ(cloudButton->objectName(), QStringLiteral("cloudAccountButton")); + ASSERT_NE(window->findChild(QStringLiteral("menuCloud")), nullptr); +} + +TEST_F(MainWindowTest, CloudAccountMenuShowsConnectedUserAsDisabledRow) +{ + CloudCredentialStore::clearSession(); + CloudSession session; + session.token = QStringLiteral("test-token"); + session.email = QStringLiteral("dev@example.com"); + ASSERT_TRUE(CloudCredentialStore::saveSession(session)); + QSettings().setValue(AppSettingsKeys::cloudUserName(), QStringLiteral("Dev User")); + + window->updateCloudAuthActions(); + app->processEvents(); + + QAction* info = findActionByObjectName(QStringLiteral("actionQtMeshCloudAccountInfo")); + QAction* signIn = findActionByObjectName(QStringLiteral("actionQtMeshCloudSignIn")); + QAction* signOut = findActionByObjectName(QStringLiteral("actionQtMeshCloudSignOut")); + ASSERT_NE(info, nullptr); + ASSERT_NE(signIn, nullptr); + ASSERT_NE(signOut, nullptr); + EXPECT_TRUE(info->isVisible()); + EXPECT_FALSE(info->isEnabled()); + EXPECT_EQ(info->text(), QStringLiteral("Signed in as Dev User")); + EXPECT_FALSE(signIn->isVisible()); + EXPECT_TRUE(signOut->isVisible()); + + CloudCredentialStore::clearSession(); + window->updateCloudAuthActions(); + app->processEvents(); + EXPECT_FALSE(info->isVisible()); + EXPECT_TRUE(signIn->isVisible()); + EXPECT_FALSE(signOut->isVisible()); +} + +TEST_F(MainWindowTest, CloudAccountControlVisibleInEveryEditorMode) +{ + QAction* cloud = findActionByObjectName(QStringLiteral("modeAnyCloudAccountAction")); + ASSERT_NE(cloud, nullptr); + auto* modeController = EditorModeController::instance(); + const QList modes = { + EditorModeController::ObjectMode, + EditorModeController::EditMode, + EditorModeController::AnimationMode, + EditorModeController::MaterialMode, + EditorModeController::ValidationMode, + }; + for (int mode : modes) { + modeController->requestMode(mode); + app->processEvents(); + EXPECT_TRUE(cloud->isVisible()) << "mode index " << mode; + } +} + +TEST_F(MainWindowTest, CloudMenuIsNotOnMenuBar) +{ + bool cloudTopLevel = false; + for (QAction* action : window->menuBar()->actions()) { + if (action->menu() && action->menu()->objectName() == QStringLiteral("menuCloud")) { + cloudTopLevel = true; + break; + } + } + EXPECT_FALSE(cloudTopLevel); +} + TEST_F(MainWindowTest, ContextualToolRailKeepsSharedMenuActionsReachable) { auto* modeController = EditorModeController::instance(); From 1ee27887e7fdacbf27410dbb75bc659a01287031 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 1 Jun 2026 19:23:17 -0400 Subject: [PATCH 2/8] ci: retrigger unit-tests after runner archive unzip failure Co-authored-by: Cursor From 34adcacdb38ff1c2f3f34b4ee7a46c8ae3b15150 Mon Sep 17 00:00:00 2001 From: Fernando Date: Mon, 1 Jun 2026 20:07:07 -0400 Subject: [PATCH 3/8] fix(cloud): address toolbar review feedback Clear CloudCredentialStore in MainWindowTest teardown, add ui.action Sentry breadcrumbs for cloud toolbar menu actions, and warn when the browser fails to open QtMesh Cloud. Co-authored-by: Cursor --- src/mainwindow.cpp | 33 +++++++++++++++++++++++++++------ src/mainwindow_test.cpp | 5 +++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 217375a26..a90707735 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2304,25 +2304,46 @@ void MainWindow::setupCloudAccountStatusControl() m_cloudSignInAction = m_cloudAccountMenu->addAction(tr("Sign in to QtMesh Cloud...")); m_cloudSignInAction->setObjectName(QStringLiteral("actionQtMeshCloudSignIn")); - connect(m_cloudSignInAction, &QAction::triggered, this, &MainWindow::signInToQtMeshCloud); + connect(m_cloudSignInAction, &QAction::triggered, this, [this]() { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Cloud toolbar: Sign in")); + signInToQtMeshCloud(); + }); m_cloudSignOutAction = m_cloudAccountMenu->addAction(tr("Sign out")); m_cloudSignOutAction->setObjectName(QStringLiteral("actionQtMeshCloudSignOut")); - connect(m_cloudSignOutAction, &QAction::triggered, this, &MainWindow::signOutOfQtMeshCloud); + connect(m_cloudSignOutAction, &QAction::triggered, this, [this]() { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Cloud toolbar: Sign out")); + signOutOfQtMeshCloud(); + }); m_cloudAccountMenu->addSeparator(); m_cloudUploadFilesAction = m_cloudAccountMenu->addAction(tr("Upload Files...")); m_cloudUploadFilesAction->setObjectName(QStringLiteral("actionQtMeshCloudUploadFiles")); - connect(m_cloudUploadFilesAction, &QAction::triggered, this, &MainWindow::uploadFilesToQtMeshCloud); + connect(m_cloudUploadFilesAction, &QAction::triggered, this, [this]() { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Cloud toolbar: Upload Files")); + uploadFilesToQtMeshCloud(); + }); m_cloudOpenDashboardAction = m_cloudAccountMenu->addAction(tr("Open My Projects")); m_cloudOpenDashboardAction->setObjectName(QStringLiteral("actionQtMeshCloudOpenProjects")); - connect(m_cloudOpenDashboardAction, &QAction::triggered, this, []() { - QDesktopServices::openUrl(QUrl(QStringLiteral(QTMESH_CLOUD_WEB_URL))); + connect(m_cloudOpenDashboardAction, &QAction::triggered, this, [this]() { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Cloud toolbar: Open My Projects")); + if (!QDesktopServices::openUrl(QUrl(QStringLiteral(QTMESH_CLOUD_WEB_URL)))) { + QMessageBox::warning(this, tr("QtMesh Cloud"), + tr("Could not open QtMesh Cloud in your browser.")); + } }); - connect(m_cloudAccountMenu, &QMenu::aboutToShow, this, &MainWindow::updateCloudAuthActions); + connect(m_cloudAccountMenu, &QMenu::aboutToShow, this, [this]() { + SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), + QStringLiteral("Cloud toolbar menu opened")); + updateCloudAuthActions(); + }); // Push the account control to the bottom of the left objects toolbar (VS Code-style). QWidget* toolbarStretch = new QWidget(ui->objectsToolbar); diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index d99e4bc8d..7bb57da36 100644 --- a/src/mainwindow_test.cpp +++ b/src/mainwindow_test.cpp @@ -86,6 +86,11 @@ class MainWindowTest : public ::testing::Test { void TearDown() override { delete window; window = nullptr; + CloudCredentialStore::clearSession(); + QSettings settings; + settings.remove(AppSettingsKeys::cloudUserName()); + settings.remove(AppSettingsKeys::cloudUserSlug()); + settings.remove(AppSettingsKeys::cloudUserEmail()); // Tests below switch the editor mode (Animation/Material/etc). Reset // the singleton so subsequent test cases see a fresh ObjectMode // controller — otherwise stale state leaks into m_editModeLabel and From 1204dcbf4eb1f06f551cd720109d29d1f2561cf0 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 2 Jun 2026 08:08:10 -0400 Subject: [PATCH 4/8] feat(cloud): redesign account menu button and popup Replace the generic network icon with a dedicated CloudAccountMenuButton component: outline user icon when signed out, initials avatar when signed in, and a status badge for connected/offline. Restructure the popup with a non-clickable name header, main cloud actions, and account sign-in/out. Fix signed-out paint crash (nested QPainter) and remove the header from the menu when logged out so stale name text no longer bleeds through. Co-authored-by: Cursor --- resources/cloud_account_user.svg | 10 + resources/resource.qrc | 1 + src/CMakeLists.txt | 1 + src/CloudAccountMenuButton.cpp | 310 ++++++++++++++++++++++++++++ src/CloudAccountMenuButton.h | 57 +++++ src/CloudAccountMenuButton_test.cpp | 41 ++++ src/mainwindow.cpp | 93 +-------- src/mainwindow.h | 10 +- src/mainwindow_test.cpp | 17 +- tests/CMakeLists.txt | 1 + 10 files changed, 441 insertions(+), 100 deletions(-) create mode 100644 resources/cloud_account_user.svg create mode 100644 src/CloudAccountMenuButton.cpp create mode 100644 src/CloudAccountMenuButton.h create mode 100644 src/CloudAccountMenuButton_test.cpp diff --git a/resources/cloud_account_user.svg b/resources/cloud_account_user.svg new file mode 100644 index 000000000..83bf46975 --- /dev/null +++ b/resources/cloud_account_user.svg @@ -0,0 +1,10 @@ + + + + + + diff --git a/resources/resource.qrc b/resources/resource.qrc index df1e7f49c..de652deaf 100755 --- a/resources/resource.qrc +++ b/resources/resource.qrc @@ -17,6 +17,7 @@ roundedbox.png addVertice.png paintbrush.svg + cloud_account_user.svg spring.png scale.png diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d1f1529f2..16dc312d9 100755 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -122,6 +122,7 @@ PlatformProfile.cpp QtMeshCloudClient.cpp CloudCredentialStore.cpp CloudUploadPlanner.cpp +CloudAccountMenuButton.cpp AssetBrowserController.cpp MaterialPreviewRenderer.cpp ModelTurntableRenderer.cpp diff --git a/src/CloudAccountMenuButton.cpp b/src/CloudAccountMenuButton.cpp new file mode 100644 index 000000000..85bcf71d7 --- /dev/null +++ b/src/CloudAccountMenuButton.cpp @@ -0,0 +1,310 @@ +#include "CloudAccountMenuButton.h" + +#include "AppSettingsKeys.h" +#include "CloudCredentialStore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +QString cloudDisplayName() +{ + QSettings settings; + QString display = settings.value(AppSettingsKeys::cloudUserName()).toString().trimmed(); + if (display.isEmpty()) + display = settings.value(AppSettingsKeys::cloudUserSlug()).toString().trimmed(); + if (display.isEmpty()) + display = CloudCredentialStore::loadSession().email.trimmed(); + return display; +} + +} // namespace + +class CloudAccountMenuButton::AvatarButton : public QToolButton { +public: + explicit AvatarButton(QWidget* parent = nullptr) + : QToolButton(parent) + { + setFixedSize(28, 28); + setAutoRaise(true); + setPopupMode(QToolButton::InstantPopup); + setToolButtonStyle(Qt::ToolButtonIconOnly); + setCursor(Qt::PointingHandCursor); + setStyleSheet(QStringLiteral( + "QToolButton { padding: 0; border: none; background: transparent; }" + "QToolButton:hover { background: palette(midlight); border-radius: 14px; }" + "QToolButton:pressed { background: palette(mid); border-radius: 14px; }")); + } + + void setSignedIn(bool signedIn, const QString& initials) + { + m_signedIn = signedIn; + m_initials = initials; + if (signedIn && !initials.isEmpty()) { + setIcon(QIcon()); + setText(QString()); + setToolButtonStyle(Qt::ToolButtonIconOnly); + } else { + setText(QString()); + setToolButtonStyle(Qt::ToolButtonIconOnly); + if (m_loggedOutIcon.isNull()) + m_loggedOutIcon = QIcon(QStringLiteral(":/icones/cloud_account_user.svg")); + setIcon(m_loggedOutIcon); + } + update(); + } + +protected: + void paintEvent(QPaintEvent* event) override + { + if (m_signedIn && !m_initials.isEmpty()) { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + + const QRect r = rect().adjusted(2, 2, -2, -2); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0x4a, 0x7a, 0xa8)); + painter.drawEllipse(r); + QFont font = painter.font(); + font.setBold(true); + font.setPixelSize(10); + painter.setFont(font); + painter.setPen(QColor(0xf0, 0xf4, 0xf8)); + painter.drawText(r, Qt::AlignCenter, m_initials); + paintStatusBadge(painter); + return; + } + + if (m_signedIn) { + if (m_loggedOutIcon.isNull()) + m_loggedOutIcon = QIcon(QStringLiteral(":/icones/cloud_account_user.svg")); + const QRect r = rect().adjusted(2, 2, -2, -2); + const QPixmap pix = m_loggedOutIcon.pixmap(r.size()); + if (!pix.isNull()) { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.drawPixmap(r, pix); + paintStatusBadge(painter); + return; + } + } + + QToolButton::paintEvent(event); + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing, true); + paintStatusBadge(painter); + } + +private: + void paintStatusBadge(QPainter& painter) + { + const int badgeD = 7; + const QRect badge(rect().right() - badgeD - 1, + rect().bottom() - badgeD - 1, + badgeD, + badgeD); + painter.setPen(QPen(QColor(0x2b, 0x2b, 0x2b), 1.5)); + painter.setBrush(m_signedIn ? QColor(0x4c, 0xaf, 0x50) : QColor(0x6e, 0x6e, 0x6e)); + painter.drawEllipse(badge); + } + +private: + bool m_signedIn = false; + QString m_initials; + QIcon m_loggedOutIcon; +}; + +QString CloudAccountMenuButton::initialsFromDisplayName(const QString& displayName) +{ + const QString trimmed = displayName.trimmed(); + if (trimmed.isEmpty()) + return QString(); + + const QStringList parts = + trimmed.split(QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + if (parts.isEmpty()) + return QString(); + + auto firstChar = [](const QString& word) -> QChar { + for (const QChar ch : word) { + if (ch.isLetter()) + return ch.toUpper(); + } + return QChar(); + }; + + if (parts.size() == 1) { + const QChar a = firstChar(parts.front()); + return a.isNull() ? QString() : QString(a); + } + + const QChar first = firstChar(parts.front()); + const QChar last = firstChar(parts.back()); + if (first.isNull() && last.isNull()) + return QString(); + if (first.isNull()) + return QString(last); + if (last.isNull()) + return QString(first); + return QString(first) + last; +} + +CloudAccountMenuButton::CloudAccountMenuButton(QWidget* parent) + : QWidget(parent) +{ + auto* layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); + + m_button = new AvatarButton(this); + m_button->setObjectName(QStringLiteral("cloudAccountButton")); + + m_menu = new QMenu(this); + m_menu->setObjectName(QStringLiteral("menuCloud")); + applyMenuStyle(); + buildMenu(); + + m_button->setMenu(m_menu); + layout->addWidget(m_button); + + connect(m_menu, &QMenu::aboutToShow, this, &CloudAccountMenuButton::refresh); + + refresh(); +} + +void CloudAccountMenuButton::applyMenuStyle() +{ + m_menu->setStyleSheet(QStringLiteral( + "QMenu {" + " background-color: #2b2b2b;" + " border: 1px solid #3d3d3d;" + " padding: 4px 0;" + "}" + "QMenu::item {" + " padding: 7px 20px;" + " color: #e0e0e0;" + "}" + "QMenu::item:selected {" + " background-color: #3a3a3a;" + "}" + "QMenu::item:disabled {" + " color: #9a9a9a;" + "}" + "QMenu::separator {" + " height: 1px;" + " background: #3d3d3d;" + " margin: 5px 10px;" + "}")); +} + +void CloudAccountMenuButton::buildMenu() +{ + m_headerWidget = new QWidget(m_menu); + m_headerWidget->setObjectName(QStringLiteral("cloudAccountMenuHeader")); + auto* headerLayout = new QVBoxLayout(m_headerWidget); + headerLayout->setContentsMargins(14, 10, 14, 8); + headerLayout->setSpacing(2); + + m_headerNameLabel = new QLabel(m_headerWidget); + m_headerNameLabel->setObjectName(QStringLiteral("cloudAccountMenuHeaderName")); + m_headerNameLabel->setStyleSheet(QStringLiteral( + "color: #ececec; font-size: 13px; font-weight: 600; background: transparent;")); + m_headerNameLabel->setWordWrap(true); + + m_headerSubtitleLabel = new QLabel(tr("QtMesh Cloud account"), m_headerWidget); + m_headerSubtitleLabel->setObjectName(QStringLiteral("cloudAccountMenuHeaderSubtitle")); + m_headerSubtitleLabel->setStyleSheet(QStringLiteral( + "color: #9a9a9a; font-size: 11px; background: transparent;")); + + headerLayout->addWidget(m_headerNameLabel); + headerLayout->addWidget(m_headerSubtitleLabel); + + m_headerAction = new QWidgetAction(m_menu); + m_headerAction->setDefaultWidget(m_headerWidget); + m_headerAction->setEnabled(false); + m_menu->addAction(m_headerAction); + m_headerSeparator = m_menu->addSeparator(); + + m_openProjectsAction = m_menu->addAction(tr("Open My Projects")); + m_openProjectsAction->setObjectName(QStringLiteral("actionQtMeshCloudOpenProjects")); + connect(m_openProjectsAction, &QAction::triggered, this, &CloudAccountMenuButton::openProjectsRequested); + + m_uploadAction = m_menu->addAction(tr("Upload Files...")); + m_uploadAction->setObjectName(QStringLiteral("actionQtMeshCloudUploadFiles")); + connect(m_uploadAction, &QAction::triggered, this, &CloudAccountMenuButton::uploadFilesRequested); + + m_mainSeparator = m_menu->addSeparator(); + + m_signOutAction = m_menu->addAction(tr("Sign out")); + m_signOutAction->setObjectName(QStringLiteral("actionQtMeshCloudSignOut")); + connect(m_signOutAction, &QAction::triggered, this, &CloudAccountMenuButton::signOutRequested); + + m_signInAction = m_menu->addAction(tr("Sign in to QtMesh Cloud")); + m_signInAction->setObjectName(QStringLiteral("actionQtMeshCloudSignIn")); + connect(m_signInAction, &QAction::triggered, this, &CloudAccountMenuButton::signInRequested); +} + +void CloudAccountMenuButton::updateHeader(const QString& displayName, bool signedIn) +{ + const bool showHeader = signedIn && !displayName.isEmpty(); + + if (showHeader) { + m_headerNameLabel->setText(displayName); + + // Ensure the header is actually present in the menu, otherwise the menu can + // end up drawing stale pixels behind items when we toggle auth state while + // the menu is open. + if (!m_menu->actions().contains(m_headerAction)) + m_menu->insertAction(m_openProjectsAction, m_headerAction); + if (!m_menu->actions().contains(m_headerSeparator)) + m_menu->insertAction(m_openProjectsAction, m_headerSeparator); + } else { + m_headerNameLabel->clear(); + if (m_menu->actions().contains(m_headerAction)) + m_menu->removeAction(m_headerAction); + if (m_menu->actions().contains(m_headerSeparator)) + m_menu->removeAction(m_headerSeparator); + } + + m_menu->updateGeometry(); + m_menu->adjustSize(); + m_menu->update(); +} + +void CloudAccountMenuButton::refresh() +{ + CloudCredentialStore::migrateLegacySettingsIfNeeded(); + const bool signedIn = CloudCredentialStore::hasSession(); + const QString display = cloudDisplayName(); + + if (signedIn && !display.isEmpty()) { + m_button->setToolTip( + tr("QtMesh Cloud: signed in as %1").arg(display)); + } else { + m_button->setToolTip(tr("Sign in to QtMesh Cloud")); + } + + const QString initials = signedIn ? initialsFromDisplayName(display) : QString(); + if (auto* avatar = dynamic_cast(m_button)) + avatar->setSignedIn(signedIn, initials); + + updateHeader(display, signedIn); + + m_openProjectsAction->setEnabled(signedIn); + m_uploadAction->setEnabled(true); + + m_signInAction->setVisible(!signedIn); + m_signInAction->setEnabled(!signedIn); + m_signOutAction->setVisible(signedIn); + m_signOutAction->setEnabled(signedIn); +} diff --git a/src/CloudAccountMenuButton.h b/src/CloudAccountMenuButton.h new file mode 100644 index 000000000..b64d8ca13 --- /dev/null +++ b/src/CloudAccountMenuButton.h @@ -0,0 +1,57 @@ +#ifndef CLOUD_ACCOUNT_MENU_BUTTON_H +#define CLOUD_ACCOUNT_MENU_BUTTON_H + +#include + +class QAction; +class QLabel; +class QMenu; +class QToolButton; +class QWidgetAction; + +/// VS Code-style QtMesh Cloud account control: avatar button + popup menu. +/// Self-contained so MainWindow can host it on the objects toolbar today and +/// move it to a top bar later without rewiring auth logic. +class CloudAccountMenuButton : public QWidget { + Q_OBJECT + +public: + explicit CloudAccountMenuButton(QWidget* parent = nullptr); + + QToolButton* toolButton() const { return m_button; } + QMenu* menu() const { return m_menu; } + + /// Reads CloudCredentialStore / QSettings and updates button + menu visibility. + void refresh(); + + /// Exposed for unit tests. + static QString initialsFromDisplayName(const QString& displayName); + +signals: + void signInRequested(); + void signOutRequested(); + void uploadFilesRequested(); + void openProjectsRequested(); + +private: + class AvatarButton; + + void buildMenu(); + void applyMenuStyle(); + void updateHeader(const QString& displayName, bool signedIn); + + QToolButton* m_button = nullptr; + QMenu* m_menu = nullptr; + QWidget* m_headerWidget = nullptr; + QLabel* m_headerNameLabel = nullptr; + QLabel* m_headerSubtitleLabel = nullptr; + QWidgetAction* m_headerAction = nullptr; + QAction* m_headerSeparator = nullptr; + QAction* m_mainSeparator = nullptr; + QAction* m_signInAction = nullptr; + QAction* m_signOutAction = nullptr; + QAction* m_uploadAction = nullptr; + QAction* m_openProjectsAction = nullptr; +}; + +#endif diff --git a/src/CloudAccountMenuButton_test.cpp b/src/CloudAccountMenuButton_test.cpp new file mode 100644 index 000000000..647e17977 --- /dev/null +++ b/src/CloudAccountMenuButton_test.cpp @@ -0,0 +1,41 @@ +#include "CloudAccountMenuButton.h" + +#include "CloudCredentialStore.h" + +#include +#include + +TEST(CloudAccountMenuButtonTest, InitialsFromDisplayName) +{ + EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral("Fernando Tonon")), + QStringLiteral("FT")); + EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral("Dev User")), + QStringLiteral("DU")); + EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral("Ada")), + QStringLiteral("A")); + EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral(" ")), QString()); +} + +TEST(CloudAccountMenuButtonTest, SignedOutButtonRepaintsWithoutCrash) +{ + CloudCredentialStore::clearSession(); + + CloudAccountMenuButton button; + button.show(); + button.refresh(); + button.repaint(); + QApplication::processEvents(); + + CloudSession session; + session.token = QStringLiteral("test-token"); + session.email = QStringLiteral("dev@example.com"); + ASSERT_TRUE(CloudCredentialStore::saveSession(session)); + button.refresh(); + button.repaint(); + QApplication::processEvents(); + + CloudCredentialStore::clearSession(); + button.refresh(); + button.repaint(); + QApplication::processEvents(); +} diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index a90707735..e3ad91145 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -40,6 +40,7 @@ #include "mainwindow.h" #include "AppConsoleLog.h" #include "AppSettingsKeys.h" +#include "CloudAccountMenuButton.h" #include "CloudCredentialStore.h" #include "CloudUploadPlanner.h" #include "ui_mainwindow.h" @@ -2294,43 +2295,24 @@ const QPalette &MainWindow::darkPalette() void MainWindow::setupCloudAccountStatusControl() { - m_cloudAccountMenu = new QMenu(this); - m_cloudAccountMenu->setObjectName(QStringLiteral("menuCloud")); + m_cloudAccountControl = new CloudAccountMenuButton(this); - m_cloudAccountInfoAction = m_cloudAccountMenu->addAction(tr("Signed in")); - m_cloudAccountInfoAction->setObjectName(QStringLiteral("actionQtMeshCloudAccountInfo")); - m_cloudAccountInfoAction->setEnabled(false); - m_cloudAccountInfoSeparator = m_cloudAccountMenu->addSeparator(); - - m_cloudSignInAction = m_cloudAccountMenu->addAction(tr("Sign in to QtMesh Cloud...")); - m_cloudSignInAction->setObjectName(QStringLiteral("actionQtMeshCloudSignIn")); - connect(m_cloudSignInAction, &QAction::triggered, this, [this]() { + connect(m_cloudAccountControl, &CloudAccountMenuButton::signInRequested, this, [this]() { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Cloud toolbar: Sign in")); signInToQtMeshCloud(); }); - - m_cloudSignOutAction = m_cloudAccountMenu->addAction(tr("Sign out")); - m_cloudSignOutAction->setObjectName(QStringLiteral("actionQtMeshCloudSignOut")); - connect(m_cloudSignOutAction, &QAction::triggered, this, [this]() { + connect(m_cloudAccountControl, &CloudAccountMenuButton::signOutRequested, this, [this]() { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Cloud toolbar: Sign out")); signOutOfQtMeshCloud(); }); - - m_cloudAccountMenu->addSeparator(); - - m_cloudUploadFilesAction = m_cloudAccountMenu->addAction(tr("Upload Files...")); - m_cloudUploadFilesAction->setObjectName(QStringLiteral("actionQtMeshCloudUploadFiles")); - connect(m_cloudUploadFilesAction, &QAction::triggered, this, [this]() { + connect(m_cloudAccountControl, &CloudAccountMenuButton::uploadFilesRequested, this, [this]() { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Cloud toolbar: Upload Files")); uploadFilesToQtMeshCloud(); }); - - m_cloudOpenDashboardAction = m_cloudAccountMenu->addAction(tr("Open My Projects")); - m_cloudOpenDashboardAction->setObjectName(QStringLiteral("actionQtMeshCloudOpenProjects")); - connect(m_cloudOpenDashboardAction, &QAction::triggered, this, [this]() { + connect(m_cloudAccountControl, &CloudAccountMenuButton::openProjectsRequested, this, [this]() { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Cloud toolbar: Open My Projects")); if (!QDesktopServices::openUrl(QUrl(QStringLiteral(QTMESH_CLOUD_WEB_URL)))) { @@ -2338,11 +2320,9 @@ void MainWindow::setupCloudAccountStatusControl() tr("Could not open QtMesh Cloud in your browser.")); } }); - - connect(m_cloudAccountMenu, &QMenu::aboutToShow, this, [this]() { + connect(m_cloudAccountControl->menu(), &QMenu::aboutToShow, this, []() { SentryReporter::addBreadcrumb(QStringLiteral("ui.action"), QStringLiteral("Cloud toolbar menu opened")); - updateCloudAuthActions(); }); // Push the account control to the bottom of the left objects toolbar (VS Code-style). @@ -2352,68 +2332,15 @@ void MainWindow::setupCloudAccountStatusControl() QAction* stretchAction = ui->objectsToolbar->addWidget(toolbarStretch); stretchAction->setObjectName(QStringLiteral("modeAnyObjectsToolbarStretch")); - m_cloudAccountButton = new QToolButton(ui->objectsToolbar); - m_cloudAccountButton->setObjectName(QStringLiteral("cloudAccountButton")); - m_cloudAccountButton->setAutoRaise(true); - m_cloudAccountButton->setPopupMode(QToolButton::InstantPopup); - m_cloudAccountButton->setToolButtonStyle(Qt::ToolButtonIconOnly); - m_cloudAccountButton->setMenu(m_cloudAccountMenu); - - QIcon cloudIcon = QIcon::fromTheme(QStringLiteral("internet-services")); - if (cloudIcon.isNull()) - cloudIcon = style()->standardIcon(QStyle::SP_DriveNetIcon); - m_cloudAccountButton->setIcon(cloudIcon); - m_cloudAccountButton->setStyleSheet( - QStringLiteral("QToolButton { padding: 4px; border: none; }" - "QToolButton:hover { background: palette(midlight); }" - "QToolButton:pressed { background: palette(mid); }")); - - QAction* cloudAction = ui->objectsToolbar->addWidget(m_cloudAccountButton); + QAction* cloudAction = ui->objectsToolbar->addWidget(m_cloudAccountControl); cloudAction->setObjectName(QStringLiteral("modeAnyCloudAccountAction")); updateCloudAuthActions(); } void MainWindow::updateCloudAuthActions() { - QSettings settings; - CloudCredentialStore::migrateLegacySettingsIfNeeded(); - const bool signedIn = CloudCredentialStore::hasSession(); - const QString display = storedCloudDisplayName(); - - if (m_cloudAccountButton) { - if (signedIn && !display.isEmpty()) { - m_cloudAccountButton->setToolTip( - tr("Signed in as %1. Click for QtMesh Cloud account options.").arg(display)); - } else { - m_cloudAccountButton->setToolTip(tr("QtMesh Cloud — click to sign in")); - } - } - - if (m_cloudAccountInfoAction) { - if (signedIn) { - m_cloudAccountInfoAction->setText(display.isEmpty() - ? tr("Signed in") - : tr("Signed in as %1").arg(display)); - m_cloudAccountInfoAction->setVisible(true); - } else { - m_cloudAccountInfoAction->setVisible(false); - } - } - if (m_cloudAccountInfoSeparator) - m_cloudAccountInfoSeparator->setVisible(signedIn); - - if (m_cloudSignInAction) { - m_cloudSignInAction->setVisible(!signedIn); - m_cloudSignInAction->setEnabled(!signedIn); - } - if (m_cloudSignOutAction) { - m_cloudSignOutAction->setVisible(signedIn); - m_cloudSignOutAction->setEnabled(signedIn); - } - if (m_cloudUploadFilesAction) - m_cloudUploadFilesAction->setEnabled(true); - if (m_cloudOpenDashboardAction) - m_cloudOpenDashboardAction->setEnabled(signedIn); + if (m_cloudAccountControl) + m_cloudAccountControl->refresh(); } void MainWindow::signInToQtMeshCloud() diff --git a/src/mainwindow.h b/src/mainwindow.h index 7794529e1..dfaddc531 100755 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -27,6 +27,7 @@ class QLabel; class QToolBar; class QAction; class QToolButton; +class CloudAccountMenuButton; class OgreWidget; namespace Ui { @@ -191,14 +192,7 @@ public slots: QToolBar* m_modeBarShell = nullptr; QToolBar* m_topBarStretch = nullptr; QQuickWidget* m_modeBar = nullptr; - QToolButton* m_cloudAccountButton = nullptr; - QMenu* m_cloudAccountMenu = nullptr; - QAction* m_cloudAccountInfoAction = nullptr; - QAction* m_cloudAccountInfoSeparator = nullptr; - QAction* m_cloudSignInAction = nullptr; - QAction* m_cloudSignOutAction = nullptr; - QAction* m_cloudUploadFilesAction = nullptr; - QAction* m_cloudOpenDashboardAction = nullptr; + CloudAccountMenuButton* m_cloudAccountControl = nullptr; /// View menu entries for bottom tabbed docks — checked state follows user /// preference, not QDockWidget::isVisible() (inactive tabs would otherwise diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index 7bb57da36..144e55803 100644 --- a/src/mainwindow_test.cpp +++ b/src/mainwindow_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -374,13 +375,14 @@ TEST_F(MainWindowTest, CloudAccountControlLivesAtBottomOfObjectsToolbar) EXPECT_LT(stretchIdx, cloudIdx); EXPECT_EQ(cloudIdx, actions.size() - 1) << "Cloud account should be the last rail item"; - auto* cloudButton = qobject_cast(window->ui->objectsToolbar->widgetForAction(cloud)); + auto* cloudWidget = window->ui->objectsToolbar->widgetForAction(cloud); + ASSERT_NE(cloudWidget, nullptr); + auto* cloudButton = cloudWidget->findChild(QStringLiteral("cloudAccountButton")); ASSERT_NE(cloudButton, nullptr); - EXPECT_EQ(cloudButton->objectName(), QStringLiteral("cloudAccountButton")); ASSERT_NE(window->findChild(QStringLiteral("menuCloud")), nullptr); } -TEST_F(MainWindowTest, CloudAccountMenuShowsConnectedUserAsDisabledRow) +TEST_F(MainWindowTest, CloudAccountMenuShowsSignedInHeader) { CloudCredentialStore::clearSession(); CloudSession session; @@ -392,22 +394,19 @@ TEST_F(MainWindowTest, CloudAccountMenuShowsConnectedUserAsDisabledRow) window->updateCloudAuthActions(); app->processEvents(); - QAction* info = findActionByObjectName(QStringLiteral("actionQtMeshCloudAccountInfo")); + auto* headerName = window->findChild(QStringLiteral("cloudAccountMenuHeaderName")); QAction* signIn = findActionByObjectName(QStringLiteral("actionQtMeshCloudSignIn")); QAction* signOut = findActionByObjectName(QStringLiteral("actionQtMeshCloudSignOut")); - ASSERT_NE(info, nullptr); + ASSERT_NE(headerName, nullptr); ASSERT_NE(signIn, nullptr); ASSERT_NE(signOut, nullptr); - EXPECT_TRUE(info->isVisible()); - EXPECT_FALSE(info->isEnabled()); - EXPECT_EQ(info->text(), QStringLiteral("Signed in as Dev User")); + EXPECT_EQ(headerName->text(), QStringLiteral("Dev User")); EXPECT_FALSE(signIn->isVisible()); EXPECT_TRUE(signOut->isVisible()); CloudCredentialStore::clearSession(); window->updateCloudAuthActions(); app->processEvents(); - EXPECT_FALSE(info->isVisible()); EXPECT_TRUE(signIn->isVisible()); EXPECT_FALSE(signOut->isVisible()); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 3405f8a28..6f5b8dc6d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -65,6 +65,7 @@ if(BUILD_TESTS) ${CMAKE_CURRENT_SOURCE_DIR}/../src/QtMeshCloudClient.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/CloudCredentialStore.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/CloudUploadPlanner.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../src/CloudAccountMenuButton.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/RTShaderHelper.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/QMLMaterialHighlighter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../src/ViewCube/ViewCubeController.cpp From 75eaac55adc6af1b36b5177773ae9af05be6b156 Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 2 Jun 2026 08:36:38 -0400 Subject: [PATCH 5/8] fix(cloud): address PR review feedback for account menu Route credential storage to an isolated fallback file when tests set organization to QtMeshEditorTests so unit tests do not touch the OS keychain. Use gtest fixtures with isolated QSettings in cloud tests, show "Signed in to QtMesh Cloud" in the menu header subtitle, and centralize MainWindow cloud-session cleanup in TearDown. Co-authored-by: Cursor --- src/CloudAccountMenuButton.cpp | 1 + src/CloudAccountMenuButton_test.cpp | 39 ++++++++++++++++++++++++++--- src/CloudCredentialStore.cpp | 29 +++++++++++++++++++++ src/CloudCredentialStore_test.cpp | 34 +++++++++++++++++++------ src/mainwindow_test.cpp | 5 +++- 5 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/CloudAccountMenuButton.cpp b/src/CloudAccountMenuButton.cpp index 85bcf71d7..61bbe45d0 100644 --- a/src/CloudAccountMenuButton.cpp +++ b/src/CloudAccountMenuButton.cpp @@ -260,6 +260,7 @@ void CloudAccountMenuButton::updateHeader(const QString& displayName, bool signe if (showHeader) { m_headerNameLabel->setText(displayName); + m_headerSubtitleLabel->setText(tr("Signed in to QtMesh Cloud")); // Ensure the header is actually present in the menu, otherwise the menu can // end up drawing stale pixels behind items when we toggle auth state while diff --git a/src/CloudAccountMenuButton_test.cpp b/src/CloudAccountMenuButton_test.cpp index 647e17977..541b2e555 100644 --- a/src/CloudAccountMenuButton_test.cpp +++ b/src/CloudAccountMenuButton_test.cpp @@ -1,11 +1,39 @@ #include "CloudAccountMenuButton.h" +#include "AppSettingsKeys.h" #include "CloudCredentialStore.h" #include +#include +#include +#include #include -TEST(CloudAccountMenuButtonTest, InitialsFromDisplayName) +class CloudAccountMenuButtonTest : public ::testing::Test { +protected: + QString previousOrganizationName; + QString previousApplicationName; + + void SetUp() override + { + previousOrganizationName = QCoreApplication::organizationName(); + previousApplicationName = QCoreApplication::applicationName(); + QCoreApplication::setOrganizationName(QStringLiteral("QtMeshEditorTests")); + QCoreApplication::setApplicationName(QStringLiteral("CloudAccountMenuButtonTest")); + QSettings().clear(); + CloudCredentialStore::clearSession(); + } + + void TearDown() override + { + CloudCredentialStore::clearSession(); + QSettings().clear(); + QCoreApplication::setOrganizationName(previousOrganizationName); + QCoreApplication::setApplicationName(previousApplicationName); + } +}; + +TEST_F(CloudAccountMenuButtonTest, InitialsFromDisplayName) { EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral("Fernando Tonon")), QStringLiteral("FT")); @@ -16,10 +44,8 @@ TEST(CloudAccountMenuButtonTest, InitialsFromDisplayName) EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral(" ")), QString()); } -TEST(CloudAccountMenuButtonTest, SignedOutButtonRepaintsWithoutCrash) +TEST_F(CloudAccountMenuButtonTest, SignedOutButtonRepaintsWithoutCrash) { - CloudCredentialStore::clearSession(); - CloudAccountMenuButton button; button.show(); button.refresh(); @@ -30,10 +56,15 @@ TEST(CloudAccountMenuButtonTest, SignedOutButtonRepaintsWithoutCrash) session.token = QStringLiteral("test-token"); session.email = QStringLiteral("dev@example.com"); ASSERT_TRUE(CloudCredentialStore::saveSession(session)); + QSettings().setValue(AppSettingsKeys::cloudUserName(), QStringLiteral("Dev User")); button.refresh(); button.repaint(); QApplication::processEvents(); + auto* subtitle = button.findChild(QStringLiteral("cloudAccountMenuHeaderSubtitle")); + ASSERT_NE(subtitle, nullptr); + EXPECT_EQ(subtitle->text(), QStringLiteral("Signed in to QtMesh Cloud")); + CloudCredentialStore::clearSession(); button.refresh(); button.repaint(); diff --git a/src/CloudCredentialStore.cpp b/src/CloudCredentialStore.cpp index 5a9ba6c72..ec446de04 100644 --- a/src/CloudCredentialStore.cpp +++ b/src/CloudCredentialStore.cpp @@ -1,7 +1,10 @@ #include "CloudCredentialStore.h" #include "AppSettingsKeys.h" +#include +#include #include +#include #include #include #include @@ -28,6 +31,13 @@ namespace { +constexpr auto kTestOrganizationName = "QtMeshEditorTests"; + +bool useIsolatedTestStorage() +{ + return QCoreApplication::organizationName() == QLatin1StringView(kTestOrganizationName); +} + QByteArray sessionToPayload(const CloudSession& session) { QJsonObject obj; @@ -65,6 +75,14 @@ QString fallbackFilePath() bool writeFallbackFile(const QByteArray& payload) { const QString path = fallbackFilePath(); + if (path.isEmpty()) + return false; + + const QFileInfo info(path); + QDir dir = info.dir(); + if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) + return false; + QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) return false; @@ -90,6 +108,9 @@ void removeFallbackFile() bool storeSecretBytes(const QByteArray& payload) { + if (useIsolatedTestStorage()) + return writeFallbackFile(payload); + #if defined(Q_OS_MACOS) const QByteArray service = QByteArrayLiteral("QtMeshEditor"); const QByteArray account = QByteArrayLiteral("QtMeshCloud"); @@ -149,6 +170,9 @@ bool storeSecretBytes(const QByteArray& payload) QByteArray loadSecretBytes() { + if (useIsolatedTestStorage()) + return readFallbackFile(); + #if defined(Q_OS_MACOS) const QByteArray service = QByteArrayLiteral("QtMeshEditor"); const QByteArray account = QByteArrayLiteral("QtMeshCloud"); @@ -198,6 +222,11 @@ QByteArray loadSecretBytes() void deleteSecretBytes() { + if (useIsolatedTestStorage()) { + removeFallbackFile(); + return; + } + #if defined(Q_OS_MACOS) const QByteArray service = QByteArrayLiteral("QtMeshEditor"); const QByteArray account = QByteArrayLiteral("QtMeshCloud"); diff --git a/src/CloudCredentialStore_test.cpp b/src/CloudCredentialStore_test.cpp index 5faa96434..37c79ea60 100644 --- a/src/CloudCredentialStore_test.cpp +++ b/src/CloudCredentialStore_test.cpp @@ -6,10 +6,32 @@ #include #include -TEST(CloudCredentialStoreTest, RoundTripAndClear) -{ - CloudCredentialStore::clearSession(); +class CloudCredentialStoreTest : public ::testing::Test { +protected: + QString previousOrganizationName; + QString previousApplicationName; + + void SetUp() override + { + previousOrganizationName = QCoreApplication::organizationName(); + previousApplicationName = QCoreApplication::applicationName(); + QCoreApplication::setOrganizationName(QStringLiteral("QtMeshEditorTests")); + QCoreApplication::setApplicationName(QStringLiteral("CloudCredentialStoreTest")); + QSettings().clear(); + CloudCredentialStore::clearSession(); + } + void TearDown() override + { + CloudCredentialStore::clearSession(); + QSettings().clear(); + QCoreApplication::setOrganizationName(previousOrganizationName); + QCoreApplication::setApplicationName(previousApplicationName); + } +}; + +TEST_F(CloudCredentialStoreTest, RoundTripAndClear) +{ CloudSession session; session.token = QStringLiteral("test-token"); session.expiresAt = 1234567890; @@ -26,10 +48,8 @@ TEST(CloudCredentialStoreTest, RoundTripAndClear) EXPECT_FALSE(CloudCredentialStore::hasSession()); } -TEST(CloudCredentialStoreTest, MigratesLegacyPlaintextSettings) +TEST_F(CloudCredentialStoreTest, MigratesLegacyPlaintextSettings) { - CloudCredentialStore::clearSession(); - QSettings settings; settings.setValue(AppSettingsKeys::cloudToken(), QStringLiteral("legacy-token")); settings.setValue(AppSettingsKeys::cloudTokenExpiresAt(), 42); @@ -44,6 +64,4 @@ TEST(CloudCredentialStoreTest, MigratesLegacyPlaintextSettings) EXPECT_EQ(loaded.email, QStringLiteral("legacy@example.com")); EXPECT_TRUE(settings.value(AppSettingsKeys::cloudToken()).toString().isEmpty()); EXPECT_TRUE(settings.value(AppSettingsKeys::cloudUserEmail()).toString().isEmpty()); - - CloudCredentialStore::clearSession(); } diff --git a/src/mainwindow_test.cpp b/src/mainwindow_test.cpp index 144e55803..420dfee97 100644 --- a/src/mainwindow_test.cpp +++ b/src/mainwindow_test.cpp @@ -384,7 +384,6 @@ TEST_F(MainWindowTest, CloudAccountControlLivesAtBottomOfObjectsToolbar) TEST_F(MainWindowTest, CloudAccountMenuShowsSignedInHeader) { - CloudCredentialStore::clearSession(); CloudSession session; session.token = QStringLiteral("test-token"); session.email = QStringLiteral("dev@example.com"); @@ -401,10 +400,14 @@ TEST_F(MainWindowTest, CloudAccountMenuShowsSignedInHeader) ASSERT_NE(signIn, nullptr); ASSERT_NE(signOut, nullptr); EXPECT_EQ(headerName->text(), QStringLiteral("Dev User")); + auto* headerSubtitle = window->findChild(QStringLiteral("cloudAccountMenuHeaderSubtitle")); + ASSERT_NE(headerSubtitle, nullptr); + EXPECT_EQ(headerSubtitle->text(), QStringLiteral("Signed in to QtMesh Cloud")); EXPECT_FALSE(signIn->isVisible()); EXPECT_TRUE(signOut->isVisible()); CloudCredentialStore::clearSession(); + QSettings().remove(AppSettingsKeys::cloudUserName()); window->updateCloudAuthActions(); app->processEvents(); EXPECT_TRUE(signIn->isVisible()); From 19ae6084c03626c6aef489c711e9642574817a1f Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 2 Jun 2026 11:09:45 -0400 Subject: [PATCH 6/8] fix(sonar): resolve quality gate issues on cloud account UI Consolidate duplicate fallback branches in CloudCredentialStore, apply C++17 init-statements, const-correctness, and auto per SonarCloud rules for the account menu changes. Co-authored-by: Cursor --- src/CloudAccountMenuButton.cpp | 8 +++--- src/CloudCredentialStore.cpp | 48 +++++++++++++--------------------- src/mainwindow.cpp | 2 +- 3 files changed, 22 insertions(+), 36 deletions(-) diff --git a/src/CloudAccountMenuButton.cpp b/src/CloudAccountMenuButton.cpp index 61bbe45d0..c8562e2f6 100644 --- a/src/CloudAccountMenuButton.cpp +++ b/src/CloudAccountMenuButton.cpp @@ -106,7 +106,7 @@ class CloudAccountMenuButton::AvatarButton : public QToolButton { } private: - void paintStatusBadge(QPainter& painter) + void paintStatusBadge(QPainter& painter) const { const int badgeD = 7; const QRect badge(rect().right() - badgeD - 1, @@ -135,7 +135,7 @@ QString CloudAccountMenuButton::initialsFromDisplayName(const QString& displayNa if (parts.isEmpty()) return QString(); - auto firstChar = [](const QString& word) -> QChar { + auto firstChar = [](const QString& word) { for (const QChar ch : word) { if (ch.isLetter()) return ch.toUpper(); @@ -256,9 +256,7 @@ void CloudAccountMenuButton::buildMenu() void CloudAccountMenuButton::updateHeader(const QString& displayName, bool signedIn) { - const bool showHeader = signedIn && !displayName.isEmpty(); - - if (showHeader) { + if (const bool showHeader = signedIn && !displayName.isEmpty(); showHeader) { m_headerNameLabel->setText(displayName); m_headerSubtitleLabel->setText(tr("Signed in to QtMesh Cloud")); diff --git a/src/CloudCredentialStore.cpp b/src/CloudCredentialStore.cpp index ec446de04..bb540f232 100644 --- a/src/CloudCredentialStore.cpp +++ b/src/CloudCredentialStore.cpp @@ -79,8 +79,7 @@ bool writeFallbackFile(const QByteArray& payload) return false; const QFileInfo info(path); - QDir dir = info.dir(); - if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) + if (QDir dir = info.dir(); !dir.exists() && !dir.mkpath(QStringLiteral("."))) return false; QFile file(path); @@ -108,9 +107,7 @@ void removeFallbackFile() bool storeSecretBytes(const QByteArray& payload) { - if (useIsolatedTestStorage()) - return writeFallbackFile(payload); - + if (!useIsolatedTestStorage()) { #if defined(Q_OS_MACOS) const QByteArray service = QByteArrayLiteral("QtMeshEditor"); const QByteArray account = QByteArrayLiteral("QtMeshCloud"); @@ -161,18 +158,15 @@ bool storeSecretBytes(const QByteArray& payload) return CredWriteW(&cred, 0) != FALSE; #elif defined(Q_OS_LINUX) && defined(HAVE_LIBSECRET) - return qtmesh_cloud_secret_store(payload.constData()) != 0; - -#else - return writeFallbackFile(payload); + return qtmesh_cloud_secret_store(payload.constData()) != 0; #endif + } + return writeFallbackFile(payload); } QByteArray loadSecretBytes() { - if (useIsolatedTestStorage()) - return readFallbackFile(); - + if (!useIsolatedTestStorage()) { #if defined(Q_OS_MACOS) const QByteArray service = QByteArrayLiteral("QtMeshEditor"); const QByteArray account = QByteArrayLiteral("QtMeshCloud"); @@ -208,25 +202,20 @@ QByteArray loadSecretBytes() return payload; #elif defined(Q_OS_LINUX) && defined(HAVE_LIBSECRET) - char* raw = qtmesh_cloud_secret_load(); - if (!raw) - return {}; - const QByteArray payload(raw); - qtmesh_cloud_secret_free(raw); - return payload; - -#else - return readFallbackFile(); + char* raw = qtmesh_cloud_secret_load(); + if (!raw) + return {}; + const QByteArray payload(raw); + qtmesh_cloud_secret_free(raw); + return payload; #endif + } + return readFallbackFile(); } void deleteSecretBytes() { - if (useIsolatedTestStorage()) { - removeFallbackFile(); - return; - } - + if (!useIsolatedTestStorage()) { #if defined(Q_OS_MACOS) const QByteArray service = QByteArrayLiteral("QtMeshEditor"); const QByteArray account = QByteArrayLiteral("QtMeshCloud"); @@ -244,11 +233,10 @@ void deleteSecretBytes() CredDeleteW(L"QtMeshEditor/QtMeshCloud", CRED_TYPE_GENERIC, 0); #elif defined(Q_OS_LINUX) && defined(HAVE_LIBSECRET) - qtmesh_cloud_secret_delete(); - -#else - removeFallbackFile(); + qtmesh_cloud_secret_delete(); #endif + } + removeFallbackFile(); } } // namespace diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e3ad91145..3d314a851 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2326,7 +2326,7 @@ void MainWindow::setupCloudAccountStatusControl() }); // Push the account control to the bottom of the left objects toolbar (VS Code-style). - QWidget* toolbarStretch = new QWidget(ui->objectsToolbar); + auto* toolbarStretch = new QWidget(ui->objectsToolbar); toolbarStretch->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding); toolbarStretch->setMinimumSize(0, 0); QAction* stretchAction = ui->objectsToolbar->addWidget(toolbarStretch); From 2bd7a0b39a95e3c8e1695c98c282e518deeee23c Mon Sep 17 00:00:00 2001 From: Fernando Date: Tue, 2 Jun 2026 11:59:49 -0400 Subject: [PATCH 7/8] fix(cloud): address review feedback and improve device sign-in UX Poll for browser approval while the sign-in dialog stays open so copy/paste approval works without clicking Open Browser. Show status text without repeating the user code. Tests: assert legacy cloudTokenExpiresAt is cleared on migration, and that the signed-out menu no longer lists the account header widget action. Co-authored-by: Cursor --- src/CloudAccountMenuButton_test.cpp | 18 ++- src/CloudCredentialStore_test.cpp | 1 + src/mainwindow.cpp | 164 +++++++++++++++++----------- 3 files changed, 118 insertions(+), 65 deletions(-) diff --git a/src/CloudAccountMenuButton_test.cpp b/src/CloudAccountMenuButton_test.cpp index 541b2e555..61993e073 100644 --- a/src/CloudAccountMenuButton_test.cpp +++ b/src/CloudAccountMenuButton_test.cpp @@ -3,10 +3,13 @@ #include "AppSettingsKeys.h" #include "CloudCredentialStore.h" +#include #include #include #include +#include #include +#include #include class CloudAccountMenuButtonTest : public ::testing::Test { @@ -61,12 +64,25 @@ TEST_F(CloudAccountMenuButtonTest, SignedOutButtonRepaintsWithoutCrash) button.repaint(); QApplication::processEvents(); - auto* subtitle = button.findChild(QStringLiteral("cloudAccountMenuHeaderSubtitle")); + auto* subtitle = button.findChild(QStringLiteral("cloudAccountMenuHeaderSubtitle"), + Qt::FindChildrenRecursively); ASSERT_NE(subtitle, nullptr); EXPECT_EQ(subtitle->text(), QStringLiteral("Signed in to QtMesh Cloud")); CloudCredentialStore::clearSession(); + QSettings().remove(AppSettingsKeys::cloudUserName()); button.refresh(); button.repaint(); QApplication::processEvents(); + + bool headerListedInMenu = false; + for (QAction* action : button.menu()->actions()) { + auto* widgetAction = qobject_cast(action); + if (!widgetAction) + continue; + QWidget* widget = widgetAction->defaultWidget(); + if (widget && widget->objectName() == QStringLiteral("cloudAccountMenuHeader")) + headerListedInMenu = true; + } + EXPECT_FALSE(headerListedInMenu); } diff --git a/src/CloudCredentialStore_test.cpp b/src/CloudCredentialStore_test.cpp index 37c79ea60..370c5f1a5 100644 --- a/src/CloudCredentialStore_test.cpp +++ b/src/CloudCredentialStore_test.cpp @@ -63,5 +63,6 @@ TEST_F(CloudCredentialStoreTest, MigratesLegacyPlaintextSettings) EXPECT_EQ(loaded.expiresAt, 42); EXPECT_EQ(loaded.email, QStringLiteral("legacy@example.com")); EXPECT_TRUE(settings.value(AppSettingsKeys::cloudToken()).toString().isEmpty()); + EXPECT_TRUE(settings.value(AppSettingsKeys::cloudTokenExpiresAt()).toString().isEmpty()); EXPECT_TRUE(settings.value(AppSettingsKeys::cloudUserEmail()).toString().isEmpty()); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 3d314a851..aceba0952 100755 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -2357,13 +2357,20 @@ void MainWindow::signInToQtMeshCloud() QDialog prompt(this); prompt.setWindowTitle(tr("Sign in to QtMesh Cloud")); + prompt.setWindowModality(Qt::WindowModal); auto* layout = new QVBoxLayout(&prompt); auto* label = new QLabel( - tr("A browser window will open for QtMesh Cloud sign in.\n\nCode: %1").arg(code.userCode), + tr("Enter this code on the QtMesh Cloud website, or click Open Browser.\n\n" + "Code: %1") + .arg(code.userCode), &prompt); label->setTextInteractionFlags(Qt::TextSelectableByMouse); layout->addWidget(label); + auto* statusLabel = new QLabel(tr("Waiting for approval…"), &prompt); + statusLabel->setWordWrap(true); + layout->addWidget(statusLabel); + auto* buttons = new QHBoxLayout(); auto* copyButton = new QPushButton(tr("Copy Code"), &prompt); auto* openButton = new QPushButton(tr("Open Browser"), &prompt); @@ -2373,86 +2380,115 @@ void MainWindow::signInToQtMeshCloud() buttons->addWidget(cancelButton); buttons->addWidget(openButton); layout->addLayout(buttons); - connect(copyButton, &QPushButton::clicked, this, [userCode = code.userCode]() { - if (QApplication::clipboard()) - QApplication::clipboard()->setText(userCode); - }); - connect(cancelButton, &QPushButton::clicked, &prompt, &QDialog::reject); - connect(openButton, &QPushButton::clicked, &prompt, &QDialog::accept); - openButton->setDefault(true); - prompt.resize(420, prompt.sizeHint().height()); - if (prompt.exec() != QDialog::Accepted) - return; - - QDesktopServices::openUrl(QUrl(code.verificationUriComplete)); - - QProgressDialog progress(tr("Waiting for browser approval..."), tr("Cancel"), 0, 0, this); - progress.setWindowTitle(tr("QtMesh Cloud Sign In")); - progress.setWindowModality(Qt::WindowModal); - progress.setMinimumDuration(0); - progress.show(); + QTimer pollTimer(&prompt); + bool signedIn = false; int intervalMs = qMax(1, code.intervalSeconds) * 1000; const int maxAttempts = qMax(1, code.expiresInSeconds / qMax(1, code.intervalSeconds) + 2); - for (int attempt = 0; attempt < maxAttempts && !progress.wasCanceled(); ++attempt) { + int attempts = 0; + + const auto applyToken = [&](const QtMeshCloudClient::DeviceTokenResult& token) -> bool { + CloudSession session; + session.token = token.token; + session.expiresAt = token.expiresAt; + session.email = token.user.value(QStringLiteral("email")).toString(); + if (!CloudCredentialStore::saveSession(session)) { + QMessageBox::warning(this, tr("QtMesh Cloud Sign In"), + tr("Signed in, but the session could not be saved securely.")); + return false; + } + + QSettings settings; + settings.setValue(AppSettingsKeys::cloudUserName(), + token.user.value(QStringLiteral("name")).toString()); + settings.setValue(AppSettingsKeys::cloudUserSlug(), + token.user.value(QStringLiteral("slug")).toString()); + settings.remove(AppSettingsKeys::cloudToken()); + settings.remove(AppSettingsKeys::cloudTokenExpiresAt()); + settings.remove(AppSettingsKeys::cloudUserEmail()); + settings.sync(); + updateCloudAuthActions(); + return true; + }; + + const auto failSignIn = [&](const QString& message) { + pollTimer.stop(); + QMessageBox::warning(this, tr("QtMesh Cloud Sign In"), message); + prompt.reject(); + }; + + const auto pollOnce = [&]() { + if (attempts >= maxAttempts) { + failSignIn(tr("The sign-in request expired. Start sign in again.")); + return; + } + ++attempts; + const auto token = QtMeshCloudClient::pollDeviceToken(code.deviceCode); if (token.ok) { - CloudSession session; - session.token = token.token; - session.expiresAt = token.expiresAt; - session.email = token.user.value(QStringLiteral("email")).toString(); - if (!CloudCredentialStore::saveSession(session)) { - progress.close(); - QMessageBox::warning(this, tr("QtMesh Cloud Sign In"), - tr("Signed in, but the session could not be saved securely.")); - return; + pollTimer.stop(); + if (applyToken(token)) { + signedIn = true; + prompt.accept(); + } else { + prompt.reject(); } - - QSettings settings; - settings.setValue(AppSettingsKeys::cloudUserName(), token.user.value(QStringLiteral("name")).toString()); - settings.setValue(AppSettingsKeys::cloudUserSlug(), token.user.value(QStringLiteral("slug")).toString()); - settings.remove(AppSettingsKeys::cloudToken()); - settings.remove(AppSettingsKeys::cloudTokenExpiresAt()); - settings.remove(AppSettingsKeys::cloudUserEmail()); - settings.sync(); - updateCloudAuthActions(); - progress.close(); - QMessageBox::information(this, tr("QtMesh Cloud Sign In"), - tr("Signed in to QtMesh Cloud as %1.") - .arg(storedCloudDisplayName())); - SentryReporter::addBreadcrumb(QStringLiteral("cloud.auth"), - QStringLiteral("QtMesh Cloud sign-in completed")); return; } if (token.errorCode == QStringLiteral("authorization_pending")) { - progress.setLabelText(tr("Waiting for browser approval...\nCode: %1").arg(code.userCode)); - } else if (token.errorCode == QStringLiteral("slow_down")) { + statusLabel->setText(tr("Waiting for approval…")); + return; + } + if (token.errorCode == QStringLiteral("slow_down")) { intervalMs = qMax(intervalMs + 2000, qMax(1, token.intervalSeconds) * 1000); - progress.setLabelText(tr("Waiting for browser approval...\nCode: %1").arg(code.userCode)); - } else if (token.errorCode == QStringLiteral("access_denied")) { - progress.close(); - QMessageBox::warning(this, tr("QtMesh Cloud Sign In"), - tr("Sign in was denied in the browser.")); + pollTimer.setInterval(intervalMs); + statusLabel->setText(tr("Waiting for approval…")); return; - } else if (token.errorCode == QStringLiteral("expired_token")) { - progress.close(); - QMessageBox::warning(this, tr("QtMesh Cloud Sign In"), - tr("The sign-in code expired. Start sign in again.")); + } + if (token.errorCode == QStringLiteral("access_denied")) { + failSignIn(tr("Sign in was denied in the browser.")); return; - } else { - progress.close(); - QMessageBox::warning(this, tr("QtMesh Cloud Sign In"), - tr("Sign in failed.\n\n%1").arg(token.errorString)); + } + if (token.errorCode == QStringLiteral("expired_token")) { + failSignIn(tr("The sign-in code expired. Start sign in again.")); return; } + failSignIn(tr("Sign in failed.\n\n%1").arg(token.errorString)); + }; - waitWithEvents(intervalMs, &progress); - } + connect(copyButton, &QPushButton::clicked, this, [userCode = code.userCode]() { + if (QApplication::clipboard()) + QApplication::clipboard()->setText(userCode); + }); + connect(cancelButton, &QPushButton::clicked, &prompt, [&pollTimer, &prompt]() { + pollTimer.stop(); + prompt.reject(); + }); + connect(openButton, &QPushButton::clicked, &prompt, [this, statusLabel, verificationUri = code.verificationUriComplete]() { + if (!QDesktopServices::openUrl(QUrl(verificationUri))) { + QMessageBox::warning(this, tr("QtMesh Cloud"), + tr("Could not open QtMesh Cloud in your browser.")); + return; + } + statusLabel->setText(tr("Complete sign-in in your browser…")); + }); + connect(&pollTimer, &QTimer::timeout, pollOnce); - if (!progress.wasCanceled()) { - QMessageBox::warning(this, tr("QtMesh Cloud Sign In"), - tr("The sign-in request expired. Start sign in again.")); + openButton->setDefault(true); + prompt.resize(420, prompt.sizeHint().height()); + + pollTimer.start(intervalMs); + pollOnce(); + prompt.exec(); + pollTimer.stop(); + + if (signedIn) { + QMessageBox::information(this, tr("QtMesh Cloud Sign In"), + tr("Signed in to QtMesh Cloud as %1.") + .arg(storedCloudDisplayName())); + SentryReporter::addBreadcrumb(QStringLiteral("cloud.auth"), + QStringLiteral("QtMesh Cloud sign-in completed")); } } From 83eb78247c21a30c37e70ea371f0dce3a82fca30 Mon Sep 17 00:00:00 2001 From: Fernando Date: Wed, 3 Jun 2026 02:14:53 -0400 Subject: [PATCH 8/8] chore: bump to 3.4.0 and expand cloud account test coverage Raise project version to 3.4.0 and sync pinned doc references. Add unit tests for CloudAccountMenuButton (auth refresh, menu actions, display-name fallbacks, signals) and CloudCredentialStore (empty token, corrupt session file, migration no-op) to improve coverage on new cloud UI code. Co-authored-by: Cursor --- CMakeLists.txt | 2 +- README.md | 20 +- src/CloudAccountMenuButton_test.cpp | 249 ++++++++++++++++++++++-- src/CloudCredentialStore_test.cpp | 62 ++++++ website/src/hooks/useQtmeshActionRef.js | 2 +- 5 files changed, 304 insertions(+), 31 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 836430637..e02d603b4 100755 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,7 +13,7 @@ cmake_minimum_required(VERSION 3.24.0) cmake_policy(SET CMP0005 NEW) cmake_policy(SET CMP0048 NEW) # manages project version -project(QtMeshEditor VERSION 3.3.1 LANGUAGES C CXX) +project(QtMeshEditor VERSION 3.4.0 LANGUAGES C CXX) message(STATUS "Building QtMeshEditor version ${PROJECT_VERSION}") set(QTMESHEDITOR_VERSION_STRING "\"${PROJECT_VERSION}\"") diff --git a/README.md b/README.md index 6933488e6..6072315bc 100755 --- a/README.md +++ b/README.md @@ -51,10 +51,10 @@ jobs: - uses: actions/checkout@v4 - name: Run QtMesh scan - uses: fernandotonon/QtMeshEditor@3.3.1 + uses: fernandotonon/QtMeshEditor@3.4.0 with: command: scan - image-tag: "3.3.1" + image-tag: "3.4.0" env: QTMESH_CLOUD_TOKEN: ${{ secrets.QTMESH_CLOUD_TOKEN }} ``` @@ -79,37 +79,37 @@ Release tags are listed on the [releases page](https://github.com/fernandotonon/ ```yaml # Validate a specific mesh -- uses: fernandotonon/QtMeshEditor@3.3.1 +- uses: fernandotonon/QtMeshEditor@3.4.0 with: command: validate input-file: ./models/character.fbx - image-tag: "3.3.1" + image-tag: "3.4.0" # Convert FBX → glTF -- uses: fernandotonon/QtMeshEditor@3.3.1 +- uses: fernandotonon/QtMeshEditor@3.4.0 with: command: convert input-file: ./models/character.fbx output-file: ./output/character.gltf2 - image-tag: "3.3.1" + image-tag: "3.4.0" # Resample Mixamo animations (200+ keyframes → 30) -- uses: fernandotonon/QtMeshEditor@3.3.1 +- uses: fernandotonon/QtMeshEditor@3.4.0 with: command: anim input-file: ./animations/dance.fbx output-file: ./output/dance_optimized.fbx options: --resample 30 - image-tag: "3.3.1" + image-tag: "3.4.0" # Get mesh info as JSON -- uses: fernandotonon/QtMeshEditor@3.3.1 +- uses: fernandotonon/QtMeshEditor@3.4.0 id: info with: command: info input-file: ./models/character.fbx options: --json - image-tag: "3.3.1" + image-tag: "3.4.0" # Docker (alternative — :latest tracks newest image; pin :3.1.0 to match semver action ref) docker run --rm -v $(pwd):/workspace ghcr.io/fernandotonon/qtmesh:latest scan ./assets --fail-on error diff --git a/src/CloudAccountMenuButton_test.cpp b/src/CloudAccountMenuButton_test.cpp index 61993e073..3846d96dd 100644 --- a/src/CloudAccountMenuButton_test.cpp +++ b/src/CloudAccountMenuButton_test.cpp @@ -9,9 +9,37 @@ #include #include #include +#include +#include #include #include +namespace { + +QAction* findMenuAction(CloudAccountMenuButton& button, const QString& objectName) +{ + for (QAction* action : button.menu()->actions()) { + if (action->objectName() == objectName) + return action; + } + return nullptr; +} + +bool menuListsHeader(CloudAccountMenuButton& button) +{ + for (QAction* action : button.menu()->actions()) { + auto* widgetAction = qobject_cast(action); + if (!widgetAction) + continue; + QWidget* widget = widgetAction->defaultWidget(); + if (widget && widget->objectName() == QStringLiteral("cloudAccountMenuHeader")) + return true; + } + return false; +} + +} // namespace + class CloudAccountMenuButtonTest : public ::testing::Test { protected: QString previousOrganizationName; @@ -34,6 +62,14 @@ class CloudAccountMenuButtonTest : public ::testing::Test { QCoreApplication::setOrganizationName(previousOrganizationName); QCoreApplication::setApplicationName(previousApplicationName); } + + static void saveTestSession(const QString& email = QStringLiteral("dev@example.com")) + { + CloudSession session; + session.token = QStringLiteral("test-token"); + session.email = email; + ASSERT_TRUE(CloudCredentialStore::saveSession(session)); + } }; TEST_F(CloudAccountMenuButtonTest, InitialsFromDisplayName) @@ -45,29 +81,204 @@ TEST_F(CloudAccountMenuButtonTest, InitialsFromDisplayName) EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral("Ada")), QStringLiteral("A")); EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral(" ")), QString()); + EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral("123")), + QString()); + EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral("Jean-Pierre Martin")), + QStringLiteral("JM")); + EXPECT_EQ(CloudAccountMenuButton::initialsFromDisplayName(QStringLiteral("x y")), + QStringLiteral("XY")); } -TEST_F(CloudAccountMenuButtonTest, SignedOutButtonRepaintsWithoutCrash) +TEST_F(CloudAccountMenuButtonTest, RefreshSignedOutState) { CloudAccountMenuButton button; - button.show(); button.refresh(); - button.repaint(); - QApplication::processEvents(); + auto* signIn = findMenuAction(button, QStringLiteral("actionQtMeshCloudSignIn")); + auto* signOut = findMenuAction(button, QStringLiteral("actionQtMeshCloudSignOut")); + auto* openProjects = findMenuAction(button, QStringLiteral("actionQtMeshCloudOpenProjects")); + auto* upload = findMenuAction(button, QStringLiteral("actionQtMeshCloudUploadFiles")); + ASSERT_NE(signIn, nullptr); + ASSERT_NE(signOut, nullptr); + ASSERT_NE(openProjects, nullptr); + ASSERT_NE(upload, nullptr); + + EXPECT_TRUE(signIn->isVisible()); + EXPECT_TRUE(signIn->isEnabled()); + EXPECT_FALSE(signOut->isVisible()); + EXPECT_FALSE(openProjects->isEnabled()); + EXPECT_TRUE(upload->isEnabled()); + EXPECT_FALSE(menuListsHeader(button)); + EXPECT_EQ(button.toolButton()->toolTip(), QStringLiteral("Sign in to QtMesh Cloud")); +} + +TEST_F(CloudAccountMenuButtonTest, RefreshSignedInShowsHeaderAndAccountActions) +{ + saveTestSession(); + QSettings().setValue(AppSettingsKeys::cloudUserName(), QStringLiteral("Dev User")); + + CloudAccountMenuButton button; + button.refresh(); + + auto* signIn = findMenuAction(button, QStringLiteral("actionQtMeshCloudSignIn")); + auto* signOut = findMenuAction(button, QStringLiteral("actionQtMeshCloudSignOut")); + auto* openProjects = findMenuAction(button, QStringLiteral("actionQtMeshCloudOpenProjects")); + ASSERT_NE(signIn, nullptr); + ASSERT_NE(signOut, nullptr); + ASSERT_NE(openProjects, nullptr); + + EXPECT_FALSE(signIn->isVisible()); + EXPECT_TRUE(signOut->isVisible()); + EXPECT_TRUE(signOut->isEnabled()); + EXPECT_TRUE(openProjects->isEnabled()); + EXPECT_TRUE(menuListsHeader(button)); + + auto* headerName = button.findChild(QStringLiteral("cloudAccountMenuHeaderName"), + Qt::FindChildrenRecursively); + ASSERT_NE(headerName, nullptr); + EXPECT_EQ(headerName->text(), QStringLiteral("Dev User")); + + auto* subtitle = button.findChild(QStringLiteral("cloudAccountMenuHeaderSubtitle"), + Qt::FindChildrenRecursively); + ASSERT_NE(subtitle, nullptr); + EXPECT_EQ(subtitle->text(), QStringLiteral("Signed in to QtMesh Cloud")); + + EXPECT_TRUE(button.toolButton()->toolTip().contains(QStringLiteral("Dev User"))); +} + +TEST_F(CloudAccountMenuButtonTest, RefreshUsesSlugWhenUserNameMissing) +{ + saveTestSession(); + QSettings().setValue(AppSettingsKeys::cloudUserSlug(), QStringLiteral("dev-slug")); + + CloudAccountMenuButton button; + button.refresh(); + + auto* headerName = button.findChild(QStringLiteral("cloudAccountMenuHeaderName"), + Qt::FindChildrenRecursively); + ASSERT_NE(headerName, nullptr); + EXPECT_EQ(headerName->text(), QStringLiteral("dev-slug")); +} + +TEST_F(CloudAccountMenuButtonTest, RefreshUsesEmailWhenNameAndSlugMissing) +{ + saveTestSession(QStringLiteral("person@example.com")); + + CloudAccountMenuButton button; + button.refresh(); + + auto* headerName = button.findChild(QStringLiteral("cloudAccountMenuHeaderName"), + Qt::FindChildrenRecursively); + ASSERT_NE(headerName, nullptr); + EXPECT_EQ(headerName->text(), QStringLiteral("person@example.com")); +} + +TEST_F(CloudAccountMenuButtonTest, SignedInWithoutDisplayNameHidesHeader) +{ CloudSession session; session.token = QStringLiteral("test-token"); - session.email = QStringLiteral("dev@example.com"); ASSERT_TRUE(CloudCredentialStore::saveSession(session)); + + CloudAccountMenuButton button; + button.refresh(); + + EXPECT_FALSE(menuListsHeader(button)); + EXPECT_EQ(button.toolButton()->toolTip(), QStringLiteral("Sign in to QtMesh Cloud")); +} + +TEST_F(CloudAccountMenuButtonTest, MenuEmitsSignInRequested) +{ + CloudAccountMenuButton button; + QSignalSpy spy(&button, &CloudAccountMenuButton::signInRequested); + + auto* signIn = findMenuAction(button, QStringLiteral("actionQtMeshCloudSignIn")); + ASSERT_NE(signIn, nullptr); + signIn->trigger(); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(CloudAccountMenuButtonTest, MenuEmitsSignOutRequested) +{ + saveTestSession(); QSettings().setValue(AppSettingsKeys::cloudUserName(), QStringLiteral("Dev User")); + + CloudAccountMenuButton button; + button.refresh(); + QSignalSpy spy(&button, &CloudAccountMenuButton::signOutRequested); + + auto* signOut = findMenuAction(button, QStringLiteral("actionQtMeshCloudSignOut")); + ASSERT_NE(signOut, nullptr); + signOut->trigger(); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(CloudAccountMenuButtonTest, MenuEmitsUploadFilesRequested) +{ + CloudAccountMenuButton button; + QSignalSpy spy(&button, &CloudAccountMenuButton::uploadFilesRequested); + + auto* upload = findMenuAction(button, QStringLiteral("actionQtMeshCloudUploadFiles")); + ASSERT_NE(upload, nullptr); + upload->trigger(); + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(CloudAccountMenuButtonTest, MenuEmitsOpenProjectsRequested) +{ + saveTestSession(); + QSettings().setValue(AppSettingsKeys::cloudUserName(), QStringLiteral("Dev User")); + + CloudAccountMenuButton button; + button.refresh(); + + auto* openProjects = findMenuAction(button, QStringLiteral("actionQtMeshCloudOpenProjects")); + ASSERT_NE(openProjects, nullptr); + ASSERT_TRUE(openProjects->isEnabled()); + + int openProjectsSignalCount = 0; + QMetaObject::Connection conn = connect( + &button, &CloudAccountMenuButton::openProjectsRequested, this, + [&openProjectsSignalCount]() { ++openProjectsSignalCount; }); + + QSignalSpy actionSpy(openProjects, &QAction::triggered); + openProjects->trigger(); + EXPECT_EQ(actionSpy.count(), 1); + EXPECT_EQ(openProjectsSignalCount, 1); + disconnect(conn); +} + +TEST_F(CloudAccountMenuButtonTest, AboutToShowRefreshesSignedInState) +{ + CloudAccountMenuButton button; + button.show(); + button.refresh(); + QApplication::processEvents(); + + saveTestSession(); + QSettings().setValue(AppSettingsKeys::cloudUserName(), QStringLiteral("Late User")); + + emit button.menu()->aboutToShow(); + QApplication::processEvents(); + + auto* signOut = findMenuAction(button, QStringLiteral("actionQtMeshCloudSignOut")); + ASSERT_NE(signOut, nullptr); + EXPECT_TRUE(signOut->isVisible()); + EXPECT_TRUE(menuListsHeader(button)); +} + +TEST_F(CloudAccountMenuButtonTest, SignedOutButtonRepaintsWithoutCrash) +{ + CloudAccountMenuButton button; + button.show(); button.refresh(); button.repaint(); QApplication::processEvents(); - auto* subtitle = button.findChild(QStringLiteral("cloudAccountMenuHeaderSubtitle"), - Qt::FindChildrenRecursively); - ASSERT_NE(subtitle, nullptr); - EXPECT_EQ(subtitle->text(), QStringLiteral("Signed in to QtMesh Cloud")); + saveTestSession(); + QSettings().setValue(AppSettingsKeys::cloudUserName(), QStringLiteral("Dev User")); + button.refresh(); + button.toolButton()->repaint(); + QApplication::processEvents(); CloudCredentialStore::clearSession(); QSettings().remove(AppSettingsKeys::cloudUserName()); @@ -75,14 +286,14 @@ TEST_F(CloudAccountMenuButtonTest, SignedOutButtonRepaintsWithoutCrash) button.repaint(); QApplication::processEvents(); - bool headerListedInMenu = false; - for (QAction* action : button.menu()->actions()) { - auto* widgetAction = qobject_cast(action); - if (!widgetAction) - continue; - QWidget* widget = widgetAction->defaultWidget(); - if (widget && widget->objectName() == QStringLiteral("cloudAccountMenuHeader")) - headerListedInMenu = true; - } - EXPECT_FALSE(headerListedInMenu); + EXPECT_FALSE(menuListsHeader(button)); +} + +TEST_F(CloudAccountMenuButtonTest, ToolButtonAndMenuObjectNames) +{ + CloudAccountMenuButton button; + EXPECT_NE(button.toolButton(), nullptr); + EXPECT_EQ(button.toolButton()->objectName(), QStringLiteral("cloudAccountButton")); + EXPECT_NE(button.menu(), nullptr); + EXPECT_EQ(button.menu()->objectName(), QStringLiteral("menuCloud")); } diff --git a/src/CloudCredentialStore_test.cpp b/src/CloudCredentialStore_test.cpp index 370c5f1a5..14023bc2c 100644 --- a/src/CloudCredentialStore_test.cpp +++ b/src/CloudCredentialStore_test.cpp @@ -4,7 +4,9 @@ #include #include +#include #include +#include class CloudCredentialStoreTest : public ::testing::Test { protected: @@ -28,6 +30,12 @@ class CloudCredentialStoreTest : public ::testing::Test { QCoreApplication::setOrganizationName(previousOrganizationName); QCoreApplication::setApplicationName(previousApplicationName); } + + static QString sessionFilePath() + { + const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation); + return dir + QStringLiteral("/cloud_session.dat"); + } }; TEST_F(CloudCredentialStoreTest, RoundTripAndClear) @@ -48,6 +56,47 @@ TEST_F(CloudCredentialStoreTest, RoundTripAndClear) EXPECT_FALSE(CloudCredentialStore::hasSession()); } +TEST_F(CloudCredentialStoreTest, SaveSessionRejectsEmptyToken) +{ + CloudSession session; + session.email = QStringLiteral("user@example.com"); + EXPECT_FALSE(CloudCredentialStore::saveSession(session)); + EXPECT_FALSE(CloudCredentialStore::hasSession()); +} + +TEST_F(CloudCredentialStoreTest, HasSessionFalseWhenCleared) +{ + EXPECT_FALSE(CloudCredentialStore::hasSession()); +} + +TEST_F(CloudCredentialStoreTest, LoadSessionReturnsEmptyForCorruptFile) +{ + const QString path = sessionFilePath(); + ASSERT_FALSE(path.isEmpty()); + + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + ASSERT_EQ(file.write("not-json"), 8); + file.close(); + + const CloudSession loaded = CloudCredentialStore::loadSession(); + EXPECT_FALSE(loaded.hasToken()); + EXPECT_TRUE(loaded.email.isEmpty()); +} + +TEST_F(CloudCredentialStoreTest, MigrateNoOpWhenNoLegacyToken) +{ + QSettings settings; + settings.setValue(AppSettingsKeys::cloudUserName(), QStringLiteral("still-here")); + settings.sync(); + + CloudCredentialStore::migrateLegacySettingsIfNeeded(); + + EXPECT_FALSE(CloudCredentialStore::hasSession()); + EXPECT_EQ(settings.value(AppSettingsKeys::cloudUserName()).toString(), + QStringLiteral("still-here")); +} + TEST_F(CloudCredentialStoreTest, MigratesLegacyPlaintextSettings) { QSettings settings; @@ -66,3 +115,16 @@ TEST_F(CloudCredentialStoreTest, MigratesLegacyPlaintextSettings) EXPECT_TRUE(settings.value(AppSettingsKeys::cloudTokenExpiresAt()).toString().isEmpty()); EXPECT_TRUE(settings.value(AppSettingsKeys::cloudUserEmail()).toString().isEmpty()); } + +TEST_F(CloudCredentialStoreTest, RoundTripWithoutEmail) +{ + CloudSession session; + session.token = QStringLiteral("token-only"); + session.expiresAt = 99; + ASSERT_TRUE(CloudCredentialStore::saveSession(session)); + + const CloudSession loaded = CloudCredentialStore::loadSession(); + EXPECT_EQ(loaded.token, session.token); + EXPECT_EQ(loaded.expiresAt, session.expiresAt); + EXPECT_TRUE(loaded.email.isEmpty()); +} diff --git a/website/src/hooks/useQtmeshActionRef.js b/website/src/hooks/useQtmeshActionRef.js index 51b31d911..8cc2105fe 100644 --- a/website/src/hooks/useQtmeshActionRef.js +++ b/website/src/hooks/useQtmeshActionRef.js @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; const QTMESH_RELEASES_LATEST_API = 'https://api.github.com/repos/fernandotonon/QtMeshEditor/releases/latest'; -const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.3.1'; +const QTMESH_ACTION_REF_FALLBACK = 'fernandotonon/QtMeshEditor@3.4.0'; const CACHE_KEY = 'qtmesh.actionRef.cache.v1'; const CACHE_TTL_MS = 6 * 60 * 60 * 1000;