diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 5baf10b1e..e59326214 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -60,7 +60,6 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/path_safety_warning.cpp - src/interface/widgets/dialogs/settings_view.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp src/interface/widgets/deck_share/deck_share_utils.cpp src/interface/widgets/deck_share/shared_deck_preview_widget.cpp diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index ab870a2c8..d41c948eb 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -1,81 +1,205 @@ /** * @file dlg_settings.cpp - * @brief Implementation of the main settings dialog (thin frame around SettingsView) + * @brief Implementation of the main settings dialog * @ingroup Dialogs */ #include "dlg_settings.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../main.h" #include "../settings_page/abstract_settings_page.h" +#include "../settings_page/appearance_settings_page.h" +#include "../settings_page/deck_editor_settings_page.h" +#include "../settings_page/general_settings_page.h" +#include "../settings_page/messages_settings_page.h" +#include "../settings_page/settings_search_delegate.h" +#include "../settings_page/settings_search_model.h" +#include "../settings_page/shortcut_settings_page.h" +#include "../settings_page/sound_settings_page.h" +#include "../settings_page/storage_settings_page.h" +#include "../settings_page/user_interface_settings_page.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_manager.h" +#include +#include #include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include #include #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include #include #include #include #include +#include -static constexpr int MIN_DIALOG_WIDTH = 750; -static constexpr int MIN_DIALOG_HEIGHT = 700; -static constexpr int MAX_DIALOG_WIDTH = 1280; -static constexpr double DIALOG_SIZE_RATIO = 0.9; +class QModelIndex; /** - * @brief Calculates the initial dialog size from the host window or screen - * @param hostSize Size of the hosting main window, or empty when parentless - * @param availableSize Available screen area the dialog must fit into - * @param minimumSize Minimum dialog size already applied via setMinimumSize - * @return Target size at ~90% of the host with small margins, clamped to fit + * @brief Wraps a widget in a scroll area for long settings pages + * @param widget The widget to wrap + * @return The scroll area containing the widget */ -static QSize calculateDialogSize(const QSize &hostSize, const QSize &availableSize, const QSize &minimumSize) +static QScrollArea *makeScrollable(QWidget *widget) { - QSize target = !hostSize.isEmpty() ? QSize(qRound(hostSize.width() * DIALOG_SIZE_RATIO), - qRound(hostSize.height() * DIALOG_SIZE_RATIO)) - : QSize(qRound(availableSize.width() * DIALOG_SIZE_RATIO), - qRound(availableSize.height() * DIALOG_SIZE_RATIO)); - // Evenly-spaced columns leave unnatural dead space on very wide windows, so clamp the width. - target.setWidth(qMin(target.width(), MAX_DIALOG_WIDTH)); - return target.expandedTo(minimumSize).boundedTo(availableSize); + widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); + + auto *scrollArea = new QScrollArea; + scrollArea->setWidgetResizable(true); + scrollArea->setContentsMargins(0, 0, 0, 0); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->horizontalScrollBar()->setEnabled(false); + scrollArea->setWidget(widget); + return scrollArea; } -DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) +/** + * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order + */ +static QStringList pageIconResources() { - // Fill the main window with small margins so multi-group pages (e.g. Deck Editor) are not buried in a scroll. - // 90% of the host window leaves ~5% margins on each side; falls back to 90% of the screen when parentless. - QWidget *hostWindow = parent ? parent->window() : nullptr; - QScreen *screen = nullptr; - QSize hostSize; - if (hostWindow) { - hostSize = hostWindow->size(); - screen = QGuiApplication::screenAt(hostWindow->geometry().center()); - } - if (!screen) { - screen = QGuiApplication::primaryScreen(); - } - const QRect available = screen ? screen->availableGeometry() : QRect(0, 0, 1280, 800); - const QSize minimum(qMin(MIN_DIALOG_WIDTH, available.width()), qMin(MIN_DIALOG_HEIGHT, available.height())); - setMinimumSize(minimum); + return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"), + QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"), + QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"), + QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; +} - resize(calculateDialogSize(hostSize, available.size(), minimum)); - setSizeGripEnabled(true); +DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false) +{ + auto rec = QGuiApplication::primaryScreen()->availableGeometry(); + setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height())); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); - settingsView = new SettingsView(this); + setupUi(); + + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); + retranslateUi(); + + searchEdit->setFocus(); + + adjustSize(); +} + +void DlgSettings::setupUi() +{ + // Search bar + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->installEventFilter(this); + connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged); + + auto *searchLayout = new QHBoxLayout; + searchLayout->addWidget(searchEdit); + + // Tab bar (built in setupTabBar) + setupTabBar(); + + // Pages stacked widget + pagesWidget = new QStackedWidget; + + auto *generalPage = new GeneralSettingsPage; + auto *appearancePage = new AppearanceSettingsPage; + auto *userInterfacePage = new UserInterfaceSettingsPage; + auto *deckEditorPage = new DeckEditorSettingsPage; + auto *storagePage = new StorageSettingsPage; + auto *messagesPage = new MessagesSettingsPage; + auto *soundPage = new SoundSettingsPage; + auto *shortcutsPage = new ShortcutSettingsPage; + + pages.append(generalPage); + pages.append(appearancePage); + pages.append(userInterfacePage); + pages.append(deckEditorPage); + pages.append(storagePage); + pages.append(messagesPage); + pages.append(soundPage); + pages.append(shortcutsPage); + + pagesWidget->addWidget(makeScrollable(generalPage)); + pagesWidget->addWidget(makeScrollable(appearancePage)); + pagesWidget->addWidget(makeScrollable(userInterfacePage)); + pagesWidget->addWidget(makeScrollable(deckEditorPage)); + pagesWidget->addWidget(makeScrollable(storagePage)); + pagesWidget->addWidget(makeScrollable(messagesPage)); + pagesWidget->addWidget(soundPage); + pagesWidget->addWidget(shortcutsPage); + + Q_ASSERT(pages.size() == NumPages); + + // Search results view (hidden by default) + searchResultsView = new QListView; + searchResultsView->setUniformItemSizes(false); + searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection); + searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + searchResultsView->setVisible(false); + searchResultsView->setStyleSheet( + "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); + + searchModel = new SettingsSearchModel(this); + searchDelegate = new SettingsSearchDelegate(this); + searchResultsView->setModel(searchModel); + searchResultsView->setItemDelegate(searchDelegate); + connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked); + + connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { + const QStringList icons = pageIconResources(); + for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) { + tabButtons[i]->setIcon(QPixmap(icons[i])); + } + searchDelegate->setPageIcons(icons); + searchResultsView->viewport()->update(); + }); + + // Build search index after pages are created + buildSearchIndex(); + + // Pages container (stacked widget + search results overlay) + pagesContainer = new QWidget; + auto *containerLayout = new QStackedLayout; + containerLayout->setStackingMode(QStackedLayout::StackAll); + containerLayout->addWidget(pagesWidget); + containerLayout->addWidget(searchResultsView); + pagesContainer->setLayout(containerLayout); // Bottom buttons auto *buttonBox = new QHBoxLayout; @@ -87,23 +211,241 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) // Main layout auto *mainLayout = new QVBoxLayout; - mainLayout->addWidget(settingsView); + mainLayout->addLayout(searchLayout); + mainLayout->addWidget(tabBarWidget); + auto *separator = new QFrame; + separator->setFrameShape(QFrame::HLine); + separator->setFrameShadow(QFrame::Sunken); + mainLayout->addWidget(separator); + mainLayout->addWidget(pagesContainer); mainLayout->addSpacing(4); mainLayout->addLayout(buttonBox); setLayout(mainLayout); - connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); - retranslateUi(); + // Keyboard shortcuts + auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this); + connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus)); + + auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); + connect(nextTabShortcut, &QShortcut::activated, this, [this] { + int next = (currentTabIndex + 1) % tabButtons.size(); + setActiveTab(next); + }); + + auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); + connect(prevTabShortcut, &QShortcut::activated, this, [this] { + int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size(); + setActiveTab(prev); + }); + + // Initialize to first tab + setActiveTab(0); +} + +void DlgSettings::setupTabBar() +{ + tabBarWidget = new QWidget; + auto *tabLayout = new QHBoxLayout; + tabLayout->setContentsMargins(0, 0, 0, 0); + tabLayout->setSpacing(2); + + const QStringList iconResources = pageIconResources(); + + for (int i = 0; i < iconResources.size(); ++i) { + auto *tabButton = new QToolButton; + tabButton->setCheckable(true); + tabButton->setIcon(QPixmap(iconResources[i])); + tabButton->setIconSize(QSize(48, 48)); + tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + tabButton->setAutoExclusive(true); + tabButton->setMinimumHeight(85); + tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); }); + + tabButtons.append(tabButton); + tabLayout->addWidget(tabButton); + } + + tabBarWidget->setLayout(tabLayout); +} + +void DlgSettings::buildSearchIndex() +{ + QList allEntries; + + const QStringList pageNames = translatedPageNames(); + searchDelegate->setPageNames(pageNames); + searchDelegate->setPageIcons(pageIconResources()); + + for (int i = 0; i < pages.size(); ++i) { + QList pageEntries = pages[i]->getSearchEntries(); + for (auto &entry : pageEntries) { + if (entry.pageIndex == -1) { + entry.pageIndex = i; + } + } + allEntries.append(pageEntries); + } + + searchModel->setSourceEntries(allEntries); +} + +void DlgSettings::onTabClicked(int index) +{ + if (searchActive) { + switchToTabMode(); + } + setActiveTab(index); +} + +void DlgSettings::setActiveTab(int index) +{ + if (index < 0 || index >= tabButtons.size()) { + return; + } + + currentTabIndex = index; + pagesWidget->setCurrentIndex(index); + + for (int i = 0; i < tabButtons.size(); ++i) { + tabButtons[i]->setChecked(i == index); + } + + // Style active tab with a thick accent border + subtle background tint + for (int i = 0; i < tabButtons.size(); ++i) { + if (i == index) { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: palette(window); padding-bottom: 1px; }"); + } else { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: transparent; }"); + } + } +} + +void DlgSettings::flashWidget(QWidget *widget) +{ + auto *overlay = new QWidget(widget); + overlay->setGeometry(widget->rect()); + overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); + + QPalette pal = overlay->palette(); + QColor flashColor = pal.color(QPalette::Highlight); + flashColor.setAlpha(100); + pal.setBrush(QPalette::Window, flashColor); + overlay->setPalette(pal); + overlay->setAutoFillBackground(true); + + auto *effect = new QGraphicsOpacityEffect(overlay); + effect->setOpacity(0.0); + overlay->setGraphicsEffect(effect); + overlay->show(); + overlay->raise(); + + auto *flashIn = new QPropertyAnimation(effect, "opacity"); + flashIn->setDuration(120); + flashIn->setStartValue(0.0); + flashIn->setEndValue(0.6); + flashIn->setEasingCurve(QEasingCurve::OutCubic); + + auto *fadeOut = new QPropertyAnimation(effect, "opacity"); + fadeOut->setDuration(900); + fadeOut->setStartValue(0.6); + fadeOut->setEndValue(0.0); + fadeOut->setEasingCurve(QEasingCurve::InCubic); + + auto *group = new QSequentialAnimationGroup(overlay); + group->addAnimation(flashIn); + group->addAnimation(fadeOut); + + connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater); + + group->start(QAbstractAnimation::DeleteWhenStopped); +} + +void DlgSettings::onSearchTextChanged(const QString &text) +{ + searchModel->setFilterString(text); + + if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) { + if (!searchActive) { + switchToSearchMode(); + } + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } + } else if (searchActive) { + switchToTabMode(); + } +} + +void DlgSettings::switchToSearchMode() +{ + searchActive = true; + tabBarWidget->setVisible(false); + pagesWidget->setVisible(false); + searchResultsView->setVisible(true); + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } +} + +void DlgSettings::switchToTabMode() +{ + searchActive = false; + tabBarWidget->setVisible(true); + pagesWidget->setVisible(true); + searchResultsView->setVisible(false); + searchEdit->blockSignals(true); + searchEdit->clear(); + searchEdit->blockSignals(false); + setActiveTab(currentTabIndex); +} + +void DlgSettings::onSearchResultClicked(const QModelIndex &index) +{ + navigateToSearchResult(index); +} + +void DlgSettings::navigateToSearchResult(const QModelIndex &index) +{ + SettingsSearchEntry entry = searchModel->entryForIndex(index); + if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) { + return; + } + + // Switch to the page + switchToTabMode(); + setActiveTab(entry.pageIndex); + + // Scroll to the widget, focus it, and flash to highlight it + if (entry.widget) { + QWidget *widget = entry.widget; + while (widget) { + if (auto *scrollArea = qobject_cast(widget)) { + scrollArea->ensureWidgetVisible(entry.widget); + break; + } + widget = widget->parentWidget(); + } + entry.widget->setFocus(); + flashWidget(entry.widget); + } } void DlgSettings::setTab(int index) { - settingsView->setTab(index); + if (index >= 0 && index < tabButtons.size()) { + setActiveTab(index); + } } AbstractSettingsPage *DlgSettings::page(SettingsPage which) const { - return settingsView->page(which); + return pages.value(static_cast(which)); } void DlgSettings::updateLanguage() @@ -112,6 +454,49 @@ void DlgSettings::updateLanguage() installNewTranslator(); } +bool DlgSettings::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == searchEdit && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Escape) { + if (searchActive) { + switchToTabMode(); + return true; + } + } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { + if (searchActive) { + if (searchResultsView->currentIndex().isValid()) { + navigateToSearchResult(searchResultsView->currentIndex()); + } + return true; + } + } else if (keyEvent->key() == Qt::Key_Down) { + if (searchActive) { + int nextRow = searchResultsView->currentIndex().row() + 1; + if (nextRow >= searchModel->rowCount()) { + nextRow = 0; + } + searchResultsView->setCurrentIndex(searchModel->index(nextRow)); + searchResultsView->scrollTo(searchModel->index(nextRow)); + return true; + } + } else if (keyEvent->key() == Qt::Key_Up) { + if (searchActive) { + int prevRow = searchResultsView->currentIndex().row() - 1; + if (prevRow < 0) { + prevRow = searchModel->rowCount() - 1; + } + if (prevRow >= 0) { + searchResultsView->setCurrentIndex(searchModel->index(prevRow)); + searchResultsView->scrollTo(searchModel->index(prevRow)); + } + return true; + } + } + } + return QDialog::eventFilter(watched, event); +} + void DlgSettings::closeEvent(QCloseEvent *event) { bool showLoadError = true; @@ -191,5 +576,26 @@ void DlgSettings::closeEvent(QCloseEvent *event) void DlgSettings::retranslateUi() { setWindowTitle(tr("Settings")); + retranslateTabNames(); + + searchEdit->setPlaceholderText(tr("Search settings...")); okButton->setText(tr("OK")); + + // Rebuild search index for translated text + buildSearchIndex(); +} + +QStringList DlgSettings::translatedPageNames() +{ + return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"), + tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")}; +} + +void DlgSettings::retranslateTabNames() +{ + const QStringList tabLabels = translatedPageNames(); + + for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { + tabButtons[i]->setText(tabLabels[i]); + } } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index 03ccd6b7e..1f15e76d5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -1,60 +1,105 @@ /** * @file dlg_settings.h - * @brief Main settings dialog for the Cockatrice client (thin frame around SettingsView) + * @brief Main settings dialog for the Cockatrice client * @ingroup Dialogs */ #ifndef DLG_SETTINGS_H #define DLG_SETTINGS_H -#include "settings_view.h" - #include +#include #include +#include +#include #include class QPushButton; -class AbstractSettingsPage; +class QModelIndex; +class QWidget; inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings"); +class QStackedWidget; +class QToolButton; +class QListView; +class QLineEdit; +class AbstractSettingsPage; +class SettingsSearchModel; +class SettingsSearchDelegate; + /** - * @brief Main application settings dialog hosting the shared SettingsView + * @brief Main application settings dialog with tabbed navigation and search * - * Thin QDialog frame around SettingsView: only the window title, the OK button - * and the close validation live here. All pages, navigation and search are - * owned by SettingsView. The dialog fills ~90% of the main window so dense - * pages stay visible without losing the modal adjust-and-return flow. + * Provides a modern settings interface organized into tabbed pages. Users can + * either navigate by clicking tabs or search for specific settings using the + * built-in search bar. Search results are filtered and ranked by relevance. */ class DlgSettings : public QDialog { Q_OBJECT public: - using SettingsPage = SettingsView::SettingsPage; - static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage; - static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage; - static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage; - static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage; - static constexpr SettingsPage StoragePage = SettingsView::StoragePage; - static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage; - static constexpr SettingsPage SoundPage = SettingsView::SoundPage; - static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage; - static constexpr SettingsPage NumPages = SettingsView::NumPages; + /** + * @brief Page order in the tab bar, matching the order pages are added in setupUi() + * + * Use these values instead of raw indices so reordering pages never silently + * breaks external callers like tab_room.cpp. + */ + enum SettingsPage + { + GeneralPage = 0, + AppearancePage, + UserInterfacePage, + DeckEditorPage, + StoragePage, + MessagesPage, + SoundPage, + ShortcutsPage, + NumPages + }; explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); AbstractSettingsPage *page(SettingsPage which) const; private slots: + void onTabClicked(int index); + void onSearchTextChanged(const QString &text); + void onSearchResultClicked(const QModelIndex &index); void updateLanguage(); private: - SettingsView *settingsView; ///< Shared settings content - QPushButton *okButton; ///< Button to close the dialog + // UI elements + QLineEdit *searchEdit; ///< Search bar for filtering settings + QWidget *tabBarWidget; ///< Container widget for the tab buttons + QList tabButtons; ///< Navigation tab buttons + QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages + QListView *searchResultsView; ///< Search results list view + QWidget *pagesContainer; ///< Container stacking pages and search results + QPushButton *okButton; ///< Button to close the dialog + + // Data + QList pages; ///< All settings page instances + SettingsSearchModel *searchModel; ///< Model for search results + SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering + int currentTabIndex; ///< Currently active tab index + bool searchActive; ///< Whether search mode is active + + void setupUi(); + void setupTabBar(); + void buildSearchIndex(); + void switchToTabMode(); + void switchToSearchMode(); + void navigateToSearchResult(const QModelIndex &index); + void setActiveTab(int index); + static void flashWidget(QWidget *widget); + static QStringList translatedPageNames(); void retranslateUi(); + void retranslateTabNames(); protected: void closeEvent(QCloseEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; }; #endif // DLG_SETTINGS_H diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp deleted file mode 100644 index ebac4e22f..000000000 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ /dev/null @@ -1,760 +0,0 @@ -/** - * @file settings_view.cpp - * @brief Implementation of the settings content widget - * @ingroup Dialogs - */ -#include "settings_view.h" - -#include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" -#include "../settings_page/abstract_settings_page.h" -#include "../settings_page/appearance_settings_page.h" -#include "../settings_page/deck_editor_settings_page.h" -#include "../settings_page/general_settings_page.h" -#include "../settings_page/messages_settings_page.h" -#include "../settings_page/settings_search_delegate.h" -#include "../settings_page/settings_search_model.h" -#include "../settings_page/shortcut_settings_page.h" -#include "../settings_page/sound_settings_page.h" -#include "../settings_page/storage_settings_page.h" -#include "../settings_page/user_interface_settings_page.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -/** - * @brief Wraps a widget in a scroll area for long settings pages - * @param widget The widget to wrap - * @return The scroll area containing the widget - */ -static QScrollArea *makeScrollable(QWidget *widget) -{ - widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); - - auto *scrollArea = new QScrollArea; - scrollArea->setWidgetResizable(true); - scrollArea->setContentsMargins(0, 0, 0, 0); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->horizontalScrollBar()->setEnabled(false); - scrollArea->setWidget(widget); - return scrollArea; -} - -/** - * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order - */ -static QStringList pageIconResources() -{ - return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"), - QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"), - QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"), - QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; -} - -namespace -{ -/// Item data role holding a sub-page row's section widget, stored as a WidgetId -constexpr int SectionWidgetRole = Qt::UserRole + 1; -using WidgetId = quintptr; - -/** - * @brief Packs a section widget for storage in a tree item's data role - * @param widget The section's group box (owned by its settings page) - * @return The widget as an opaque id - */ -WidgetId toWidgetId(QWidget *widget) -{ - return reinterpret_cast(widget); -} - -/** - * @brief Unpacks a section widget from a tree item's data role - * @param id The stored id, or 0 for page rows without a section - * @return The section widget, or nullptr when unset - */ -QWidget *fromWidgetId(WidgetId id) -{ - return reinterpret_cast(id); -} -} // namespace - -SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false) -{ - setupUi(); - - connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi); - retranslateUi(); - - searchEdit->setFocus(); -} - -void SettingsView::setupUi() -{ - // Search bar - searchEdit = new QLineEdit; - searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); - searchEdit->installEventFilter(this); - connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); - - auto *searchLayout = new QHBoxLayout; - searchLayout->addWidget(searchEdit); - - // Pages stacked widget - pagesWidget = new QStackedWidget; - - auto *generalPage = new GeneralSettingsPage; - auto *appearancePage = new AppearanceSettingsPage; - auto *userInterfacePage = new UserInterfaceSettingsPage; - auto *deckEditorPage = new DeckEditorSettingsPage; - auto *storagePage = new StorageSettingsPage; - auto *messagesPage = new MessagesSettingsPage; - auto *soundPage = new SoundSettingsPage; - auto *shortcutsPage = new ShortcutSettingsPage; - - pages.append(generalPage); - pages.append(appearancePage); - pages.append(userInterfacePage); - pages.append(deckEditorPage); - pages.append(storagePage); - pages.append(messagesPage); - pages.append(soundPage); - pages.append(shortcutsPage); - - pagesWidget->addWidget(makeScrollable(generalPage)); - pagesWidget->addWidget(makeScrollable(appearancePage)); - pagesWidget->addWidget(makeScrollable(userInterfacePage)); - pagesWidget->addWidget(makeScrollable(deckEditorPage)); - pagesWidget->addWidget(makeScrollable(storagePage)); - pagesWidget->addWidget(makeScrollable(messagesPage)); - pagesWidget->addWidget(soundPage); - pagesWidget->addWidget(shortcutsPage); - - Q_ASSERT(pages.size() == NumPages); - - // Navigation tree (needs the pages for their sections) - setupNavTree(); - - // Search results view (hidden by default) - searchResultsView = new QListView; - searchResultsView->setUniformItemSizes(false); - searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection); - searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - searchResultsView->setVisible(false); - searchResultsView->setStyleSheet( - "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); - - searchModel = new SettingsSearchModel(this); - searchDelegate = new SettingsSearchDelegate(this); - searchResultsView->setModel(searchModel); - searchResultsView->setItemDelegate(searchDelegate); - connect(searchResultsView, &QListView::clicked, this, &SettingsView::onSearchResultClicked); - - connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { - const QStringList icons = pageIconResources(); - for (int i = 0; i < pageItems.size() && i < icons.size(); ++i) { - if (pageItems[i]) { - pageItems[i]->setIcon(0, QPixmap(icons[i])); - } - } - searchDelegate->setPageIcons(icons); - searchResultsView->viewport()->update(); - }); - - // Build search index after pages are created - buildSearchIndex(); - - // Pages container (stacked widget + search results overlay) - pagesContainer = new QWidget; - auto *containerLayout = new QStackedLayout; - containerLayout->setStackingMode(QStackedLayout::StackAll); - containerLayout->addWidget(pagesWidget); - containerLayout->addWidget(searchResultsView); - pagesContainer->setLayout(containerLayout); - - // Main layout: search on top, navigation + pages side by side below - auto *contentLayout = new QHBoxLayout; - contentLayout->addWidget(navTree); - contentLayout->addWidget(pagesContainer, 1); - - auto *mainLayout = new QVBoxLayout; - mainLayout->addLayout(searchLayout); - mainLayout->addLayout(contentLayout); - setLayout(mainLayout); - - // Keyboard shortcuts - auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this); - connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus)); - - auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); - connect(nextTabShortcut, &QShortcut::activated, this, [this] { - int next = (currentTabIndex + 1) % pages.size(); - setActiveTab(next); - }); - - auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); - connect(prevTabShortcut, &QShortcut::activated, this, [this] { - int prev = (currentTabIndex - 1 + pages.size()) % pages.size(); - setActiveTab(prev); - }); - - // Initialize to first tab - setActiveTab(0); -} - -void SettingsView::setupNavTree() -{ - navTree = new QTreeWidget; - navTree->setHeaderHidden(true); - navTree->setRootIsDecorated(true); - navTree->setIconSize(QSize(24, 24)); - navTree->setMinimumWidth(230); - navTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - navTree->setStyleSheet( - "QTreeWidget::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); - - // Re-click detection has to sample the selection before the view handles the - // press: QAbstractItemView updates the current item (emitting - // currentItemChanged) and only then emits itemPressed, so itemPressed always - // sees the pressed item as "current" and cannot tell a re-click from a first - // click. The viewport event filter runs before QAbstractItemView::mousePressEvent. - navTree->viewport()->installEventFilter(this); - - // Groups of SettingsPage values, in navigation order - static const QList> groupedPages = { - {GeneralPage, AppearancePage, UserInterfacePage}, - {DeckEditorPage, StoragePage}, - {MessagesPage, SoundPage}, - {ShortcutsPage}, - }; - - const QStringList groupNames = translatedGroupNames(); - const QStringList pageNames = translatedPageNames(); - const QStringList iconResources = pageIconResources(); - - pageItems.resize(NumPages); - for (int i = 0; i < groupedPages.size(); ++i) { - auto *group = new QTreeWidgetItem(navTree, {groupNames[i]}); - group->setFlags(Qt::ItemIsEnabled); // header row: expandable, not selectable - group->setExpanded(true); - groupItems.append(group); - for (SettingsPage page : groupedPages[i]) { - auto *item = new QTreeWidgetItem(group, {pageNames[page]}); - item->setIcon(0, QPixmap(iconResources[page])); - item->setData(0, Qt::UserRole, static_cast(page)); - pageItems[page] = item; - // Sub-page rows deep-link into the page's sections (scroll + highlight) - for (const SettingsPageSection §ion : pages[page]->getSections()) { - auto *sectionItem = new QTreeWidgetItem(item, {section.title}); - sectionItem->setData(0, Qt::UserRole, static_cast(page)); - sectionItem->setData(0, SectionWidgetRole, QVariant::fromValue(toWidgetId(section.sectionWidget))); - } - } - } - - Q_ASSERT(groupItems.size() == groupedPages.size()); - Q_ASSERT(!pageItems.contains(nullptr)); - - connect(navTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current) { - if (!current) { - return; - } - bool ok = false; - int index = current->data(0, Qt::UserRole).toInt(&ok); - if (!ok) { - return; - } - if (QWidget *section = fromWidgetId(current->data(0, SectionWidgetRole).value())) { - navigateToSection(index, section); - } else { - onTabClicked(index); - } - }); - - connect(navTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item) { - const bool wasReClick = pressWasOnCurrentItem; - pressWasOnCurrentItem = false; - // A fresh click already navigated via currentItemChanged; only a press that - // started on the already-current item (captured by the viewport event filter - // before the view updated the selection) is a re-click. Re-clicking the - // isolated section restores the full page. - if (!wasReClick || item == nullptr || item != navTree->currentItem() || isolatedSection == nullptr || - fromWidgetId(item->data(0, SectionWidgetRole).value()) != isolatedSection) { - return; - } - bool ok = false; - const int index = item->data(0, Qt::UserRole).toInt(&ok); - if (ok) { - // setActiveTab, not onTabClicked: the page is already current and - // only setActiveTab clears the section filter - setActiveTab(index); - } - }); -} - -void SettingsView::buildSearchIndex() -{ - QList allEntries; - - const QStringList pageNames = translatedPageNames(); - searchDelegate->setPageNames(pageNames); - searchDelegate->setPageIcons(pageIconResources()); - - for (int i = 0; i < pages.size(); ++i) { - QList pageEntries = pages[i]->getSearchEntries(); - for (auto &entry : pageEntries) { - if (entry.pageIndex == -1) { - entry.pageIndex = i; - } - } - allEntries.append(pageEntries); - } - - searchModel->setSourceEntries(allEntries); -} - -void SettingsView::onTabClicked(int index) -{ - if (searchActive) { - switchToTabMode(); - } else if (index == currentTabIndex && isolatedSection == nullptr) { - // Re-selecting the current page row is a no-op, but it must still clear - // an isolated section so the page's widgets come back. - return; - } - setActiveTab(index); -} - -void SettingsView::setActiveTab(int index) -{ - if (index < 0 || index >= pageItems.size() || !pageItems[index]) { - return; - } - - switchPageContent(index); - - // Selecting the item also expands a collapsed group; selecting the already - // current item emits nothing, so the currentItemChanged handler cannot loop. - navTree->setCurrentItem(pageItems[index]); -} - -void SettingsView::switchPageContent(int index) -{ - if (index < 0 || index >= pageItems.size() || !pageItems[index]) { - return; - } - - currentTabIndex = index; - pagesWidget->setCurrentIndex(index); - - // A page row always shows the full page; sections filter down from here - applySectionFilter(index, nullptr); - - if (QTreeWidgetItem *parent = pageItems[index]->parent()) { - parent->setExpanded(true); - } -} - -void SettingsView::flashWidget(QWidget *widget) -{ - auto *overlay = new QWidget(widget); - overlay->setGeometry(widget->rect()); - overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); - - QPalette pal = overlay->palette(); - QColor flashColor = pal.color(QPalette::Highlight); - flashColor.setAlpha(100); - pal.setBrush(QPalette::Window, flashColor); - overlay->setPalette(pal); - overlay->setAutoFillBackground(true); - - auto *effect = new QGraphicsOpacityEffect(overlay); - effect->setOpacity(0.0); - overlay->setGraphicsEffect(effect); - overlay->show(); - overlay->raise(); - - auto *flashIn = new QPropertyAnimation(effect, "opacity"); - flashIn->setDuration(120); - flashIn->setStartValue(0.0); - flashIn->setEndValue(0.6); - flashIn->setEasingCurve(QEasingCurve::OutCubic); - - auto *fadeOut = new QPropertyAnimation(effect, "opacity"); - fadeOut->setDuration(900); - fadeOut->setStartValue(0.6); - fadeOut->setEndValue(0.0); - fadeOut->setEasingCurve(QEasingCurve::InCubic); - - auto *group = new QSequentialAnimationGroup(overlay); - group->addAnimation(flashIn); - group->addAnimation(fadeOut); - - connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater); - - group->start(QAbstractAnimation::DeleteWhenStopped); -} - -void SettingsView::onSearchTextChanged(const QString &text) -{ - searchModel->setFilterString(text); - - if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) { - if (!searchActive) { - switchToSearchMode(); - } - if (searchModel->rowCount(QModelIndex()) > 0) { - searchResultsView->setCurrentIndex(searchModel->index(0)); - } - } else if (searchActive) { - switchToTabMode(); - } -} - -void SettingsView::switchToSearchMode() -{ - searchActive = true; - navTree->setVisible(false); - pagesWidget->setVisible(false); - searchResultsView->setVisible(true); - if (searchModel->rowCount(QModelIndex()) > 0) { - searchResultsView->setCurrentIndex(searchModel->index(0)); - } -} - -void SettingsView::switchToTabMode() -{ - searchActive = false; - navTree->setVisible(true); - pagesWidget->setVisible(true); - searchResultsView->setVisible(false); - searchEdit->blockSignals(true); - searchEdit->clear(); - searchEdit->blockSignals(false); - setActiveTab(currentTabIndex); -} - -void SettingsView::onSearchResultClicked(const QModelIndex &index) -{ - navigateToSearchResult(index); -} - -void SettingsView::navigateToSearchResult(const QModelIndex &index) -{ - SettingsSearchEntry entry = searchModel->entryForIndex(index); - if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) { - return; - } - - // Switch to the page - switchToTabMode(); - setActiveTab(entry.pageIndex); - - // Isolate the control's section, then scroll to the widget, focus it, and flash it - if (entry.widget) { - QWidget *section = nullptr; - for (const SettingsPageSection &candidate : pages[entry.pageIndex]->getSections()) { - if (candidate.sectionWidget == entry.widget || - (candidate.sectionWidget && candidate.sectionWidget->isAncestorOf(entry.widget))) { - section = candidate.sectionWidget; - break; - } - } - applySectionFilter(entry.pageIndex, section); - // Keep the sidebar in sync with the deep link: highlight the setting's section row - selectSectionRow(entry.pageIndex, section); - scrollToAndFlash(entry.widget); - entry.widget->setFocus(); - } -} - -void SettingsView::navigateToSection(int pageIndex, QWidget *sectionWidget) -{ - if (pageIndex < 0 || pageIndex >= pages.size() || !sectionWidget) { - return; - } - - // Never steal the view's own selection mid-gesture: when this runs from - // currentItemChanged the section row is already current (mouse press or arrow key), - // and re-selecting the page row here is what made the selection visibly snap back - // to the parent on mouse release. selectSectionRow only acts on a known section - // that is not current yet, so it syncs programmatic callers without disturbing a gesture. - if (currentTabIndex != pageIndex) { - if (searchActive) { - switchToTabMode(); - } - const QSignalBlocker blocker(navTree); - switchPageContent(pageIndex); - } - applySectionFilter(pageIndex, sectionWidget); - - // Unknown widgets show the full page; known sections keep their row selected - selectSectionRow(pageIndex, sectionWidget); -} - -/** - * @brief Selects the nav row for a section, expanding its page row - * - * A null or unknown section (a page whose sections are its own group boxes) leaves - * the current selection untouched, so callers can pass a lookup result directly. - * - * @param pageIndex SettingsPage owning the section - * @param sectionWidget Section's group box, or nullptr for no section - */ -void SettingsView::selectSectionRow(int pageIndex, QWidget *sectionWidget) -{ - if (!sectionWidget) { - return; - } - - QTreeWidgetItem *sectionItem = findSectionItem(pageIndex, sectionWidget); - if (!sectionItem) { - return; - } - - pageItems[pageIndex]->setExpanded(true); - if (navTree->currentItem() != sectionItem) { - const QSignalBlocker blocker(navTree); - navTree->setCurrentItem(sectionItem); - } -} - -/** - * @brief Finds the nav row for a section widget - * @param pageIndex SettingsPage owning the section - * @param sectionWidget Section's group box - * @return The section's tree item, or nullptr when it has no row - */ -QTreeWidgetItem *SettingsView::findSectionItem(int pageIndex, QWidget *sectionWidget) const -{ - if (pageIndex < 0 || pageIndex >= pageItems.size() || !pageItems[pageIndex]) { - return nullptr; - } - for (int c = 0; c < pageItems[pageIndex]->childCount(); ++c) { - QTreeWidgetItem *child = pageItems[pageIndex]->child(c); - if (fromWidgetId(child->data(0, SectionWidgetRole).value()) == sectionWidget) { - return child; - } - } - return nullptr; -} - -/** - * @brief Shows only the given section of a page, hiding its siblings - * - * A null sectionWidget restores the full page. Unknown widgets fall back to - * the full page so a stale reference can never leave a blank page. - * - * @param pageIndex SettingsPage to filter - * @param sectionWidget Section's group box, or nullptr to show all sections - */ -void SettingsView::applySectionFilter(int pageIndex, QWidget *sectionWidget) -{ - if (pageIndex < 0 || pageIndex >= pages.size()) { - return; - } - - const QList sections = pages[pageIndex]->getSections(); - - bool known = sectionWidget == nullptr; - for (const SettingsPageSection §ion : sections) { - if (section.sectionWidget == sectionWidget) { - known = true; - } - } - if (!known) { - sectionWidget = nullptr; - } - isolatedSection = sectionWidget; - - for (const SettingsPageSection §ion : sections) { - if (section.sectionWidget) { - section.sectionWidget->setVisible(sectionWidget == nullptr || section.sectionWidget == sectionWidget); - } - } - - if (auto *scrollArea = qobject_cast(pagesWidget->widget(pageIndex))) { - if (QScrollBar *scrollBar = scrollArea->verticalScrollBar()) { - scrollBar->setValue(0); - } - } -} - -void SettingsView::scrollToAndFlash(QWidget *target) -{ - if (!target) { - return; - } - QWidget *widget = target; - while (widget) { - if (auto *scrollArea = qobject_cast(widget)) { - scrollArea->ensureWidgetVisible(target); - break; - } - widget = widget->parentWidget(); - } - flashWidget(target); -} - -void SettingsView::setTab(int index) -{ - if (index >= 0 && index < pageItems.size()) { - setActiveTab(index); - } -} - -AbstractSettingsPage *SettingsView::page(SettingsPage which) const -{ - return pages.value(static_cast(which)); -} - -bool SettingsView::eventFilter(QObject *watched, QEvent *event) -{ - if (watched == navTree->viewport()) { - if (event->type() == QEvent::MouseButtonPress) { - auto *mouseEvent = static_cast(event); - // Left button only: context presses must never toggle the filter - QTreeWidgetItem *pressedItem = navTree->itemAt(mouseEvent->position().toPoint()); - pressWasOnCurrentItem = mouseEvent->button() == Qt::LeftButton && pressedItem != nullptr && - pressedItem == navTree->currentItem(); - } - return false; // observation only: the view still handles the press - } - - if (watched == searchEdit && event->type() == QEvent::KeyPress) { - auto *keyEvent = static_cast(event); - if (keyEvent->key() == Qt::Key_Escape) { - if (searchActive) { - switchToTabMode(); - return true; - } - } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { - if (searchActive) { - if (searchResultsView->currentIndex().isValid()) { - navigateToSearchResult(searchResultsView->currentIndex()); - } - return true; - } - } else if (keyEvent->key() == Qt::Key_Down) { - if (searchActive) { - int nextRow = searchResultsView->currentIndex().row() + 1; - if (nextRow >= searchModel->rowCount()) { - nextRow = 0; - } - searchResultsView->setCurrentIndex(searchModel->index(nextRow)); - searchResultsView->scrollTo(searchModel->index(nextRow)); - return true; - } - } else if (keyEvent->key() == Qt::Key_Up) { - if (searchActive) { - int prevRow = searchResultsView->currentIndex().row() - 1; - if (prevRow < 0) { - prevRow = searchModel->rowCount() - 1; - } - if (prevRow >= 0) { - searchResultsView->setCurrentIndex(searchModel->index(prevRow)); - searchResultsView->scrollTo(searchModel->index(prevRow)); - } - return true; - } - } - } - return QWidget::eventFilter(watched, event); -} - -void SettingsView::retranslateUi() -{ - retranslateNavNames(); - - // NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings - // before the view was extracted, and keeping the context preserves existing translations. - searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings...")); - - // Rebuild search index for translated text - buildSearchIndex(); -} - -QStringList SettingsView::translatedPageNames() -{ - // NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi). - return {QCoreApplication::translate("DlgSettings", "General"), - QCoreApplication::translate("DlgSettings", "Appearance"), - QCoreApplication::translate("DlgSettings", "User Interface"), - QCoreApplication::translate("DlgSettings", "Card Sources"), - QCoreApplication::translate("DlgSettings", "Storage"), - QCoreApplication::translate("DlgSettings", "Chat"), - QCoreApplication::translate("DlgSettings", "Sound"), - QCoreApplication::translate("DlgSettings", "Shortcuts")}; -} - -void SettingsView::retranslateNavNames() -{ - const QStringList groupNames = translatedGroupNames(); - for (int i = 0; i < groupItems.size() && i < groupNames.size(); ++i) { - groupItems[i]->setText(0, groupNames[i]); - } - - const QStringList pageNames = translatedPageNames(); - for (int i = 0; i < pageItems.size() && i < pageNames.size(); ++i) { - if (pageItems[i]) { - pageItems[i]->setText(0, pageNames[i]); - } - } - - // Sub-page rows mirror the pages' group box titles (already retranslated by the pages) - for (int i = 0; i < pageItems.size() && i < pages.size(); ++i) { - if (!pageItems[i]) { - continue; - } - const QList sections = pages[i]->getSections(); - Q_ASSERT(pageItems[i]->childCount() == sections.size()); - for (int c = 0; c < pageItems[i]->childCount() && c < sections.size(); ++c) { - pageItems[i]->child(c)->setText(0, sections[c].title); - } - } -} - -QStringList SettingsView::translatedGroupNames() -{ - return {tr("Application"), tr("Cards & Data"), tr("Chat & Sound"), tr("Advanced")}; -} diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.h b/cockatrice/src/interface/widgets/dialogs/settings_view.h deleted file mode 100644 index 90dc5636f..000000000 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.h +++ /dev/null @@ -1,109 +0,0 @@ -/** - * @file settings_view.h - * @brief Settings content widget hosted by the settings dialog - * @ingroup Dialogs - */ -#ifndef SETTINGS_VIEW_H -#define SETTINGS_VIEW_H - -#include -#include -#include -#include -#include - -class QModelIndex; -class QObject; -class QEvent; - -class QStackedWidget; -class QTreeWidget; -class QTreeWidgetItem; -class QListView; -class QLineEdit; -class AbstractSettingsPage; -class SettingsSearchModel; -class SettingsSearchDelegate; - -/** - * @brief Settings pages with grouped left navigation and search, without any dialog chrome - * - * Owns the search bar, the navigation tree, the stacked settings pages and the search - * results view. The settings dialog only adds its own frame (buttons, titles). - */ -class SettingsView : public QWidget -{ - Q_OBJECT -public: - /** - * @brief Page order in the navigation, matching the order pages are added in setupUi() - * - * Use these values instead of raw indices so reordering pages never silently - * breaks external callers like tab_room.cpp. - */ - enum SettingsPage - { - GeneralPage = 0, - AppearancePage, - UserInterfacePage, - DeckEditorPage, - StoragePage, - MessagesPage, - SoundPage, - ShortcutsPage, - NumPages - }; - - explicit SettingsView(QWidget *parent = nullptr); - void setTab(int index); - AbstractSettingsPage *page(SettingsPage which) const; - -private slots: - void onTabClicked(int index); - void onSearchTextChanged(const QString &text); - void onSearchResultClicked(const QModelIndex &index); - -private: - // UI elements - QLineEdit *searchEdit; ///< Search bar for filtering settings - QTreeWidget *navTree; ///< Grouped navigation tree for the pages - QList groupItems; ///< Group header rows, indexed by group - QList pageItems; ///< Page rows, indexed by SettingsPage - QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages - QListView *searchResultsView; ///< Search results list view - QWidget *pagesContainer; ///< Container stacking pages and search results - - // Data - QList pages; ///< All settings page instances - SettingsSearchModel *searchModel; ///< Model for search results - SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering - int currentTabIndex; ///< Currently active tab index - bool searchActive; ///< Whether search mode is active - QWidget *isolatedSection = nullptr; ///< Isolated section group box, or nullptr for full pages - bool pressWasOnCurrentItem = false; ///< True when the mouse press started on the already-current item - - void setupUi(); - void setupNavTree(); - void buildSearchIndex(); - void switchToTabMode(); - void switchToSearchMode(); - void navigateToSearchResult(const QModelIndex &index); - void navigateToSection(int pageIndex, QWidget *sectionWidget); - void setActiveTab(int index); - void switchPageContent(int index); - void applySectionFilter(int pageIndex, QWidget *sectionWidget); - void selectSectionRow(int pageIndex, QWidget *sectionWidget); - QTreeWidgetItem *findSectionItem(int pageIndex, QWidget *sectionWidget) const; - static void flashWidget(QWidget *widget); - static void scrollToAndFlash(QWidget *target); - static QStringList translatedPageNames(); - static QStringList translatedGroupNames(); - - void retranslateUi(); - void retranslateNavNames(); - -protected: - bool eventFilter(QObject *watched, QEvent *event) override; -}; - -#endif // SETTINGS_VIEW_H diff --git a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp index 063f1b2a8..9c43d1f7b 100644 --- a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp @@ -137,30 +137,6 @@ QList AbstractSettingsPage::getSearchEntries() return autoDetectSearchEntries(this, -1); } -QList AbstractSettingsPage::getSections() -{ - QList sections; - - // Walk the top-level layout in display order (creation order differs on some pages) - if (QLayout *mainLayout = layout()) { - for (int i = 0; i < mainLayout->count(); ++i) { - QLayoutItem *item = mainLayout->itemAt(i); - QWidget *widget = item ? item->widget() : nullptr; - auto *groupBox = qobject_cast(widget); - if (!groupBox || groupBox->title().isEmpty()) { - continue; - } - sections.append({groupBox->title(), groupBox}); - } - } - - // A single group is the page itself; sub-page entries would only duplicate it - if (sections.size() < 2) { - sections.clear(); - } - return sections; -} - QList AbstractSettingsPage::autoDetectSearchEntries(QWidget *page, int pageIndex) { QList entries; diff --git a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h index c38a5214b..722a91e62 100644 --- a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h @@ -2,7 +2,6 @@ #define COCKATRICE_ABSTRACT_SETTINGS_PAGE_H #include -#include #include #include @@ -12,28 +11,12 @@ struct SettingsSearchEntry; -/** - * @brief A navigable section of a settings page (one group box) - */ -struct SettingsPageSection -{ - QString title; ///< Translated group title - QWidget *sectionWidget = nullptr; ///< Group box to scroll to and highlight -}; - class AbstractSettingsPage : public QWidget { Q_OBJECT public: virtual void retranslateUi() = 0; virtual QList getSearchEntries(); - /** - * @brief Sections of this page for sub-page navigation, in display order - * - * Scans the page's direct-child group boxes; pages with a single group - * report no sections so the nav stays flat for them. - */ - QList getSections(); protected: static QList autoDetectSearchEntries(QWidget *page, int pageIndex); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 636cee281..d23ce254c 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -82,12 +82,10 @@ #include #include #include -#include #include #include #include #include -#include #include #include #include @@ -357,9 +355,6 @@ void MainWindow::retranslateUi() aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); - settingsButton->setToolTip(tr("Settings")); - settingsButton->setAccessibleName(tr("Settings")); - settingsButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ @@ -564,19 +559,6 @@ MainWindow::MainWindow(QWidget *parent) setCentralWidget(tabSupervisor); - // Unobtrusive settings entry in the tab bar corner: flat icon-only button that blends into the tab - // bar until hovered, mirroring the menu action for discoverability - settingsButton = new QToolButton(tabSupervisor); - settingsButton->setAutoRaise(true); - settingsButton->setToolButtonStyle(Qt::ToolButtonIconOnly); - settingsButton->setIconSize(QSize(16, 16)); - settingsButton->setCursor(Qt::PointingHandCursor); - settingsButton->setFocusPolicy(Qt::NoFocus); - settingsButton->setStyleSheet( - QStringLiteral("QToolButton { border: none; background: transparent; padding: 4px; }")); - connect(settingsButton, &QToolButton::clicked, this, &MainWindow::actSettings); - tabSupervisor->setCornerWidget(settingsButton, Qt::TopRightCorner); - retranslateUi(); if (!restoreGeometry(SettingsCache::instance().layouts().getMainWindowGeometry())) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index bcf709cb1..4b3da716d 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -46,7 +46,6 @@ #include class QAction; -class QToolButton; class QWidget; struct LocalGameOptions; @@ -179,7 +178,6 @@ private: QList tabMenus; QMenu *cockatriceMenu, *dbMenu, *tabsMenu, *helpMenu, *trayIconMenu; QAction *aAbout, *aSettings, *aShow, *aExit; - QToolButton *settingsButton = nullptr; QAction *aConnect, *aDisconnect, *aRegister, *aForgotPassword, *aSinglePlayer, *aWatchReplay, *aFullScreen; QAction *aManageSets, *aEditTokens, *aOpenCustomFolder, *aOpenCustomsetsFolder, *aAddCustomSet, *aReloadCardDatabase; diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 255469934..f6d28d6c1 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -7,7 +7,6 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${ set(servatrice_SOURCES src/email_parser.cpp src/event_loop_watchdog.cpp - src/forwarded_address.cpp src/main.cpp src/metrics_registry.cpp src/ratelimiter.cpp @@ -19,7 +18,6 @@ set(servatrice_SOURCES src/settingscache.cpp src/isl_interface.cpp src/signalhandler.cpp - src/trusted_sources.cpp ${VERSION_STRING_CPP} src/smtpclient.cpp src/smtp/qxthmac.cpp diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 021f5157f..68478810b 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -42,11 +42,9 @@ websocket_host=any websocket_port=4748 ; If websockets are served through a reverse proxy (e.g. nginx), the real client address can be -; read from an HTTP header such as X-Real-IP or X-Forwarded-For. A comma-separated X-Forwarded-For -; list resolves to its rightmost entry, which is the one the proxy appended. The header is ONLY -; trusted when the websocket connection itself originates from an address listed in -; security/trusted_sources (default 127.0.0.1,::1), so that clients cannot spoof their address to -; bypass bans or rate limits. +; read from an HTTP header such as X-Forwarded-For. The header is ONLY trusted when the websocket +; connection itself originates from an address listed in security/trusted_sources (default 127.0.0.1,::1), +; so that clients cannot spoof their address to bypass bans or rate limits. web_socket_ip_header= ; When database is enabled, servatrice writes the server status in the "update" database table; this @@ -467,8 +465,7 @@ legacy_backup_ttl_days=180 ; You may want to allow an unlimited number of users from a trusted source. This setting can contain a ; comma-separed list of IP addresses which will allow an unlimited number of connections from each of the -; IP addresses listed (ignoring the max_users_per_address). Entries must be exact IP addresses; hostnames, prefixes -; and subnets are ignored. Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74" +; IP addresses listed (ignoring the max_users_per_address). Default is "127.0.0.1,::1"; example: "192.73.233.244,81.4.100.74" trusted_sources="127.0.0.1,::1" ; Servatrice can avoid users from flooding rooms with large number of messages in an interval of time. diff --git a/servatrice/src/forwarded_address.cpp b/servatrice/src/forwarded_address.cpp deleted file mode 100644 index 8d5d3f4c1..000000000 --- a/servatrice/src/forwarded_address.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#include "forwarded_address.h" - -#include - -QHostAddress parseForwardedAddress(const QByteArray &headerValue) -{ - const QList entries = headerValue.split(','); - for (auto it = entries.crbegin(); it != entries.crend(); ++it) { - const QByteArray entry = it->trimmed(); - if (entry.isEmpty()) { - continue; - } - - const QHostAddress parsed(QString::fromLatin1(entry)); - if (!parsed.isNull()) { - return parsed; - } - } - - return {}; -} diff --git a/servatrice/src/forwarded_address.h b/servatrice/src/forwarded_address.h deleted file mode 100644 index d77e041bd..000000000 --- a/servatrice/src/forwarded_address.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef FORWARDED_ADDRESS_H -#define FORWARDED_ADDRESS_H - -#include -#include - -/** - * @brief Parses the real client address out of a forwarded header value (X-Real-IP, X-Forwarded-For). - * - * A comma-separated list resolves to its rightmost usable entry. That entry was appended by the - * proxy closest to the server and is the only one a direct client cannot choose, since the earlier - * entries are passed through verbatim from whatever the client sent. - * - * @return the parsed address, or a null QHostAddress when the value holds none. - */ -QHostAddress parseForwardedAddress(const QByteArray &headerValue); - -#endif diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index b6eaf11f4..a306de523 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -33,7 +33,6 @@ #include "serversocketinterface.h" #include "settingscache.h" #include "smtpclient.h" -#include "trusted_sources.h" #include #include @@ -325,14 +324,6 @@ bool Servatrice::initServer() qDebug() << "Maximum websocket user limit:" << getMaxWebSocketUserLimit(); } - // trusted_sources entries are matched as exact addresses; older configs may hold hostnames, - // prefixes or subnets that substring matching used to accept and that now never match. - for (const QString &entry : getTrustedSources().split(',', Qt::SkipEmptyParts)) { - if (QHostAddress(entry.trimmed()).isNull()) { - qWarning() << "security/trusted_sources entry is not an IP address and will be ignored:" << entry.trimmed(); - } - } - qDebug() << "Accept registered users only:" << getRegOnlyServerEnabled(); qDebug() << "Registration enabled:" << getRegistrationEnabled(); if (getRegistrationEnabled()) { @@ -1333,11 +1324,6 @@ int Servatrice::getMaxUsersPerAddress() const return settingsCache->value("security/max_users_per_address", 4).toInt(); } -QString Servatrice::getTrustedSources() const -{ - return settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); -} - int Servatrice::getMessageCountingInterval() const { return settingsCache->value("security/message_counting_interval", 10).toInt(); @@ -1551,11 +1537,9 @@ bool Servatrice::isRateLimitExempt(const QString &ipAddress) const { // Mirrors the max_users_per_address bypass in initSession: trusted sources // (e.g. 127.0.0.1 for Webatrice behind local nginx, where every websocket - // client shares one address) are not subject to per-address limits. Loopback - // is always exempt, so a same-host proxy whose forwarded address could not be - // resolved cannot lock every user out together when trusted_sources is - // customized. - return QHostAddress(ipAddress).isLoopback() || isTrustedSource(getTrustedSources(), ipAddress); + // client shares one address) are not subject to per-address limits. + const QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); + return trustedSources.contains(ipAddress, Qt::CaseInsensitive); } void Servatrice::clearFailedLogins(const QString &ipAddress, const QString &userName) diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 3dba23234..119adc8b2 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -321,8 +321,6 @@ public: int getDeckShareMaxDecksPerShare() const; int getDeckShareMaxSharesPerDay() const; int getMaxUsersPerAddress() const; - /// Raw security/trusted_sources list; match addresses against it with isTrustedSource(). - QString getTrustedSources() const; int getMessageCountingInterval() const override; int getMaxMessageCountPerInterval() const override; int getMaxMessageSizePerInterval() const override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 890a93fbc..b24126d93 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -22,7 +22,6 @@ #include "deck_tag_serialization.h" #include "email_parser.h" -#include "forwarded_address.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/developer_commands.pb.h" #include "libcockatrice/protocol/pb/moderator_commands.pb.h" @@ -43,7 +42,6 @@ #include "server_logger.h" #include "server_protocolhandler.h" #include "settingscache.h" -#include "trusted_sources.h" #include "version_string.h" #include @@ -222,7 +220,8 @@ bool AbstractServerSocketInterface::initSession() delete identSe; // allow unlimited number of connections from the trusted sources - if (isTrustedSource(servatrice->getTrustedSources(), getPeerAddress())) { + QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); + if (trustedSources.contains(getAddress(), Qt::CaseInsensitive)) { return true; } @@ -1909,8 +1908,9 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com if (amountRemove != 0) { removeSaidMessages(userName, amountRemove); } + QString trustedSources = settingsCache->value("server/trusted_sources", "127.0.0.1,::1").toString(); int minutes = cmd.minutes(); - if (isTrustedSource(servatrice->getTrustedSources(), address)) { + if (trustedSources.contains(address, Qt::CaseInsensitive)) { address = ""; } @@ -4560,8 +4560,10 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) if (websocketIPHeader.length() > 0 && socket->request().hasRawHeader(websocketIPHeader)) { // Only trust the forwarded header if the connection itself comes from a trusted proxy, // otherwise a client could spoof its address to bypass bans and rate limits. - if (isTrustedSource(servatrice->getTrustedSources(), socket->peerAddress())) { - const QHostAddress parsed = parseForwardedAddress(socket->request().rawHeader(websocketIPHeader)); + QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); + if (trustedSources.contains(socket->peerAddress().toString(), Qt::CaseInsensitive)) { + QString header(socket->request().rawHeader(websocketIPHeader)); + QHostAddress parsed(header); if (!parsed.isNull()) { address = parsed; } diff --git a/servatrice/src/trusted_sources.cpp b/servatrice/src/trusted_sources.cpp deleted file mode 100644 index 327491936..000000000 --- a/servatrice/src/trusted_sources.cpp +++ /dev/null @@ -1,23 +0,0 @@ -#include "trusted_sources.h" - -#include - -bool isTrustedSource(const QString &trustedSources, const QHostAddress &address) -{ - if (address.isNull()) { - return false; - } - const QStringList entries = trustedSources.split(',', Qt::SkipEmptyParts); - for (const QString &entry : entries) { - const QHostAddress trusted(entry.trimmed()); - if (!trusted.isNull() && trusted.isEqual(address, QHostAddress::ConvertV4MappedToIPv4)) { - return true; - } - } - return false; -} - -bool isTrustedSource(const QString &trustedSources, const QString &address) -{ - return isTrustedSource(trustedSources, QHostAddress(address.trimmed())); -} diff --git a/servatrice/src/trusted_sources.h b/servatrice/src/trusted_sources.h deleted file mode 100644 index 3aea55e41..000000000 --- a/servatrice/src/trusted_sources.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifndef TRUSTED_SOURCES_H -#define TRUSTED_SOURCES_H - -#include -#include - -/** - * @brief True if @p address is listed in the comma-separated @p trustedSources (security/trusted_sources). - * - * Entries are parsed as addresses and compared exactly, never as substrings, so - * 27.0.0.1 does not match 127.0.0.1. IPv4-mapped IPv6 addresses compare equal to - * their IPv4 form: a dual-stack listener reports a local proxy as ::ffff:127.0.0.1, - * which matches a 127.0.0.1 entry. Malformed entries and null addresses never match. - */ -bool isTrustedSource(const QString &trustedSources, const QHostAddress &address); - -/** @brief Overload for an address in string form; an unparsable string never matches. */ -bool isTrustedSource(const QString &trustedSources, const QString &address); - -#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8811b8623..b58942f3f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -208,28 +208,6 @@ cockatrice_add_test( LABELS server ) -cockatrice_add_test( - NAME - forwarded_address_test - SOURCES - ${CMAKE_SOURCE_DIR}/servatrice/src/forwarded_address.cpp - forwarded_address_test.cpp - INCLUDE_DIRS - ${CMAKE_SOURCE_DIR}/servatrice/src - LABELS - server -) -cockatrice_add_test( - NAME - trusted_sources_test - SOURCES - ${CMAKE_SOURCE_DIR}/servatrice/src/trusted_sources.cpp - trusted_sources_test.cpp - INCLUDE_DIRS - ${CMAKE_SOURCE_DIR}/servatrice/src - LABELS - server -) cockatrice_add_test( NAME server_card_counter_test diff --git a/tests/forwarded_address_test.cpp b/tests/forwarded_address_test.cpp deleted file mode 100644 index 3ae2bc471..000000000 --- a/tests/forwarded_address_test.cpp +++ /dev/null @@ -1,37 +0,0 @@ -#include "forwarded_address.h" - -#include "gtest/gtest.h" - -namespace -{ - -TEST(ForwardedAddressTest, ParsesSingleAddress) -{ - EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("203.0.113.7")); - EXPECT_EQ(QHostAddress("::1"), parseForwardedAddress("::1")); - EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress(" 203.0.113.7 ")); -} - -TEST(ForwardedAddressTest, UsesRightmostEntryOfAList) -{ - // nginx's $proxy_add_x_forwarded_for appends its own view of the client to - // whatever the client sent, so only the trailing entry is trustworthy. - EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("198.51.100.9, 203.0.113.7")); - EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("spoofed.invalid, 203.0.113.7")); - EXPECT_EQ(QHostAddress("203.0.113.7"), parseForwardedAddress("203.0.113.7, ")); -} - -TEST(ForwardedAddressTest, KeepsIpv4MappedEntries) -{ - EXPECT_EQ(QHostAddress("::ffff:203.0.113.7"), parseForwardedAddress("::ffff:203.0.113.7")); -} - -TEST(ForwardedAddressTest, RejectsValuesWithoutAnAddress) -{ - EXPECT_TRUE(parseForwardedAddress("").isNull()); - EXPECT_TRUE(parseForwardedAddress(",").isNull()); - EXPECT_TRUE(parseForwardedAddress("unknown").isNull()); - EXPECT_TRUE(parseForwardedAddress("garbage, more garbage").isNull()); -} - -} // namespace diff --git a/tests/trusted_sources_test.cpp b/tests/trusted_sources_test.cpp deleted file mode 100644 index a16b21f2f..000000000 --- a/tests/trusted_sources_test.cpp +++ /dev/null @@ -1,58 +0,0 @@ -#include "trusted_sources.h" - -#include "gtest/gtest.h" -#include - -namespace -{ - -const QString kDefault = QStringLiteral("127.0.0.1,::1"); - -TEST(TrustedSourcesTest, MatchesListedAddresses) -{ - ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("127.0.0.1"))); - ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("::1"))); - ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress("10.0.0.1"))); -} - -TEST(TrustedSourcesTest, MatchesIpv4MappedPeerAgainstPlainIpv4Entry) -{ - // A dual-stack listener reports a local IPv4 proxy as ::ffff:127.0.0.1; - // it must still match the default 127.0.0.1 entry. - ASSERT_TRUE(isTrustedSource(kDefault, QHostAddress("::ffff:127.0.0.1"))); -} - -TEST(TrustedSourcesTest, MatchesPlainIpv4PeerAgainstMappedEntry) -{ - ASSERT_TRUE(isTrustedSource("::ffff:192.0.2.7", QHostAddress("192.0.2.7"))); -} - -TEST(TrustedSourcesTest, DoesNotMatchSubstrings) -{ - // Substring matching would let 27.0.0.1 or 0.0.1 pass as 127.0.0.1. - ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress("27.0.0.1"))); - ASSERT_FALSE(isTrustedSource("192.0.2.10", QHostAddress("192.0.2.1"))); - ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress(":"))); -} - -TEST(TrustedSourcesTest, ToleratesWhitespaceAndCase) -{ - ASSERT_TRUE(isTrustedSource(" 192.0.2.7 , 2001:DB8::1 ", QHostAddress("2001:db8::1"))); - ASSERT_TRUE(isTrustedSource(" 192.0.2.7 , 2001:DB8::1 ", QHostAddress("192.0.2.7"))); -} - -TEST(TrustedSourcesTest, IgnoresMalformedEntriesAndNullAddresses) -{ - ASSERT_FALSE(isTrustedSource("not-an-ip,,", QHostAddress("127.0.0.1"))); - ASSERT_FALSE(isTrustedSource("", QHostAddress("127.0.0.1"))); - ASSERT_FALSE(isTrustedSource(kDefault, QHostAddress())); -} - -TEST(TrustedSourcesTest, StringOverloadParsesAddress) -{ - ASSERT_TRUE(isTrustedSource(kDefault, QStringLiteral("::ffff:127.0.0.1"))); - ASSERT_FALSE(isTrustedSource(kDefault, QStringLiteral("27.0.0.1"))); - ASSERT_FALSE(isTrustedSource(kDefault, QString())); -} - -} // namespace