/*************************************************************************** * Copyright (C) 2008-2013 by Heiko Koehn - KoehnHeiko@googlemail.com * * Copyright (C) 2014 by Ahmed Charles - acharles@outlook.com * * Copyright (C) 2016-2018, 2020-2023, 2025-2026 by Stephen Lyons * * - slysven@virginmedia.com * * Copyright (C) 2025 by Lecker Kebap - Leris@mudlet.org * * * * This program is free software; you can redistribute it and/or modify * * it under the terms of the GNU General Public License as published by * * the Free Software Foundation; either version 2 of the License, or * * (at your option) any later version. * * * * This program is distributed in the hope that it will be useful, * * but WITHOUT ANY WARRANTY; without even the implied warranty of * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * * GNU General Public License for more details. * * * * You should have received a copy of the GNU General Public License * * along with this program; if not, write to the * * Free Software Foundation, Inc., * * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * ***************************************************************************/ #include "dlgConnectionProfiles.h" #include #include "Host.h" #include "HostManager.h" #include "LuaInterface.h" #include "TGameDetails.h" #include "XMLimport.h" #include "mudlet.h" #include "CredentialManager.h" #include "SecureStringUtils.h" #include "utils.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace std::chrono_literals; // Kept to a sub-set of ASCII because the profile name is also used as a // directory name on all supported OSes; parentheses are included so that // folders duplicated by a file manager (e.g. "profile (2)") work as-is: const QString dlgConnectionProfiles::scmAllowedProfileNameChars = qsl(". _()0123456789-#&aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsStTuUvVwWxXyYzZ"); // Returns the first character not permitted in a (new) profile name, or a // null QChar if all of them are acceptable. An embedded U+0000 is // indistinguishable from the all-clear sentinel, but a QLineEdit never lets // one through: QChar dlgConnectionProfiles::firstInvalidProfileNameChar(const QString& name) { for (const QChar& c : name) { if (!scmAllowedProfileNameChars.contains(c)) { return c; } } return {}; } // Characters that make a name unusable no matter where it came from: // utils::sanitizeForPath() silently rewrites them out of any path built from // the profile name, and CredentialManager::generateFilePath() refuses to // produce a path at all - so a profile named this way could never store or // retrieve its password. Mirrors the pattern used there: const QRegularExpression dlgConnectionProfiles::scmUnusableProfileNameChars{qsl(R"REGEX(\.\.|[/\\<>:"|?*\x00-\x1f])REGEX")}; // Listing what is expected rather than what to watch out for keeps an // unrecognised file - a stored password, a character name the user typed - on // the side of asking. ProfileDeletionSafetyTest fails if the connection form // comes to write anything this does not name: const QStringList dlgConnectionProfiles::scmConnectionDetailFiles{qsl("url"), qsl("port"), qsl("ssl_tsl"), qsl("description"), qsl("website"), qsl("autologin"), qsl("autoreconnect")}; // A lone "." is made entirely of permitted characters, yet every path built // from it addresses the profiles directory rather than a profile of its own - // as does "..", which scmUnusableProfileNameChars already covers: bool dlgConnectionProfiles::profileNameUsableAsIs(const QString& name) { return !name.isEmpty() && name != qsl(".") && !name.contains(scmUnusableProfileNameChars); } // Resolved textually rather than with QDir::canonicalPath() so that the answer // does not depend on the folder existing - a predefined game has none until it // is saved - and so that a symlinked profile folder still resolves. QString dlgConnectionProfiles::profileFolderPath(const QString& profilesPath, const QString& profile) { // Must precede the parent check below, which QDir::cleanPath() would // otherwise satisfy by collapsing "../profiles/Foo" straight back in: if (profile.isEmpty() || profile.contains(QLatin1Char('/')) || profile.contains(QLatin1Char('\\'))) { return {}; } const QString profilesDir = QDir::cleanPath(profilesPath); const QString candidate = QDir::cleanPath(qsl("%1/%2").arg(profilesDir, profile)); if (candidate == profilesDir || QFileInfo(candidate).path() != profilesDir) { return {}; } return candidate; } dlgConnectionProfiles::dlgConnectionProfiles(QWidget* parent) : QDialog(parent) { setupUi(this); mDateTimeFormat = mudlet::self()->getUserLocale().dateTimeFormat(); if (mDateTimeFormat.contains(QLatin1Char('t'))) { // There is a timezone identifier in there - which (apart from perhaps // the period around DST changes) we don't really need and which takes // up space: if (mDateTimeFormat.contains(QLatin1String(" t"))) { // Deal with the space if the time zone is appended to the end of // the string: mDateTimeFormat.remove(QLatin1String(" t"), Qt::CaseSensitive); } else { mDateTimeFormat.remove(QLatin1Char('t'), Qt::CaseSensitive); } } QPixmap holdPixmap; holdPixmap = notificationAreaIconLabelWarning->pixmap(Qt::ReturnByValue); holdPixmap.setDevicePixelRatio(5.3); notificationAreaIconLabelWarning->setPixmap(holdPixmap); holdPixmap = notificationAreaIconLabelError->pixmap(Qt::ReturnByValue); holdPixmap.setDevicePixelRatio(5.3); notificationAreaIconLabelError->setPixmap(holdPixmap); holdPixmap = notificationAreaIconLabelInformation->pixmap(Qt::ReturnByValue); holdPixmap.setDevicePixelRatio(5.3); notificationAreaIconLabelInformation->setPixmap(holdPixmap); // selection mode is important. if this is not set the selection behaviour is // undefined. this is an undocumented qt bug, as it only shows on certain OS // and certain architectures. listWidget_profiles->setSelectionMode(QAbstractItemView::SingleSelection); listWidget_profiles->setContextMenuPolicy(Qt::CustomContextMenu); connect(listWidget_profiles, &QWidget::customContextMenuRequested, this, &dlgConnectionProfiles::slot_profileContextMenu); mpTabBar = new QTabBar(this); // QTabWidget gives this dialog a second QTabBar, so this one needs a name mpTabBar->setObjectName(qsl("gamesTabBar")); //: Tab showing only the games the user already has profiles for mpTabBar->insertTab(scmMyGamesTab, tr("My games")); //: Tab showing every game Mudlet has a built-in profile for mpTabBar->insertTab(scmAllGamesTab, tr("All games")); mpTabBar->setExpanding(false); mpTabBar->setAccessibleName(tr("games shown")); mpTabBar->setAccessibleDescription(tr("Switch between showing only your own games and all of the games Mudlet knows about.")); verticalLayout_gamesList->insertWidget(0, mpTabBar); setTabOrder(mpTabBar, listWidget_profiles); if (!mudlet::self()->mOnlyShownPredefinedProfiles.isEmpty()) { // dedicated single-game builds only ever show their own game(s), so // there is nothing to switch between mpTabBar->hide(); } else { auto& settings = *mudlet::self()->mpSettings; int initialTab = scmMyGamesTab; if (settings.contains(qsl("connectionDialogActiveTab"))) { initialTab = settings.value(qsl("connectionDialogActiveTab")).toInt() == scmAllGamesTab ? scmAllGamesTab : scmMyGamesTab; } else if (settings.value(qsl("showOnlyMyProfiles"), false).toBool()) { // migrate the retired "Show my profiles only" context menu filter, // which the "My games" tab replaces initialTab = scmMyGamesTab; settings.setValue(qsl("connectionDialogActiveTab"), initialTab); } else if (QDir(mudlet::getMudletPath(enums::profilesPath)).entryList(QDir::Dirs | QDir::NoDotAndDotDot).isEmpty()) { // a newcomer has no profiles yet, so show them the catalog initialTab = scmAllGamesTab; } // the retired filter's setting is dropped even when it was false, so it // cannot resurface should connectionDialogActiveTab ever go missing settings.remove(qsl("showOnlyMyProfiles")); mpTabBar->setCurrentIndex(initialTab); } // connected only after the initial tab is set, so that setting it is not // mistaken for the user switching tabs connect(mpTabBar, &QTabBar::currentChanged, this, &dlgConnectionProfiles::slot_activeTabChanged); QAbstractButton* abort = dialog_buttonbox->button(QDialogButtonBox::Cancel); connect_button = dialog_buttonbox->addButton(tr("Connect"), QDialogButtonBox::AcceptRole); connect_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); offline_button = dialog_buttonbox->addButton(tr("Offline"), QDialogButtonBox::AcceptRole); offline_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); //: Button shown on first launch to skip the tutorial and show the full games list mpSkipToGamesButton = new QPushButton(tr("Skip - show me the games list"), this); mpSkipToGamesButton->setObjectName(qsl("skipToGamesButton")); mpSkipToGamesButton->hide(); horizontalLayout_3->insertWidget(0, mpSkipToGamesButton); connect(mpSkipToGamesButton, &QPushButton::clicked, this, &dlgConnectionProfiles::slot_skipToGamesList); // Pressing Enter in the connection form must always mean "Connect". The // tutorial invitation hides the Connect button on first show, which stops // Qt's automatic default-button tracking from ever settling on it - Enter // would then activate the first autoDefault button in the dialog, which // happens to be Remove, silently deleting the profile being created: remove_profile_button->setAutoDefault(false); new_profile_button->setAutoDefault(false); mpSkipToGamesButton->setAutoDefault(false); connect_button->setDefault(true); // Test and set if needed mudlet::mIsIconShownOnDialogButtonBoxes - if there // is already a Qt provided icon on a predefined button, this is probably // the first and best place to test this as the "Cancel" button is a built- // in dialog button which will have an icon if the current system style // settings suggest it: mudlet::self()->mShowIconsOnDialogs = !abort->icon().isNull(); //: Welcome message shown on first launch, focused on starting the tutorial. auto Welcome_text_template = tr("

" "

Welcome to Mudlet!

" "

Play a short guided adventure to learn
" "how to navigate in games, use triggers, aliases, and scripting.

" "

Start Tutorial

" "

The Mudlet Team " "

"); auto pWelcome_document = new QTextDocument(this); QPixmap tutorialIcon(qsl(":/icons/mudlet-tutorial.png")); tutorialIcon = tutorialIcon.scaled(160, 40, Qt::KeepAspectRatio, Qt::SmoothTransformation); pWelcome_document->addResource(QTextDocument::ImageResource, QUrl(qsl("tutorialIcon")), tutorialIcon); mpCopyProfile = new QAction(tr("Copy"), this); mpCopyProfile->setObjectName(qsl("copyProfile")); auto copyProfileSettings = new QAction(tr("Copy settings only"), this); copyProfileSettings->setObjectName(qsl("copyProfileSettingsOnly")); copy_profile_toolbutton->addAction(mpCopyProfile); copy_profile_toolbutton->addAction(copyProfileSettings); copy_profile_toolbutton->setDefaultAction(mpCopyProfile); auto objectList = mpCopyProfile->associatedObjects(); QList widgetList; for (const auto pObjectItem : std::as_const(objectList)) { auto pWidgetItem = qobject_cast(pObjectItem); if (pWidgetItem) { widgetList << pWidgetItem; } } Q_ASSERT_X(!widgetList.isEmpty(), "dlgConnectionProfiles::dlgConnectionProfiles(...)", "A QWidget for mpCopyProfile QAction not found."); widgetList.first()->setAccessibleName(tr("copy profile")); widgetList.first()->setAccessibleDescription(tr("copy the entire profile to new one that will require a different new name.")); objectList = copyProfileSettings->associatedObjects(); widgetList.clear(); for (const auto pObjectItem : std::as_const(objectList)) { auto pWidgetItem = qobject_cast(pObjectItem); if (pWidgetItem) { widgetList << pWidgetItem; } } Q_ASSERT_X(!widgetList.isEmpty(), "dlgConnectionProfiles::dlgConnectionProfiles(...)", "A QWidget for copyProfileSettings QAction not found."); widgetList.first()->setAccessibleName(tr("copy profile settings")); widgetList.first()->setAccessibleDescription(tr("copy the settings and some other parts of the profile to a new one that will require a different new name.")); if (mudlet::self()->mShowIconsOnDialogs) { // As we are repurposing the cancel to be a close button we do want to // change it anyhow: abort->setIcon(QIcon::fromTheme(qsl("dialog-close"), QIcon(qsl(":/icons/dialog-close.png")))); const QIcon icon_new(QIcon::fromTheme(qsl("document-new"), QIcon(qsl(":/icons/document-new.png")))); const QIcon icon_connect(QIcon::fromTheme(qsl("dialog-ok-apply"), QIcon(qsl(":/icons/preferences-web-browser-cache.png")))); offline_button->setIcon(QIcon(qsl(":/icons/mudlet_editor.png"))); connect_button->setIcon(icon_connect); new_profile_button->setIcon(icon_new); remove_profile_button->setIcon(QIcon::fromTheme(qsl("edit-delete"), QIcon(qsl(":/icons/edit-delete.png")))); copy_profile_toolbutton->setIcon(QIcon::fromTheme(qsl("edit-copy"), QIcon(qsl(":/icons/edit-copy.png")))); copy_profile_toolbutton->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); mpCopyProfile->setIcon(QIcon::fromTheme(qsl("edit-copy"), QIcon(qsl(":/icons/edit-copy.png")))); } pWelcome_document->setHtml(qsl("%1").arg(Welcome_text_template)); welcome_message->setDocument(pWelcome_document); welcome_message->setOpenLinks(false); welcome_message->setOpenExternalLinks(false); connect(welcome_message, &QTextBrowser::anchorClicked, this, [this](const QUrl& link) { if (link.toString() == qsl("mudlet-tutorial")) { mTutorialDismissed = true; profile_name_entry->setText(qsl("Mudlet Tutorial")); host_name_entry->setText(qsl("localhost")); port_entry->setText(qsl("0")); validName = true; validUrl = true; validPort = true; loadProfile(true); } }); mpAction_revealPassword = new QAction(this); mpAction_revealPassword->setCheckable(true); mpAction_revealPassword->setObjectName(qsl("mpAction_revealPassword")); slot_togglePasswordVisibility(false); character_password_entry->addAction(mpAction_revealPassword, QLineEdit::TrailingPosition); if (mudlet::self()->storingPasswordsSecurely()) { character_password_entry->setToolTip(utils::richText(tr("Characters password, stored securely in the computer's credential manager"))); } else { character_password_entry->setToolTip(utils::richText(tr("Characters password. Note that the password is not encrypted in storage"))); } connect(mpAction_revealPassword, &QAction::triggered, this, &dlgConnectionProfiles::slot_togglePasswordVisibility); connect(offline_button, &QAbstractButton::clicked, this, &dlgConnectionProfiles::slot_load); connect(connect_button, &QAbstractButton::clicked, this, &dlgConnectionProfiles::accept); connect(abort, &QAbstractButton::clicked, this, &dlgConnectionProfiles::slot_cancel); connect(new_profile_button, &QAbstractButton::clicked, this, &dlgConnectionProfiles::slot_addProfile); connect(mpCopyProfile, &QAction::triggered, this, &dlgConnectionProfiles::slot_copyProfile); connect(copyProfileSettings, &QAction::triggered, this, &dlgConnectionProfiles::slot_copyOnlySettingsOfProfile); connect(remove_profile_button, &QAbstractButton::clicked, this, &dlgConnectionProfiles::slot_deleteProfile); connect(profile_name_entry, &QLineEdit::textChanged, this, &dlgConnectionProfiles::slot_updateName); connect(profile_name_entry, &QLineEdit::editingFinished, this, &dlgConnectionProfiles::slot_saveName); connect(host_name_entry, &QLineEdit::textChanged, this, &dlgConnectionProfiles::slot_updateUrl); connect(port_entry, &QLineEdit::textChanged, this, &dlgConnectionProfiles::slot_updatePort); #if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) connect(port_ssl_tsl, &QCheckBox::checkStateChanged, this, &dlgConnectionProfiles::slot_updateSslTslPort); connect(autologin_checkBox, &QCheckBox::checkStateChanged, this, &dlgConnectionProfiles::slot_updateAutoConnect); connect(auto_reconnect, &QCheckBox::checkStateChanged, this, &dlgConnectionProfiles::slot_updateAutoReconnect); #else connect(port_ssl_tsl, &QCheckBox::stateChanged, this, &dlgConnectionProfiles::slot_updateSslTslPort); connect(autologin_checkBox, &QCheckBox::stateChanged, this, &dlgConnectionProfiles::slot_updateAutoConnect); connect(auto_reconnect, &QCheckBox::stateChanged, this, &dlgConnectionProfiles::slot_updateAutoReconnect); #endif connect(login_entry, &QLineEdit::textEdited, this, &dlgConnectionProfiles::slot_updateLogin); // Use textChanged with timer debouncing to avoid saving on every keystroke connect(character_password_entry, &QLineEdit::textChanged, this, &dlgConnectionProfiles::slot_passwordTextChanged); // Listen for password migration completion to refresh the form connect(mudlet::self(), &mudlet::signal_passwordsMigratedToSecure, this, [this]() { // Refresh the current profile's password field after migration; this // is not the user picking a game so keep the welcome message up mProgrammaticProfileSelection = true; slot_itemClicked(listWidget_profiles->currentItem()); mProgrammaticProfileSelection = false; }); // Listen for character password migration completion to refresh the form connect(mudlet::self(), &mudlet::signal_characterPasswordsMigrated, this, [this]() { // Refresh the current profile's password field after migration; this // is not the user picking a game so keep the welcome message up mProgrammaticProfileSelection = true; slot_itemClicked(listWidget_profiles->currentItem()); mProgrammaticProfileSelection = false; }); connect(mud_description_textedit, &QPlainTextEdit::textChanged, this, &dlgConnectionProfiles::slot_updateDescription); connect(listWidget_profiles, &QListWidget::currentItemChanged, this, &dlgConnectionProfiles::slot_itemClicked); // clicking the item that is already current (a profile gets pre-selected // before the dialog is shown) does not change the current item, so it // still needs to reveal the connection details on a fresh install connect(listWidget_profiles, &QListWidget::itemClicked, this, &dlgConnectionProfiles::revealConnectionDetails); connect(listWidget_profiles, &QListWidget::itemDoubleClicked, this, &dlgConnectionProfiles::accept); // website_entry atm is only a label //connect(website_entry, SIGNAL(textEdited(const QString)), this, SLOT(slot_updateWebsite(const QString))); clearNotificationArea(); #if !defined(QT_NO_SSL) if (QSslSocket::supportsSsl()) { port_ssl_tsl->setEnabled(true); } else { #endif port_ssl_tsl->setEnabled(false); #if !defined(QT_NO_SSL) } #endif mReadOnlyPalette.setColor(QPalette::Base, QColor(125, 125, 125, 25)); mOKPalette.setColor(QPalette::Base, QColor(150, 255, 150, 50)); mErrorPalette.setColor(QPalette::Base, QColor(255, 150, 150, 50)); listWidget_profiles->setViewMode(QListView::IconMode); btn_load_enabled_accessDesc = tr("Click to load but not connect the selected profile."); btn_connect_enabled_accessDesc = tr("Click to load and connect the selected profile."); btn_connOrLoad_disabled_accessDesc = tr("Need to have a valid profile name, game server address and port before this button can be enabled."); item_profile_accessName = tr("Game name: %1"); //: Some text to speech engines will spell out initials like MUD so stick to lower case if that is a better option item_profile_accessDesc = tr("Button to select a mud game to play, double-click it to connect and start playing it."); // Set up some initial black/white/greys: mCustomIconColors = {{QColor(0, 0, 0)}, {QColor(63, 63, 63)}, {QColor(128, 128, 128)}, {QColor(192, 192, 192)}, {QColor(255, 255, 255)}}; // Add some color ones with evenly spaced hue for (quint16 i = 0; i < 360; i += 24) { mCustomIconColors.append(QColor::fromHsv(i, 255, 255)); mCustomIconColors.append(QColor::fromHsv(i, 192, 255)); mCustomIconColors.append(QColor::fromHsv(i, 128, 255)); } mSearchTextTimer.setInterval(1s); mSearchTextTimer.setSingleShot(true); QCoreApplication::instance()->installEventFilter(this); connect(&mSearchTextTimer, &QTimer::timeout, this, &dlgConnectionProfiles::slot_reenableAllProfileItems); profile_history->view()->setTextElideMode(Qt::ElideNone); } dlgConnectionProfiles::~dlgConnectionProfiles() { // ~QDialog hides the dialog once this destructor is done, and the profile // name field reacts to losing the focus by emitting editingFinished() into // slot_saveName() when this object is no longer a valid receiver (#9574) utils::disconnectChildSignals(this); if (mPasswordSaveTimer) { mPasswordSaveTimer->stop(); } mPendingPasswordSaveProfile.clear(); // Clear any pending operation flags mKeychainOperationInProgress = false; mPendingProfileLoad.clear(); // Check if QCoreApplication is still valid during shutdown if (QCoreApplication::instance()) { QCoreApplication::instance()->removeEventFilter(this); } } // Restores the widgets that the first-launch tutorial invitation hides, so // every path out of the invitation (Skip button, New profile) leaves the // dialog in its regular state: void dlgConnectionProfiles::dismissTutorialInvitation() { mTutorialDismissed = true; if (!widget_topLeft->isHidden()) { // the invitation is not up, so there is nothing to restore - and the // resize below would make the dialog jump in size for no reason return; } widget_topLeft->show(); welcome_message->hide(); tabWidget_connectionInfo->show(); informationArea->show(); mpSkipToGamesButton->hide(); connect_button->show(); offline_button->show(); // The invitation shrank the dialog to fit its short message; size the // restored full interface from its own layout instead: resize(sizeHint().expandedTo(minimumSize())); } void dlgConnectionProfiles::slot_skipToGamesList() { dismissTutorialInvitation(); const auto items = findData(*listWidget_profiles, qsl("Mudlet Tutorial"), csmNameRole); if (!items.isEmpty()) { listWidget_profiles->setCurrentItem(items.first()); } } // the dialog can be accepted by pressing Enter on an qlineedit; this is a safeguard against it // accepting invalid data void dlgConnectionProfiles::accept() { if (validName && validUrl && validPort) { setVisible(false); // This is needed to make the above take effect as fast as possible: qApp->processEvents(); // Check if keychain authentication is pending - if so, wait for it ensurePasswordLoadedThenConnect(true); } } void dlgConnectionProfiles::slot_load() { setVisible(false); // This is needed to make the above take effect as fast as possible: qApp->processEvents(); // Check if keychain authentication is pending - if so, wait for it ensurePasswordLoadedThenConnect(false); } void dlgConnectionProfiles::ensurePasswordLoadedThenConnect(bool alsoConnect) { const QString profile_name = profile_name_entry->text().trimmed(); if (profile_name.isEmpty()) { QDialog::accept(); return; } // Check if we have any pending keychain operations for this profile if (hasPendingKeychainOperation(profile_name)) { // Queue the profile loading until keychain completes mPendingConnect = alsoConnect; mPendingProfileLoad = profile_name; return; // Will be handled by keychain callback } // No pending keychain operations, proceed immediately loadProfile(alsoConnect); QDialog::accept(); } bool dlgConnectionProfiles::hasPendingKeychainOperation(const QString& profile_name) const { Q_UNUSED(profile_name) // Simply check if we have a keychain operation in progress return mKeychainOperationInProgress; } void dlgConnectionProfiles::slot_updateDescription() { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (pItem) { const QString description = mud_description_textedit->toPlainText(); writeProfileData(pItem->data(csmNameRole).toString(), qsl("description"), description); // don't display custom profile descriptions as a tooltip, as passwords could be stored in there } } void dlgConnectionProfiles::indicatePackagesInstallOnConnect(QStringList packages) { if (packages.isEmpty()) { return; } QWidget widget; QGroupBox* packageGroupBox = new QGroupBox("Select and load a profile to install the following package(s) into:", this); QVBoxLayout* packageInfoLayout = new QVBoxLayout(packageGroupBox); packageInfoLayout->setContentsMargins(8, 8, 8, 8); packageGroupBox->setStyleSheet("QGroupBox:title { padding-left: 8px; }"); for (const QString& package : packages) { QFileInfo fileInfo(package); QString packageName = fileInfo.baseName(); QLabel* packageLabel = new QLabel(packageName); packageInfoLayout->addWidget(packageLabel); } layout()->addWidget(packageGroupBox); } // Not used: //void dlgConnectionProfiles::slot_updateWebsite(const QString& url) //{ // QListWidgetItem* pItem = listWidget_profiles->currentItem(); // if (pItem) { // writeProfileData(pItem->data(csmNameRole).toString(), qsl("website"), url); // } //} void dlgConnectionProfiles::slot_updatePassword(const QString& pass) { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (!pItem) { return; } const QString profileName = pItem->data(csmNameRole).toString(); if (mudlet::self()->storingPasswordsSecurely()) { if (pass.trimmed().isEmpty()) { // If password is empty, remove it from secure storage deleteSecurePassword(profileName); } else { // Store the password securely writeSecurePassword(profileName, pass); } } else { auto result = mudlet::self()->writeProfileData(profileName, qsl("password"), pass); if (!result.first) { qWarning().noquote().nospace() << "dlgConnectionProfiles::slot_updatePassword() ERROR - failed to save password for profile \"" << profileName << "\": " << result.second; } } } void dlgConnectionProfiles::writeSecurePassword(const QString& profile, const QString& pass) { // Validate that we have a password to store if (pass.trimmed().isEmpty()) { qDebug() << "dlgConnectionProfiles: Skipping storage of empty password for profile" << profile; return; } // Use async API for QtKeychain integration with file fallback auto* credManager = new CredentialManager(this); credManager->storePassword(profile, "character", pass, [credManager, profile](bool success, const QString& errorMessage) { if (success) { qDebug() << "dlgConnectionProfiles: Successfully stored password for profile" << profile; } else { qWarning() << "dlgConnectionProfiles: Failed to store password for profile" << profile << ":" << errorMessage; } // Clean up the credential manager credManager->deleteLater(); }); } void dlgConnectionProfiles::deleteSecurePassword(const QString& profile) { // Use async API for QtKeychain integration with file fallback auto* credManager = new CredentialManager(this); credManager->removePassword(profile, "character", [credManager, profile](bool success, const QString& errorMessage) { if (success) { qDebug() << "dlgConnectionProfiles: Successfully removed password for profile" << profile; } else { qWarning() << "dlgConnectionProfiles: Failed to remove password for profile" << profile << ":" << errorMessage; } // Clean up the credential manager credManager->deleteLater(); }); } void dlgConnectionProfiles::slot_updateLogin(const QString& login) { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (pItem) { const QString profileName = pItem->data(csmNameRole).toString(); auto result = mudlet::self()->writeProfileData(profileName, qsl("login"), login); if (!result.first) { qWarning().noquote().nospace() << "dlgConnectionProfiles::slot_updateLogin() ERROR - failed to save character name for profile \"" << profileName << "\": " << result.second; // Could optionally show user notification here } } } void dlgConnectionProfiles::slot_updateUrl(const QString& url) { if (url.isEmpty()) { validUrl = false; offline_button->setEnabled(false); connect_button->setEnabled(false); offline_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); connect_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); return; } if (validateProfile()) { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (!pItem) { return; } writeProfileData(pItem->data(csmNameRole).toString(), qsl("url"), host_name_entry->text()); } } void dlgConnectionProfiles::slot_updateAutoConnect(int state) { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (!pItem) { return; } writeProfileData(pItem->data(csmNameRole).toString(), qsl("autologin"), QString::number(state)); } void dlgConnectionProfiles::slot_updateAutoReconnect(int state) { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (!pItem) { return; } writeProfileData(pItem->data(csmNameRole).toString(), qsl("autoreconnect"), QString::number(state)); } void dlgConnectionProfiles::slot_updatePort(const QString& ignoreBlank) { const QString port = port_entry->text().trimmed(); if (ignoreBlank.isEmpty()) { validPort = false; if (offline_button) { offline_button->setEnabled(false); offline_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); } if (connect_button) { connect_button->setEnabled(false); connect_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); } return; } if (validateProfile()) { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (!pItem) { return; } writeProfileData(pItem->data(csmNameRole).toString(), qsl("port"), port); } } void dlgConnectionProfiles::slot_updateSslTslPort(int state) { if (validateProfile()) { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (!pItem) { return; } writeProfileData(pItem->data(csmNameRole).toString(), qsl("ssl_tsl"), QString::number(state)); } } void dlgConnectionProfiles::slot_updateName(const QString& newName) { Q_UNUSED(newName) validateProfile(); } void dlgConnectionProfiles::slot_saveName() { QListWidgetItem* pItem = listWidget_profiles->currentItem(); const QString newProfileName = profile_name_entry->text().trimmed(); const QString newProfileHost = host_name_entry->text().trimmed(); const QString newProfilePort = port_entry->text().trimmed(); const int newProfileSslTsl = port_ssl_tsl->isChecked() * 2; validateProfile(); if (!validName || newProfileName.isEmpty() || !pItem) { return; } const QString currentProfileEditName = pItem->data(csmNameRole).toString(); // don't do anything if this was just a normal click, and not an edit of any sort if (currentProfileEditName == newProfileName) { return; } // Check for orphaned keychain entries when creating a new profile with a name // that doesn't exist as a directory but might have keychain entries from // a previously deleted profile (deleted outside Mudlet interface) if (mudlet::self()->storingPasswordsSecurely() && currentProfileEditName == tr("new profile name") && !QDir(mudlet::getMudletPath(enums::profileHomePath, newProfileName)).exists()) { // Check if there are orphaned keychain entries for this profile name // Use QPointer to safely detect if dialog or credManager is destroyed during async operations // Create CredentialManager without a parent to avoid destruction when dialog closes QPointer safeThis = this; QPointer safeCredManager = new CredentialManager(nullptr); safeCredManager->retrievePassword( newProfileName, "character", [safeThis, safeCredManager, currentProfileEditName, newProfileName, newProfileHost, newProfilePort, newProfileSslTsl]( bool foundCharacterEntry, const QString& characterPassword, const QString& errorMessage) { Q_UNUSED(characterPassword) Q_UNUSED(errorMessage) // Check if credManager was destroyed if (!safeCredManager) { qWarning() << "dlgConnectionProfiles: CredentialManager destroyed during keychain operation, aborting"; return; } // Check if dialog was destroyed during async operation if (!safeThis) { qWarning() << "dlgConnectionProfiles: Dialog destroyed during keychain operation, aborting"; safeCredManager->deleteLater(); return; } safeCredManager->retrievePassword( newProfileName, "proxy", [safeThis, safeCredManager, currentProfileEditName, newProfileName, newProfileHost, newProfilePort, newProfileSslTsl, foundCharacterEntry]( bool foundProxyEntry, const QString& proxyPassword, const QString& errorMessage) { Q_UNUSED(proxyPassword) Q_UNUSED(errorMessage) // Check if credManager was destroyed if (!safeCredManager) { qWarning() << "dlgConnectionProfiles: CredentialManager destroyed during keychain operation, aborting"; return; } // Check if dialog was destroyed during async operation if (!safeThis) { qWarning() << "dlgConnectionProfiles: Dialog destroyed during keychain operation, aborting"; safeCredManager->deleteLater(); return; } // Define a helper lambda to continue after cleanup is done // CredentialManager only supports one operation at a time, so we must chain removals // Captures QPointers to safely detect if dialog or credManager has been destroyed // -- each must be checked for null before use auto continueAfterCleanup = [safeThis, safeCredManager, currentProfileEditName, newProfileName, newProfileHost, newProfilePort, newProfileSslTsl]() { // Clean up credManager if still valid if (safeCredManager) { safeCredManager->deleteLater(); } // Final safety check before accessing dialog members if (!safeThis) { qWarning() << "dlgConnectionProfiles: Dialog destroyed before continueProfileSave, aborting"; return; } // Find the current item by the old profile name instead of using a captured pointer auto items = safeThis->findData(*safeThis->listWidget_profiles, currentProfileEditName, csmNameRole); if (items.isEmpty()) { qWarning() << "dlgConnectionProfiles: Could not find profile item for" << currentProfileEditName << "after async operation"; return; } safeThis->continueProfileSave(items.first(), newProfileName, newProfileHost, newProfilePort, newProfileSslTsl); }; // If we found any orphaned entries, clean them up (chained to avoid lost callbacks) if (foundCharacterEntry && foundProxyEntry) { // Both need cleanup - chain them safeCredManager->removePassword(newProfileName, "character", [safeCredManager, newProfileName, continueAfterCleanup](bool success, const QString& errorMessage) { if (!success) { qWarning() << "dlgConnectionProfiles: Failed to clean up orphaned character password for" << newProfileName << ":" << errorMessage; } // Check credManager before chaining next operation if (!safeCredManager) { qWarning() << "dlgConnectionProfiles: CredentialManager destroyed, cannot clean up proxy password"; continueAfterCleanup(); return; } // Now clean up proxy (chained) safeCredManager->removePassword(newProfileName, "proxy", [newProfileName, continueAfterCleanup](bool proxySuccess, const QString& proxyError) { if (!proxySuccess) { qWarning() << "dlgConnectionProfiles: Failed to clean up orphaned proxy password for" << newProfileName << ":" << proxyError; } continueAfterCleanup(); }); }); } else if (foundCharacterEntry) { safeCredManager->removePassword(newProfileName, "character", [newProfileName, continueAfterCleanup](bool success, const QString& errorMessage) { if (!success) { qWarning() << "dlgConnectionProfiles: Failed to clean up orphaned character password for" << newProfileName << ":" << errorMessage; } continueAfterCleanup(); }); } else if (foundProxyEntry) { safeCredManager->removePassword(newProfileName, "proxy", [newProfileName, continueAfterCleanup](bool success, const QString& errorMessage) { if (!success) { qWarning() << "dlgConnectionProfiles: Failed to clean up orphaned proxy password for" << newProfileName << ":" << errorMessage; } continueAfterCleanup(); }); } else { // No cleanup needed continueAfterCleanup(); } }); }); return; // Exit here - continueProfileSave will be called from the callback } if (mudlet::self()->storingPasswordsSecurely()) { migrateSecuredPassword(currentProfileEditName, newProfileName); } continueProfileSave(pItem, newProfileName, newProfileHost, newProfilePort, newProfileSslTsl); } void dlgConnectionProfiles::continueProfileSave(QListWidgetItem* pItem, const QString& newProfileName, const QString& newProfileHost, const QString& newProfilePort, const int newProfileSslTsl) { const QString currentProfileEditName = pItem->data(csmNameRole).toString(); setItemName(pItem, newProfileName); const QDir currentPath(mudlet::getMudletPath(enums::profileHomePath, currentProfileEditName)); const QDir dir; if (currentPath.exists()) { // CHECKME: previous code specified a path ending in a '/' QDir parentpath(mudlet::getMudletPath(enums::profilesPath)); if (!parentpath.rename(currentProfileEditName, newProfileName)) { notificationArea->show(); notificationAreaIconLabelWarning->show(); notificationAreaIconLabelError->hide(); notificationAreaIconLabelInformation->hide(); notificationAreaMessageBox->show(); notificationAreaMessageBox->setText(tr("Could not rename your profile data on the computer.")); } } else if (!dir.mkpath(mudlet::getMudletPath(enums::profileHomePath, newProfileName))) { notificationArea->show(); notificationAreaIconLabelWarning->show(); notificationAreaIconLabelError->hide(); notificationAreaIconLabelInformation->hide(); notificationAreaMessageBox->show(); notificationAreaMessageBox->setText(tr("Could not create the new profile folder on your computer.")); } if (!newProfileHost.isEmpty()) { slot_updateUrl(newProfileHost); } if (!newProfilePort.isEmpty()) { slot_updatePort(newProfilePort); } slot_updateSslTslPort(newProfileSslTsl); // if this was a previously deleted profile, restore it auto& settings = *mudlet::self()->mpSettings; auto deletedDefaultMuds = settings.value(qsl("deletedDefaultMuds"), QStringList()).toStringList(); if (deletedDefaultMuds.contains(newProfileName)) { deletedDefaultMuds.removeOne(newProfileName); settings.setValue(qsl("deletedDefaultMuds"), deletedDefaultMuds); // run fillout_form to re-create the default profile icon and description fillout_form(); // and re-select the profile since focus is lost auto pRestoredItems = findData(*listWidget_profiles, newProfileName, csmNameRole); Q_ASSERT_X(pRestoredItems.count() == 1, "dlgConnectionProfiles::continueProfileSave", "Couldn't find exactly 1 restored profile to select"); // As we are using QAbstractItemView::SingleSelection this will // automatically unselect the previous item: listWidget_profiles->setCurrentItem(pRestoredItems.first()); slot_itemClicked(pRestoredItems.first()); } else { setItemName(pItem, newProfileName); pItem->setIcon(customIcon(newProfileName, std::nullopt)); } } bool dlgConnectionProfiles::showingOnlyMyProfiles() const { // the tab bar is hidden for dedicated single-game builds, which list their // own game(s) unfiltered return !mpTabBar->isHidden() && mpTabBar->currentIndex() == scmMyGamesTab; } void dlgConnectionProfiles::slot_activeTabChanged(const int index) { mudlet::self()->mpSettings->setValue(qsl("connectionDialogActiveTab"), index); const auto* pCurrentItem = listWidget_profiles->currentItem(); const QString previousSelection = pCurrentItem ? pCurrentItem->data(csmNameRole).toString() : QString(); fillout_form(); if (previousSelection.isEmpty()) { return; } // keep the same game selected if the newly shown tab also lists it, // otherwise the automatic selection made by fillout_form() stands const auto pPreviousItems = findData(*listWidget_profiles, previousSelection, csmNameRole); if (!pPreviousItems.isEmpty()) { listWidget_profiles->setCurrentItem(pPreviousItems.first()); } } // On a fresh install with no saved profiles the dialog shows the welcome // message in place of the connection details; swap them back in and undo the // shrink that was applied to fit the welcome message. void dlgConnectionProfiles::revealConnectionDetails() { if (mProgrammaticProfileSelection || welcome_message->isHidden()) { return; } welcome_message->hide(); tabWidget_connectionInfo->show(); informationArea->show(); if (mDialogHeightBeforeShrink > height()) { resize(width(), mDialogHeightBeforeShrink); } } void dlgConnectionProfiles::slot_addProfile() { profile_name_entry->setReadOnly(false); // while normally handled by fillout_form, due to it's asynchronous nature it is better UX to reset it here // Block signals to prevent triggering password save for the previously selected profile { const QSignalBlocker blocker(character_password_entry); character_password_entry->setText(QString()); } dismissTutorialInvitation(); fillout_form(); revealConnectionDetails(); const QString newname = tr("new profile name"); auto pItem = new (std::nothrow) QListWidgetItem(); if (!pItem) { return; } setItemName(pItem, newname); // without an icon the item is an invisible blank in the list pItem->setIcon(customIcon(newname, std::nullopt)); // insert the new entry at the top of the list - appending would bury it // at the bottom, below all the predefined games listWidget_profiles->insertItem(0, pItem); // As we are using QAbstractItemView::SingleSelection this will // automatically unselect the previous item, and auto-scroll brings the // new item into view: listWidget_profiles->setCurrentItem(pItem); profile_name_entry->setText(newname); profile_name_entry->setFocus(); profile_name_entry->selectAll(); profile_name_entry->setReadOnly(false); host_name_entry->setReadOnly(false); port_entry->setReadOnly(false); validName = false; validUrl = false; validPort = false; offline_button->setEnabled(false); offline_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); connect_button->setEnabled(false); connect_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); } void dlgConnectionProfiles::showRemovalProblem(const QString& message) { notificationArea->show(); notificationAreaIconLabelWarning->show(); notificationAreaIconLabelError->hide(); notificationAreaIconLabelInformation->hide(); notificationAreaMessageBox->show(); notificationAreaMessageBox->setText(message); } void dlgConnectionProfiles::reallyDeleteProfile(const QString& profile) { const QString profilesPath = mudlet::getMudletPath(enums::profilesPath); const QString profileFolder = profileFolderPath(profilesPath, profile); if (profileFolder.isEmpty()) { qWarning().nospace() << "dlgConnectionProfiles::reallyDeleteProfile(\"" << profile << "\") ERROR - refusing to delete: that name does not address a folder inside \"" << profilesPath << "\"."; // rebuild the list first: it re-selects a profile, and that clears the // notification area on its way through validateProfile() fillout_form(); //: %1 is a profile name that does not name a folder of its own, so there is nothing that could be removed for it showRemovalProblem(tr("'%1' has no profile folder of its own, so there is nothing to remove.").arg(profile)); return; } QDir dir(profileFolder); if (!dir.removeRecursively()) { // the profile is still on disk, so its password and its list entry stay: // removing either would strand the data that is left qWarning().nospace() << "dlgConnectionProfiles::reallyDeleteProfile(\"" << profile << "\") ERROR - could not completely remove \"" << profileFolder << "\"."; fillout_form(); //: %1 is a profile name. Shown when some of the profile's files could not be deleted, e.g. because another program has them open showRemovalProblem(tr("Could not remove everything belonging to '%1'. Close it if it is open elsewhere, check that you may write to its folder, and try again.").arg(profile)); return; } // Clean up keychain entries for the deleted profile // Note: CredentialManager only supports one operation at a time, so we must // chain the operations - the second removal starts only after the first completes. // This prevents lost callbacks from aborting in-progress keychain operations. // Crash prevention comes from parentless CredentialManager + QPointer guards. // Create CredentialManager without a parent to avoid destruction when dialog closes if (mudlet::self()->storingPasswordsSecurely()) { QPointer safeCredManager = new CredentialManager(nullptr); // Clean up character password entry first, then chain proxy cleanup safeCredManager->removePassword(profile, "character", [safeCredManager, profile](bool success, const QString& errorMessage) { if (!success) { qWarning() << "dlgConnectionProfiles: Failed to clean up character password for deleted profile" << profile << ":" << errorMessage; } // Check if credManager was destroyed before chaining next operation if (!safeCredManager) { qWarning() << "dlgConnectionProfiles: CredentialManager destroyed, cannot clean up proxy password"; return; } // Now clean up proxy password entry (chained after character password removal completes) safeCredManager->removePassword(profile, "proxy", [safeCredManager, profile](bool proxySuccess, const QString& proxyErrorMessage) { if (!proxySuccess) { qWarning() << "dlgConnectionProfiles: Failed to clean up proxy password for deleted profile" << profile << ":" << proxyErrorMessage; } // Clean up the credential manager after both operations complete (if still valid) if (safeCredManager) { safeCredManager->deleteLater(); } }); }); } // record the deletion; the games catalog deliberately ignores this list // now - only the self-test entry in fillout_form() still honours it, and // continueProfileSave() clears the entry on profile re-creation auto& settings = *mudlet::self()->mpSettings; auto deletedDefaultMuds = settings.value(qsl("deletedDefaultMuds"), QStringList()).toStringList(); if (!deletedDefaultMuds.contains(profile)) { deletedDefaultMuds.append(profile); } settings.setValue(qsl("deletedDefaultMuds"), deletedDefaultMuds); fillout_form(); listWidget_profiles->setFocus(); } // called when the 'delete' button is pressed, raises a dialog to confirm deletion // if this profile has been used void dlgConnectionProfiles::slot_deleteProfile() { if (!listWidget_profiles->currentItem()) { return; } const QString profile = listWidget_profiles->currentItem()->data(csmNameRole).toString(); const QStringList& onlyShownPredefinedProfiles{mudlet::self()->mOnlyShownPredefinedProfiles}; if (!onlyShownPredefinedProfiles.isEmpty() && onlyShownPredefinedProfiles.contains(profile)) { // Do NOT allow deletion of the prioritised predefined MUD: return; } const QDir profileDir(mudlet::getMudletPath(enums::profileHomePath, profile)); bool nothingToLose = !profileDir.exists() || profileDir.entryList(QDir::Dirs | QDir::Hidden | QDir::NoDotAndDotDot).isEmpty(); if (nothingToLose) { for (const QString& fileName : profileDir.entryList(QDir::Files | QDir::Hidden)) { if (!scmConnectionDetailFiles.contains(fileName)) { nothingToLose = false; break; } } } if (nothingToLose) { reallyDeleteProfile(profile); return; } QUiLoader loader; QFile file(qsl(":/ui/delete_profile_confirmation.ui")); if (!file.open(QFile::ReadOnly)) { qWarning() << "dlgConnectionProfiles: failed to open UI file for reading:" << file.errorString(); return; } auto* delete_profile_dialog = dynamic_cast(loader.load(&file, this)); file.close(); if (!delete_profile_dialog) { qWarning() << "dlgConnectionProfiles::slot_deleteProfile() ERROR - the deletion confirmation did not load as a dialog."; //: %1 is a profile name. Shown when the dialog asking the user to confirm a removal could not be built showRemovalProblem(tr("Could not open the confirmation, so '%1' has not been removed.").arg(profile)); return; } auto* nameEntry = delete_profile_dialog->findChild(qsl("delete_profile_lineedit")); auto* deleteButton = delete_profile_dialog->findChild(qsl("delete_button")); auto* cancelButton = delete_profile_dialog->findChild(qsl("cancel_button")); if (!nameEntry || !deleteButton || !cancelButton) { qWarning() << "dlgConnectionProfiles::slot_deleteProfile() ERROR - the deletion confirmation is missing one of its widgets."; showRemovalProblem(tr("Could not open the confirmation, so '%1' has not been removed.").arg(profile)); delete delete_profile_dialog; return; } // The confirmation is not modal, so by the time it is answered the selection // may have moved on, or a second confirmation may be open alongside it: connect(nameEntry, &QLineEdit::textChanged, delete_profile_dialog, [deleteButton, profile](const QString& text) { deleteButton->setEnabled(text == profile); if (deleteButton->isEnabled()) { deleteButton->setFocus(); } }); connect(delete_profile_dialog, &QDialog::accepted, this, [this, profile]() { reallyDeleteProfile(profile); }); nameEntry->setPlaceholderText(profile); nameEntry->setFocus(); deleteButton->setEnabled(false); delete_profile_dialog->setWindowTitle(tr("Deleting '%1'").arg(profile)); delete_profile_dialog->setAttribute(Qt::WA_DeleteOnClose); delete_profile_dialog->show(); delete_profile_dialog->raise(); } QString dlgConnectionProfiles::readProfileData(const QString& profile, const QString& item) const { QFile file(mudlet::getMudletPath(enums::profileDataItemPath, profile, item)); const bool success = file.open(QIODevice::ReadOnly); QString ret; if (success) { QDataStream ifs(&file); if (mudlet::scmRunTimeQtVersion >= QVersionNumber(5, 13, 0)) { ifs.setVersion(mudlet::scmQDataStreamFormat_5_12); } ifs >> ret; file.close(); } return ret; } // A new item here may need adding to scmConnectionDetailFiles above. Unlike // mudlet::writeProfileData() this does not create the profile's folder, so a // write before there is one is quietly dropped. QPair dlgConnectionProfiles::writeProfileData(const QString& profile, const QString& item, const QString& what) { QSaveFile file(mudlet::getMudletPath(enums::profileDataItemPath, profile, item)); if (file.open(QIODevice::WriteOnly | QIODevice::Unbuffered)) { QDataStream ofs(&file); if (mudlet::scmRunTimeQtVersion >= QVersionNumber(5, 13, 0)) { ofs.setVersion(mudlet::scmQDataStreamFormat_5_12); } ofs << what; if (!file.commit()) { qDebug().noquote().nospace() << "dlgConnectionProfiles::writeProfileData(...) ERROR - writing profile: \"" << profile << "\", item: \"" << item << "\", reason: \"" << file.errorString() << "\"."; } } if (file.error() == QFileDevice::NoError) { return {true, QString()}; } return {false, file.errorString()}; } QString dlgConnectionProfiles::getDescription(const QString& profile_name) const { QString profileDesc = readProfileData(profile_name, qsl("description")); if (profileDesc.isEmpty()) { auto itDetails = TGameDetails::findGame(profile_name); if (itDetails != TGameDetails::scmDefaultGames.constEnd()) { if (!(*itDetails).description.isEmpty()) { return (*itDetails).description; } } } return profileDesc; } void dlgConnectionProfiles::slot_itemClicked(QListWidgetItem* pItem) { if (!pItem) { qDebug() << "dlgConnectionProfiles::slot_itemClicked() called with null item"; return; } // on a fresh install picking a game has to swap the welcome message for // the connection details, just as creating a new profile does revealConnectionDetails(); const QString profile_name = pItem->data(csmNameRole).toString(); // Prevent rapid duplicate clicks on the same profile static QString lastProfileClicked; static QTime lastClickTime; if (profile_name == lastProfileClicked && lastClickTime.isValid() && lastClickTime.msecsTo(QTime::currentTime()) < 100) { return; } lastProfileClicked = profile_name; lastClickTime = QTime::currentTime(); slot_togglePasswordVisibility(false); welcome_message->hide(); tabWidget_connectionInfo->show(); informationArea->show(); profile_name_entry->setText(profile_name); QString host_url = readProfileData(profile_name, qsl("url")); if (host_url.isEmpty()) { // Host to connect to, see below for port auto it = TGameDetails::findGame(profile_name); if (it != TGameDetails::scmDefaultGames.end()) { host_url = (*it).hostUrl; } } host_name_entry->setText(host_url); QString host_port = readProfileData(profile_name, qsl("port")); QString val = readProfileData(profile_name, qsl("ssl_tsl")); if (val.toInt() == Qt::Checked) { port_ssl_tsl->setChecked(true); } else { port_ssl_tsl->setChecked(false); } if (host_port.isEmpty()) { auto it = TGameDetails::findGame(profile_name); if (it != TGameDetails::scmDefaultGames.end()) { host_port = QString::number((*it).port); port_ssl_tsl->setChecked((*it).tlsEnabled); } } port_entry->setText(host_port); // if we're currently copying a profile, don't blank and re-load the password, // because there isn't one in storage yet. It'll be copied over into the widget // by the copy method if (!mCopyingProfile) { // Cancel any pending password save from the previous profile to prevent // cross-profile password corruption when rapidly switching profiles if (mPasswordSaveTimer) { mPasswordSaveTimer->stop(); } mPendingPasswordSaveProfile.clear(); // Block signals when clearing to prevent triggering a save for the wrong profile { const QSignalBlocker blocker(character_password_entry); character_password_entry->setText(QString()); } // Schedule password loading asynchronously to avoid event loop issues auto* timer = new QTimer(this); timer->setSingleShot(true); timer->setProperty("profileName", profile_name); connect(timer, &QTimer::timeout, this, &dlgConnectionProfiles::slot_loadPasswordAsync); timer->start(0ms); } val = readProfileData(profile_name, qsl("login")); login_entry->setText(val); val = readProfileData(profile_name, qsl("autologin")); if (val.toInt() == Qt::Checked) { autologin_checkBox->setChecked(true); } else { autologin_checkBox->setChecked(false); } val = readProfileData(profile_name, qsl("autoreconnect")); if (!val.isEmpty() && val.toInt() == Qt::Checked) { auto_reconnect->setChecked(true); } else { auto_reconnect->setChecked(false); } mDiscordApplicationId = readProfileData(profile_name, qsl("discordApplicationId")); mDiscordInviteURL = readProfileData(profile_name, qsl("discordInviteURL")); mud_description_textedit->setPlainText(getDescription(profile_name)); val = readProfileData(profile_name, qsl("website")); if (val.isEmpty()) { auto it = TGameDetails::findGame(profile_name); if (it != TGameDetails::scmDefaultGames.end()) { val = (*it).websiteInfo; } website_entry->setVisible(!val.isEmpty()); } else { website_entry->show(); } website_entry->setText(val); profile_history->clear(); QDir dir(mudlet::getMudletPath(enums::profileXmlFilesPath, profile_name)); dir.setSorting(QDir::Time); // Only offer real profile saves (*.xml) as history entries; leftover QSaveFile // temporaries from an interrupted save (e.g. "....xml.AbCdEf") must not be loadable const QStringList entries = dir.entryList(QStringList{qsl("*.xml")}, QDir::Files | QDir::NoDotAndDotDot, QDir::Time); for (const auto& entry : entries) { const QRegularExpression rx(qsl("(\\d+)\\-(\\d+)\\-(\\d+)#(\\d+)\\-(\\d+)\\-(\\d+).xml")); const QRegularExpressionMatch match = rx.match(entry); if (match.capturedStart() != -1) { QString day; const QString month = match.captured(2); QString year; const QString hour = match.captured(4); const QString minute = match.captured(5); const QString second = match.captured(6); if (match.captured(1).toInt() > 31 && match.captured(3).toInt() >= 1 && match.captured(3).toInt() <= 31) { // I have been experimenting with code that puts the year first // which is actually quite useful - this accommodates such cases // as well... - SlySven year = match.captured(1); day = match.captured(3); } else { day = match.captured(1); year = match.captured(3); } QDateTime datetime; datetime.setTime(QTime(hour.toInt(), minute.toInt(), second.toInt())); datetime.setDate(QDate(year.toInt(), month.toInt(), day.toInt())); profile_history->addItem(mudlet::self()->getUserLocale().toString(datetime, mDateTimeFormat), QVariant(entry)); } else if (entry == QLatin1String("autosave.xml")) { const QFileInfo fileInfo(dir, entry); auto lastModified = fileInfo.lastModified(); profile_history->addItem( QIcon::fromTheme(qsl("document-save"), QIcon(qsl(":/icons/document-save.png"))), mudlet::self()->getUserLocale().toString(lastModified, mDateTimeFormat), QVariant(entry)); } else if (entry.endsWith(QLatin1String(".xml"), Qt::CaseInsensitive)) { profile_history->addItem(entry, QVariant(entry)); // if it has a custom name, use it as it is } } profile_history->setEnabled(static_cast(profile_history->count())); const QString profileLoadedMessage = tr("This profile is currently loaded - close it before changing the connection parameters."); if (mudlet::self()->getHostManager().getHost(profile_name)) { remove_profile_button->setEnabled(false); remove_profile_button->setToolTip(utils::richText(tr("A profile that is in use cannot be removed"))); connect_button->setEnabled(false); offline_button->setEnabled(false); profile_name_entry->setReadOnly(true); host_name_entry->setReadOnly(true); port_entry->setReadOnly(true); profile_name_entry->setFocusPolicy(Qt::NoFocus); host_name_entry->setFocusPolicy(Qt::NoFocus); port_entry->setFocusPolicy(Qt::NoFocus); profile_name_entry->setPalette(mReadOnlyPalette); host_name_entry->setPalette(mReadOnlyPalette); port_entry->setPalette(mReadOnlyPalette); notificationArea->show(); notificationAreaIconLabelWarning->hide(); notificationAreaIconLabelError->hide(); notificationAreaIconLabelInformation->show(); notificationAreaMessageBox->show(); notificationAreaMessageBox->setText(profileLoadedMessage); } else { profile_name_entry->setReadOnly(false); host_name_entry->setReadOnly(false); port_entry->setReadOnly(false); profile_name_entry->setFocusPolicy(Qt::StrongFocus); host_name_entry->setFocusPolicy(Qt::StrongFocus); port_entry->setFocusPolicy(Qt::StrongFocus); profile_name_entry->setPalette(mRegularPalette); host_name_entry->setPalette(mRegularPalette); port_entry->setPalette(mRegularPalette); if (notificationAreaMessageBox->text() == profileLoadedMessage) { clearNotificationArea(); } remove_profile_button->setEnabled(true); remove_profile_button->setToolTip(QString()); } } // (re-)creates the dialogs profile list void dlgConnectionProfiles::fillout_form() { listWidget_profiles->clear(); profile_name_entry->clear(); host_name_entry->clear(); port_entry->clear(); mProfileList = QDir(mudlet::getMudletPath(enums::profilesPath)).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); // mProfileList gains non-disk entries (e.g. the QT_DEBUG-only self-test // profile) further down, so capture whether the user has any saved // profiles while it still only holds the on-disk ones: const bool noSavedProfiles = mProfileList.isEmpty(); if (noSavedProfiles) { // remember the height so revealConnectionDetails() can undo the // shrink below, but not when the welcome message is already up as the // dialog is then already shrunken if (!mDialogHeightBeforeShrink || welcome_message->isHidden()) { mDialogHeightBeforeShrink = height(); } // hide before show: with both visible for a moment the layout grows // the dialog to fit them together and it never shrinks back tabWidget_connectionInfo->hide(); informationArea->hide(); welcome_message->show(); } else { welcome_message->hide(); tabWidget_connectionInfo->show(); informationArea->show(); } listWidget_profiles->setIconSize(QSize(120, 30)); QString description; QListWidgetItem* pItem; const QStringList& onlyShownPredefinedProfiles{mudlet::self()->mOnlyShownPredefinedProfiles}; const bool showOnlyMyProfiles = showingOnlyMyProfiles(); const QString selfTestProfile = qsl("Mudlet self-test"); const auto deletedDefaultMuds = mudlet::self()->mpSettings->value(qsl("deletedDefaultMuds"), QStringList()).toStringList(); if (onlyShownPredefinedProfiles.isEmpty()) { const auto defaultGames = TGameDetails::keys(); // "My games" only lists games with profile data on disk; "All games" // must keep offering every pre-installed game, even ones whose // profile was deleted (recorded in deletedDefaultMuds). The self-test // entry is the exception: it is a testing aid rather than a game, and // is offered even without profile data on disk, so dismissing it has // to keep it out of both tabs for (auto& game : defaultGames) { if (game == selfTestProfile && deletedDefaultMuds.contains(game)) { continue; } if (showOnlyMyProfiles && !mProfileList.contains(game, Qt::CaseInsensitive)) { continue; } pItem = new QListWidgetItem(); auto details = TGameDetails::findGame(game); setupMudProfile(pItem, game, (*details).description, (*details).icon); } #if defined(QT_DEBUG) if (!deletedDefaultMuds.contains(selfTestProfile) && !mProfileList.contains(selfTestProfile)) { mProfileList.append(selfTestProfile); // "All games" already listed it from TGameDetails above, only // "My games" is still missing an entry: if (findData(*listWidget_profiles, selfTestProfile, csmNameRole).isEmpty()) { pItem = new QListWidgetItem(); // Can't use setupMudProfile(...) here as we do not set the icon in the same way: setItemName(pItem, selfTestProfile); listWidget_profiles->addItem(pItem); description = getDescription(qsl("mudlet.org")); if (!description.isEmpty()) { pItem->setToolTip(utils::richText(description)); } } } #endif } else { for (const QString& onlyShownPredefinedProfile : onlyShownPredefinedProfiles) { pItem = new QListWidgetItem(); auto details = TGameDetails::findGame(onlyShownPredefinedProfile); setupMudProfile(pItem, onlyShownPredefinedProfile, (*details).description, (*details).icon); } } setProfileIcon(); QDateTime test_date; QString toselectProfileName; int toselectRow = -1; int test_profile_row = -1; int predefined_profile_row = -1; bool firstMudletLaunch = true; for (int i = 0; i < listWidget_profiles->count(); i++) { const auto profile = listWidget_profiles->item(i); const auto profileName = profile->data(csmNameRole).toString(); if (profileName == qsl("Mudlet self-test")) { test_profile_row = i; } const auto fileinfo = QFileInfo(mudlet::getMudletPath(enums::profileXmlFilesPath, profileName)); if (fileinfo.exists()) { firstMudletLaunch = false; const QDateTime profile_lastRead = fileinfo.lastModified(); // Since Qt 5.x null QTimes and QDateTimes are invalid - and might not // work as expected - so test for validity of the test_date value as well if ((!test_date.isValid()) || profile_lastRead > test_date) { test_date = profile_lastRead; toselectProfileName = profileName; toselectRow = i; } } if (!onlyShownPredefinedProfiles.isEmpty() && profileName == onlyShownPredefinedProfiles.first()) { predefined_profile_row = i; } } if (firstMudletLaunch) { if (onlyShownPredefinedProfiles.isEmpty()) { // Select the tutorial profile on first launch for (int i = 0; i < listWidget_profiles->count(); i++) { if (listWidget_profiles->item(i)->data(csmNameRole).toString() == qsl("Mudlet Tutorial")) { toselectRow = i; break; } } if (listWidget_profiles->count() == 1 && test_profile_row != 0) { // The "My games" tab can show a single profile that has not been // saved to its XML yet, so select it to fill in its details // instead of leaving the form blank with a game highlighted toselectRow = 0; } } else if (predefined_profile_row >= 0) { // If the user is starting one of a MUD's "dedicated" Mudlet versions then // select the first of THAT/THOSE predefined one(s) on first launch: toselectRow = predefined_profile_row; } } if (toselectRow != -1) { // this automatic selection must not be taken for the user picking a // game, which would dismiss the welcome message shown above mProgrammaticProfileSelection = true; listWidget_profiles->setCurrentRow(toselectRow); mProgrammaticProfileSelection = false; } // Dedicated single-game builds go straight to their game's profile instead // of the Mudlet tutorial invitation: if (firstMudletLaunch && noSavedProfiles && !mTutorialDismissed && onlyShownPredefinedProfiles.isEmpty()) { // Hide the profile list and show only the tutorial-focused welcome widget_topLeft->hide(); welcome_message->show(); tabWidget_connectionInfo->hide(); informationArea->hide(); connect_button->hide(); offline_button->hide(); mpSkipToGamesButton->show(); adjustSize(); } } void dlgConnectionProfiles::setProfileIcon() const { const QStringList defaultGames = TGameDetails::keys(); for (const QString& profileName : mProfileList) { if (profileName.isEmpty()) { continue; } if (hasCustomIcon(profileName)) { loadCustomProfile(profileName); } else { // mProfileList is derived from a filesystem directory, but MacOS is not // necessarily case preserving for file names so any tests on them // should be case insensitive // skip creating icons for default MUDs as they are already created above if (defaultGames.contains(profileName, Qt::CaseInsensitive)) { continue; } // This will instantiate a new QListWidgetItem for the profile: generateCustomProfile(profileName); } } } bool dlgConnectionProfiles::hasCustomIcon(const QString& profileName) const { return QFileInfo::exists(mudlet::getMudletPath(enums::profileDataItemPath, profileName, qsl("profileicon"))); } void dlgConnectionProfiles::loadCustomProfile(const QString& profileName) const { auto pItem = new QListWidgetItem(); setItemName(pItem, profileName); setCustomIcon(profileName, pItem); auto description = getDescription(profileName); if (!description.isEmpty()) { pItem->setToolTip(utils::richText(description)); } listWidget_profiles->addItem(pItem); } void dlgConnectionProfiles::setCustomIcon(const QString& profileName, QListWidgetItem* profile) const { auto profileIconPath = mudlet::getMudletPath(enums::profileDataItemPath, profileName, qsl("profileicon")); auto icon = QIcon(QPixmap(profileIconPath).scaled(QSize(120, 30), Qt::IgnoreAspectRatio, Qt::SmoothTransformation).copy()); profile->setIcon(icon); } // When a profile is renamed, migrate password storage to the new profile void dlgConnectionProfiles::migrateSecuredPassword(const QString& oldProfile, const QString& newProfile) { const auto& password = character_password_entry->text().trimmed(); deleteSecurePassword(oldProfile); // Only store the password if it's not empty if (!password.isEmpty()) { writeSecurePassword(newProfile, password); } } template void dlgConnectionProfiles::loadSecuredPassword(const QString& profile, L callback) { // Use async API for QtKeychain integration with file fallback auto* credManager = new CredentialManager(this); credManager->retrievePassword(profile, "character", [credManager, callback = std::move(callback)](bool success, const QString& password, const QString& errorMessage) { if (success) { callback(password); QString passwordCopy = password; // Make a copy for secure clearing SecureStringUtils::secureStringClear(passwordCopy); } else { if (!errorMessage.isEmpty()) { qDebug() << "dlgConnectionProfiles: Failed to retrieve password:" << errorMessage; } callback(QString()); // Call with empty string on failure } // Clean up the credential manager credManager->deleteLater(); }); } std::optional getCustomColor(const QString& profileName) { auto profileColorPath = mudlet::getMudletPath(enums::profileDataItemPath, profileName, qsl("profilecolor")); if (QFileInfo::exists(profileColorPath)) { QFile file(profileColorPath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return std::nullopt; } QTextStream in(&file); const QString colorString = in.readLine(); QColor color(colorString); if (color.isValid()) { return {color}; } } return std::nullopt; } void dlgConnectionProfiles::generateCustomProfile(const QString& profileName) const { auto pItem = new QListWidgetItem(); setItemName(pItem, profileName); pItem->setIcon(customIcon(profileName, getCustomColor(profileName))); listWidget_profiles->addItem(pItem); } // fillout_form() destroys and rebuilds every item, so callers that have let the // event loop run cannot hold on to one. void dlgConnectionProfiles::setIconOfListedProfile(const QString& profileName, const QIcon& icon) const { const auto pItems = findData(*listWidget_profiles, profileName, csmNameRole); if (pItems.isEmpty()) { return; } pItems.first()->setIcon(icon); } // Empty when nothing is selected. The context-menu actions re-check rather than // trust slot_profileContextMenu(): menu.exec() runs a nested event loop, and the // profile-copy completion handler calls fillout_form() from it, which can clear // the selection - or leave a different profile current, in which case the action // still mis-targets. Only the crash of acting on nothing is handled here. QString dlgConnectionProfiles::selectedProfileName() const { const auto* pItem = listWidget_profiles->currentItem(); return pItem ? pItem->data(csmNameRole).toString() : QString(); } void dlgConnectionProfiles::slot_profileContextMenu(QPoint pos) { // "My games" on a fresh install lists nothing, so nothing is current const auto profileName = selectedProfileName(); if (profileName.isEmpty()) { return; } const QPoint globalPos = listWidget_profiles->mapToGlobal(pos); QMenu menu; if (hasCustomIcon(profileName)) { //: Reset the custom picture for this profile in the connection dialog and show the default one instead menu.addAction(tr("Reset icon"), this, &dlgConnectionProfiles::slot_resetCustomIcon); } else { menu.addAction(QIcon(":/icons/mudlet_main_16px.png"), //: Set a custom picture to show for the profile in the connection dialog tr("Set custom icon"), this, &dlgConnectionProfiles::slot_setCustomIcon); menu.addAction(QIcon(":/icons/mudlet_main_16px.png"), //: Set a custom color to show for the profile in the connection dialog tr("Set custom color"), this, &dlgConnectionProfiles::slot_setCustomColor); } menu.exec(globalPos); } void dlgConnectionProfiles::slot_setCustomIcon() { const auto profileName = selectedProfileName(); if (profileName.isEmpty()) { return; } QSettings& settings = *mudlet::getQSettings(); QString lastDir = settings.value("lastFileDialogLocation", QDir::homePath()).toString(); const QString imageLocation = QFileDialog::getOpenFileName(this, tr("Select custom image for profile (should be 120x30)"), lastDir, tr("Images (%1)").arg(qsl("*.png *.gif *.jpg"))); if (imageLocation.isEmpty()) { return; } lastDir = QFileInfo(imageLocation).absolutePath(); settings.setValue("lastFileDialogLocation", lastDir); const bool success = mudlet::self()->setProfileIcon(profileName, imageLocation).first; if (!success) { return; } auto icon = QIcon(QPixmap(imageLocation).scaled(QSize(120, 30), Qt::IgnoreAspectRatio, Qt::SmoothTransformation).copy()); // the file dialog ran a nested event loop, so the current item may have moved setIconOfListedProfile(profileName, icon); } void dlgConnectionProfiles::slot_setCustomColor() { const auto profileName = selectedProfileName(); if (profileName.isEmpty()) { return; } QColor color = QColorDialog::getColor(getCustomColor(profileName).value_or(QColor(255, 255, 255))); if (color.isValid()) { auto profileColorPath = mudlet::getMudletPath(enums::profileDataItemPath, profileName, qsl("profilecolor")); QSaveFile file(profileColorPath); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { qWarning() << "dlgConnectionProfiles: failed to open profile color file for writing:" << file.errorString(); return; } auto colorName = color.name(); file.write(colorName.toUtf8(), colorName.length()); if (!file.commit()) { qDebug() << "dlgConnectionProfiles::slot_setCustomColor: error saving custom icon color: " << file.errorString(); } setIconOfListedProfile(profileName, customIcon(profileName, {color})); } } void dlgConnectionProfiles::slot_resetCustomIcon() { const auto profileName = selectedProfileName(); if (profileName.isEmpty()) { return; } const bool success = mudlet::self()->resetProfileIcon(profileName).first; if (!success) { return; } auto currentRow = listWidget_profiles->currentRow(); fillout_form(); listWidget_profiles->setCurrentRow(currentRow); } void dlgConnectionProfiles::slot_cancel() { // QDialog::Rejected is the enum value (= 0) return value for a "cancelled" // outcome... QDialog::done(QDialog::Rejected); } void dlgConnectionProfiles::slot_copyProfile() { mCopyingProfile = true; QString profile_name; QString oldname; QListWidgetItem* pItem; const auto oldPassword = character_password_entry->text(); const CopiedProfileData data = captureProfileData(); if (!copyProfileWidget(profile_name, oldname, pItem)) { mCopyingProfile = false; return; } // A default profile (one of the predefined games) only exists in memory, so // there is no folder to copy on-disk. Persist the displayed connection data // into the new profile the same way saving a profile does, so the copy is const QDir dir(mudlet::getMudletPath(enums::profileHomePath, oldname)); if (!dir.exists()) { saveDefaultProfileCopy(profile_name, data, oldPassword); return; } QApplication::setOverrideCursor(Qt::BusyCursor); mpCopyProfile->setText(tr("Copying...")); mpCopyProfile->setEnabled(false); auto future = QtConcurrent::run(dlgConnectionProfiles::copyFolder, mudlet::getMudletPath(enums::profileHomePath, oldname), mudlet::getMudletPath(enums::profileHomePath, profile_name)); auto watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcher::finished, this, [this, profile_name, oldPassword, watcher]() { if (!mProfileList.contains(profile_name)) { mProfileList << profile_name; } // The dialog stays usable while the copy runs, and switching the games // tab calls fillout_form(), which destroys every item - including the // one made for this copy. Hence look it up by name rather than hold it. auto pCopiedItems = findData(*listWidget_profiles, profile_name, csmNameRole); if (pCopiedItems.isEmpty()) { // that rebuild scanned the profiles directory before the copy // landed in it fillout_form(); pCopiedItems = findData(*listWidget_profiles, profile_name, csmNameRole); } if (!pCopiedItems.isEmpty()) { auto* pCopiedItem = pCopiedItems.first(); if (listWidget_profiles->currentItem() == pCopiedItem) { slot_itemClicked(pCopiedItem); } else { // reaches slot_itemClicked() through currentItemChanged listWidget_profiles->setCurrentItem(pCopiedItem); } } // restore the password, which won't be copied by the disk copy if stored in the credential manager // Temporarily block textChanged signal to avoid triggering save on programmatic setText { const QSignalBlocker blocker(character_password_entry); character_password_entry->setText(oldPassword); } if (mudlet::self()->storingPasswordsSecurely() && !oldPassword.trimmed().isEmpty()) { writeSecurePassword(profile_name, oldPassword); } mCopyingProfile = false; mpCopyProfile->setText(tr("Copy")); mpCopyProfile->setEnabled(true); QApplication::restoreOverrideCursor(); validateProfile(); watcher->deleteLater(); }); watcher->setFuture(future); } dlgConnectionProfiles::CopiedProfileData dlgConnectionProfiles::captureProfileData() const { return {host_name_entry->text(), port_entry->text(), port_ssl_tsl->isChecked() ? Qt::Checked : Qt::Unchecked, login_entry->text(), website_entry->text(), mud_description_textedit->toPlainText()}; } // Copying a default profile (one of the predefined games) has nothing to copy // on-disk, because such profiles only exist in memory until saved. Create the // new profile's folder and persist the captured connection data, so the copy is // a faithful, functional profile that survives reopening the connection screen. // url/port/SSL go through the same writers used when saving a profile; the // remaining fields are written directly. void dlgConnectionProfiles::saveDefaultProfileCopy(const QString& profileName, const CopiedProfileData& data, const QString& oldPassword) { const QDir dir; if (!dir.mkpath(mudlet::getMudletPath(enums::profileHomePath, profileName))) { notificationArea->show(); notificationAreaIconLabelWarning->show(); notificationAreaIconLabelError->hide(); notificationAreaIconLabelInformation->hide(); notificationAreaMessageBox->show(); notificationAreaMessageBox->setText(tr("Could not create the new profile folder on your computer.")); mCopyingProfile = false; return; } mProfileList << profileName; // keep the copying flag up to the end: it stops the re-selection below // from blanking the password field and stops validateProfile() from // flagging the half-filled intermediate states mCopyingProfile = true; // the copy now exists on disk, so rebuilding the list shows it like any // other saved profile - on a fresh install this also swaps the welcome // message for the connection details, which would otherwise leave the // copy invisible - then select it and fill in its details fillout_form(); const auto pCopiedItems = findData(*listWidget_profiles, profileName, csmNameRole); if (!pCopiedItems.isEmpty()) { listWidget_profiles->setCurrentItem(pCopiedItems.first()); } { const QSignalBlocker nameBlocker(profile_name_entry); const QSignalBlocker urlBlocker(host_name_entry); const QSignalBlocker portBlocker(port_entry); const QSignalBlocker sslBlocker(port_ssl_tsl); const QSignalBlocker loginBlocker(login_entry); profile_name_entry->setText(profileName); host_name_entry->setText(data.host); port_entry->setText(data.port); port_ssl_tsl->setChecked(data.sslTsl == Qt::Checked); login_entry->setText(data.login); website_entry->setText(data.website); website_entry->setVisible(!data.website.isEmpty()); mud_description_textedit->setPlainText(data.description); } slot_updateUrl(data.host); slot_updatePort(data.port); slot_updateSslTslPort(data.sslTsl); if (!data.login.isEmpty()) { writeProfileData(profileName, qsl("login"), data.login); } if (!data.website.isEmpty()) { writeProfileData(profileName, qsl("website"), data.website); } if (!data.description.isEmpty()) { writeProfileData(profileName, qsl("description"), data.description); } { const QSignalBlocker blocker(character_password_entry); character_password_entry->setText(oldPassword); } if (mudlet::self()->storingPasswordsSecurely() && !oldPassword.trimmed().isEmpty()) { writeSecurePassword(profileName, oldPassword); } mCopyingProfile = false; validateProfile(); } void dlgConnectionProfiles::slot_copyOnlySettingsOfProfile() { QString profile_name; QString oldname; QListWidgetItem* pItem; const auto oldPassword = character_password_entry->text(); const CopiedProfileData data = captureProfileData(); if (!copyProfileWidget(profile_name, oldname, pItem)) { return; } const QDir oldProfileDir(mudlet::getMudletPath(enums::profileHomePath, oldname)); if (!oldProfileDir.exists()) { saveDefaultProfileCopy(profile_name, data, oldPassword); return; } const QDir newProfileDir(mudlet::getMudletPath(enums::profileHomePath, profile_name)); newProfileDir.mkpath(newProfileDir.path()); if (!newProfileDir.exists()) { return; } // copy relevant profile files for (const QString& file : {qsl("url"), qsl("port"), qsl("password"), qsl("login"), qsl("description")}) { auto filePath = qsl("%1/%2").arg(mudlet::getMudletPath(enums::profileHomePath, oldname), file); auto newFilePath = qsl("%1/%2").arg(mudlet::getMudletPath(enums::profileHomePath, profile_name), file); QFile::copy(filePath, newFilePath); } copyProfileSettingsOnly(oldname, profile_name); mProfileList << profile_name; slot_itemClicked(pItem); } bool dlgConnectionProfiles::copyProfileWidget(QString& profile_name, QString& oldname, QListWidgetItem*& pItem) const { profile_name = profile_name_entry->text().trimmed(); oldname = profile_name; if (profile_name.isEmpty()) { return false; } // prepend n+1 to end of the profile name if (profile_name.at(profile_name.size() - 1).isDigit()) { int i = 1; do { profile_name = profile_name.left(profile_name.size() - 1) + QString::number(profile_name.at(profile_name.size() - 1).digitValue() + i++); } while (mProfileList.contains(profile_name)); } else { int i = 1; QString profile_name2; do { profile_name2 = profile_name + QString::number(i++); } while (mProfileList.contains(profile_name2)); profile_name = profile_name2; } pItem = new (std::nothrow) QListWidgetItem(); if (!pItem) { return false; } setItemName(pItem, profile_name); // add the new widget in listWidget_profiles->addItem(pItem); pItem->setIcon(customIcon(profile_name, std::nullopt)); listWidget_profiles->setCurrentItem(pItem); profile_name_entry->setText(profile_name); profile_name_entry->setFocus(); profile_name_entry->selectAll(); profile_name_entry->setReadOnly(false); host_name_entry->setReadOnly(false); port_entry->setReadOnly(false); return true; } void dlgConnectionProfiles::copyProfileSettingsOnly(const QString& oldname, const QString& newname) { const QDir oldProfiledir(mudlet::getMudletPath(enums::profileXmlFilesPath, oldname)); const QDir newProfiledir(mudlet::getMudletPath(enums::profileXmlFilesPath, newname)); newProfiledir.mkpath(newProfiledir.absolutePath()); // Only copy from a real profile save (*.xml): the newest file of any name could // be a leftover QSaveFile temporary from an interrupted save (e.g. "....xml.AbCdEf") QStringList entries = oldProfiledir.entryList(QStringList{qsl("*.xml")}, QDir::Files | QDir::NoDotAndDotDot, QDir::Time); if (entries.empty()) { return; } auto copySettingsFromFile = oldProfiledir.absoluteFilePath(entries.first()); pugi::xml_document newProfileXml; if (extractSettingsFromProfile(newProfileXml, copySettingsFromFile)) { saveProfileCopy(newProfiledir, newProfileXml); } } bool dlgConnectionProfiles::extractSettingsFromProfile(pugi::xml_document& newProfile, const QString& copySettingsFrom) { pugi::xml_document oldProfile; pugi::xml_parse_result const result = oldProfile.load_file(copySettingsFrom.toUtf8().constData()); if (!result) { qWarning() << "dlgConnectionProfiles::copyProfileSettingsOnly() ERROR: couldn't parse" << copySettingsFrom; qWarning() << "Parse error: " << result.description() << ", character pos= " << result.offset; return false; } // write header auto declaration = newProfile.prepend_child(pugi::node_declaration); declaration.append_attribute("version") = "1.0"; declaration.append_attribute("encoding") = "UTF-8"; newProfile.append_child(pugi::node_doctype).set_value("MudletPackage"); // copy /MudletPackage attributes auto mudletPackage = newProfile.append_child("MudletPackage"); const auto attributeNodes = oldProfile.select_nodes("/MudletPackage/attribute::*"); for (pugi::xpath_node_set::const_iterator it = attributeNodes.begin(); it != attributeNodes.end(); ++it) { auto node = *it; mudletPackage.append_attribute(node.attribute().name()) = node.attribute().value(); } // remove installed packages/modules const auto hostPackageResults = oldProfile.select_nodes("/MudletPackage/HostPackage"); pugi::xml_node const hostPackage = hostPackageResults.first().node(); auto host = hostPackage.child("Host"); host.remove_child("mInstalledPackages"); host.remove_child("mInstalledModules"); // copy in the /Mudlet/HostPackage mudletPackage.append_copy(hostPackage); return true; } // save profile using Qt's API's which handle non-ASCII characters in Windows paths fine void dlgConnectionProfiles::saveProfileCopy(const QDir& newProfiledir, const pugi::xml_document& newProfileXml) const { QSaveFile file(newProfiledir.absoluteFilePath(qsl("Copied profile (settings only).xml"))); if (!file.open(QFile::WriteOnly)) { qDebug() << "dlgConnectionProfiles::copyProfileSettingsOnly ERROR - couldn't create new profile file:" << file.fileName() << "-" << file.errorString(); return; } std::stringstream saveStringStream(std::ios::out); newProfileXml.save(saveStringStream); std::string output(saveStringStream.str()); file.write(output.data()); if (!file.commit()) { qDebug() << "dlgConnectionProfiles::saveProfileCopy: error copying profile: " << file.errorString(); } } void dlgConnectionProfiles::loadProfile(bool alsoConnect) { const QString profile_name = profile_name_entry->text().trimmed(); if (profile_name.isEmpty()) { return; } // Check if the host already exists before calling mudlet::loadProfile() Host* pHostBeforeLoad = mudlet::self()->getHostManager().getHost(profile_name); bool hostExistedBefore = (pHostBeforeLoad != nullptr); Host* pHost = mudlet::self()->loadProfile(profile_name, alsoConnect, profile_history->currentData().toString()); // overwrite the generic profile with user supplied name, url and login information if (pHost) { Host* pActiveHost = mudlet::self()->getActiveHost(); if (pActiveHost && pActiveHost->getName() == profile_name) { // Skip reconnect if mudlet::loadProfile already connected for existing hosts if (alsoConnect && hostExistedBefore) { QDialog::accept(); return; } // Reconnect to the active profile pActiveHost->mTelnet.reconnect(); QDialog::accept(); return; } // Skip signal emission if mudlet::loadProfile already handled the connection if (alsoConnect && hostExistedBefore) { QDialog::accept(); return; } pHost->setName(profile_name); if (!host_name_entry->text().trimmed().isEmpty()) { pHost->setUrl(host_name_entry->text().trimmed()); } else { slot_updateUrl(pHost->getUrl()); } if (!port_entry->text().trimmed().isEmpty()) { pHost->setPort(port_entry->text().trimmed().toInt()); } else { slot_updatePort(QString::number(pHost->getPort())); } pHost->mSslTsl = port_ssl_tsl->isChecked(); if (!character_password_entry->text().trimmed().isEmpty()) { pHost->setPass(character_password_entry->text().trimmed()); } // Note: If password field is empty, we don't call slot_updatePassword() because: // 1. The host's password was already loaded via Host::loadSecuredPassword() // 2. Calling slot_updatePassword with empty password would delete the stored password // 3. slot_updatePassword reads profile from list widget which could mismatch if (!login_entry->text().trimmed().isEmpty()) { pHost->setLogin(login_entry->text().trimmed()); } else { slot_updateLogin(pHost->getLogin()); } // This settings also need to be configured, note that the only time not to // save the setting is on profile loading. Only override the default UTF-8 // encoding if a saved encoding exists: const QByteArray savedEncoding = readProfileData(profile_name, qsl("encoding")).toUtf8(); if (!savedEncoding.isEmpty()) { pHost->mTelnet.setEncoding(savedEncoding, false); } // Needed to ensure setting is correct on start-up: pHost->setWideAmbiguousEAsianGlyphs(pHost->getWideAmbiguousEAsianGlyphsControlState()); pHost->setAutoReconnect(auto_reconnect->isChecked()); // This also writes the value out to the profile's base directory: mudlet::self()->mDiscord.setApplicationID(pHost, mDiscordApplicationId); } emit signal_load_profile(profile_name, alsoConnect); QDialog::accept(); } bool dlgConnectionProfiles::validateProfile() { bool valid = true; // don't validate url duplication during copy, as information will already exist when we try to set it if (mCopyingProfile) { return true; } validName = true, validPort = true, validUrl = true; clearNotificationArea(); QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (pItem) { QString name = profile_name_entry->text().trimmed(); // Only check the characters of a new or edited name: a profile folder // already on disk may have been created outside of Mudlet (e.g. by a // file manager copying a folder) with characters we would not permit // for a new name - such a profile must still be loadable. Comparing // against the trimmed item name covers folders with leading/trailing // whitespace too, as the entered name always arrives trimmed. Names // the rest of Mudlet cannot work with get no exemption: renaming them // is worse for the user than a profile whose password never saves. // "." and ".." name something that exists without being a profile, so // the exemption needs a folder that is genuinely the profile's own. const QString selectedName = pItem->data(csmNameRole).toString(); const QString selectedFolder = profileFolderPath(mudlet::getMudletPath(enums::profilesPath), selectedName); const bool nameIsFolderOnDisk = (name == selectedName.trimmed()) && !selectedFolder.isEmpty() && QDir(selectedFolder).exists(); const bool nameUnchangedAndOnDisk = nameIsFolderOnDisk && profileNameUsableAsIs(name); const QChar invalidChar = nameUnchangedAndOnDisk ? QChar() : firstInvalidProfileNameChar(name); if (!invalidChar.isNull()) { notificationAreaIconLabelWarning->show(); notificationAreaMessageBox->setText( qsl("%1\n%2\n%3\n").arg(notificationAreaMessageBox->text(), tr("The %1 character is not permitted. Use one of the following:").arg(invalidChar), scmAllowedProfileNameChars)); name.remove(invalidChar); profile_name_entry->setText(name); validName = false; valid = false; } else if (!nameIsFolderOnDisk && !name.isEmpty() && !profileNameUsableAsIs(name)) { // Nothing to strip here, unlike the branch above: the characters // are all permitted, it is the whole name that cannot be a folder notificationAreaIconLabelWarning->show(); notificationAreaMessageBox->setText( qsl("%1\n%2\n") .arg(notificationAreaMessageBox->text(), //: Shown when a profile name would not name a folder of its own. Keep the quoted dots as they are, they are literal characters the user typed tr("A profile name cannot be \".\" or contain \"..\", as those refer to other folders on your computer. Please pick a different name."))); validName = false; valid = false; } // see if there is an edit that already uses a similar name if ((QString::compare(pItem->data(csmNameRole).toString(), name, Qt::CaseInsensitive) != 0) && mProfileList.contains(name, Qt::CaseInsensitive)) { notificationAreaIconLabelError->show(); notificationAreaMessageBox->setText(qsl("%1\n%2").arg(notificationAreaMessageBox->text(), tr("This profile name is already in use."))); validName = false; valid = false; } const QString port = port_entry->text().trimmed(); if (!port.isEmpty() && (port.indexOf(QRegularExpression(qsl("^\\d+$")), 0) == -1)) { QString val = port; val.chop(1); port_entry->setText(val); notificationAreaIconLabelError->show(); notificationAreaMessageBox->setText(qsl("%1\n%2").arg(notificationAreaMessageBox->text(), tr("You have to enter a number. Other characters are not permitted."))); port_entry->setPalette(mErrorPalette); validPort = false; valid = false; } bool ok; const int num = port.trimmed().toInt(&ok); if (!port.isEmpty() && (num > 65536 && ok)) { notificationAreaIconLabelError->show(); notificationAreaMessageBox->setText(qsl("%1\n%2\n\n").arg(notificationAreaMessageBox->text(), tr("Port number must be above zero and below 65535."))); port_entry->setPalette(mErrorPalette); validPort = false; valid = false; } #if defined(QT_NO_SSL) port_ssl_tsl->setEnabled(false); port_ssl_tsl->setToolTip(utils::richText(tr("Mudlet is not configured for secure connections."))); if (port_ssl_tsl->isChecked()) { notificationAreaIconLabelError->show(); notificationAreaMessageBox->setText(qsl("%1\n%2\n\n").arg(notificationAreaMessageBox->text(), tr("Mudlet is not configured for secure connections."))); port_ssl_tsl->setEnabled(true); validPort = false; valid = false; } #else if (!QSslSocket::supportsSsl()) { if (port_ssl_tsl->isChecked()) { notificationAreaIconLabelError->show(); notificationAreaMessageBox->setText(qsl("%1\n%2\n\n").arg(notificationAreaMessageBox->text(), tr("Mudlet can not load support for secure connections."))); validPort = false; valid = false; } } else { port_ssl_tsl->setEnabled(true); port_ssl_tsl->setToolTip(QString()); } #endif QUrl check; const QString url = host_name_entry->text().trimmed(); check.setHost(url); if (url.isEmpty()) { host_name_entry->setPalette(mErrorPalette); validUrl = false; valid = false; } if (!check.isValid()) { notificationAreaIconLabelError->show(); notificationAreaMessageBox->setText(qsl("%1\n%2\n\n%3").arg(notificationAreaMessageBox->text(), tr("Please enter the URL or IP address of the Game server."), check.errorString())); host_name_entry->setPalette(mErrorPalette); validUrl = false; valid = false; } // Need to reject raw IP addresses (of either version 4 or 6 type) as // it is very unlikely that the Security Certificates include them as // a Host Name. if (port_ssl_tsl->isChecked() && (cTelnet::isRawIPv4Address(url) || cTelnet::isRawIPv6Address(url))) { notificationAreaIconLabelError->show(); // As the only tags are not on the first line the default // Qt::AutoFormat won't detect that rich-text is present in this text! notificationAreaMessageBox->setTextFormat(Qt::RichText); /*: Please use two line-feeds after the first line so the second line can be italicised and spaced out - if appropriate for the locale.*/ notificationAreaMessageBox->setText(qsl("%1%2\n\n%3") .arg(!notificationAreaMessageBox->text().isEmpty() ? notificationAreaMessageBox->text().append(QChar::LineFeed) : QString(), tr("Please enter the URL of the Game server.\n\n" "SSL/TLS connections require a URL, as an IP address is not a suitable " "identifier for the certification of the Game Server."), check.errorString())); host_name_entry->setPalette(mErrorPalette); validUrl = false; valid = false; } if (valid) { port_entry->setPalette(mOKPalette); host_name_entry->setPalette(mOKPalette); clearNotificationArea(); validName = true; validPort = true; validUrl = true; if (offline_button) { offline_button->setEnabled(true); offline_button->setToolTip(utils::richText(tr("Load profile without connecting."))); offline_button->setAccessibleDescription(btn_load_enabled_accessDesc); } if (connect_button) { connect_button->setEnabled(true); connect_button->setToolTip(QString()); connect_button->setAccessibleDescription(btn_connect_enabled_accessDesc); } return true; } if (!notificationAreaMessageBox->text().isEmpty()) { notificationArea->show(); notificationAreaMessageBox->show(); } if (offline_button) { offline_button->setEnabled(false); offline_button->setToolTip(utils::richText(tr("Please set a valid profile name, game server address and the game port before loading."))); offline_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); } if (connect_button) { connect_button->setEnabled(false); connect_button->setToolTip(utils::richText(tr("Please set a valid profile name, game server address and the game port before connecting."))); connect_button->setAccessibleDescription(btn_connOrLoad_disabled_accessDesc); } return false; } return false; } // credit: http://www.qtcentre.org/archive/index.php/t-23469.html bool dlgConnectionProfiles::copyFolder(const QString& sourceFolder, const QString& destFolder) { const QDir sourceDir(sourceFolder); if (!sourceDir.exists()) { return false; } const QDir destDir(destFolder); if (!destDir.exists()) { destDir.mkdir(destFolder); } QStringList files = sourceDir.entryList(QDir::Files); for (const QString& file : std::as_const(files)) { const QString srcName = sourceFolder + QDir::separator() + file; const QString destName = destFolder + QDir::separator() + file; QFile::copy(srcName, destName); } files.clear(); files = sourceDir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot); for (const QString& file : std::as_const(files)) { const QString srcName = sourceFolder + QDir::separator() + file; const QString destName = destFolder + QDir::separator() + file; copyFolder(srcName, destName); } return true; } // As it is wired to the triggered() signal it is only called that way when // the user clicks on the action, and not when setChecked() is used. void dlgConnectionProfiles::slot_togglePasswordVisibility(const bool showPassword) { if (mpAction_revealPassword->isChecked() != showPassword) { // This will only be reached and needed by a call NOT prompted by the // user clicking on the icon - i.e. either when a different profile is // selected or when called from the constructor: mpAction_revealPassword->setChecked(showPassword); } if (mpAction_revealPassword->isChecked()) { character_password_entry->setEchoMode(QLineEdit::Normal); // In practice I could not get the icon to change based upon supplying // different QPixmaps for the QIcon for different states - so let's do it // directly: mpAction_revealPassword->setIcon(QIcon::fromTheme(qsl("password-show-on"), QIcon(qsl(":/icons/password-show-on.png")))); mpAction_revealPassword->setToolTip(utils::richText(tr("Click to hide the password; it will also hide if another profile is selected."))); } else { character_password_entry->setEchoMode(QLineEdit::Password); mpAction_revealPassword->setIcon(QIcon::fromTheme(qsl("password-show-off"), QIcon(qsl(":/icons/password-show-off.png")))); mpAction_revealPassword->setToolTip(utils::richText(tr("Click to reveal the password for this profile."))); } } QList dlgConnectionProfiles::findData(const QListWidget& listWidget, const QVariant& what, const int role) const { QList results; for (int index = 0, total = listWidget.count(); index < total; ++index) { if (listWidget.item(index)->data(role) == what) { results.append(listWidget.item(index)); } } return results; } QList dlgConnectionProfiles::findProfilesBeginningWith(const QString& what) const { QList results; for (int index = 0, total = listWidget_profiles->count(); index < total; ++index) { if (listWidget_profiles->item(index)->data(csmNameRole).toString().startsWith(what, Qt::CaseInsensitive)) { results.append(index); } } return results; } void dlgConnectionProfiles::setItemName(QListWidgetItem* pI, const QString& name) const { if (!pI) { // Avoid any problems should the supplied argument be a nullptr: return; } pI->setData(csmNameRole, name); pI->setData(Qt::AccessibleTextRole, item_profile_accessName.arg(name)); pI->setData(Qt::AccessibleDescriptionRole, item_profile_accessDesc); } void dlgConnectionProfiles::setupMudProfile(QListWidgetItem* pItem, const QString& mudServer, const QString& serverDescription, const QString& iconFileName) { setItemName(pItem, mudServer); listWidget_profiles->addItem(pItem); if (!hasCustomIcon(mudServer)) { const QPixmap pixmap(iconFileName); if (pixmap.isNull()) { qWarning() << mudServer << "doesn't have a valid icon"; return; } if (pixmap.width() != 120) { pItem->setIcon(pixmap.scaled(QSize(120, 30), Qt::IgnoreAspectRatio, Qt::SmoothTransformation)); } else { pItem->setIcon(QIcon(iconFileName)); } } else { setCustomIcon(mudServer, pItem); } if (!serverDescription.isEmpty()) { pItem->setToolTip(utils::richText(serverDescription)); } } QIcon dlgConnectionProfiles::customIcon(const QString& text, const std::optional& backgroundColor) const { QPixmap background(120, 30); const QColor color = backgroundColor.value_or(mCustomIconColors.at(static_cast((qHash(text) * 8131) % mCustomIconColors.count()))); background.fill(color); // Set to one larger than wanted so that do loop can contain the decrementor int fontSize = 30; QFont font(qsl("Bitstream Vera Sans Mono"), fontSize, QFont::Normal); // For an icon of size 120x30 allow 89x29 for the text: const QRect textRectangle(0, 0, 89, 29); QRect testRect; // Really long names will be drawn very small (font size 6) with the ends clipped off: do { font.setPointSize(--fontSize); const QFontMetrics metrics(font); testRect = metrics.boundingRect(textRectangle, Qt::AlignCenter | Qt::TextWordWrap, text); } while (fontSize > 6 && !textRectangle.contains(testRect)); { // Enclosed in braces to limit lifespan of QPainter: QPainter painter(&background); painter.setCompositionMode(QPainter::CompositionMode_SourceOver); const QPixmap pixmap(qsl(":/icons/mudlet_main_32px.png")); painter.drawPixmap(QRect(5, 5, 20, 20), pixmap); if (color.lightness() > 127) { painter.setPen(Qt::black); } else { painter.setPen(Qt::white); } painter.setFont(font); painter.drawText(QRect(30, 0, 90, 30), Qt::AlignCenter | Qt::TextWordWrap, text); } return QIcon(background); } void dlgConnectionProfiles::clearNotificationArea() { notificationArea->hide(); notificationAreaIconLabelWarning->hide(); notificationAreaIconLabelError->hide(); notificationAreaIconLabelInformation->hide(); notificationAreaMessageBox->clear(); } void dlgConnectionProfiles::slot_reenableAllProfileItems() { for (int i = 0, total = listWidget_profiles->count(); i < total; ++i) { listWidget_profiles->item(i)->setFlags(listWidget_profiles->item(i)->flags() | Qt::ItemIsEnabled); } } bool dlgConnectionProfiles::eventFilter(QObject* obj, QEvent* event) { if (obj == listWidget_profiles && event->type() == QEvent::KeyPress) { QKeyEvent* keyEvent = static_cast(event); switch (keyEvent->key()) { // Process all the keys that could be used in a profile name, // i.e. the "scmAllowedProfileNameChars" list default: // For other keys handle them as normal: return QObject::eventFilter(obj, event); case Qt::Key_Escape: // Clear the search: mSearchText.clear(); slot_reenableAllProfileItems(); // Eat (filter) this event so it goes no further: return true; case Qt::Key_Period: case Qt::Key_Space: case Qt::Key_Underscore: case Qt::Key_0: case Qt::Key_1: case Qt::Key_2: case Qt::Key_3: case Qt::Key_4: case Qt::Key_5: case Qt::Key_6: case Qt::Key_7: case Qt::Key_8: case Qt::Key_9: case Qt::Key_Minus: case Qt::Key_NumberSign: case Qt::Key_Ampersand: case Qt::Key_ParenLeft: case Qt::Key_ParenRight: case Qt::Key_A: case Qt::Key_B: case Qt::Key_C: case Qt::Key_D: case Qt::Key_E: case Qt::Key_F: case Qt::Key_G: case Qt::Key_H: case Qt::Key_I: case Qt::Key_J: case Qt::Key_K: case Qt::Key_L: case Qt::Key_M: case Qt::Key_N: case Qt::Key_O: case Qt::Key_P: case Qt::Key_Q: case Qt::Key_R: case Qt::Key_S: case Qt::Key_T: case Qt::Key_U: case Qt::Key_V: case Qt::Key_W: case Qt::Key_X: case Qt::Key_Y: case Qt::Key_Z: if (keyEvent->modifiers() & ~(Qt::ShiftModifier)) { // There is a modifier in play OTHER than the shift one so treat // it as normal: return QObject::eventFilter(obj, event); } if (!mSearchTextTimer.isActive()) { // Too long since the last keypress so forget any previously // entered keypresses: mSearchText.clear(); } mSearchTextTimer.stop(); addLetterToProfileSearch(keyEvent->key()); // Restart the timeout for another keypress: mSearchTextTimer.start(); // Eat (filter) this event so it goes no further: return true; } } // standard event processing return QObject::eventFilter(obj, event); } void dlgConnectionProfiles::addLetterToProfileSearch(const int key) { if ((key < 0) || (key > 128)) { // out of range of normal ASCII keys return; } // As it happens the values for key correspond to those of the corresponding // ASCII (upper-case for letters) character codes mSearchText.append(QLatin1Char(static_cast(key))); auto indexes = findProfilesBeginningWith(mSearchText); if (indexes.isEmpty()) { // No matches at all so clearing search term and reset all profiles to // be enabled: mSearchText.clear(); slot_reenableAllProfileItems(); return; } for (int i = 0, total = listWidget_profiles->count(); i < total; ++i) { auto flags = listWidget_profiles->item(i)->flags(); if (indexes.isEmpty() || !indexes.contains(i)) { flags &= ~Qt::ItemIsEnabled; } else { flags |= Qt::ItemIsEnabled; } listWidget_profiles->item(i)->setFlags(flags); } listWidget_profiles->setCurrentRow(indexes.first()); } void dlgConnectionProfiles::slot_loadPasswordAsync() { if (!sender()) { return; } // Get the profile name from the timer's property QTimer* timer = qobject_cast(sender()); if (!timer) { return; } const QString profile_name = timer->property("profileName").toString(); // Prevent duplicate password loading operations for the same profile if (mKeychainOperationInProgress) { return; } if (profile_name.isEmpty()) { return; } // Clean up the timer timer->deleteLater(); // Check if this dialog is still valid and the profile is still selected if (listWidget_profiles->currentItem() == nullptr) { return; } const QString currentProfileName = listWidget_profiles->currentItem()->data(csmNameRole).toString(); if (currentProfileName != profile_name) { // Selection has changed, ignore this async load return; } // If secure storage is enabled, try keychain first, then fallback to QSettings if (mudlet::self()->storingPasswordsSecurely()) { mKeychainOperationInProgress = true; auto* credManager = new CredentialManager(this); credManager->retrievePassword(profile_name, "character", [this, credManager, profile_name](bool success, const QString& retrievedPassword, const QString& errorMessage) { // Clear the operation flag first mKeychainOperationInProgress = false; // Check if profile selection has changed while we were waiting if (listWidget_profiles->currentItem() && listWidget_profiles->currentItem()->data(csmNameRole).toString() == profile_name) { if (success) { // Keychain operation succeeded - set the password (even if empty) // Temporarily block textChanged signal to avoid triggering save on programmatic setText { const QSignalBlocker blocker(character_password_entry); character_password_entry->setText(retrievedPassword); } if (retrievedPassword.isEmpty()) { qDebug() << "dlgConnectionProfiles: Keychain returned empty password for" << profile_name; } else { qDebug() << "dlgConnectionProfiles: Successfully loaded password from keychain for" << profile_name; } } else { // Fallback to QSettings only if credential retrieval failed loadPasswordFromSettings(profile_name); qDebug() << "dlgConnectionProfiles: Credential retrieval unsuccessful for" << profile_name << "-" << errorMessage; } } // Check if there's a pending connection waiting for this password load // (do this regardless of profile selection state to avoid hanging) if (!mPendingProfileLoad.isEmpty() && mPendingProfileLoad == profile_name) { qDebug() << "dlgConnectionProfiles: Password load completed, proceeding with pending connection for" << profile_name; // Clear pending state QString profileToLoad = mPendingProfileLoad; bool shouldConnect = mPendingConnect; mPendingProfileLoad.clear(); // Proceed with the connection loadProfile(shouldConnect); QDialog::accept(); } credManager->deleteLater(); }); } else { // Secure storage disabled, use QSettings directly loadPasswordFromSettings(profile_name); // Check if there's a pending connection waiting if (!mPendingProfileLoad.isEmpty() && mPendingProfileLoad == profile_name) { qDebug() << "dlgConnectionProfiles: Password loaded from settings, proceeding with pending connection for" << profile_name; // Clear pending state QString profileToLoad = mPendingProfileLoad; bool shouldConnect = mPendingConnect; mPendingProfileLoad.clear(); // Proceed with the connection loadProfile(shouldConnect); QDialog::accept(); } } } void dlgConnectionProfiles::loadPasswordFromSettings(const QString& profile_name) { auto& settings = *mudlet::self()->mpSettings; settings.beginGroup(qsl("profiles/%1").arg(profile_name)); // Get password and handle migration const QString password = settings.value(qsl("password"), QString()).toString(); const QString oldPassword = settings.value(qsl("login"), QString()).toString(); // Temporarily block textChanged signal to avoid triggering save on programmatic setText { const QSignalBlocker blocker(character_password_entry); if (!password.isEmpty()) { character_password_entry->setText(password); } else if (!oldPassword.isEmpty()) { // Migrate old password character_password_entry->setText(oldPassword); settings.setValue(qsl("password"), oldPassword); settings.remove(qsl("login")); } else { character_password_entry->setText(QString()); } } settings.endGroup(); } void dlgConnectionProfiles::slot_passwordTextChanged() { QListWidgetItem* pItem = listWidget_profiles->currentItem(); if (!pItem) { return; } mPendingPasswordSaveProfile = pItem->data(csmNameRole).toString(); // Cancel any pending password save if (mPasswordSaveTimer) { mPasswordSaveTimer->stop(); } else { mPasswordSaveTimer = new QTimer(this); mPasswordSaveTimer->setSingleShot(true); mPasswordSaveTimer->setInterval(500ms); // 500ms debounce connect(mPasswordSaveTimer, &QTimer::timeout, this, [this]() { if (!mPendingPasswordSaveProfile.isEmpty()) { // Check if this profile is STILL selected - if not, don't save // (user switched away, so the password field content is for a different profile) QListWidgetItem* currentItem = listWidget_profiles->currentItem(); if (currentItem && currentItem->data(csmNameRole).toString() == mPendingPasswordSaveProfile) { slot_updatePassword(character_password_entry->text()); } } }); } mPasswordSaveTimer->start(); }