diff --git a/.github/workflows/clang-tidy-review.yml b/.github/workflows/clang-tidy-review.yml index fd0425ffd4ad9..34c28b254079b 100644 --- a/.github/workflows/clang-tidy-review.yml +++ b/.github/workflows/clang-tidy-review.yml @@ -24,7 +24,9 @@ jobs: - name: Analyze run: | /usr/bin/git config --global --add safe.directory "$GITHUB_WORKSPACE" - /usr/bin/git diff -U0 HEAD^ | clang-tidy-diff-21.py -checks='-*,modernize-use-auto,modernize-use-using,modernize-use-nodiscard,modernize-use-nullptr,modernize-use-override,cppcoreguidelines-pro-type-static-cast-downcast' -p1 -path build -regex '^(?!.*/macOS/)(?!.*/MacOSX/).*\.(cpp|cc|cxx|c|h|hpp|hxx)$' -export-fixes clang-tidy-result/fixes.yml + # Header-only test helper: clang-tidy-diff has no target compile command for it. + # Its consumer test targets compile it with the required GUI include paths. + /usr/bin/git diff -U0 HEAD^ -- ':(exclude)test/macOS/*' ':(exclude)test/systraysynccontroltesthelper.h' ':(exclude)*macOS*' | clang-tidy-diff-21.py -checks='-*,modernize-use-auto,modernize-use-using,modernize-use-nodiscard,modernize-use-nullptr,modernize-use-override,cppcoreguidelines-pro-type-static-cast-downcast' -p1 -path build -regex '^(?!.*/macOS/)(?!.*/MacOSX/).*\.(cpp|cc|cxx|c|h|hpp|hxx)$' -export-fixes clang-tidy-result/fixes.yml - name: Run clang-tidy-pr-comments action uses: platisd/clang-tidy-pr-comments@28cfb84edafa771c044bde7e4a2a3fae57463818 # v1.6.1 # >1.4.3 switches to composite method w/ a forced python version and breaks things: https://github.com/actions/setup-python/issues/871 with: diff --git a/src/gui/folderman.h b/src/gui/folderman.h index e5722a25dd7ac..129b5082fe4c1 100644 --- a/src/gui/folderman.h +++ b/src/gui/folderman.h @@ -29,6 +29,7 @@ class TestSyncConflictsModel; class TestRemoteWipe; class FolderManTestHelper; class TestFileActionsModel; +class TestBrowserReAuthController; namespace OCC { @@ -416,6 +417,7 @@ private slots: friend class ::TestRemoteWipe; friend class ::FolderManTestHelper; friend class ::TestFileActionsModel; + friend class ::TestBrowserReAuthController; }; } // namespace OCC diff --git a/src/gui/macOS/trayaccountpopup/nctraypopup.mm b/src/gui/macOS/trayaccountpopup/nctraypopup.mm index 670e25359b764..ba656b9ae467e 100644 --- a/src/gui/macOS/trayaccountpopup/nctraypopup.mm +++ b/src/gui/macOS/trayaccountpopup/nctraypopup.mm @@ -77,6 +77,24 @@ - (void)clearActiveAccountRow _activeAccountRow = nil; } +/** @brief Adds a root action that pauses or resumes every classic synchronization folder. */ +- (void)addSyncControlRowPausing:(BOOL)pausesSync +{ + __unsafe_unretained NCTrayPopup *weakSelf = self; + const auto syncControlTitle = pausesSync + ? OCC::Systray::tr("Pause sync for all") + : OCC::Systray::tr("Resume sync for all"); + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:syncControlTitle.toNSString() + width:kPopupWidth + enabled:YES + action:^{ + [weakSelf closeAllPopups]; + OCC::Systray::instance()->setSyncIsPaused(pausesSync); + } hoverAction:^(NSView *) { + [weakSelf closeAccountActionsPopup]; + }]); +} + - (NCAccountRow *)makeRowForIndex:(int)index name:(NSString *)name server:(NSString *)server @@ -215,6 +233,23 @@ - (void)populate [weakSelf closeAccountActionsPopup]; }]); } + + const auto syncControlState = OCC::Systray::instance()->syncControlState(); + switch (syncControlState) { + case OCC::Systray::SyncControlState::Pause: + [self addSyncControlRowPausing:YES]; + break; + case OCC::Systray::SyncControlState::Resume: + [self addSyncControlRowPausing:NO]; + break; + case OCC::Systray::SyncControlState::PauseAndResume: + [self addSyncControlRowPausing:YES]; + [self addSyncControlRowPausing:NO]; + break; + case OCC::Systray::SyncControlState::Unavailable: + break; + } + addOwnedArrangedSubview(_stack, [[NCActionRow alloc] initWithTitle:OCC::Systray::tr("Settings").toNSString() width:kPopupWidth enabled:YES diff --git a/src/gui/systray.cpp b/src/gui/systray.cpp index e9459e41c2734..7e843e3e39555 100644 --- a/src/gui/systray.cpp +++ b/src/gui/systray.cpp @@ -1195,6 +1195,25 @@ bool Systray::anySyncFolders() const return _anySyncFolders; } +Systray::SyncControlState Systray::syncControlState() const +{ + const auto folders = FolderMan::instance()->map(); + if (folders.isEmpty()) { + return SyncControlState::Unavailable; + } + + const auto anyPaused = std::any_of(std::cbegin(folders), std::cend(folders), [](const Folder *folder) { + return folder->syncPaused(); + }); + const auto anyRunning = std::any_of(std::cbegin(folders), std::cend(folders), [](const Folder *folder) { + return !folder->syncPaused(); + }); + if (anyPaused && anyRunning) { + return SyncControlState::PauseAndResume; + } + return anyPaused ? SyncControlState::Resume : SyncControlState::Pause; +} + /********************************************************************************************/ /* Helper functions for cross-platform tray icon position and taskbar orientation detection */ /********************************************************************************************/ diff --git a/src/gui/systray.h b/src/gui/systray.h index e976b1bee88cf..8a910a3734f4b 100644 --- a/src/gui/systray.h +++ b/src/gui/systray.h @@ -83,6 +83,15 @@ class Systray : public QSystemTrayIcon enum class WindowPosition { Default, Center }; Q_ENUM(WindowPosition); + /** @brief Actions offered by the global tray synchronization control. */ + enum class SyncControlState { + Unavailable, //!< No classic synchronization folders are configured. + Pause, //!< All classic synchronization folders are running. + Resume, //!< All classic synchronization folders are paused. + PauseAndResume, //!< Some classic synchronization folders are paused and others are running. + }; + Q_ENUM(SyncControlState); + enum class FileDetailsPage { Activity, Sharing }; Q_ENUM(FileDetailsPage); @@ -91,6 +100,8 @@ class Systray : public QSystemTrayIcon [[nodiscard]] bool syncIsPaused() const; [[nodiscard]] bool anySyncFolders() const; + /** @brief Returns the actions that the global tray synchronization control should offer. */ + [[nodiscard]] SyncControlState syncControlState() const; [[nodiscard]] bool isOpen() const; [[nodiscard]] bool isActivitySurfaceVisible() const; void setTrayContextMenuVisible(const bool visible); diff --git a/src/gui/trayaccountpopup_qt.cpp b/src/gui/trayaccountpopup_qt.cpp index 8242b16fd2f41..db8b021c43223 100644 --- a/src/gui/trayaccountpopup_qt.cpp +++ b/src/gui/trayaccountpopup_qt.cpp @@ -874,6 +874,35 @@ void populateTrayMenu(QMenu *menu, Systray *systray) }); } + const auto syncControlState = systray->syncControlState(); + const auto addSyncControlAction = [menu, systray, &menuIconPalette, &menuIconSize](const bool pausesSync) { + const auto syncControlIconUrl = pausesSync ? Theme::instance()->pause() : Theme::instance()->sync(); + const auto syncControlAction = addMenuAction(menu, + templateIconFromIcon(iconFromUrl(syncControlIconUrl), menuIconSize, menuIconPalette), + pausesSync ? Systray::tr("Pause sync for all") : Systray::tr("Resume sync for all")); + syncControlAction->setObjectName(pausesSync + ? QStringLiteral("trayPauseSyncAction") + : QStringLiteral("trayResumeSyncAction")); + QObject::connect(syncControlAction, &QAction::triggered, syncControlAction, [systray, pausesSync] { + closeTrayPopup(); + systray->setSyncIsPaused(pausesSync); + }); + }; + switch (syncControlState) { + case Systray::SyncControlState::Pause: + addSyncControlAction(true); + break; + case Systray::SyncControlState::Resume: + addSyncControlAction(false); + break; + case Systray::SyncControlState::PauseAndResume: + addSyncControlAction(true); + addSyncControlAction(false); + break; + case Systray::SyncControlState::Unavailable: + break; + } + const auto settingsAction = addMenuAction(menu, templateThemeIcon(QStringLiteral("settings.svg"), menuIconSize, menuIconPalette), Systray::tr("Settings")); diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 9f5aa21df81a8..d4f20a481c0e9 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -104,6 +104,9 @@ nextcloud_add_test(SetUserStatusDialog) nextcloud_add_test(TrayAccountMenuPolicy) nextcloud_add_test(TrayActivationPolicy) nextcloud_add_test(TrayAccountPopupPresentation) +if(NOT APPLE) + nextcloud_add_test(SystraySyncControlQt) +endif() nextcloud_add_test(UnifiedSearchListmodel) nextcloud_add_test(ActivityListModel) nextcloud_add_test(SortedActivityListModel) diff --git a/test/macOS/CMakeLists.txt b/test/macOS/CMakeLists.txt index fa9584a79a7ab..ab994b8fb5ab4 100644 --- a/test/macOS/CMakeLists.txt +++ b/test/macOS/CMakeLists.txt @@ -5,4 +5,5 @@ # no GUI, so it runs in PR CI — unlike the signed-bundle checks, which need a signing identity # and a login session and therefore belong to the release pipeline. nextcloud_add_test(FinderSyncBrokerIdentity) +nextcloud_add_test(SystraySyncControlMacOS) nextcloud_add_test(MacSandboxUtility) diff --git a/test/macOS/testsystraysynccontrolmacos.cpp b/test/macOS/testsystraysynccontrolmacos.cpp new file mode 100644 index 0000000000000..c08cc38349f5f --- /dev/null +++ b/test/macOS/testsystraysynccontrolmacos.cpp @@ -0,0 +1,81 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: CC0-1.0 + * + * This software is in the public domain, furnished "as is", without technical + * support, and with no warranty, express or implied, as to its usefulness for + * any purpose. + */ + +#include + +#include "systray.h" + +#include "systraysynccontroltesthelper.h" + +using namespace OCC; + +class TestSystraySyncControlMacOS : public QObject +{ + Q_OBJECT + + SystraySyncControlTestHelper _helper; + +private slots: + void initTestCase() + { + QVERIFY(_helper.initialize()); + } + + void cleanupTestCase() + { + _helper.cleanup(); + } + + void globalControlIsUnavailableWithoutClassicFoldersAndPausesAndResumesAllFolders() + { + const auto systray = Systray::instance(); + + // An account without classic folders is also the state used by a File Provider-only client. + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Unavailable); + + QVERIFY(_helper.addClassicFolders()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Pause); + + systray->setSyncIsPaused(true); + + QVERIFY(_helper.firstFolder()->syncPaused()); + QVERIFY(_helper.secondFolder()->syncPaused()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Resume); + + systray->setSyncIsPaused(false); + + QVERIFY(!_helper.firstFolder()->syncPaused()); + QVERIFY(!_helper.secondFolder()->syncPaused()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Pause); + } + + void partiallyPausedFoldersCanPauseAndResumeAll() + { + const auto systray = Systray::instance(); + + _helper.firstFolder()->setSyncPaused(true); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::PauseAndResume); + + systray->setSyncIsPaused(false); + QVERIFY(!_helper.firstFolder()->syncPaused()); + QVERIFY(!_helper.secondFolder()->syncPaused()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Pause); + + _helper.firstFolder()->setSyncPaused(true); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::PauseAndResume); + + systray->setSyncIsPaused(true); + QVERIFY(_helper.firstFolder()->syncPaused()); + QVERIFY(_helper.secondFolder()->syncPaused()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Resume); + } +}; + +QTEST_MAIN(TestSystraySyncControlMacOS) +#include "testsystraysynccontrolmacos.moc" diff --git a/test/systraysynccontroltesthelper.h b/test/systraysynccontroltesthelper.h new file mode 100644 index 0000000000000..28ef984ed0ac1 --- /dev/null +++ b/test/systraysynccontroltesthelper.h @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: CC0-1.0 + * + * This software is in the public domain, furnished "as is", without technical + * support, and with no warranty, express or implied, as to its usefulness for + * any purpose. + */ + +#pragma once + +#include +#include +#include + +#include "account.h" +#include "accountmanager.h" +#include "configfile.h" +#include "folder.h" +#include "folderman.h" +#include "systray.h" + +#include "foldermantestutils.h" +#include "testhelper.h" + +using namespace OCC; + +/** @brief Provides the shared account and folder setup for systray sync-control tests. */ +class SystraySyncControlTestHelper +{ +public: + /** @brief Initializes the test configuration and first account. */ + [[nodiscard]] bool initialize() + { + if (!_configDir.isValid() || !_firstFolderDir.isValid() || !_secondFolderDir.isValid()) { + return false; + } + + QStandardPaths::setTestModeEnabled(true); + ConfigFile::setConfDir(_configDir.path()); + + // User construction references the Systray singleton, so initialize the tray before adding accounts. + const auto systray = Systray::instance(); + systray->create(); + + _firstAccountState = addTestAccount(QStringLiteral("https://one.example.com"), QStringLiteral("alice")); + return _firstAccountState != nullptr; + } + + /** @brief Removes the folders and accounts created by the helper. */ + void cleanup() + { + const auto folderMan = FolderMan::instance(); + if (_firstFolder) { + folderMan->removeFolder(_firstFolder); + } + if (_secondFolder) { + folderMan->removeFolder(_secondFolder); + } + _firstFolder = nullptr; + _secondFolder = nullptr; + + if (_firstAccountState) { + AccountManager::instance()->removeAccountState(_firstAccountState); + } + if (_secondAccountState) { + AccountManager::instance()->removeAccountState(_secondAccountState); + } + _firstAccountState = nullptr; + _secondAccountState = nullptr; + } + + /** @brief Adds one classic sync folder to each of two accounts. */ + [[nodiscard]] bool addClassicFolders() + { + _firstFolder = FolderMan::instance()->addFolder(_firstAccountState, folderDefinition(_firstFolderDir.path())); + if (!_firstFolder) { + return false; + } + + _secondAccountState = addTestAccount(QStringLiteral("https://two.example.com"), QStringLiteral("bob")); + if (!_secondAccountState) { + return false; + } + + _secondFolder = FolderMan::instance()->addFolder(_secondAccountState, folderDefinition(_secondFolderDir.path())); + return _secondFolder != nullptr; + } + + /** @brief Returns the first classic sync folder, or null before it is added. */ + [[nodiscard]] Folder *firstFolder() const + { + return _firstFolder; + } + + /** @brief Returns the second classic sync folder, or null before it is added. */ + [[nodiscard]] Folder *secondFolder() const + { + return _secondFolder; + } + +private: + /** @brief Adds an account configured with test credentials. */ + static AccountState *addTestAccount(const QString &url, const QString &user) + { + auto account = Account::create(); + account->setUrl(QUrl(url)); + account->setDavUser(user); + account->setCredentials(new HttpCredentialsTest(user, QStringLiteral("secret"))); + return AccountManager::instance()->addAccount(account); + } + + QTemporaryDir _configDir; + QTemporaryDir _firstFolderDir; + QTemporaryDir _secondFolderDir; + FolderManTestHelper _folderManHelper; + AccountState *_firstAccountState = nullptr; + AccountState *_secondAccountState = nullptr; + Folder *_firstFolder = nullptr; + Folder *_secondFolder = nullptr; +}; diff --git a/test/testbrowserreauthcontroller.cpp b/test/testbrowserreauthcontroller.cpp index a38ab80a1d1ca..224f7fe6b37eb 100644 --- a/test/testbrowserreauthcontroller.cpp +++ b/test/testbrowserreauthcontroller.cpp @@ -135,14 +135,23 @@ class TestBrowserReAuthController : public QObject { Q_OBJECT + std::unique_ptr _fm; + private slots: void initTestCase() { + OCC::Logger::instance()->setLogFlush(true); + OCC::Logger::instance()->setLogDebug(true); + QStandardPaths::setTestModeEnabled(true); + Q_INIT_RESOURCE(resources); Q_INIT_RESOURCE(theme); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "UserModel", UserModel::instance()); qmlRegisterSingletonInstance("com.nextcloud.desktopclient", 1, 0, "Theme", Theme::instance()); + + _fm.reset(new FolderMan{}); + Systray::instance()->setTrayEngine(new QQmlApplicationEngine(QCoreApplication::instance())); } diff --git a/test/testsystraysynccontrolqt.cpp b/test/testsystraysynccontrolqt.cpp new file mode 100644 index 0000000000000..40b518fe0c6d7 --- /dev/null +++ b/test/testsystraysynccontrolqt.cpp @@ -0,0 +1,119 @@ +/* + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: CC0-1.0 + * + * This software is in the public domain, furnished "as is", without technical + * support, and with no warranty, express or implied, as to its usefulness for + * any purpose. + */ + +#include + +#include + +#include "systray.h" + +#include "systraysynccontroltesthelper.h" + +using namespace OCC; + +class TestSystraySyncControlQt : public QObject +{ + Q_OBJECT + + SystraySyncControlTestHelper _helper; + + static QAction *pauseSyncAction(QMenu &menu, Systray *systray) + { + setupQtTrayContextMenu(&menu, systray); + return menu.findChild(QStringLiteral("trayPauseSyncAction")); + } + + static QAction *resumeSyncAction(const QMenu &menu) + { + return menu.findChild(QStringLiteral("trayResumeSyncAction")); + } + +private slots: + void initTestCase() + { + QVERIFY(_helper.initialize()); + } + + void cleanupTestCase() + { + _helper.cleanup(); + } + + void globalActionIsHiddenWithoutClassicFoldersAndTogglesAllFolders() + { + const auto systray = Systray::instance(); + + // An account without classic folders is also the state used by a File Provider-only client. + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Unavailable); + + auto unavailableMenu = QMenu{}; + QVERIFY(!pauseSyncAction(unavailableMenu, systray)); + QVERIFY(!resumeSyncAction(unavailableMenu)); + + QVERIFY(_helper.addClassicFolders()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Pause); + + auto pauseMenu = QMenu{}; + const auto pauseAction = pauseSyncAction(pauseMenu, systray); + QVERIFY(pauseAction); + QVERIFY(!resumeSyncAction(pauseMenu)); + QCOMPARE(pauseAction->text(), Systray::tr("Pause sync for all")); + pauseAction->trigger(); + + QVERIFY(_helper.firstFolder()->syncPaused()); + QVERIFY(_helper.secondFolder()->syncPaused()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Resume); + + auto resumeMenu = QMenu{}; + QVERIFY(!pauseSyncAction(resumeMenu, systray)); + const auto resumeAction = resumeSyncAction(resumeMenu); + QVERIFY(resumeAction); + QCOMPARE(resumeAction->text(), Systray::tr("Resume sync for all")); + resumeAction->trigger(); + + QVERIFY(!_helper.firstFolder()->syncPaused()); + QVERIFY(!_helper.secondFolder()->syncPaused()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Pause); + } + + void partiallyPausedFoldersOfferPauseAndResume() + { + const auto systray = Systray::instance(); + + _helper.firstFolder()->setSyncPaused(true); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::PauseAndResume); + + auto resumeMenu = QMenu{}; + const auto pauseAction = pauseSyncAction(resumeMenu, systray); + const auto resumeAction = resumeSyncAction(resumeMenu); + QVERIFY(pauseAction); + QVERIFY(resumeAction); + QCOMPARE(pauseAction->text(), Systray::tr("Pause sync for all")); + QCOMPARE(resumeAction->text(), Systray::tr("Resume sync for all")); + resumeAction->trigger(); + + QVERIFY(!_helper.firstFolder()->syncPaused()); + QVERIFY(!_helper.secondFolder()->syncPaused()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Pause); + + _helper.firstFolder()->setSyncPaused(true); + auto pauseMenu = QMenu{}; + const auto mixedPauseAction = pauseSyncAction(pauseMenu, systray); + QVERIFY(mixedPauseAction); + QVERIFY(resumeSyncAction(pauseMenu)); + mixedPauseAction->trigger(); + + QVERIFY(_helper.firstFolder()->syncPaused()); + QVERIFY(_helper.secondFolder()->syncPaused()); + QVERIFY(systray->syncControlState() == Systray::SyncControlState::Resume); + } +}; + +QTEST_MAIN(TestSystraySyncControlQt) +#include "testsystraysynccontrolqt.moc"