mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions - Hoists the list `slot_deleteProfile()` checks a never-played profile against out of the function to `dlgConnectionProfiles::scmConnectionDetailFiles`, beside the file's other `scm*` constants, and points at it from both `writeProfileData()` implementations. - Adds a `ProfileDeletionSafetyTest` case that sets a profile up through the real dialog (New profile, name it, fill the connection form in, re-select it) and fails if anything it wrote is missing from the list. - Pins the deliberate exclusions too: a profile holding a stored password or a typed-in character name still asks before removal. #### Motivation for adding to Mudlet Nothing linked that list to the ~15 places profile data gets written, so it could silently go stale; because it is an allowlist a stale entry only ever costs an extra confirmation prompt, but the maintenance trap was worth closing. #### Other info (issues closed, discussion etc) Follows up https://github.com/Mudlet/Mudlet/pull/9722#discussion_r3740581754 on #9722 (fix: a profile named "." or ".." deletes every profile when removed). No behaviour change. **Test case:** `ctest -R ProfileDeletionSafetyTest` (20 cases). Removing an entry from the constant makes it fail naming the file; adding `login` makes the character-name case fail. Assisted-by: Claude:claude-opus-5
2886 lines
124 KiB
C++
2886 lines
124 KiB
C++
/***************************************************************************
|
|
* 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 <pugixml.hpp>
|
|
|
|
#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 <QtConcurrentRun>
|
|
#include <QtUiTools>
|
|
#include <QApplication>
|
|
#include <QColorDialog>
|
|
#include <QDir>
|
|
#include <QFileInfo>
|
|
#include <QPointer>
|
|
#include <QRandomGenerator>
|
|
#include <QSettings>
|
|
#include <QSignalBlocker>
|
|
#include <QTabBar>
|
|
#include <QTime>
|
|
#include <chrono>
|
|
#include <sstream>
|
|
|
|
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("<p><center><img src=\"tutorialIcon\"/></center></p>"
|
|
"<p><center><big><b>Welcome to Mudlet!</b></big></center></p>"
|
|
"<p><center>Play a short guided adventure to learn<br>"
|
|
"how to navigate in games, use triggers, aliases, and scripting.</center></p>"
|
|
"<p><center><a href=\"mudlet-tutorial\">Start Tutorial</a></center></p>"
|
|
"<p align=\"right\"><span style=\" font-family:'Sans';\">The Mudlet Team </span>"
|
|
"<img src=\":/icons/mudlet_main_16px.png\"/></p>");
|
|
|
|
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<QWidget*> widgetList;
|
|
for (const auto pObjectItem : std::as_const(objectList)) {
|
|
auto pWidgetItem = qobject_cast<QWidget*>(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<QWidget*>(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("<html><head/><body>%1</body></html>").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<dlgConnectionProfiles> safeThis = this;
|
|
QPointer<CredentialManager> 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<CredentialManager> 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<QDialog*>(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<QLineEdit*>(qsl("delete_profile_lineedit"));
|
|
auto* deleteButton = delete_profile_dialog->findChild<QPushButton*>(qsl("delete_button"));
|
|
auto* cancelButton = delete_profile_dialog->findChild<QPushButton*>(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<bool, QString> 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<bool>(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 <typename L>
|
|
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<QColor> 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<bool>(this);
|
|
connect(watcher, &QFutureWatcher<bool>::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"
|
|
"<i>SSL/TLS connections require a URL, as an IP address is not a suitable "
|
|
"identifier for the certification of the Game Server.</i>"),
|
|
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<QListWidgetItem*> dlgConnectionProfiles::findData(const QListWidget& listWidget, const QVariant& what, const int role) const
|
|
{
|
|
QList<QListWidgetItem*> 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<int> dlgConnectionProfiles::findProfilesBeginningWith(const QString& what) const
|
|
{
|
|
QList<int> 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<QColor>& backgroundColor) const
|
|
{
|
|
QPixmap background(120, 30);
|
|
|
|
const QColor color = backgroundColor.value_or(mCustomIconColors.at(static_cast<int>((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<QKeyEvent*>(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<unsigned char>(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<QTimer*>(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();
|
|
}
|