mirror of
https://github.com/Mudlet/Mudlet
synced 2026-08-13 18:26:27 -04:00
#### Brief overview of PR changes/additions - The notepad and IRC client are parentless windows freed only in `Host::closeChildren()`; a `Host` destroyed without that call orphaned them. `~Host()` now closes and deletes them, nulling each `QPointer` first so both teardown paths stay single-delete. Closing (not just deleting) the notepad also saves the notes and window state. - The toolbars are not leaked at exit, but survived their profile on screen holding a freed `TAction`; `~Host()` now deletes them synchronously. #### Motivation for adding to Mudlet Same defect class PR #9700 "fix: trigger editor and deleted item subtrees leaking memory" fixed for the editor. Two narrow production paths reach `~Host()` without `closeChildren()` (`requestClose()` returns early when `mpConsole` is already gone, and `~HostManager` runs from `~mudlet`), and the test harness takes the second on every run. #### Other info (issues closed, discussion etc) Test case: new `HostChildTeardownTest` - three teardown orderings, each fails without the fix; under ASan+LSan the binary goes from 851,208 to 3,334 leaked bytes (residue is the settings floor fixed in #9694). 79/79 functional tests pass twice; profile-close/quit with the notepad open are clean under ASan on Xvfb. Assisted-by: Claude:claude-opus-5
5502 lines
188 KiB
C++
5502 lines
188 KiB
C++
/***************************************************************************
|
|
* Copyright (C) 2008-2013 by Heiko Koehn - KoehnHeiko@googlemail.com *
|
|
* Copyright (C) 2014 by Ahmed Charles - acharles@outlook.com *
|
|
* Copyright (C) 2015-2026 by Stephen Lyons - slysven@virginmedia.com *
|
|
* Copyright (C) 2016 by Ian Adkins - ieadkins@gmail.com *
|
|
* Copyright (C) 2018 by Huadong Qi - novload@outlook.com *
|
|
* Copyright (C) 2023-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 "Host.h"
|
|
|
|
#include "dlgIRC.h"
|
|
#include "dlgMapper.h"
|
|
#include "dlgModuleManager.h"
|
|
#include "dlgNotepad.h"
|
|
#include "dlgPackageManager.h"
|
|
#include "dlgProfilePreferences.h"
|
|
#include "GifTracker.h"
|
|
#include "GMCPAuthenticator.h"
|
|
#include "LuaInterface.h"
|
|
#include "MMCP.h"
|
|
#include "MMCPServer.h"
|
|
#include "mudlet.h"
|
|
#include "TCommandLine.h"
|
|
#include "TConsole.h"
|
|
#include "TDebug.h"
|
|
#include "TDockWidget.h"
|
|
#include "TEvent.h"
|
|
#include "TLabel.h"
|
|
#include "TMainConsole.h"
|
|
#include "TMap.h"
|
|
#include "TMapViewManager.h"
|
|
#include "TMedia.h"
|
|
#include "TRoomDB.h"
|
|
#include "TScript.h"
|
|
#include "TTextBox.h"
|
|
#include "TTextEdit.h"
|
|
#include "TToolBar.h"
|
|
#include "VarUnit.h"
|
|
#include "XMLexport.h"
|
|
#include "XMLimport.h"
|
|
#include "CredentialManager.h"
|
|
#include "SecureStringUtils.h"
|
|
|
|
#include <chrono>
|
|
#include <QtConcurrentRun>
|
|
#include <QApplication>
|
|
#include <QCoreApplication>
|
|
#include <QDataStream>
|
|
#include <QDirIterator>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QJsonValue>
|
|
#include <QKeyEvent>
|
|
#include <QMovie>
|
|
#include <QNetworkProxy>
|
|
#include <QRandomGenerator>
|
|
#include <QRegularExpression>
|
|
#include <QSaveFile>
|
|
#include <QScopeGuard>
|
|
#include <QSettings>
|
|
#include <QTemporaryFile>
|
|
#include <QTextStream>
|
|
#include <zip.h>
|
|
#include <memory>
|
|
|
|
// We are now using code that won't work with really old versions of libzip;
|
|
// some of the error handling was improved in 1.0 . Unfortunately libzip 1.7.0
|
|
// (and one or two other recent versions) forgot to include the version defines
|
|
// and thus broke a test depending on them:
|
|
#if defined(LIBZIP_VERSION_MAJOR) && (LIBZIP_VERSION_MAJOR < 1)
|
|
#error Mudlet requires a version of libzip of at least 1.0
|
|
#endif
|
|
|
|
using namespace std::chrono;
|
|
|
|
stopWatch::stopWatch()
|
|
: mIsInitialised(false)
|
|
, mIsRunning(false)
|
|
, mIsPersistent(false)
|
|
, mEffectiveStartDateTime()
|
|
, mElapsedTime()
|
|
{
|
|
#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0)
|
|
mEffectiveStartDateTime.setTimeZone(QTimeZone::UTC);
|
|
#else
|
|
mEffectiveStartDateTime.setTimeSpec(Qt::UTC);
|
|
#endif
|
|
}
|
|
|
|
bool stopWatch::start()
|
|
{
|
|
if (!mIsInitialised) {
|
|
mIsInitialised = true;
|
|
mEffectiveStartDateTime = QDateTime::currentDateTimeUtc();
|
|
mIsRunning = true;
|
|
return true;
|
|
}
|
|
|
|
if (mIsRunning) {
|
|
// Nothing to do, already running
|
|
return false;
|
|
}
|
|
|
|
// Is stopped, so subtract elapsed time from current and set that to be the
|
|
// effective start time:
|
|
mEffectiveStartDateTime = QDateTime::currentDateTimeUtc().addMSecs(-mElapsedTime);
|
|
mIsRunning = true;
|
|
return true;
|
|
}
|
|
|
|
bool stopWatch::stop()
|
|
{
|
|
if (!mIsInitialised) {
|
|
// Nothing to do, never started
|
|
return false;
|
|
}
|
|
|
|
if (!mIsRunning) {
|
|
// Nothing to do, already stopped
|
|
return false;
|
|
}
|
|
|
|
// Is running - so stop and note time:
|
|
mElapsedTime = mEffectiveStartDateTime.msecsTo(QDateTime::currentDateTimeUtc());
|
|
mIsRunning = false;
|
|
return true;
|
|
}
|
|
|
|
bool stopWatch::reset()
|
|
{
|
|
if (!mIsInitialised) {
|
|
// Nothing to do, never started
|
|
return false;
|
|
}
|
|
|
|
if (!mIsRunning) {
|
|
// Not running, so reset elapsed time:
|
|
mElapsedTime = 0;
|
|
// And reset initialised flag:
|
|
mIsInitialised = false;
|
|
return true;
|
|
}
|
|
|
|
// Is running so reset effective start time - BUT THIS DOES NOT stop the
|
|
// stopwatch:
|
|
mEffectiveStartDateTime = QDateTime::currentDateTimeUtc();
|
|
return true;
|
|
}
|
|
|
|
void stopWatch::adjustMilliSeconds(const qint64 adjustment)
|
|
{
|
|
if (!mIsInitialised) {
|
|
// We can initialise things in this case by setting the flag and falling
|
|
// through into the is not running situation - with the elapsed time
|
|
// being zero up to now we just have to add on the adjustment:
|
|
mIsInitialised = true;
|
|
}
|
|
|
|
if (!mIsRunning) {
|
|
// Not running so adjust stored elapsed time:
|
|
mElapsedTime += adjustment;
|
|
}
|
|
|
|
// Is running so adjust effective start time - to increase the effective
|
|
// elapsed time we must subtract the adjustment from the effect start time:
|
|
mEffectiveStartDateTime = mEffectiveStartDateTime.addMSecs(-adjustment);
|
|
}
|
|
|
|
qint64 stopWatch::getElapsedMilliSeconds() const
|
|
{
|
|
if (!mIsInitialised) {
|
|
// Never started so no elapsed time:
|
|
return 0;
|
|
}
|
|
|
|
if (!mIsRunning) {
|
|
// Not running - so return elapsed time:
|
|
return mElapsedTime;
|
|
}
|
|
|
|
// Is running so calculate elapsed time:
|
|
return mEffectiveStartDateTime.msecsTo(QDateTime::currentDateTimeUtc());
|
|
}
|
|
|
|
QString stopWatch::getElapsedDayTimeString() const
|
|
{
|
|
using namespace std::chrono_literals;
|
|
|
|
if (!mIsInitialised) {
|
|
return qsl("+:0:0:0:0:000");
|
|
}
|
|
|
|
qint64 elapsed = 0;
|
|
if (mIsRunning) {
|
|
elapsed = mEffectiveStartDateTime.msecsTo(QDateTime::currentDateTimeUtc());
|
|
} else {
|
|
elapsed = mElapsedTime;
|
|
}
|
|
|
|
bool isNegative = false;
|
|
if (elapsed < 0) {
|
|
isNegative = true;
|
|
elapsed *= -1;
|
|
}
|
|
|
|
qint64 const days = elapsed / std::chrono::milliseconds(24h).count();
|
|
qint64 remainder = elapsed - (days * std::chrono::milliseconds(24h).count());
|
|
quint8 const hours = static_cast<quint8>(remainder / std::chrono::milliseconds(1h).count());
|
|
remainder = remainder - (hours * std::chrono::milliseconds(1h).count());
|
|
quint8 const minutes = static_cast<quint8>(remainder / std::chrono::milliseconds(1min).count());
|
|
remainder = remainder - (minutes * std::chrono::milliseconds(1min).count());
|
|
quint8 const seconds = static_cast<quint8>(remainder / std::chrono::milliseconds(1s).count());
|
|
quint16 const milliSeconds = static_cast<quint16>(remainder - (seconds * std::chrono::milliseconds(1s).count()));
|
|
return qsl("%1:%2:%3:%4:%5:%6")
|
|
.arg((isNegative ? QLatin1String("-") : QLatin1String("+")),
|
|
QString::number(days),
|
|
QString::number(hours),
|
|
QString::number(minutes),
|
|
QString::number(seconds),
|
|
QString::number(milliSeconds));
|
|
}
|
|
|
|
Host::Host(int port, const QString& hostname, const QString& login, const QString& pass, int id)
|
|
: mTelnet(this, hostname)
|
|
, mLuaInterpreter(this, hostname, id)
|
|
, mMxpClient(this)
|
|
, mMxpProcessor(&mMxpClient)
|
|
, mMxpFrameManager(this)
|
|
, mpMap(new TMap(this, hostname))
|
|
, mpMedia(new TMedia(this, hostname))
|
|
, mpAuth(new GMCPAuthenticator(this))
|
|
, mTimerDebugOutputSuppressionInterval(QTime())
|
|
, mSearchOptions(dlgTriggerEditor::SearchOption::SearchOptionNone)
|
|
, mBufferSearchOptions(TConsole::SearchOption::SearchOptionNone)
|
|
, mpDlgIRC(nullptr)
|
|
, mMMCPServer(nullptr)
|
|
, mpDlgProfilePreferences(nullptr)
|
|
, mMMCPChatPort(csDefaultMMCPHostPort)
|
|
, mMMCPChatPrefix(csDefaultChatPrefix)
|
|
, mMMCPAutostartServer(false)
|
|
, mMMCPAllowPeekRequests(false)
|
|
, mMMCPPrefixEmotes(false)
|
|
, mMMCPAddChatMessageNewline(true)
|
|
, mMMCPAutoAcceptCalls(true)
|
|
, mMMCPShowSnoopInMainConsole(true)
|
|
, mTutorialForCompactLineAlreadyShown(false)
|
|
, mLuaInterface(nullptr)
|
|
, mTriggerUnit(this)
|
|
, mTimerUnit(this)
|
|
, mScriptUnit(this)
|
|
, mAliasUnit(this)
|
|
, mActionUnit(this)
|
|
, mKeyUnit(this)
|
|
, mGifTracker()
|
|
, mHostID(id)
|
|
, mHostName(hostname)
|
|
, mLogin(login)
|
|
, mPass(pass)
|
|
, mPort(port)
|
|
{
|
|
TDebug::addHost(this, mHostName);
|
|
setDisplayFont(QFont(qsl("Bitstream Vera Sans Mono"), 14, QFont::Normal));
|
|
|
|
// The "autolog" sentinel file controls whether logging the game's text as
|
|
// plain text or HTML is immediately resumed on profile loading. Do not
|
|
// confuse it with the "autologin" item, which controls whether the profile
|
|
// is automatically started when the Mudlet application is run!
|
|
mLogStatus = QFile::exists(mudlet::getMudletPath(enums::profileDataItemPath, mHostName, qsl("autolog")));
|
|
// "autotimestamp" determines if profile loads with timestamps enabled
|
|
mTimeStampStatus = QFile::exists(mudlet::getMudletPath(enums::profileDataItemPath, mHostName, qsl("autotimestamp")));
|
|
mLuaInterface.reset(new LuaInterface(this->getLuaInterpreter()->getLuaGlobalState()));
|
|
|
|
// Copy across the details needed for the "color_table":
|
|
mLuaInterpreter.updateAnsi16ColorsInTable();
|
|
mLuaInterpreter.updateExtendedAnsiColorsInTable();
|
|
|
|
const QString directoryLogFile = mudlet::getMudletPath(enums::profileDataItemPath, mHostName, qsl("log"));
|
|
const QString logFileName = qsl("%1/errors.txt").arg(directoryLogFile);
|
|
const QDir dirLogFile;
|
|
if (!dirLogFile.exists(directoryLogFile)) {
|
|
if (!dirLogFile.mkpath(directoryLogFile)) {
|
|
qWarning() << "Host: failed to create error log directory:" << directoryLogFile;
|
|
}
|
|
}
|
|
mErrorLogFile.setFileName(logFileName);
|
|
if (!mErrorLogFile.open(QIODevice::Append)) {
|
|
qWarning() << "Host: failed to open error log file for appending:" << mErrorLogFile.errorString();
|
|
}
|
|
/*
|
|
* Mudlet will log messages in ASCII, but force a universal (UTF-8) encoding
|
|
* since user-content can contain anything and someone else reviewing
|
|
* such logs need not have the same default encoding which would be used
|
|
* otherwise - note that this must be done AFTER setDevice(...):
|
|
*/
|
|
mErrorLogStream.setDevice(&mErrorLogFile);
|
|
|
|
mGMCP_merge_table_keys.append("Char.Status");
|
|
mDoubleClickIgnore.insert('"');
|
|
mDoubleClickIgnore.insert('\'');
|
|
|
|
// search engine load entries
|
|
// clang-format off
|
|
mSearchEngineData = QMap<QString, QString>(
|
|
{
|
|
{"Bing", "https://www.bing.com/search?q="},
|
|
{"DuckDuckGo", "https://duckduckgo.com/?q="},
|
|
{"Google", "https://www.google.com/search?q="}
|
|
});
|
|
// clang-format on
|
|
|
|
// These details are filled in by the dlgConnectionProfile class when that
|
|
// is used to select a profile however when the profile is auto-loaded or
|
|
// selected from a command line argument that does not happen and they need
|
|
// to be populated ASAP from the stored details in the profile's settings.
|
|
mUrl = readProfileData(qsl("url"));
|
|
const QString host_port = readProfileData(qsl("port"));
|
|
if (bool isOk = false; !host_port.isEmpty() && host_port.toInt(&isOk) && isOk) {
|
|
mPort = host_port.toInt();
|
|
}
|
|
mLogin = readProfileData(qsl("login"));
|
|
|
|
const QString sslVal = readProfileData(qsl("ssl_tsl"));
|
|
if (!sslVal.isEmpty() && sslVal.toInt() == Qt::Checked) {
|
|
mSslTsl = true;
|
|
}
|
|
|
|
const QString val = readProfileData(qsl("autoreconnect"));
|
|
setAutoReconnect(!val.isEmpty() && val.toInt() == Qt::Checked);
|
|
|
|
// 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(qsl("encoding")).toUtf8();
|
|
if (!savedEncoding.isEmpty()) {
|
|
mTelnet.setEncoding(savedEncoding, false);
|
|
}
|
|
|
|
auto modeStr = readProfileData(qsl("discordmode"));
|
|
if (!modeStr.isEmpty()) {
|
|
const int modeInt = modeStr.toInt();
|
|
if (modeInt >= DiscordDisabled && modeInt <= DiscordShowGameDetails) {
|
|
mDiscordMode = static_cast<DiscordMode>(modeInt);
|
|
}
|
|
} else {
|
|
// Migrate old profiles: if the user had opted out of server-side
|
|
// Discord, respect that choice by using ShowMudletOnly
|
|
auto oldOptin = readProfileData(qsl("discordserveroptin"));
|
|
if (!oldOptin.isEmpty() && oldOptin.toInt() != Qt::Checked) {
|
|
mDiscordMode = DiscordShowMudletOnly;
|
|
}
|
|
}
|
|
|
|
if (mudlet::self()->storingPasswordsSecurely()) {
|
|
loadSecuredPassword();
|
|
} else {
|
|
QString password{readProfileData(qsl("password"))};
|
|
if (!password.isEmpty()) {
|
|
setPass(password);
|
|
password.clear();
|
|
}
|
|
}
|
|
|
|
if (mudlet::self()->publicTestVersion) {
|
|
thankForUsingPTB();
|
|
}
|
|
|
|
if (mudlet::self()->smFirstLaunch) {
|
|
QTimer::singleShot(0ms, this, [this]() {
|
|
if (mpConsole) {
|
|
mpConsole->mpCommandLine->setPlaceholderText(tr("Text to send to the game"));
|
|
}
|
|
});
|
|
}
|
|
|
|
connect(&mTelnet, &cTelnet::signal_disconnected, this, [this]() {
|
|
purgeTimer.start(1min);
|
|
|
|
if (getForceMXPProcessorOn()) {
|
|
mMxpProcessor.disable();
|
|
}
|
|
});
|
|
connect(&mTelnet, &cTelnet::signal_connected, this, [this]() {
|
|
purgeTimer.stop();
|
|
|
|
// Reset MXP frames on reconnect - per user feedback, frames shouldn't persist
|
|
// between sessions as the server should send new FRAME commands
|
|
mMxpFrameManager.resetAllFrames();
|
|
|
|
if (getForceMXPProcessorOn()) {
|
|
mMxpProcessor.enable();
|
|
// When force-enabling MXP (typically for games like IRE MUDs that don't
|
|
// negotiate properly), lock to secure mode for compatibility
|
|
mMxpProcessor.setMode(MXP_MODE_CODE_LOCK_SECURE);
|
|
qDebug() << "MXP enabled (forced)";
|
|
}
|
|
});
|
|
connect(&purgeTimer, &QTimer::timeout, this, &Host::slot_purgeTemps);
|
|
mDeferredSaveTimer.setSingleShot(true);
|
|
connect(&mDeferredSaveTimer, &QTimer::timeout, this, &Host::slot_saveProfileAfterPackageChange);
|
|
connect(this, &Host::signal_forceMXPProcessorOnChanged, this, [this](bool enabled) {
|
|
if (enabled) {
|
|
if (!mMxpProcessor.isEnabled()) {
|
|
mMxpProcessor.enable();
|
|
// When force-enabling MXP (typically for games like IRE MUDs that don't
|
|
// negotiate properly), lock to secure mode for compatibility with games
|
|
// that use secure tags without sending mode switches
|
|
mMxpProcessor.setMode(MXP_MODE_CODE_LOCK_SECURE);
|
|
qDebug() << "MXP enabled (forced)";
|
|
}
|
|
} else if (mMxpProcessor.isEnabled() && !mTelnet.isMXPEnabled()) {
|
|
mMxpProcessor.disable();
|
|
qDebug() << "MXP disabled (forced)";
|
|
}
|
|
});
|
|
|
|
// enable by default in case of offline connection; if the profile connects - timer will be disabled
|
|
purgeTimer.start(1min);
|
|
|
|
auto i = mudlet::self()->mpShortcutsManager->iterator();
|
|
while (i.hasNext()) {
|
|
auto entry = i.next();
|
|
profileShortcuts[entry] = std::make_unique<QKeySequence>(*mudlet::self()->mpShortcutsManager->getSequence(entry));
|
|
}
|
|
|
|
auto settings = mudlet::self()->getQSettings();
|
|
const auto interval = settings->value("autosaveIntervalMinutes", 2).toInt();
|
|
startMapAutosave(interval);
|
|
|
|
mMapperCenterSmallAreas = settings->value("mapCenterSmallAreas", false).toBool();
|
|
}
|
|
|
|
Host::~Host()
|
|
{
|
|
// Mark the host as closing down to prevent keybinding processing during destruction
|
|
mIsClosingDown = true;
|
|
|
|
// closeChildren() normally does this, but a Host whose console has already
|
|
// gone never gets there - and a package save left to fire from a Host that
|
|
// is being taken apart runs against freed members (#9653):
|
|
mDeferredSaveTimer.stop();
|
|
|
|
// The editor is a parentless top-level window, so delete it here while the
|
|
// units it references are still alive. Null the QPointer first: it only
|
|
// clears itself once ~QObject is reached, so anything looking at
|
|
// mpEditorDialog mid-teardown would find a half-destroyed widget:
|
|
if (auto* pEditor = mpEditorDialog.data()) {
|
|
mpEditorDialog = nullptr;
|
|
delete pEditor;
|
|
}
|
|
|
|
if (auto* pNotePad = mpNotePad.data()) {
|
|
if (mudlet::self()) {
|
|
pNotePad->save();
|
|
pNotePad->close();
|
|
}
|
|
mpNotePad = nullptr;
|
|
delete pNotePad;
|
|
}
|
|
|
|
if (auto* pDlgIRC = mpDlgIRC.data()) {
|
|
mpDlgIRC = nullptr;
|
|
delete pDlgIRC;
|
|
}
|
|
|
|
for (const auto& pToolBar : mActionUnit.getToolBarList()) {
|
|
delete pToolBar.data();
|
|
}
|
|
|
|
// This needs to be cleared here while the Host object is still valid,
|
|
// otherwise it'll be cleared when the Host object is being destroyed,
|
|
// which can lead to a crash when closing multiple profiles at once.
|
|
mpLastCommandLineUsed.clear();
|
|
|
|
mStopWatchMap.clear();
|
|
|
|
mErrorLogStream.flush();
|
|
mErrorLogFile.close();
|
|
// Since this is a destructor, it's risky to rely on member variables within the destructor itself.
|
|
// To avoid this, we can pass the profile name as an argument instead of accessing it
|
|
// directly as a member variable. This ensures the destructor doesn't depend on the
|
|
// object's state being valid.
|
|
|
|
// Don't pass 'this' pointer during destruction - it's unsafe as the Host is being destroyed
|
|
TDebug::removeHost(nullptr, mHostName);
|
|
}
|
|
|
|
void Host::forceClose()
|
|
{
|
|
if (!mpConsole) {
|
|
// The main console has gone away so we must already be dying
|
|
return;
|
|
}
|
|
|
|
mForcedClose = true;
|
|
postMessage(tr("[ ALERT ] - This profile will now save and close."));
|
|
// Ensure the above is displayed before proceeding...
|
|
qApp->processEvents();
|
|
|
|
// Because we have set the mForcedClose flag above a requestClose()
|
|
// afterwards WILL succeed
|
|
}
|
|
|
|
// Returns true if we are closing down
|
|
bool Host::requestClose()
|
|
{
|
|
if (!mpConsole) {
|
|
// The main console has gone away so we must already be dying
|
|
return true;
|
|
}
|
|
|
|
// This call ends up at the (void) TMainConsole::closeEvent(...) and causes
|
|
// the close() method called here to return a true if the event was
|
|
// accepted:
|
|
if (!mpConsole->close()) {
|
|
// Nope the user doesn't want this to close - and it won't have set its
|
|
// mEnableClose flag:
|
|
return false;
|
|
}
|
|
|
|
// The above will have initiated a save of the profile (and its map) if it
|
|
// got a true returned from the TMainConsole::close() call.
|
|
|
|
// Get rid of any dialogs we might have open:
|
|
closeChildren();
|
|
|
|
// This time this will succeed as mEnableClose is set:
|
|
mpConsole->close();
|
|
return true;
|
|
}
|
|
|
|
void Host::closeChildren()
|
|
{
|
|
mIsClosingDown = true;
|
|
// Drop the profile save a package install/uninstall put off: the close path
|
|
// has already saved the profile with that change in it (or the user declined
|
|
// to save at all), and a save that outlives the profile runs on a destroyed
|
|
// Host (#9653).
|
|
if (mDeferredSaveTimer.isActive()) {
|
|
qDebug().nospace().noquote() << "Host::closeChildren() INFO - dropping the profile save that a package change owed \"" << getName() << "\": the close saves the profile itself.";
|
|
mDeferredSaveTimer.stop();
|
|
}
|
|
const auto hostToolBarMap = getActionUnit()->getToolBarList();
|
|
// disconnect before removing objects from memory as sysDisconnectionEvent needs that stuff.
|
|
mTelnet.terminateConnection();
|
|
|
|
stopAllTriggers();
|
|
|
|
if (mpEditorDialog) {
|
|
mpEditorDialog->setAttribute(Qt::WA_DeleteOnClose);
|
|
mpEditorDialog->close();
|
|
mpEditorDialog = nullptr;
|
|
}
|
|
|
|
for (const QString& consoleName : mpConsole->mSubConsoleMap.keys()) {
|
|
// Only user-windows will be in this map:
|
|
auto pD = mpConsole->mDockWidgetMap.value(consoleName);
|
|
// All User TConsole's will be in this map:
|
|
auto pC = mpConsole->mSubConsoleMap.value(consoleName);
|
|
if (pD) {
|
|
// This undocks the widget
|
|
mudlet::self()->removeDockWidget(pD);
|
|
}
|
|
|
|
// This will remove both what pD and pC point at from their respective
|
|
// QMaps:
|
|
pC->close();
|
|
}
|
|
|
|
if (mpNotePad) {
|
|
mpNotePad->save();
|
|
mpNotePad->setAttribute(Qt::WA_DeleteOnClose);
|
|
mpNotePad->close();
|
|
mpNotePad = nullptr;
|
|
}
|
|
|
|
for (TToolBar* pTB : hostToolBarMap) {
|
|
if (pTB) {
|
|
pTB->setAttribute(Qt::WA_DeleteOnClose);
|
|
pTB->deleteLater();
|
|
}
|
|
}
|
|
|
|
// close IRC client window if it is open.
|
|
if (mpDlgIRC) {
|
|
mpDlgIRC->setAttribute(Qt::WA_DeleteOnClose);
|
|
mpDlgIRC->deleteLater();
|
|
mpDlgIRC = nullptr;
|
|
}
|
|
}
|
|
|
|
void Host::loadMap()
|
|
{
|
|
qDebug() << "Host::loadMap() - restore map case 4.";
|
|
if (mpMap->restore(QString())) {
|
|
mpMap->audit();
|
|
if (mpMap->mpMapper) {
|
|
mpMap->mpMapper->mp2dMap->init();
|
|
mpMap->mpMapper->updateAreaComboBox();
|
|
mpMap->mpMapper->resetAreaComboBoxToPlayerRoomArea();
|
|
mpMap->mpMapper->show();
|
|
}
|
|
}
|
|
}
|
|
|
|
void Host::startMapAutosave(const int interval)
|
|
{
|
|
if (interval > 0) {
|
|
startTimer(interval * 1min);
|
|
}
|
|
}
|
|
|
|
void Host::timerEvent(QTimerEvent* event)
|
|
{
|
|
Q_UNUSED(event)
|
|
|
|
autoSaveMap();
|
|
}
|
|
|
|
void Host::autoSaveMap()
|
|
{
|
|
if (mpMap->isUnsaved()) {
|
|
#if defined(DEBUG_MAPAUTOSAVE)
|
|
QString nowString = QDateTime::currentDateTimeUtc().toString("HH:mm:ss.zzz");
|
|
#endif
|
|
if (!mIsProfileLoadingSequence) {
|
|
#if defined(DEBUG_MAPAUTOSAVE)
|
|
qDebug().nospace().noquote() << "Host::autoSaveMap() INFO - map auto save initiated at:" << nowString << ".";
|
|
#endif
|
|
if (!mpConsole->saveMap(mudlet::getMudletPath(enums::profileMapPathFileName, mHostName, qsl("autosave.dat")))) {
|
|
mpMap->setSaveError(true);
|
|
} else {
|
|
mpMap->setSaveError(false);
|
|
}
|
|
#if defined(DEBUG_MAPAUTOSAVE)
|
|
} else {
|
|
qDebug().nospace().noquote() << "Host::autoSaveMap() INFO - map auto save requested at:" << nowString << " but declined whilst \"Host::mIsProfileLoadingSequence\" flag set.";
|
|
#endif
|
|
}
|
|
}
|
|
}
|
|
|
|
void Host::loadPackageInfo()
|
|
{
|
|
for (const auto& package : std::as_const(mInstalledPackages)) {
|
|
const QDir dir(mudlet::self()->getMudletPath(enums::profilePackagePath, getName(), package));
|
|
if (dir.exists(qsl("config.lua"))) {
|
|
getPackageConfig(dir.absoluteFilePath(qsl("config.lua")));
|
|
}
|
|
}
|
|
}
|
|
|
|
void Host::createModuleBackup(const QString& filename, const QString& saveName)
|
|
{
|
|
const QString time = QDateTime::currentDateTime().toString("yyyy-MM-dd#HH-mm-ss");
|
|
QFile::copy(filename, saveName + time);
|
|
}
|
|
|
|
QList<QFuture<bool>> Host::pendingXmlSaveFutures() const
|
|
{
|
|
QList<QFuture<bool>> futures;
|
|
for (const auto& writer : writers) {
|
|
futures += writer->saveFutures;
|
|
}
|
|
return futures;
|
|
}
|
|
|
|
void Host::waitForAsyncXmlSave()
|
|
{
|
|
// Snapshot on the main thread - see pendingXmlSaveFutures()
|
|
const auto futures = pendingXmlSaveFutures();
|
|
for (auto future : futures) {
|
|
future.waitForFinished();
|
|
}
|
|
}
|
|
|
|
QList<Host::ModuleWriteJob> Host::prepareModuleSaves(bool backup)
|
|
{
|
|
// Runs on the main thread so it can safely read the live trigger/timer/... lists
|
|
// (via writeModuleXML) and mutate the `writers`/`mModulesToSync`/`modulesToWrite`
|
|
// bookkeeping. The returned jobs carry everything writeModuleFiles() needs - the
|
|
// document and every path - so the background task touches neither that shared
|
|
// state nor the Host, which may well be destroyed before the task even starts.
|
|
QList<ModuleWriteJob> jobs;
|
|
mModulesToSync.clear();
|
|
const QString backupPath = backup ? mudlet::getMudletPath(enums::moduleBackupsPath) : QString();
|
|
QMapIterator<QString, QStringList> it(modulesToWrite);
|
|
while (it.hasNext()) {
|
|
it.next();
|
|
const QStringList entry = it.value();
|
|
const QString moduleName = it.key();
|
|
const QString filename = entry.at(0);
|
|
|
|
if (!mModulesLoadedOk.contains(moduleName)) {
|
|
continue;
|
|
}
|
|
|
|
QString xmlFilename = filename;
|
|
if (filename.endsWith(qsl("mpackage"), Qt::CaseInsensitive) || filename.endsWith(qsl("zip"), Qt::CaseInsensitive)) {
|
|
xmlFilename = mudlet::getMudletPath(enums::profilePackagePathFileName, mHostName, moduleName);
|
|
// The write below goes into this folder, so it has to exist before the
|
|
// write and not - as it used to - after it: a module whose unpacked folder
|
|
// the user has removed would otherwise fail to write, and then have its
|
|
// now-stale XML dropped from its archive without a replacement going in.
|
|
const QString packagePath = mudlet::getMudletPath(enums::profilePackagePath, mHostName, moduleName);
|
|
if (auto packageDir = QDir(packagePath); !packageDir.exists()) {
|
|
packageDir.mkpath(packagePath);
|
|
}
|
|
}
|
|
|
|
auto writer = std::make_shared<XMLexport>(this);
|
|
writer->writeModuleXML(moduleName);
|
|
// The writer stays in `writers` purely as the save-in-progress token that
|
|
// xmlSaved() retires on the main thread, so the XMLexport - a QObject with
|
|
// main-thread affinity - is only ever destroyed there.
|
|
writers.insert(xmlFilename, writer);
|
|
jobs.append({writer->takeExportDocument(), moduleName, filename, xmlFilename, backup ? backupPath + moduleName : QString()});
|
|
|
|
if (entry.at(1).toInt()) {
|
|
mModulesToSync << moduleName;
|
|
}
|
|
}
|
|
modulesToWrite.clear();
|
|
if (!backupPath.isEmpty() && !jobs.isEmpty()) {
|
|
auto backupDir = QDir(backupPath);
|
|
if (!backupDir.exists()) {
|
|
backupDir.mkpath(backupPath);
|
|
}
|
|
}
|
|
return jobs;
|
|
}
|
|
|
|
void Host::writeModuleFiles(const QList<ModuleWriteJob>& jobs)
|
|
{
|
|
// Pure file I/O over self-contained jobs, usually on a thread pool thread. It is
|
|
// static because a close that answers "No" to "Save profile?", and one that finds
|
|
// the main console already gone, wait for nothing: the Host that ordered these
|
|
// writes can be, and regularly is, destroyed while they are still queued.
|
|
for (const auto& job : jobs) {
|
|
if (!job.backupName.isEmpty()) {
|
|
createModuleBackup(job.filename, job.backupName);
|
|
}
|
|
if (!XMLexport::saveXmlDocToFile(job.xmlFilename, *job.document)) {
|
|
qWarning().noquote().nospace() << "Host::writeModuleFiles() WARNING - failed to write module \"" << job.moduleName << "\" to \"" << job.xmlFilename << "\".";
|
|
}
|
|
updateModuleZip(job);
|
|
}
|
|
}
|
|
|
|
void Host::reloadModules()
|
|
{
|
|
//synchronize modules across sessions
|
|
for (auto otherHost : mudlet::self()->getHostManager()) {
|
|
if (otherHost == this || !otherHost->mpConsole) {
|
|
continue;
|
|
}
|
|
const QMap<QString, int>& modulePri = otherHost->mModulePriorities;
|
|
QMap<int, QStringList> moduleOrder;
|
|
|
|
auto modulePrioritiesIt = modulePri.constBegin();
|
|
while (modulePrioritiesIt != modulePri.constEnd()) {
|
|
moduleOrder[modulePrioritiesIt.value()].append(modulePrioritiesIt.key());
|
|
++modulePrioritiesIt;
|
|
}
|
|
|
|
QMapIterator<int, QStringList> it(moduleOrder);
|
|
while (it.hasNext()) {
|
|
it.next();
|
|
const QStringList moduleList = it.value();
|
|
for (const auto& moduleName : moduleList) {
|
|
if (mModulesToSync.contains(moduleName)) {
|
|
otherHost->reloadModule(moduleName, mHostName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
mModulesToSync.clear();
|
|
}
|
|
|
|
void Host::updateModuleZip(const ModuleWriteJob& job)
|
|
{
|
|
// Static for the same reason writeModuleFiles() is: every path it needs was
|
|
// resolved, and every folder it needs created, on the main thread beforehand.
|
|
const QString zipName = job.filename;
|
|
const QString moduleName = job.moduleName;
|
|
if (!(zipName.endsWith(qsl("mpackage"), Qt::CaseInsensitive) || zipName.endsWith(qsl("zip"), Qt::CaseInsensitive))) {
|
|
return;
|
|
}
|
|
zip* zipFile = nullptr;
|
|
const QString filename_xml = job.xmlFilename;
|
|
int err = 0;
|
|
zipFile = zip_open(zipName.toStdString().c_str(), ZIP_CREATE, &err);
|
|
if (!zipFile) {
|
|
zip_error_t zipError;
|
|
zip_error_init_with_code(&zipError, err);
|
|
/*: This zipError message is shown when the libzip library code is unable
|
|
to open the file that was to be the end result of the export process.
|
|
As this may be an existing file anywhere in the computer's
|
|
file-system(s) it is possible that permissions on the directory or an
|
|
existing file that is to be overwritten may be a source of problems
|
|
here.
|
|
*/
|
|
qWarning().noquote().nospace() << "Host::updateModuleZip(\"" << zipName << "\", \"" << moduleName << "\") WARNING - failed to open module to update it, error: \""
|
|
<< zip_error_strerror(&zipError) << "\"";
|
|
zip_error_fini(&zipError);
|
|
return;
|
|
}
|
|
const int xmlIndex = zip_name_locate(zipFile, qsl("%1.xml").arg(moduleName).toUtf8().constData(), ZIP_FL_ENC_GUESS);
|
|
zip_delete(zipFile, xmlIndex);
|
|
struct zip_source* s = zip_source_file(zipFile, filename_xml.toUtf8().constData(), 0, -1);
|
|
if (mudlet::smDebugMode && s == nullptr) {
|
|
//: This error message will appear when the xml file inside the module zip cannot be updated for some reason.
|
|
TDebug(QColor(Qt::white), QColor(Qt::red)) << tr("Failed to open xml file \"%1\" inside module %2 to update it. Error message was: \"%3\".").arg(filename_xml, zipName, zip_strerror(zipFile));
|
|
}
|
|
err = zip_file_add(zipFile, qsl("%1.xml").arg(moduleName).toUtf8().constData(), s, ZIP_FL_ENC_UTF_8 | ZIP_FL_OVERWRITE);
|
|
|
|
if (zipFile) {
|
|
// This is the point at which the data is written out to the archive,
|
|
// might take a bit of time:
|
|
err = zip_close(zipFile);
|
|
}
|
|
|
|
if (err == -1) {
|
|
if (mudlet::smDebugMode && err == -1) {
|
|
//: This error message will appear when a module is saved as package but cannot be done for some reason.
|
|
TDebug(QColor(Qt::white), QColor(Qt::red)) << tr("Failed to save \"%1\" to module \"%2\". Error message was: \"%3\".").arg(moduleName, zipName, zip_strerror(zipFile));
|
|
}
|
|
// Properly dispose of things after failing to zip_close(...) the
|
|
// archive:
|
|
zip_discard(zipFile);
|
|
}
|
|
}
|
|
|
|
void Host::reloadModule(const QString& syncModuleName, const QString& syncingFromHost)
|
|
{
|
|
//Wait till profile is finished saving
|
|
if (syncingFromHost.isEmpty() && currentlySavingProfile()) {
|
|
//create a dummy object to singleshot connect (disconnect/delete after execution)
|
|
QObject* obj = new QObject(this);
|
|
connect(this, &Host::profileSaveFinished, obj, [=, this]() {
|
|
reloadModule(syncModuleName);
|
|
obj->deleteLater();
|
|
});
|
|
return;
|
|
}
|
|
QMap<QString, QStringList> installedModules = mInstalledModules;
|
|
QMapIterator<QString, QStringList> moduleIterator(installedModules);
|
|
while (moduleIterator.hasNext()) {
|
|
moduleIterator.next();
|
|
const auto& moduleName = moduleIterator.key();
|
|
const auto& moduleLocation = moduleIterator.value()[0];
|
|
QString fileName = moduleLocation;
|
|
if (moduleName == syncModuleName) {
|
|
if (!syncingFromHost.isEmpty() && (fileName.endsWith(qsl(".zip"), Qt::CaseInsensitive) || fileName.endsWith(qsl(".mpackage"), Qt::CaseInsensitive))) {
|
|
uninstallPackage(moduleName, enums::PackageModuleType::ModuleSync);
|
|
fileName = mudlet::getMudletPath(enums::profilePackagePathFileName, syncingFromHost, moduleName);
|
|
installPackage(fileName, enums::PackageModuleType::ModuleSync);
|
|
QStringList moduleEntry;
|
|
moduleEntry << moduleLocation;
|
|
moduleEntry << qsl("0");
|
|
mInstalledModules[moduleName] = moduleEntry;
|
|
} else {
|
|
uninstallPackage(moduleName, enums::PackageModuleType::ModuleSync);
|
|
installPackage(fileName, enums::PackageModuleType::ModuleSync);
|
|
}
|
|
}
|
|
}
|
|
//iterate through mInstalledModules again and reset the entry flag to be correct.
|
|
//both the installedModules and mInstalled should be in the same order now as well
|
|
moduleIterator.toFront();
|
|
while (moduleIterator.hasNext()) {
|
|
moduleIterator.next();
|
|
const QStringList entry = installedModules[moduleIterator.key()];
|
|
mInstalledModules[moduleIterator.key()] = entry;
|
|
}
|
|
}
|
|
|
|
std::pair<bool, QString> Host::changeModuleSync(const QString& moduleName, const QLatin1String& value)
|
|
{
|
|
if (moduleName.isEmpty()) {
|
|
return {false, qsl("module name cannot be an empty string")};
|
|
}
|
|
|
|
if (mInstalledModules.contains(moduleName)) {
|
|
QStringList moduleStringList = mInstalledModules[moduleName];
|
|
moduleStringList[1] = value;
|
|
mInstalledModules[moduleName] = moduleStringList;
|
|
return {true, QString()};
|
|
}
|
|
return {false, qsl("module name '%1' not found").arg(moduleName)};
|
|
}
|
|
|
|
std::pair<bool, QString> Host::getModuleSync(const QString& moduleName)
|
|
{
|
|
if (moduleName.isEmpty()) {
|
|
return {false, qsl("module name cannot be an empty string")};
|
|
}
|
|
|
|
if (mInstalledModules.contains(moduleName)) {
|
|
QStringList moduleStringList = mInstalledModules[moduleName];
|
|
return {true, moduleStringList[1]};
|
|
}
|
|
return {false, qsl("module name '%1' not found").arg(moduleName)};
|
|
}
|
|
|
|
bool Host::resetProfile_phase1()
|
|
{
|
|
if (mResetProfile) {
|
|
qWarning() << "Host::resetProfile_phase1() called while reset is already in progress, ignoring";
|
|
return false;
|
|
}
|
|
|
|
// Phase 2 lua_close()s the very state the pump is running Lua code on, so
|
|
// refuse rather than reset into a use-after-free.
|
|
if (mLuaInterpreter.pumpingEvents()) {
|
|
qWarning() << "Host::resetProfile_phase1() called while the test-mode event pump is running, ignoring";
|
|
return false;
|
|
}
|
|
|
|
mAliasUnit.stopAllTriggers();
|
|
mTriggerUnit.stopAllTriggers();
|
|
mTimerUnit.stopAllTriggers();
|
|
mKeyUnit.stopAllTriggers();
|
|
mResetProfile = true;
|
|
|
|
QTimer::singleShot(0ms, this, [this]() {
|
|
resetProfile_phase2();
|
|
});
|
|
return true;
|
|
}
|
|
|
|
void Host::resetProfile_phase2()
|
|
{
|
|
getAliasUnit()->removeAllTempAliases();
|
|
getTimerUnit()->removeAllTempTimers();
|
|
getTriggerUnit()->removeAllTempTriggers();
|
|
getKeyUnit()->removeAllTempKeys();
|
|
removeAllNonPersistentStopWatches();
|
|
mpMedia->stopAllMediaPlayers();
|
|
|
|
mAliasUnit.doCleanup();
|
|
mTimerUnit.doCleanup();
|
|
mTriggerUnit.doCleanup();
|
|
mKeyUnit.doCleanup();
|
|
mActionUnit.doCleanup();
|
|
mpConsole->resetMainConsole();
|
|
// Drain queued DeferredDelete events so old TLabel destructors run their
|
|
// luaL_unref against the still-live Lua state. Without this, those unrefs
|
|
// execute after initLuaGlobals() has swapped in a new state and corrupt
|
|
// freshly-issued registry indices in the new state, which surfaces as
|
|
// "attempt to call a number value" when label callbacks fire.
|
|
QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
|
|
mEventHandlerMap.clear();
|
|
mAnonymousEventHandlerFunctions.clear();
|
|
mEventMap.clear();
|
|
mLuaInterpreter.abortAllDownloads();
|
|
mLuaInterpreter.initLuaGlobals();
|
|
// initLuaGlobals() closed the old lua_State, so the LuaInterface (and its
|
|
// VarUnit tree of registry references) must be rebuilt against the new one.
|
|
// The VarUnit's name-keyed saved/hidden bookkeeping does not depend on the
|
|
// lua_State though and is only repopulated by XMLimport at profile open, so
|
|
// carry it over - otherwise the first save after a reset silently drops all
|
|
// of the user's saved variables from the profile:
|
|
const QSet<QString> savedVars = mLuaInterface->getVarUnit()->savedVars;
|
|
const QSet<QString> hiddenByUserVars = mLuaInterface->getVarUnit()->hiddenByUser;
|
|
mLuaInterface.reset(new LuaInterface(mLuaInterpreter.getLuaGlobalState()));
|
|
mLuaInterface->getVarUnit()->savedVars = savedVars;
|
|
mLuaInterface->getVarUnit()->hiddenByUser = hiddenByUserVars;
|
|
mLuaInterpreter.loadGlobal();
|
|
// Profile load hides Mudlet's own Lua API right after loadGlobal() (see
|
|
// mudlet::slot_connectionDialogueFinished()); do the same here so the
|
|
// Variables view keeps showing only the user's variables after a reset:
|
|
hideMudletsVariables();
|
|
|
|
// Have to recopy the values into the Lua "color_table"
|
|
mLuaInterpreter.updateAnsi16ColorsInTable();
|
|
mLuaInterpreter.updateExtendedAnsiColorsInTable();
|
|
|
|
mBlockScriptCompile = false;
|
|
|
|
mAliasUnit.reenableAllTriggers();
|
|
mTimerUnit.reenableAllTriggers();
|
|
mTriggerUnit.reenableAllTriggers();
|
|
mKeyUnit.reenableAllTriggers();
|
|
|
|
// This is where the scripts for the profile get compiled (which confirms
|
|
// that they are valid) and all the Lua code outside of functions gets run:
|
|
getTimerUnit()->compileAll();
|
|
getTriggerUnit()->compileAll();
|
|
getAliasUnit()->compileAll();
|
|
getActionUnit()->compileAll();
|
|
getKeyUnit()->compileAll();
|
|
getScriptUnit()->compileAll(true);
|
|
|
|
mResetProfile = false;
|
|
|
|
TEvent event{};
|
|
event.mArgumentList.append(QLatin1String("sysLoadEvent"));
|
|
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
|
|
// A zero value is how we send a "false" value - which indicates that
|
|
// this is for a reset profile and NOT a freshly loaded one:
|
|
event.mArgumentList.append(QString::number(0));
|
|
event.mArgumentTypeList.append(ARGUMENT_TYPE_BOOLEAN);
|
|
|
|
raiseEvent(event);
|
|
qDebug() << "resetProfile() DONE";
|
|
}
|
|
|
|
// Saves profile to disk - does not save items dirty in the editor, however.
|
|
// takes a directory to save in or an empty string for the default location
|
|
// as well as a boolean whenever to sync the modules or not
|
|
// returns true+filepath if successful or false+error message otherwise
|
|
std::tuple<bool, QString, QString> Host::saveProfile(const QString& saveFolder, const QString& saveName, bool syncModules)
|
|
{
|
|
QString directory_xml;
|
|
if (saveFolder.isEmpty()) {
|
|
directory_xml = mudlet::getMudletPath(enums::profileXmlFilesPath, getName());
|
|
} else {
|
|
directory_xml = saveFolder;
|
|
}
|
|
|
|
QString filename_xml;
|
|
if (saveName.isEmpty()) {
|
|
filename_xml = qsl("%1/%2.xml").arg(directory_xml, QDateTime::currentDateTime().toString(qsl("yyyy-MM-dd#HH-mm-ss")));
|
|
} else {
|
|
filename_xml = qsl("%1/%2.xml").arg(directory_xml, saveName);
|
|
}
|
|
|
|
if (!mLoadedOk) {
|
|
return {false, filename_xml, qsl("profile was not loaded correctly to begin with")};
|
|
}
|
|
|
|
if (mIsProfileLoadingSequence) {
|
|
// If we're inside of profile loading sequence modules might not be loaded yet, thus we can accidentally clear their contents
|
|
return {false, filename_xml, qsl("profile loading is in progress")};
|
|
}
|
|
|
|
const QDir dir_xml;
|
|
if (!dir_xml.exists(directory_xml)) {
|
|
dir_xml.mkpath(directory_xml);
|
|
}
|
|
|
|
if (currentlySavingProfile()) {
|
|
return {false, QString(), qsl("a save is already in progress")};
|
|
}
|
|
|
|
if (saveFolder.isEmpty() && saveName.isEmpty()) {
|
|
// This is likely to be the save as the profile is closed
|
|
qDebug().noquote().nospace() << "Host::saveProfile(...) INFO - called with no saveFolder or saveName arguments for profile '" << mHostName
|
|
<< "' so assuming it is an end of session save and the TCommandLines' histories need saving...";
|
|
emit signal_saveCommandLinesHistory();
|
|
}
|
|
|
|
auto writer = std::make_shared<XMLexport>(this);
|
|
writers.insert(qsl("profile"), writer);
|
|
if (!writer->exportHost(filename_xml)) {
|
|
writers.remove(qsl("profile"));
|
|
return {false, filename_xml, tr("the profile is no longer available")};
|
|
}
|
|
|
|
// Build the module XML documents and register their writers here, on the main
|
|
// thread, while the live profile data is quiescent: the background task below then
|
|
// only serializes the prepared documents and never touches `writers`,
|
|
// `modulesToWrite` or `mModulesToSync` (doing so from a pool thread was a
|
|
// heap-corrupting data race).
|
|
const bool backupModules = saveName != qsl("autosave");
|
|
const QList<ModuleWriteJob> moduleJobs = prepareModuleSaves(backupModules);
|
|
|
|
mWritingHostAndModules = true;
|
|
|
|
// emit signal to notify the UI that the save button should get disabled momentarily
|
|
// this needs to run after `writers` and `mWritingHostAndModules` have been set
|
|
// so that the currentlySavingProfile() check can run properly
|
|
emit profileSaveStarted();
|
|
|
|
// Only process events if we're not in the middle of closing down
|
|
// This prevents recursive closure scenarios that can lead to heap-use-after-free
|
|
if (!mIsClosingDown) {
|
|
qApp->processEvents();
|
|
}
|
|
|
|
// Snapshot the pending profile-save futures on the main thread - see
|
|
// pendingXmlSaveFutures(): the background task must not read `writers`/`saveFutures`
|
|
// itself, as the main thread mutates them whenever a save starts or finishes.
|
|
const QList<QFuture<bool>> xmlSaveFutures = pendingXmlSaveFutures();
|
|
// Parented so the profile owns the watcher outright. The deleteLater() below only
|
|
// arrives if the event loop lives long enough to deliver `finished` and then the
|
|
// deferred delete, which on the way out it does not - and a Host destroyed while
|
|
// the write is still queued would leave the watcher with no owner at all.
|
|
auto watcher = new QFutureWatcher<void>(this);
|
|
// Captures values only, never `this`: no wait for this task is guaranteed, so the
|
|
// Host can be destroyed while it is still queued or running.
|
|
mModuleFuture = QtConcurrent::run([xmlSaveFutures, moduleJobs]() {
|
|
// wait for the host xml to be ready before writing the modules out
|
|
for (auto future : xmlSaveFutures) {
|
|
future.waitForFinished();
|
|
}
|
|
Host::writeModuleFiles(moduleJobs);
|
|
});
|
|
// Only the names: holding the whole jobs would keep every module's document in
|
|
// memory until this runs, and all it needs is what to retire from `writers`.
|
|
QStringList savedModuleXmlNames;
|
|
savedModuleXmlNames.reserve(moduleJobs.size());
|
|
for (const auto& job : moduleJobs) {
|
|
savedModuleXmlNames << job.xmlFilename;
|
|
}
|
|
connect(watcher, &QFutureWatcher<void>::finished, this, [this, savedModuleXmlNames, syncModules]() {
|
|
// Finish on the main thread: the module documents are now on disk. Consume
|
|
// mModulesToSync via reloadModules() *before* the xmlSaved() loop below empties
|
|
// `writers` and emits profileSaveFinished(): that signal fires synchronously,
|
|
// and a deferred handler (e.g. a queued package install) could start another
|
|
// save that clears/replaces mModulesToSync, making this save skip the module
|
|
// sync it owes to other profiles.
|
|
if (syncModules) {
|
|
reloadModules();
|
|
}
|
|
mWritingHostAndModules = false;
|
|
// Drop each module writer from `writers`; the last removal emits
|
|
// profileSaveFinished() once the profile writer is gone too.
|
|
for (const auto& xmlFilename : savedModuleXmlNames) {
|
|
xmlSaved(xmlFilename);
|
|
}
|
|
});
|
|
connect(watcher, &QFutureWatcher<void>::finished, watcher, &QObject::deleteLater);
|
|
watcher->setFuture(mModuleFuture);
|
|
return {true, filename_xml, QString()};
|
|
}
|
|
|
|
// exports without the host settings for some reason
|
|
std::tuple<bool, QString, QString> Host::saveProfileAs(const QString& file)
|
|
{
|
|
emit profileSaveStarted();
|
|
qApp->processEvents();
|
|
|
|
if (currentlySavingProfile()) {
|
|
return {false, QString(), qsl("a save is already in progress")};
|
|
}
|
|
|
|
auto writer = std::make_shared<XMLexport>(this);
|
|
writers.insert(qsl("profile"), writer);
|
|
writer->exportProfile(file);
|
|
return {true, file, QString()};
|
|
}
|
|
|
|
void Host::xmlSaved(const QString& xmlName)
|
|
{
|
|
if (writers.contains(xmlName)) {
|
|
writers.remove(xmlName);
|
|
}
|
|
|
|
if (writers.empty()) {
|
|
emit profileSaveFinished();
|
|
}
|
|
}
|
|
|
|
bool Host::currentlySavingProfile()
|
|
{
|
|
return (mWritingHostAndModules || !writers.empty());
|
|
}
|
|
|
|
void Host::waitForProfileSave()
|
|
{
|
|
waitForAsyncXmlSave();
|
|
if (mModuleFuture.isRunning()) {
|
|
mModuleFuture.waitForFinished();
|
|
}
|
|
int iterations = 0;
|
|
while (currentlySavingProfile()) {
|
|
if (++iterations > 1000) {
|
|
qWarning().nospace() << "Host::waitForProfileSave() WARNING - save did not complete after 1000 event loop iterations. " << "State: mWritingHostAndModules=" << mWritingHostAndModules
|
|
<< ", writers pending=" << writers.size() << ". Continuing without waiting.";
|
|
break;
|
|
}
|
|
qApp->processEvents();
|
|
}
|
|
}
|
|
|
|
void Host::setMmpMapLocation(const QString& data)
|
|
{
|
|
auto document = QJsonDocument::fromJson(data.toUtf8());
|
|
if (!document.isObject()) {
|
|
return;
|
|
}
|
|
auto json = document.object();
|
|
if (json.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
auto urlValue = json.value(qsl("url"));
|
|
if (urlValue == QJsonValue::Undefined) {
|
|
return;
|
|
}
|
|
auto url = QUrl(urlValue.toString());
|
|
if (!url.isValid()) {
|
|
return;
|
|
}
|
|
|
|
mpMap->setMmpMapLocation(urlValue.toString());
|
|
}
|
|
|
|
QString Host::getMmpMapLocation() const
|
|
{
|
|
return mpMap->getMmpMapLocation();
|
|
}
|
|
|
|
// error and debug consoles inherit font of the main console
|
|
void Host::updateConsolesFont()
|
|
{
|
|
if (!mpConsole) {
|
|
qWarning().nospace().noquote() << "Host::updateConsolesFont() WARNING - no TMainConsole to deal with font related operations.";
|
|
return;
|
|
}
|
|
|
|
mpConsole->refreshView();
|
|
|
|
TEvent event{};
|
|
event.mArgumentList.append(qsl("sysSettingChanged"));
|
|
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
event.mArgumentList.append(qsl("main window font"));
|
|
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
event.mArgumentList.append(mpConsole->font().family());
|
|
event.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
event.mArgumentList.append(QString::number(mpConsole->font().pointSize()));
|
|
event.mArgumentTypeList.append(ARGUMENT_TYPE_NUMBER);
|
|
raiseEvent(event);
|
|
|
|
if (mpEditorDialog) {
|
|
mpEditorDialog->setDisplayFont(mpConsole->font());
|
|
}
|
|
|
|
if (mudlet::self()->smpDebugArea && mudlet::self()->smpDebugConsole) {
|
|
mudlet::self()->smpDebugConsole->setFont(mpConsole->font());
|
|
}
|
|
|
|
if (mpNotePad) {
|
|
mpNotePad->setFont(mpConsole->font());
|
|
}
|
|
}
|
|
|
|
// a little message to make the player feel special for helping us find bugs
|
|
void Host::thankForUsingPTB()
|
|
{
|
|
const QStringList happyIcons{"😀", "😃", "😄", "😁", "🙂", "🙃", "🤩", "🎉", "🚀", "🤟", "✌️", "👊"};
|
|
const auto randomIcon = happyIcons.at(QRandomGenerator::global()->bounded(happyIcons.size()));
|
|
postMessage(tr(R"([ OK ] - %1 Thanks a lot for using the Public Test Build!)", "%1 will be a random happy emoji").arg(randomIcon));
|
|
postMessage(tr(R"([ OK ] - %1 Help us make Mudlet better by reporting any problems.)", "%1 will be a random happy emoji").arg(randomIcon));
|
|
}
|
|
|
|
void Host::setMediaLocationGMCP(const QString& mediaUrl)
|
|
{
|
|
const QUrl url = QUrl(mediaUrl);
|
|
|
|
if (!url.isValid()) {
|
|
return;
|
|
}
|
|
|
|
mMediaLocationGMCP = mediaUrl;
|
|
}
|
|
|
|
QString Host::mediaLocationGMCP() const
|
|
{
|
|
return mMediaLocationGMCP;
|
|
}
|
|
|
|
void Host::setMediaLocationMSP(const QString& mediaUrl)
|
|
{
|
|
const QUrl url = QUrl(mediaUrl);
|
|
|
|
if (!url.isValid()) {
|
|
return;
|
|
}
|
|
|
|
mMediaLocationMSP = mediaUrl;
|
|
}
|
|
|
|
QString Host::mediaLocationMSP() const
|
|
{
|
|
return mMediaLocationMSP;
|
|
}
|
|
|
|
// Completely specifies the font for use in the main console and elsewhere:
|
|
std::pair<bool, QString> Host::setDisplayFont(const QFont& font)
|
|
{
|
|
const QFontMetrics metrics(font);
|
|
if (metrics.averageCharWidth() == 0) {
|
|
return {false, qsl("specified font is invalid (its letters have 0 width)")};
|
|
}
|
|
|
|
if (mpConsole) {
|
|
if (mpConsole->font() != font) {
|
|
mpConsole->setFont(font);
|
|
|
|
updateConsolesFont();
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
|
|
qDebug().noquote().nospace() << "Host::setDisplayFont(...) INFO - No TMainConsole to set font for - faking it";
|
|
|
|
mTempDisplayFontAttributes = TFontAttributes(font);
|
|
mTempDisplayFont = mTempDisplayFontAttributes.value().makeFont();
|
|
|
|
return {true, QString()};
|
|
}
|
|
|
|
// Also completely specifies the font for use in the main console and elsewhere:
|
|
void Host::setDisplayFontFromString(const QString& fontData)
|
|
{
|
|
QFont font;
|
|
font.fromString(fontData);
|
|
setDisplayFont(font);
|
|
}
|
|
|
|
void Host::setDisplayFontSize(int size)
|
|
{
|
|
if (mpConsole) {
|
|
if (mpConsole->font().pointSize() != size) {
|
|
mpConsole->setFontSize(size);
|
|
updateConsolesFont();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
qDebug().noquote().nospace() << "Host::setDisplayFontSize(...) INFO - No TMainConsole to set font for - faking it";
|
|
if (!mTempDisplayFontAttributes.has_value()) {
|
|
mTempDisplayFontAttributes = TFontAttributes(!mNoAntiAlias);
|
|
}
|
|
|
|
mTempDisplayFontAttributes.value().mPointSize = size;
|
|
mTempDisplayFont = mTempDisplayFontAttributes.value().makeFont();
|
|
}
|
|
|
|
// Helper function to create a QFont with proper antialiasing settings
|
|
// This ensures fonts created from a name string inherit the host's antialiasing preference
|
|
QFont Host::createFontWithSettings(const QString& fontName, int pointSize) const
|
|
{
|
|
TFontAttributes attrs(fontsAntiAlias());
|
|
attrs.mName = fontName;
|
|
attrs.mPointSize = pointSize;
|
|
return attrs.makeFont();
|
|
}
|
|
|
|
// Helper function to parse font names that may include style information
|
|
// Returns a pair of (base family name, weight) for backward compatibility with static fonts
|
|
// Examples: "EB Garamond SemiBold" -> ("EB Garamond", QFont::DemiBold)
|
|
// "Arial Bold" -> ("Arial", QFont::Bold)
|
|
std::pair<QString, QFont::Weight> Host::parseFontNameAndStyle(const QString& fontName) const
|
|
{
|
|
// Map of style keywords to QFont::Weight values
|
|
static const QMap<QString, QFont::Weight> styleWeightMap = {{qsl("thin"), QFont::Thin},
|
|
{qsl("extralight"), QFont::ExtraLight},
|
|
{qsl("ultralight"), QFont::ExtraLight},
|
|
{qsl("light"), QFont::Light},
|
|
{qsl("normal"), QFont::Normal},
|
|
{qsl("regular"), QFont::Normal},
|
|
{qsl("medium"), QFont::Medium},
|
|
{qsl("demibold"), QFont::DemiBold},
|
|
{qsl("semibold"), QFont::DemiBold},
|
|
{qsl("bold"), QFont::Bold},
|
|
{qsl("extrabold"), QFont::ExtraBold},
|
|
{qsl("ultrabold"), QFont::ExtraBold},
|
|
{qsl("black"), QFont::Black},
|
|
{qsl("heavy"), QFont::Black}};
|
|
|
|
// Try each possible split point from the end of the font name
|
|
const QStringList words = fontName.split(' ', Qt::SkipEmptyParts);
|
|
|
|
if (words.size() < 2) {
|
|
return {fontName, QFont::Normal};
|
|
}
|
|
|
|
// Try matching the last word(s) as a style
|
|
for (int i = words.size() - 1; i > 0; --i) {
|
|
QString possibleStyle = words.mid(i).join(' ').toLower();
|
|
|
|
if (styleWeightMap.contains(possibleStyle)) {
|
|
QString baseName = words.mid(0, i).join(' ');
|
|
return {baseName, styleWeightMap.value(possibleStyle)};
|
|
}
|
|
}
|
|
|
|
return {fontName, QFont::Normal};
|
|
}
|
|
|
|
// Now returns the total weight of the path
|
|
unsigned int Host::assemblePath()
|
|
{
|
|
unsigned int totalWeight = 0;
|
|
QStringList pathList;
|
|
for (const int i : std::as_const(mpMap->mPathList)) {
|
|
const QString n = QString::number(i);
|
|
pathList.append(n);
|
|
}
|
|
QStringList directionList = mpMap->mDirList;
|
|
QStringList weightList;
|
|
for (const int stepWeight : std::as_const(mpMap->mWeightList)) {
|
|
totalWeight += stepWeight;
|
|
const QString n = QString::number(stepWeight);
|
|
weightList.append(n);
|
|
}
|
|
QString tableName = qsl("speedWalkPath");
|
|
mLuaInterpreter.set_lua_table(tableName, pathList);
|
|
tableName = qsl("speedWalkDir");
|
|
mLuaInterpreter.set_lua_table(tableName, directionList);
|
|
tableName = qsl("speedWalkWeight");
|
|
mLuaInterpreter.set_lua_table(tableName, weightList);
|
|
return totalWeight;
|
|
}
|
|
|
|
bool Host::checkForMappingScript()
|
|
{
|
|
// the mapper script reminder is only shown once
|
|
// because it is too difficult and error prone (->proper script sequence)
|
|
// to disable this message
|
|
const bool ret = (mLuaInterpreter.check_for_mappingscript() || mHaveMapperScript);
|
|
mHaveMapperScript = true;
|
|
return ret;
|
|
}
|
|
|
|
void Host::check_for_mappingscript()
|
|
{
|
|
if (!checkForMappingScript()) {
|
|
emit signal_showMapperScriptReminder();
|
|
}
|
|
}
|
|
|
|
bool Host::checkForCustomSpeedwalk()
|
|
{
|
|
const bool ret = mLuaInterpreter.check_for_custom_speedwalk();
|
|
return ret;
|
|
}
|
|
|
|
void Host::startSpeedWalk()
|
|
{
|
|
const int totalWeight = assemblePath();
|
|
Q_UNUSED(totalWeight)
|
|
const QString f = qsl("doSpeedWalk");
|
|
const QString n = QString();
|
|
mLuaInterpreter.call(f, n);
|
|
}
|
|
|
|
void Host::startSpeedWalk(int sourceRoom, int targetRoom)
|
|
{
|
|
const QString sourceName = qsl("speedWalkFrom");
|
|
mLuaInterpreter.set_lua_integer(sourceName, sourceRoom);
|
|
const QString targetName = qsl("speedWalkTo");
|
|
mLuaInterpreter.set_lua_integer(targetName, targetRoom);
|
|
const QString f = qsl("doSpeedWalk");
|
|
const QString n = QString();
|
|
mLuaInterpreter.call(f, n);
|
|
}
|
|
|
|
void Host::updateDisplayDimensions()
|
|
{
|
|
mTelnet.checkNAWS();
|
|
}
|
|
|
|
void Host::stopAllTriggers()
|
|
{
|
|
mEmergencyStop = true;
|
|
mTriggerUnit.stopAllTriggers();
|
|
mAliasUnit.stopAllTriggers();
|
|
mTimerUnit.stopAllTriggers();
|
|
mKeyUnit.stopAllTriggers();
|
|
}
|
|
|
|
void Host::reenableAllTriggers()
|
|
{
|
|
mEmergencyStop = false;
|
|
mTriggerUnit.reenableAllTriggers();
|
|
mAliasUnit.reenableAllTriggers();
|
|
mTimerUnit.reenableAllTriggers();
|
|
mKeyUnit.reenableAllTriggers();
|
|
}
|
|
|
|
QPair<QString, QString> Host::getSearchEngine()
|
|
{
|
|
if (mSearchEngineData.contains(mSearchEngineName)) {
|
|
return qMakePair(mSearchEngineName, mSearchEngineData.value(mSearchEngineName));
|
|
}
|
|
return qMakePair(qsl("Google"), mSearchEngineData.value(qsl("Google")));
|
|
}
|
|
|
|
// cmd is UTF-16BE encoded here, but will be transcoded to Server's one by
|
|
// cTelnet::sendData(...) call:
|
|
void Host::send(QString cmd, bool wantPrint, bool dontExpandAliases)
|
|
{
|
|
// Record that the player (or a script acting for them) has interacted this connection; a later
|
|
// unsolicited GMCP Char.Login.URL may then auto-open the browser (see GMCPAuthenticator).
|
|
mUserSentInputThisConnection = true;
|
|
|
|
// Determine if we should print the command based on the echo mode
|
|
bool shouldPrint = false;
|
|
switch (mCommandEchoMode) {
|
|
case CommandEchoMode::Never:
|
|
shouldPrint = false;
|
|
break;
|
|
case CommandEchoMode::Always:
|
|
shouldPrint = true;
|
|
break;
|
|
case CommandEchoMode::ScriptControl:
|
|
shouldPrint = wantPrint;
|
|
break;
|
|
}
|
|
|
|
if (shouldPrint && !mIsRemoteEchoingActive) {
|
|
if (!cmd.isEmpty() || !mUSE_IRE_DRIVER_BUGFIX || mUSE_FORCE_LF_AFTER_PROMPT) {
|
|
// used to print the terminal <LF> that terminates a telnet command
|
|
// this is important to get the cursor position right
|
|
mpConsole->printCommand(cmd);
|
|
}
|
|
|
|
//If 3D Mapper is active mpConsole->update(); seems to be superfluous and even cause problems in MacOS
|
|
#if defined(INCLUDE_3DMAPPER)
|
|
if (!mpMap->mpMapper || !mpMap->mpMapper->glWidget) {
|
|
#else
|
|
if (!mpMap->mpMapper) {
|
|
#endif
|
|
mpConsole->update();
|
|
}
|
|
}
|
|
|
|
QStringList commandList;
|
|
|
|
if (!mCommandSeparator.isEmpty()) {
|
|
commandList = cmd.split(QString(mCommandSeparator), Qt::SkipEmptyParts);
|
|
} else if (!cmd.isEmpty()) {
|
|
// don't split command if the command separator is blank
|
|
commandList << cmd;
|
|
}
|
|
|
|
// allow sending blank commands
|
|
if (commandList.empty()) {
|
|
QString payload(QChar::LineFeed);
|
|
mTelnet.sendData(payload, true, true);
|
|
return;
|
|
}
|
|
|
|
for (QString& command : commandList) {
|
|
if (command.isEmpty()) {
|
|
continue;
|
|
}
|
|
command.remove(QChar::LineFeed);
|
|
if (dontExpandAliases) {
|
|
mTelnet.sendData(command, true, true);
|
|
continue;
|
|
}
|
|
|
|
if (!mAliasUnit.processDataStream(command)) {
|
|
mTelnet.sendData(command, true, true);
|
|
}
|
|
}
|
|
}
|
|
|
|
QPair<int, QString> Host::createStopWatch(const QString& name)
|
|
{
|
|
if (mResetProfile || mBlockStopWatchCreation) {
|
|
// Don't create stopwatches when test loading scripts or during a profile reset:
|
|
return qMakePair(0, qsl("unable to create a stopwatch at this time"));
|
|
}
|
|
|
|
if (!mStopWatchMap.empty() && !name.isEmpty()) {
|
|
for (auto it = mStopWatchMap.cbegin(); it != mStopWatchMap.cend(); ++it) {
|
|
if (it->second->name() == name) {
|
|
return qMakePair(0, qsl("stopwatch with id %1 called '%2' already exists").arg(QString::number(it->first), name));
|
|
}
|
|
}
|
|
}
|
|
int newWatchId = 1;
|
|
while (mStopWatchMap.count(newWatchId) > 0) {
|
|
++newWatchId;
|
|
}
|
|
|
|
auto pStopWatch = std::make_unique<stopWatch>();
|
|
if (!name.isEmpty()) {
|
|
pStopWatch->setName(name);
|
|
}
|
|
|
|
mStopWatchMap[newWatchId] = std::move(pStopWatch);
|
|
return qMakePair(newWatchId, QString());
|
|
}
|
|
|
|
bool Host::destroyStopWatch(const int id)
|
|
{
|
|
auto it = mStopWatchMap.find(id);
|
|
if (it == mStopWatchMap.end()) {
|
|
return false;
|
|
}
|
|
|
|
mStopWatchMap.erase(it);
|
|
return true;
|
|
}
|
|
|
|
bool Host::adjustStopWatch(const int id, const qint64 milliSeconds)
|
|
{
|
|
auto pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
pStopWatch->adjustMilliSeconds(milliSeconds);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Find the first (lowest ID) stopwatch with the given name and return its id,
|
|
// returns 0 (not a valid id) if the name is not found. It WILL return the first
|
|
// unnamed one if a blank string is given:
|
|
int Host::findStopWatchId(const QString& name) const
|
|
{
|
|
// Scan through existing names, in ascending id order (std::map is sorted)
|
|
for (auto it = mStopWatchMap.cbegin(); it != mStopWatchMap.cend(); ++it) {
|
|
if (it->second->name() == name) {
|
|
return it->first;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
QPair<bool, double> Host::getStopWatchTime(const int id) const
|
|
{
|
|
auto pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
return qMakePair(true, pStopWatch->getElapsedMilliSeconds() / 1000.0);
|
|
}
|
|
return qMakePair(false, 0.0);
|
|
}
|
|
|
|
QPair<bool, QString> Host::getBrokenDownStopWatchTime(const int id) const
|
|
{
|
|
auto pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
return qMakePair(true, pStopWatch->getElapsedDayTimeString());
|
|
}
|
|
return qMakePair(false, qsl("stopwatch with id %1 not found").arg(id));
|
|
}
|
|
|
|
QPair<bool, QString> Host::startStopWatch(const QString& name)
|
|
{
|
|
auto watchId = findStopWatchId(name);
|
|
if (!watchId) {
|
|
if (name.isEmpty()) {
|
|
return qMakePair(false, QLatin1String("no unnamed stopwatches found"));
|
|
}
|
|
return qMakePair(false, qsl("stopwatch with name '%1' not found").arg(name));
|
|
}
|
|
|
|
auto pStopWatch = getStopWatch(watchId);
|
|
if (Q_LIKELY(pStopWatch)) {
|
|
if (pStopWatch->start()) {
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with name '%1' (id:%2) was already running").arg(name, QString::number(watchId)));
|
|
}
|
|
|
|
// This should, indeed, be:
|
|
Q_UNREACHABLE();
|
|
return qMakePair(false, qsl("stopwatch with name '%1' (id:%2) not found").arg(name, QString::number(watchId)));
|
|
}
|
|
|
|
QPair<bool, QString> Host::startStopWatch(int id)
|
|
{
|
|
auto pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
if (pStopWatch->start()) {
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with id %1 was already running").arg(id));
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with id %1 not found").arg(id));
|
|
}
|
|
|
|
QPair<bool, QString> Host::stopStopWatch(const QString& name)
|
|
{
|
|
auto watchId = findStopWatchId(name);
|
|
if (!watchId) {
|
|
if (name.isEmpty()) {
|
|
return qMakePair(false, QLatin1String("no unnamed stopwatches found"));
|
|
}
|
|
return qMakePair(false, qsl("stopwatch with name '%1' not found").arg(name));
|
|
}
|
|
|
|
auto pStopWatch = getStopWatch(watchId);
|
|
if (Q_LIKELY(pStopWatch)) {
|
|
if (pStopWatch->stop()) {
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with name '%1' (id:%2) was already stopped").arg(name, QString::number(watchId)));
|
|
}
|
|
|
|
// This should, indeed, be:
|
|
Q_UNREACHABLE();
|
|
return qMakePair(false, qsl("stopwatch with name '%1' (id:%2) not found").arg(name, QString::number(watchId)));
|
|
}
|
|
|
|
QPair<bool, QString> Host::stopStopWatch(const int id)
|
|
{
|
|
auto pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
if (pStopWatch->stop()) {
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with id %1 was already stopped").arg(id));
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with id %1 not found").arg(id));
|
|
}
|
|
|
|
QPair<bool, QString> Host::resetStopWatch(const QString& name)
|
|
{
|
|
auto watchId = findStopWatchId(name);
|
|
if (!watchId) {
|
|
if (name.isEmpty()) {
|
|
return qMakePair(false, QLatin1String("no unnamed stopwatches found"));
|
|
}
|
|
return qMakePair(false, qsl("stopwatch with name '%1' not found").arg(name));
|
|
}
|
|
|
|
auto pStopWatch = getStopWatch(watchId);
|
|
if (Q_LIKELY(pStopWatch)) {
|
|
if (pStopWatch->reset()) {
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
if (name.isEmpty()) {
|
|
return qMakePair(false, qsl("the first unnamed stopwatch (id:%1) was already reset").arg(watchId));
|
|
}
|
|
return qMakePair(false, qsl("stopwatch with name '%1' (id:%2) was already reset").arg(name, QString::number(watchId)));
|
|
}
|
|
|
|
// This should, indeed, be:
|
|
Q_UNREACHABLE();
|
|
return qMakePair(false, qsl("stopwatch with name '%1' (id:%2) not found").arg(name, QString::number(watchId)));
|
|
}
|
|
|
|
QPair<bool, QString> Host::resetStopWatch(const int id)
|
|
{
|
|
auto pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
if (pStopWatch->reset()) {
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with id %1 was already reset").arg(id));
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with id %1 not found").arg(id));
|
|
}
|
|
|
|
// Used when emulating past behavior for startStopWatch - there is only one
|
|
// which takes a numeric argument as that is what old scripts will be using and
|
|
// not a text name:
|
|
QPair<bool, QString> Host::resetAndRestartStopWatch(const int id)
|
|
{
|
|
auto pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
pStopWatch->stop();
|
|
pStopWatch->reset();
|
|
pStopWatch->start();
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with id %1 not found").arg(id));
|
|
}
|
|
|
|
bool Host::makeStopWatchPersistent(const int id, const bool state)
|
|
{
|
|
auto pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
pStopWatch->setPersistent(state);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
QPair<bool, QString> Host::setStopWatchName(const int id, const QString& newName)
|
|
{
|
|
stopWatch* pStopWatch = nullptr;
|
|
if (!newName.isEmpty()) {
|
|
// Scan through existing names
|
|
for (auto it = mStopWatchMap.cbegin(); it != mStopWatchMap.cend(); ++it) {
|
|
if (it->second->name() == newName) {
|
|
if (it->first != id) {
|
|
return qMakePair(false, qsl("the name '%1' is already in use for another stopwatch (id:%2)").arg(newName, QString::number(it->first)));
|
|
}
|
|
// Trivial case - the stopwatch is already called by the NEW name:
|
|
return qMakePair(true, QString());
|
|
}
|
|
}
|
|
}
|
|
|
|
pStopWatch = getStopWatch(id);
|
|
if (pStopWatch) {
|
|
// Okay found the one we want:
|
|
pStopWatch->setName(newName);
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
return qMakePair(false, qsl("stopwatch with id %1 not found").arg(id));
|
|
}
|
|
|
|
QPair<bool, QString> Host::setStopWatchName(const QString& currentName, const QString& newName)
|
|
{
|
|
stopWatch* pStopWatch = nullptr;
|
|
int id = 0;
|
|
// Although it would be quicker code to return immediately if we detect the
|
|
// newName is in use the run-time error about it being used will mask a
|
|
// failure to find the current one (perhaps because it has already been set
|
|
// to the new value) - so don't moan about that until we have (or have not)
|
|
// found the current name:
|
|
bool isAlreadyUsed = false;
|
|
int alreadyUsedId = 0;
|
|
// we are looking BOTH for the current name and checking that any other
|
|
// ones WITH names do not match the new name (std::map is sorted by key):
|
|
for (auto it = mStopWatchMap.cbegin(); it != mStopWatchMap.cend(); ++it) {
|
|
const int currentId = it->first;
|
|
stopWatch* pCurrentStopWatch = it->second.get();
|
|
// This will also pick up the FIRST (lowest id) currently unnamed
|
|
// stopwatch:
|
|
if (!id && pCurrentStopWatch->name() == currentName) {
|
|
pStopWatch = pCurrentStopWatch;
|
|
id = currentId;
|
|
}
|
|
// As zero is never used as an Id it okay to use it here before it
|
|
// is set to the value of the wanted entry:
|
|
if (!newName.isEmpty() && currentId != id && pCurrentStopWatch->name() == newName) {
|
|
isAlreadyUsed = true;
|
|
alreadyUsedId = currentId;
|
|
}
|
|
}
|
|
|
|
// if pStopWatch is nullptr then the currentName was not found:
|
|
if (!pStopWatch) {
|
|
if (currentName.isEmpty()) {
|
|
return qMakePair(false, QLatin1String("no unnamed stopwatches found"));
|
|
}
|
|
return qMakePair(false, qsl("stopwatch with name '%1' not found").arg(currentName));
|
|
}
|
|
|
|
if (isAlreadyUsed) {
|
|
return qMakePair(false, qsl("the name '%1' is already in use for another stopwatch (id:%2)").arg(newName, QString::number(alreadyUsedId)));
|
|
}
|
|
|
|
if (currentName == newName) {
|
|
// Trivial case that always succeeds
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
pStopWatch->setName(newName);
|
|
return qMakePair(true, QString());
|
|
}
|
|
|
|
QList<int> Host::getStopWatchIds() const
|
|
{
|
|
QList<int> ids;
|
|
ids.reserve(static_cast<int>(mStopWatchMap.size()));
|
|
for (auto it = mStopWatchMap.cbegin(); it != mStopWatchMap.cend(); ++it) {
|
|
ids.append(it->first);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
void Host::incomingStreamProcessor(const QString& data, int line)
|
|
{
|
|
mTriggerUnit.processDataStream(data, line);
|
|
|
|
mAliasUnit.doCleanup();
|
|
mTimerUnit.doCleanup();
|
|
mTriggerUnit.doCleanup();
|
|
mKeyUnit.doCleanup();
|
|
mActionUnit.doCleanup();
|
|
// ScriptUnit defers deletes too (a package script uninstalling its own package
|
|
// mid-compile or mid-event-dispatch), so flush it here alongside the others:
|
|
mScriptUnit.doCleanup();
|
|
}
|
|
|
|
// When Mudlet is running in online mode, deleted temp* objects are cleaned up in bulk
|
|
// on every new line. When in offline mode, new lines don't come - so they are
|
|
// cleaned up in bulk periodically.
|
|
void Host::slot_purgeTemps()
|
|
{
|
|
mAliasUnit.doCleanup();
|
|
mTimerUnit.doCleanup();
|
|
mTriggerUnit.doCleanup();
|
|
mKeyUnit.doCleanup();
|
|
mActionUnit.doCleanup();
|
|
mScriptUnit.doCleanup();
|
|
}
|
|
|
|
// The profile save that installPackage()/uninstallPackage() put off to the next
|
|
// event loop pass - see mDeferredSaveTimer.
|
|
void Host::slot_saveProfileAfterPackageChange()
|
|
{
|
|
if (currentlySavingProfile()) {
|
|
// saveProfile() would refuse outright, and this is the only save the
|
|
// package change has coming: ask again once the one in flight is out of
|
|
// the way rather than leaving the change unwritten until something else
|
|
// happens to save. The profile close stops this timer, so the retries
|
|
// cannot outlive the profile.
|
|
mDeferredSaveTimer.start(100ms);
|
|
return;
|
|
}
|
|
// If a package's own script uninstalled it mid-compile (from a script
|
|
// reached outside the compileAll()/editor/raiseEvent flush points, e.g. a
|
|
// permScript() run from an alias or key), the script deletes were deferred
|
|
// and are still registered. Flush them now, at depth 0, before saving so
|
|
// the save below does not serialize the just-uninstalled scripts back in:
|
|
mScriptUnit.doCleanup();
|
|
if (auto [ok, filename, error] = saveProfile(); !ok) {
|
|
qWarning() << qsl("Host::slot_saveProfileAfterPackageChange() WARNING - couldn't save '%1' to '%2' because: %3").arg(getName(), filename, error);
|
|
}
|
|
}
|
|
|
|
void Host::registerEventHandler(const QString& name, TScript* pScript)
|
|
{
|
|
if (mEventHandlerMap.contains(name)) {
|
|
if (!mEventHandlerMap[name].contains(pScript)) {
|
|
mEventHandlerMap[name].append(pScript);
|
|
}
|
|
} else {
|
|
QList<TScript*> scriptList;
|
|
scriptList.append(pScript);
|
|
mEventHandlerMap.insert(name, scriptList);
|
|
}
|
|
}
|
|
void Host::registerAnonymousEventHandler(const QString& name, const QString& fun)
|
|
{
|
|
if (mAnonymousEventHandlerFunctions.contains(name)) {
|
|
if (!mAnonymousEventHandlerFunctions[name].contains(fun)) {
|
|
mAnonymousEventHandlerFunctions[name].push_back(fun);
|
|
}
|
|
} else {
|
|
QStringList newList;
|
|
newList << fun;
|
|
mAnonymousEventHandlerFunctions[name] = newList;
|
|
}
|
|
}
|
|
|
|
void Host::unregisterEventHandler(const QString& name, TScript* pScript)
|
|
{
|
|
if (mEventHandlerMap.contains(name)) {
|
|
mEventHandlerMap[name].removeAll(pScript);
|
|
}
|
|
}
|
|
|
|
// Handlers run on this profile's shared lua_State, but each unwinds it back to
|
|
// the level it found, so a C function raising an event mid-flight keeps its own
|
|
// arguments and any return values it has already pushed
|
|
void Host::raiseEvent(const TEvent& pE)
|
|
{
|
|
if (Q_UNLIKELY(mEmergencyStop)) {
|
|
return;
|
|
}
|
|
|
|
if (pE.mArgumentList.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
static const QString star = qsl("*");
|
|
|
|
// A handler can uninstall its own package mid-dispatch (a common package
|
|
// auto-updater pattern): whilst this frame is on the stack
|
|
// ScriptUnit::uninstall() defers its deletes so neither the executing
|
|
// handler nor the other TScript pointers in the lists copied below get
|
|
// freed under us; the deferred deletes are flushed once the outermost
|
|
// dispatch finishes:
|
|
mScriptUnit.beginProcessing();
|
|
const auto processingGuard = qScopeGuard([this] {
|
|
mScriptUnit.endProcessing();
|
|
mScriptUnit.doCleanup();
|
|
});
|
|
|
|
if (mEventHandlerMap.contains(pE.mArgumentList.at(0))) {
|
|
QList<TScript*> scriptList = mEventHandlerMap.value(pE.mArgumentList.at(0));
|
|
for (auto& script : scriptList) {
|
|
script->callEventHandler(pE);
|
|
}
|
|
}
|
|
if (mEventHandlerMap.contains(star)) {
|
|
QList<TScript*> scriptList = mEventHandlerMap.value(star);
|
|
for (auto& script : scriptList) {
|
|
script->callEventHandler(pE);
|
|
}
|
|
}
|
|
|
|
if (mAnonymousEventHandlerFunctions.contains(pE.mArgumentList.at(0))) {
|
|
const QStringList functionsList = mAnonymousEventHandlerFunctions.value(pE.mArgumentList.at(0));
|
|
for (const QString& function : functionsList) {
|
|
mLuaInterpreter.callEventHandler(function, pE);
|
|
}
|
|
}
|
|
if (mAnonymousEventHandlerFunctions.contains(star)) {
|
|
const QStringList functionsList = mAnonymousEventHandlerFunctions.value(star);
|
|
for (const QString& function : functionsList) {
|
|
mLuaInterpreter.callEventHandler(function, pE);
|
|
}
|
|
}
|
|
|
|
// Let any test-mode waitForEvent() call blocked on this event capture its
|
|
// arguments and unblock. Cheap (an empty-list check) when nothing is waiting.
|
|
mLuaInterpreter.captureEventForWaits(pE);
|
|
|
|
// After the event has been raised but before 'event' goes out of scope,
|
|
// we need to safely dereference the members of 'event' that point to
|
|
// values in the Lua registry
|
|
mLuaInterpreter.freeAllInLuaRegistry(pE);
|
|
}
|
|
|
|
void Host::postIrcMessage(const QString& a, const QString& b, const QString& c)
|
|
{
|
|
TEvent event{};
|
|
event.mArgumentList << QLatin1String("sysIrcMessage");
|
|
event.mArgumentList << a << b << c;
|
|
event.mArgumentTypeList << ARGUMENT_TYPE_STRING << ARGUMENT_TYPE_STRING << ARGUMENT_TYPE_STRING << ARGUMENT_TYPE_STRING;
|
|
raiseEvent(event);
|
|
}
|
|
|
|
void Host::enableTimer(const QString& name)
|
|
{
|
|
mTimerUnit.enableTimer(name);
|
|
}
|
|
|
|
void Host::disableTimer(const QString& name)
|
|
{
|
|
mTimerUnit.disableTimer(name);
|
|
}
|
|
|
|
bool Host::killTimer(const QString& name)
|
|
{
|
|
return mTimerUnit.killTimer(name);
|
|
}
|
|
|
|
void Host::enableKey(const QString& name)
|
|
{
|
|
mKeyUnit.enableKey(name);
|
|
}
|
|
|
|
void Host::disableKey(const QString& name)
|
|
{
|
|
mKeyUnit.disableKey(name);
|
|
}
|
|
|
|
|
|
void Host::enableTrigger(const QString& name)
|
|
{
|
|
mTriggerUnit.enableTrigger(name);
|
|
}
|
|
|
|
void Host::disableTrigger(const QString& name)
|
|
{
|
|
mTriggerUnit.disableTrigger(name);
|
|
}
|
|
|
|
bool Host::killTrigger(const QString& name)
|
|
{
|
|
return mTriggerUnit.killTrigger(name);
|
|
}
|
|
|
|
std::pair<bool, QString> Host::installPackage(const QString& fileName, enums::PackageModuleType thing, bool quiet)
|
|
{
|
|
// Wait for profile save to complete before installing package
|
|
// to prevent Lua state corruption during concurrent operations
|
|
if (currentlySavingProfile()) {
|
|
// Auto-retry installation after save completes
|
|
QObject* obj = new QObject(this);
|
|
connect(this, &Host::profileSaveFinished, obj, [=, this]() {
|
|
// The synchronous caller has already been told {true, ""} below;
|
|
// surface any deferred failure via the warning log and the
|
|
// profile's message area so it isn't lost silently.
|
|
auto [ok, msg] = installPackage(fileName, thing, quiet);
|
|
if (!ok) {
|
|
qWarning() << "Host::installPackage() deferred install of" << fileName << "failed:" << msg;
|
|
postMessage(tr("[ ERROR ] - Package install failed for \"%1\": %2").arg(fileName, msg));
|
|
}
|
|
obj->deleteLater();
|
|
});
|
|
return {true, QString()};
|
|
}
|
|
|
|
bool showedUnpackingDialog = false;
|
|
|
|
QString actualFileName = fileName;
|
|
std::unique_ptr<QTemporaryFile> tempFile;
|
|
|
|
if ((fileName.startsWith(QStringLiteral(":/")) || fileName.startsWith(QStringLiteral("qrc:/")))
|
|
&& (fileName.endsWith(qsl(".zip"), Qt::CaseInsensitive) || fileName.endsWith(qsl(".mpackage"), Qt::CaseInsensitive))) {
|
|
tempFile = std::make_unique<QTemporaryFile>();
|
|
if (!tempFile->open()) {
|
|
qWarning() << "Host::installPackage() failed to create temporary file for resource:" << fileName;
|
|
return {false, qsl("Failed to create temporary file for resource package")};
|
|
}
|
|
|
|
QFile resourceFile(fileName);
|
|
if (!resourceFile.open(QIODevice::ReadOnly)) {
|
|
qWarning() << "Host::installPackage() failed to open resource file:" << fileName << "Error:" << resourceFile.errorString();
|
|
return {false, qsl("Failed to open resource package file")};
|
|
}
|
|
|
|
if (tempFile->write(resourceFile.readAll()) == -1) {
|
|
qWarning() << "Host::installPackage() failed to write resource data to temp file. Error:" << tempFile->errorString();
|
|
return {false, qsl("Failed to write resource package data to temporary file")};
|
|
}
|
|
|
|
tempFile->close();
|
|
actualFileName = tempFile->fileName();
|
|
}
|
|
|
|
// Module notes:
|
|
// For the module install, a module flag of 0 is a package,
|
|
// a flag of 1 means the module is being installed for the first time via the UI,
|
|
// a flag of 2 means the module is being synced (so it's "installed" already),
|
|
// a flag of 3 means the module is being installed from a script.
|
|
// This separation is necessary to be able to reuse code while avoiding infinite loops from script installations.
|
|
|
|
if (actualFileName.isEmpty()) {
|
|
return {false, qsl("no package file was actually given")};
|
|
}
|
|
|
|
QFile file(actualFileName);
|
|
if (!file.open(QFile::ReadOnly | QFile::Text)) {
|
|
return {false, qsl("could not open file '%1").arg(actualFileName)};
|
|
}
|
|
|
|
QString packageName = sanitizePackageName(fileName);
|
|
if (thing != enums::PackageModuleType::Package) {
|
|
if ((thing == enums::PackageModuleType::ModuleSync) && (mActiveModules.contains(packageName))) {
|
|
uninstallPackage(packageName, enums::PackageModuleType::ModuleSync);
|
|
} else if ((thing == enums::PackageModuleType::ModuleFromUI) && !mIsProfileLoadingSequence && (mInstalledModules.contains(packageName) || mActiveModules.contains(packageName))) {
|
|
// Check if this is just a stale reference by verifying if module files actually exist
|
|
// Skip this check during profile loading - modules are expected to be in mInstalledModules already
|
|
QString modulePath = mudlet::getMudletPath(enums::profilePackagePath, getName(), packageName);
|
|
QString moduleFile = mInstalledModules.value(packageName).value(0); // Get the actual file path from stored reference
|
|
|
|
bool moduleExists = QDir(modulePath).exists() || QFile::exists(moduleFile);
|
|
if (!moduleExists) {
|
|
// Module files don't exist, clean up stale references
|
|
mInstalledModules.remove(packageName);
|
|
mActiveModules.removeAll(packageName);
|
|
mModulesLoadedOk.remove(packageName);
|
|
} else {
|
|
// Module actually exists, show duplicate error
|
|
return {false, tr("Module \"%1\" is already installed. Please uninstall it first or choose a different name.").arg(packageName)};
|
|
}
|
|
} else if ((thing == enums::PackageModuleType::ModuleFromScript) && (mActiveModules.contains(packageName))) {
|
|
return {false, qsl("module %1 is already installed").arg(packageName)}; //we're already installed
|
|
}
|
|
} else {
|
|
if (mInstalledPackages.contains(packageName)) {
|
|
return {false, qsl("package %1 is already installed").arg(packageName)};
|
|
}
|
|
}
|
|
//the extra module check is needed here to prevent infinite loops from script loaded modules
|
|
if (mpEditorDialog && thing != enums::PackageModuleType::ModuleFromScript) {
|
|
mpEditorDialog->doCleanReset();
|
|
}
|
|
QFile file2;
|
|
if (fileName.endsWith(qsl(".zip"), Qt::CaseInsensitive) || fileName.endsWith(qsl(".mpackage"), Qt::CaseInsensitive)) {
|
|
const QString _home = mudlet::getMudletPath(enums::profileHomePath, getName());
|
|
const QString _dest = mudlet::getMudletPath(enums::profilePackagePath, getName(), packageName);
|
|
// home directory for the PROFILE
|
|
const QDir _tmpDir(_home);
|
|
// directory to store the expanded archive file contents
|
|
// Noted before it is made: the only folder this install may ever delete
|
|
// again is one it made itself. The package name is the archive's own file
|
|
// name, and then whatever its config.lua says, so it can just as well name
|
|
// a folder of the profile's that was already here ("map", "log",
|
|
// "current") - see the refusal further down.
|
|
const bool destinationAlreadyExisted = QDir(_dest).exists();
|
|
const bool mkpathSuccessful = _tmpDir.mkpath(_dest);
|
|
if (!mkpathSuccessful) {
|
|
return {false, qsl("could not create destination folder")};
|
|
}
|
|
QString folderThisInstallMade = destinationAlreadyExisted ? QString() : QDir(_dest).absolutePath();
|
|
|
|
// Skip the unpacking dialog for modules created from UI, and for
|
|
// script-initiated installs (passed via quiet) to avoid stealing
|
|
// window-manager focus from the user's other applications - see
|
|
// issue #9170.
|
|
if (thing != enums::PackageModuleType::ModuleFromUI && !quiet) {
|
|
const QString message =
|
|
(thing != enums::PackageModuleType::Package) ? tr("Unpacking module:\n\"%1\"\nplease wait...").arg(packageName) : tr("Unpacking package:\n\"%1\"\nplease wait...").arg(packageName);
|
|
emit signal_showUnpackingProgress(message, tr("Unpacking"));
|
|
showedUnpackingDialog = true;
|
|
}
|
|
|
|
auto unzipSuccessful = mudlet::unzip(actualFileName, _dest, _tmpDir);
|
|
|
|
if (showedUnpackingDialog) {
|
|
emit signal_hideUnpackingProgress();
|
|
}
|
|
if (!unzipSuccessful) {
|
|
return {false, qsl("could not unzip package")};
|
|
}
|
|
|
|
// requirements for zip packages:
|
|
// - packages must be compressed in zip format
|
|
// - file extension should be .mpackage (though .zip is accepted)
|
|
// - there can only be a single xml file per package
|
|
// - the xml file must be located in the root directory of the zip package. example: myPack.zip contains: the folder images and the file myPack.xml
|
|
|
|
QDir _dir(_dest);
|
|
// before we start importing xmls in, see if the config.lua manifest file exists
|
|
// - if it does, update the packageName from it
|
|
if (_dir.exists(qsl("config.lua"))) {
|
|
// read in the new packageName from Lua. Should be expanded in future to whatever else config.lua will have
|
|
readPackageConfig(_dir.absoluteFilePath(qsl("config.lua")), packageName, thing != enums::PackageModuleType::Package);
|
|
// now that the packageName changed, redo relevant checks to make sure it's still valid
|
|
if (thing != enums::PackageModuleType::Package) {
|
|
if (mActiveModules.contains(packageName)) {
|
|
uninstallPackage(packageName, enums::PackageModuleType::ModuleSync);
|
|
}
|
|
} else {
|
|
if (mInstalledPackages.contains(packageName)) {
|
|
// cleanup and quit if already installed
|
|
removeDir(_dir.absolutePath(), _dir.absolutePath());
|
|
return {false, qsl("package %1 is already installed").arg(packageName)};
|
|
}
|
|
}
|
|
// continuing, so update the folder name on disk
|
|
const QString newpath(qsl("%1/%2").arg(_home, packageName));
|
|
// A rename onto a folder that is already there fails, and then the
|
|
// folder this install made is still at its old name while _dir goes
|
|
// on to the folder that was already here - which is not ours to
|
|
// delete, whatever the archive would like.
|
|
if (_dir.rename(_dir.absolutePath(), newpath) && !folderThisInstallMade.isEmpty()) {
|
|
folderThisInstallMade = QDir(newpath).absolutePath();
|
|
}
|
|
_dir = QDir(newpath);
|
|
}
|
|
QStringList _filterList;
|
|
_filterList << qsl("*.xml") << qsl("*.trigger");
|
|
const QFileInfoList entries = _dir.entryInfoList(_filterList, QDir::Files);
|
|
bool registeredFromArchive = false;
|
|
for (auto& entry : entries) {
|
|
file2.setFileName(entry.absoluteFilePath());
|
|
if (!file2.open(QFile::ReadOnly | QFile::Text)) {
|
|
qWarning() << "Host: failed to open file for reading:" << entry.absoluteFilePath() << file2.errorString();
|
|
continue;
|
|
}
|
|
registeredFromArchive = true;
|
|
XMLimport reader(this);
|
|
if (thing != enums::PackageModuleType::Package) {
|
|
QStringList moduleEntry;
|
|
moduleEntry << fileName;
|
|
moduleEntry << qsl("0");
|
|
mInstalledModules[packageName] = moduleEntry;
|
|
mActiveModules.append(packageName);
|
|
} else {
|
|
mInstalledPackages.append(packageName);
|
|
}
|
|
auto [success, errorMsg] = reader.importPackage(&file2, packageName, static_cast<int>(thing));
|
|
if (thing != enums::PackageModuleType::Package) {
|
|
if (success) {
|
|
mModulesLoadedOk.insert(packageName);
|
|
} else {
|
|
qWarning() << "Host::installPackage() WARNING - failed to load module" << packageName << ":" << errorMsg;
|
|
postMessage(tr("[ WARN ] - Failed to load module \"%1\": %2").arg(packageName, errorMsg));
|
|
}
|
|
}
|
|
file2.close();
|
|
}
|
|
|
|
// Registering the package is this loop's job, so an archive that holds no
|
|
// package XML that could be read is not installed anywhere: it would be
|
|
// missing from getPackages()/getModules(), uninstallPackage() would refuse
|
|
// it, and the folder it was just unpacked into would stay in the profile
|
|
// for good (#9654). Take that folder away again and say so. Asking the
|
|
// loop whether it registered anything, rather than asking
|
|
// mInstalledPackages/mInstalledModules afterwards, is what makes this hold
|
|
// for a module whose name is already in mInstalledModules on the way in
|
|
// (profile loading, and installModule() over a stale entry, both do that).
|
|
if (!registeredFromArchive) {
|
|
// Only ever remove the folder this install made, and only if it is
|
|
// inside the profile: the package name can come out empty (a file
|
|
// called ".mpackage"), name a folder of the user's ("map"), or be
|
|
// whatever an untrusted archive's config.lua says (".." - the folder
|
|
// holding every profile), and removeDir() takes everything below what
|
|
// it is given.
|
|
const QString profileHome = QDir(mudlet::getMudletPath(enums::profileHomePath, getName())).absolutePath();
|
|
if (!folderThisInstallMade.isEmpty() && folderThisInstallMade.startsWith(profileHome + QLatin1Char('/'))) {
|
|
removeDir(folderThisInstallMade, folderThisInstallMade);
|
|
} else {
|
|
qWarning() << "Host::installPackage() WARNING - refused" << fileName << "as package" << packageName << "but leaving" << _dir.absolutePath() << "alone: this install did not make it";
|
|
}
|
|
return {false, qsl("no package found in %1 - no Mudlet package file in it could be read").arg(fileName)};
|
|
}
|
|
} else {
|
|
file2.setFileName(fileName);
|
|
if (!file2.open(QFile::ReadOnly | QFile::Text)) {
|
|
qWarning() << "Host: failed to open file for reading:" << fileName << file2.errorString();
|
|
return {false, qsl("could not open package file")};
|
|
}
|
|
XMLimport reader(this);
|
|
if (thing != enums::PackageModuleType::Package) {
|
|
QStringList moduleEntry;
|
|
moduleEntry << fileName;
|
|
moduleEntry << qsl("0");
|
|
mInstalledModules[packageName] = moduleEntry;
|
|
mActiveModules.append(packageName);
|
|
} else {
|
|
mInstalledPackages.append(packageName);
|
|
}
|
|
auto [success, errorMsg] = reader.importPackage(&file2, packageName, static_cast<int>(thing));
|
|
if (thing != enums::PackageModuleType::Package) {
|
|
if (success) {
|
|
mModulesLoadedOk.insert(packageName);
|
|
} else {
|
|
qWarning() << "Host::installPackage() WARNING - failed to load module" << packageName << ":" << errorMsg;
|
|
postMessage(tr("[ WARN ] - Failed to load module \"%1\": %2").arg(packageName, errorMsg));
|
|
}
|
|
}
|
|
file2.close();
|
|
}
|
|
if (mpEditorDialog) {
|
|
mpEditorDialog->doCleanReset();
|
|
}
|
|
if (thing == enums::PackageModuleType::Package) {
|
|
saveProfile();
|
|
}
|
|
// reorder permanent and temporary triggers: perm first, temp second
|
|
mTriggerUnit.reorderTriggersAfterPackageImport();
|
|
|
|
// make any fonts in the package available to Mudlet for use
|
|
if (thing != enums::PackageModuleType::ModuleSync) {
|
|
installPackageFonts(packageName);
|
|
}
|
|
|
|
// Defer raising install events until the next event loop iteration
|
|
// This ensures all package installation is complete (including variable loading)
|
|
// before event handlers execute, preventing Lua state corruption
|
|
QTimer::singleShot(0ms, this, [this, guard = QPointer<Host>(this), thing, packageName, fileName]() {
|
|
// The queued call can still be delivered once this Host has been
|
|
// destroyed - the profile save queued the same way was #9653 - and the
|
|
// isClosingDown() check below would then be read off freed memory. The
|
|
// guard lives in the queued call rather than in the Host, so it is safe
|
|
// to ask and is null by then:
|
|
if (!guard) {
|
|
return;
|
|
}
|
|
// Don't raise events if Host is shutting down to avoid handlers executing during teardown
|
|
if (isClosingDown()) {
|
|
return;
|
|
}
|
|
|
|
// Don't raise install events for modules that failed to load
|
|
if (thing != enums::PackageModuleType::Package && !mModulesLoadedOk.contains(packageName)) {
|
|
return;
|
|
}
|
|
|
|
// raise 2 events - a generic one and a more detailed one to serve both
|
|
// a simple need ("I just want the install event") and a more specific need
|
|
// ("I specifically need to know when the module was synced")
|
|
TEvent genericInstallEvent{};
|
|
genericInstallEvent.mArgumentList.append(QLatin1String("sysInstall"));
|
|
genericInstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
genericInstallEvent.mArgumentList.append(packageName);
|
|
genericInstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
raiseEvent(genericInstallEvent);
|
|
|
|
TEvent detailedInstallEvent{};
|
|
switch (thing) {
|
|
case enums::PackageModuleType::Package:
|
|
detailedInstallEvent.mArgumentList.append(QLatin1String("sysInstallPackage"));
|
|
break;
|
|
case enums::PackageModuleType::ModuleFromUI:
|
|
detailedInstallEvent.mArgumentList.append(QLatin1String("sysInstallModule"));
|
|
break;
|
|
case enums::PackageModuleType::ModuleSync:
|
|
detailedInstallEvent.mArgumentList.append(QLatin1String("sysSyncInstallModule"));
|
|
break;
|
|
case enums::PackageModuleType::ModuleFromScript:
|
|
detailedInstallEvent.mArgumentList.append(QLatin1String("sysLuaInstallModule"));
|
|
break;
|
|
}
|
|
detailedInstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
detailedInstallEvent.mArgumentList.append(packageName);
|
|
detailedInstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
detailedInstallEvent.mArgumentList.append(fileName);
|
|
detailedInstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
raiseEvent(detailedInstallEvent);
|
|
});
|
|
|
|
if (mpPackageManager) {
|
|
mpPackageManager->resetPackageList();
|
|
}
|
|
if (mpModuleManager) {
|
|
mpModuleManager->layoutModules();
|
|
}
|
|
|
|
// Save profile to ensure modules persist and appear in module manager
|
|
if (thing != enums::PackageModuleType::Package) {
|
|
// Use a timer to save profile after module installation completes
|
|
mDeferredSaveTimer.start(100ms);
|
|
}
|
|
|
|
return {true, QString()};
|
|
}
|
|
|
|
|
|
QString Host::sanitizePackageName(const QString packageName) const
|
|
{
|
|
auto tempName = packageName.section(qsl("/"), -1);
|
|
tempName.remove(qsl(".trigger"), Qt::CaseInsensitive);
|
|
tempName.remove(qsl(".xml"), Qt::CaseInsensitive);
|
|
tempName.remove(qsl(".zip"), Qt::CaseInsensitive);
|
|
tempName.remove(qsl(".mpackage"), Qt::CaseInsensitive);
|
|
tempName.remove(QLatin1Char('\\'));
|
|
return tempName;
|
|
}
|
|
|
|
// credit: http://john.nachtimwald.com/2010/06/08/qt-remove-directory-and-its-contents/
|
|
bool Host::removeDir(const QString& dirName, const QString& originalPath)
|
|
{
|
|
bool result = true;
|
|
const QDir dir(dirName);
|
|
if (dir.exists(dirName)) {
|
|
for (QFileInfo const& info : dir.entryInfoList(QDir::NoDotAndDotDot | QDir::System | QDir::Hidden | QDir::AllDirs | QDir::Files, QDir::DirsFirst)) {
|
|
// prevent recursion outside of the original branch
|
|
if (info.isDir() && info.absoluteFilePath().startsWith(originalPath)) {
|
|
result = removeDir(info.absoluteFilePath(), originalPath);
|
|
} else {
|
|
result = QFile::remove(info.absoluteFilePath());
|
|
}
|
|
|
|
if (!result) {
|
|
return result;
|
|
}
|
|
}
|
|
result = dir.rmdir(dirName);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
void Host::removePackageInfo(const QString& packageName, const bool isModule)
|
|
{
|
|
if (isModule) {
|
|
mModuleInfo.remove(packageName);
|
|
} else {
|
|
mPackageInfo.remove(packageName);
|
|
}
|
|
}
|
|
|
|
// This may be called by installPackage(...) in that case however it will have
|
|
// module == 2 and in THAT situation it will NOT RE-invoke installPackage(...)
|
|
// again - Slysven
|
|
bool Host::uninstallPackage(const QString& packageName, enums::PackageModuleType thing)
|
|
{
|
|
// As with the installPackage, the module codes are:
|
|
// 0=package, 1=uninstall from dialog, 2=uninstall due to module syncing,
|
|
// 3=uninstall from a script
|
|
|
|
// block packages/modules from being uninstalled while a profile save is in progress
|
|
// just so the save mechanism doesn't get surprised with something getting removed from memory under its feet
|
|
if (currentlySavingProfile()) {
|
|
return false;
|
|
}
|
|
|
|
bool isModule = thing != enums::PackageModuleType::Package;
|
|
if (isModule) {
|
|
if (!mInstalledModules.contains(packageName)) {
|
|
return false;
|
|
}
|
|
} else {
|
|
if (!mInstalledPackages.contains(packageName)) {
|
|
return false;
|
|
}
|
|
}
|
|
//PackageModuleType::ModuleSync seems to be only used for reloading/syncing
|
|
//No need to remove package info as it can cause the info to be lost
|
|
if (thing != enums::PackageModuleType::ModuleSync) {
|
|
removePackageInfo(packageName, isModule);
|
|
}
|
|
// raise 2 events - a generic one and a more detailed one to serve both
|
|
// a simple need ("I just want the uninstall event") and a more specific need
|
|
// ("I specifically need to know when the module was uninstalled via Lua")
|
|
TEvent genericUninstallEvent{};
|
|
genericUninstallEvent.mArgumentList.append(QLatin1String("sysUninstall"));
|
|
genericUninstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
genericUninstallEvent.mArgumentList.append(packageName);
|
|
genericUninstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
raiseEvent(genericUninstallEvent);
|
|
|
|
TEvent detailedUninstallEvent{};
|
|
switch (thing) {
|
|
case enums::PackageModuleType::Package:
|
|
detailedUninstallEvent.mArgumentList.append(QLatin1String("sysUninstallPackage"));
|
|
break;
|
|
case enums::PackageModuleType::ModuleFromUI:
|
|
detailedUninstallEvent.mArgumentList.append(QLatin1String("sysUninstallModule"));
|
|
break;
|
|
case enums::PackageModuleType::ModuleSync:
|
|
detailedUninstallEvent.mArgumentList.append(QLatin1String("sysSyncUninstallModule"));
|
|
break;
|
|
case enums::PackageModuleType::ModuleFromScript:
|
|
detailedUninstallEvent.mArgumentList.append(QLatin1String("sysLuaUninstallModule"));
|
|
break;
|
|
}
|
|
detailedUninstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
detailedUninstallEvent.mArgumentList.append(packageName);
|
|
detailedUninstallEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
raiseEvent(detailedUninstallEvent);
|
|
|
|
int dualInstallations = 0;
|
|
if (mInstalledModules.contains(packageName) && mInstalledPackages.contains(packageName)) {
|
|
dualInstallations = 1;
|
|
}
|
|
//we check for ModuleFromScript because if we reset the editor, we will re-execute the
|
|
//module uninstall, thus creating an infinite loop.
|
|
if (mpEditorDialog && thing != enums::PackageModuleType::ModuleFromScript) {
|
|
mpEditorDialog->doCleanReset();
|
|
}
|
|
|
|
mTriggerUnit.uninstall(packageName);
|
|
mTimerUnit.uninstall(packageName);
|
|
mAliasUnit.uninstall(packageName);
|
|
mActionUnit.uninstall(packageName);
|
|
mScriptUnit.uninstall(packageName);
|
|
mKeyUnit.uninstall(packageName);
|
|
mudlet::self()->mFontManager.unloadFonts(getName(), packageName);
|
|
if (isModule) {
|
|
//if ModuleSync, this is a temporary uninstall for reloading so we exit here
|
|
QStringList entry = mInstalledModules[packageName];
|
|
mInstalledModules.remove(packageName);
|
|
mModulesLoadedOk.remove(packageName);
|
|
mActiveModules.removeAll(packageName);
|
|
if (thing == enums::PackageModuleType::ModuleSync) {
|
|
return true;
|
|
}
|
|
//if ModuleFromUI/ModuleFromScript, we actually uninstall it.
|
|
//reinstall the package if it shared a module name. This is a kludge, but it's cleaner than adding extra arguments/etc imo
|
|
if (dualInstallations) {
|
|
//we're a dual install, reinstalling package
|
|
mInstalledPackages.removeAll(packageName); //so we don't get denied from installPackage
|
|
//get the pre package list so we don't get duplicates
|
|
installPackage(entry[0], enums::PackageModuleType::Package);
|
|
}
|
|
} else {
|
|
mInstalledPackages.removeAll(packageName);
|
|
if (dualInstallations) {
|
|
QStringList entry = mInstalledModules[packageName];
|
|
installPackage(entry[0], enums::PackageModuleType::ModuleFromUI);
|
|
//restore the module edit flag
|
|
mInstalledModules[packageName] = entry;
|
|
}
|
|
}
|
|
if (mpEditorDialog && thing != enums::PackageModuleType::ModuleFromScript) {
|
|
mpEditorDialog->doCleanReset();
|
|
}
|
|
|
|
getActionUnit()->updateAllToolbars();
|
|
|
|
const QString dest = mudlet::getMudletPath(enums::profilePackagePath, getName(), packageName);
|
|
removeDir(dest, dest);
|
|
|
|
// save the profile on the next Qt main loop cycle in order for the asyncronous save mechanism
|
|
// not to try to write to disk a package/module that just got uninstalled and removed from memory
|
|
mDeferredSaveTimer.start(0ms);
|
|
|
|
//NOW we reset if we're uninstalling a module
|
|
if (mpEditorDialog && thing == enums::PackageModuleType::ModuleFromScript) {
|
|
mpEditorDialog->doCleanReset();
|
|
}
|
|
if (mpPackageManager) {
|
|
mpPackageManager->resetPackageList();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void Host::readPackageConfig(const QString& luaConfig, QString& packageName, bool isModule)
|
|
{
|
|
const QString newName = getPackageConfig(luaConfig, isModule);
|
|
if (!newName.isEmpty()) {
|
|
packageName = sanitizePackageName(newName);
|
|
}
|
|
}
|
|
|
|
void Host::setupSandboxedLuaState(lua_State* L)
|
|
{
|
|
// Load only safe libraries
|
|
luaopen_base(L);
|
|
luaopen_table(L);
|
|
luaopen_string(L);
|
|
luaopen_math(L);
|
|
|
|
// Remove dangerous functions
|
|
const char* dangerousFunctions[] = {// File operations
|
|
"dofile",
|
|
"loadfile",
|
|
"load",
|
|
"loadstring",
|
|
// Module system
|
|
"require",
|
|
"module",
|
|
// Library access
|
|
"io",
|
|
"os",
|
|
"debug",
|
|
// Environment manipulation
|
|
"getfenv",
|
|
"setfenv",
|
|
// Raw memory access
|
|
"collectgarbage"};
|
|
|
|
for (const auto& function : dangerousFunctions) {
|
|
lua_pushnil(L);
|
|
lua_setglobal(L, function);
|
|
}
|
|
|
|
// Remove package system entirely to prevent require() restoration
|
|
lua_pushnil(L);
|
|
lua_setglobal(L, "package");
|
|
|
|
// Remove potentially dangerous base functions
|
|
lua_getglobal(L, "_G");
|
|
if (lua_istable(L, -1)) {
|
|
const char* removedBaseFunctions[] = {"rawget", "rawset", "rawequal", "rawlen"};
|
|
|
|
for (const auto& function : removedBaseFunctions) {
|
|
lua_pushnil(L);
|
|
lua_setfield(L, -2, function);
|
|
}
|
|
}
|
|
lua_pop(L, 1); // pop _G
|
|
}
|
|
|
|
QString Host::getPackageConfig(const QString& luaConfig, bool isModule)
|
|
{
|
|
QString packageName;
|
|
// We don't use luaL_loadfile here because that breaks on Windows as it won't work if there are accented characters in the path or file name -
|
|
// QFile which can work with whatever local8Bit encoding is used for file names - the luaL_loadfile(...) uses std::iostream which doesn't...
|
|
QFile configFile(luaConfig);
|
|
QStringList strings;
|
|
if (configFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
|
QTextStream in(&configFile);
|
|
|
|
while (!in.atEnd()) {
|
|
strings += in.readLine();
|
|
}
|
|
configFile.close();
|
|
}
|
|
|
|
lua_State* L = luaL_newstate();
|
|
setupSandboxedLuaState(L);
|
|
|
|
int error = luaL_loadstring(L, strings.join("\n").toUtf8().constData());
|
|
|
|
if (!error) {
|
|
error = lua_pcall(L, 0, 0, 0);
|
|
}
|
|
if (!error) {
|
|
lua_getglobal(L, "mpackage");
|
|
if (lua_isstring(L, -1)) {
|
|
packageName = QString(lua_tostring(L, -1));
|
|
}
|
|
lua_pop(L, -1);
|
|
if (!packageName.isEmpty()) {
|
|
//get rid of lua version
|
|
lua_getglobal(L, "_G");
|
|
lua_pushnil(L);
|
|
lua_setfield(L, -2, "_VERSION");
|
|
QMap<QString, QString> packageInfo;
|
|
lua_pushnil(L);
|
|
while (lua_next(L, -2) != 0) {
|
|
if (lua_isstring(L, -1) && lua_isstring(L, -2)) {
|
|
packageInfo[lua_tostring(L, -2)] = lua_tostring(L, -1);
|
|
}
|
|
lua_pop(L, 1);
|
|
}
|
|
|
|
if (isModule) {
|
|
mModuleInfo[packageName] = packageInfo;
|
|
} else {
|
|
mPackageInfo[packageName] = packageInfo;
|
|
}
|
|
}
|
|
lua_close(L);
|
|
return packageName;
|
|
}
|
|
|
|
// error
|
|
std::string e = lua_tostring(L, -1);
|
|
if (e.empty()) {
|
|
e = "no error message available from Lua";
|
|
}
|
|
|
|
std::string reason;
|
|
switch (error) {
|
|
case 4:
|
|
reason = "Out of memory";
|
|
break;
|
|
case 3:
|
|
reason = "Syntax error";
|
|
break;
|
|
case 2:
|
|
reason = "Runtime error";
|
|
break;
|
|
case 1:
|
|
reason = "Yield error";
|
|
break;
|
|
default:
|
|
reason = "Unknown error";
|
|
break;
|
|
}
|
|
|
|
if (mudlet::smDebugMode) {
|
|
TDebug(QColor(Qt::white), QColor(Qt::red)) << "LUA: " << reason.c_str() << " in " << luaConfig << " ERROR:" << e.c_str() << "\n" >> 0;
|
|
}
|
|
|
|
lua_pop(L, -1);
|
|
lua_close(L);
|
|
return QString();
|
|
}
|
|
|
|
// writeProfileIniData(...) and readProfileIniData(...) might eventually
|
|
// replace writeProfileData(...) and readProfileData(...) but for now are just
|
|
// used to store some information about one or more TCommandLine's mHistoryData:
|
|
bool Host::writeProfileIniData(const QString& item, const QString& what)
|
|
{
|
|
QSettings settings(mudlet::getMudletPath(enums::profileDataItemPath, getName(), qsl("profile.ini")), QSettings::IniFormat);
|
|
settings.setValue(item, what);
|
|
settings.sync();
|
|
switch (settings.status()) {
|
|
case QSettings::NoError:
|
|
return true;
|
|
case QSettings::FormatError:
|
|
qWarning().nospace().noquote() << "Host::writeProfileIniData(\"" << item << "\", \"" << what << "\") ERROR - failed to save this detail, reason: \"Format error\".";
|
|
return false;
|
|
case QSettings::AccessError:
|
|
qWarning().nospace().noquote() << "Host::writeProfileIniData(\"" << item << "\", \"" << what << "\") ERROR - failed to save this detail, reason: \"Access error\".";
|
|
return false;
|
|
}
|
|
Q_UNREACHABLE();
|
|
}
|
|
|
|
QString Host::readProfileIniData(const QString& item)
|
|
{
|
|
QSettings settings(mudlet::getMudletPath(enums::profileDataItemPath, getName(), qsl("profile.ini")), QSettings::IniFormat);
|
|
return settings.value(item).toString();
|
|
}
|
|
|
|
// This function retrieves command line history settings based on the given
|
|
// command line type and name. It reads the saveCommands setting from the
|
|
// profile.ini file, which is intended to replace all other single data item
|
|
// files in the profile's home directory. For the main command line, a
|
|
// predefined file name is used, while for any other command line, the function
|
|
// first looks for a mapping from a number-suffixed file to a command line
|
|
// name. If the mapping is not found, a new file name and default setting for
|
|
// saveCommands is created, stored, and returned.
|
|
// Because '/' and '\\' are used by the QSettings class in key names for
|
|
// special purposes we MUST filter them out in the name, we'll replace them
|
|
// with '_'s:
|
|
std::tuple<QString, bool> Host::getCmdLineSettings(const TCommandLine::CommandLineType type, const QString& name)
|
|
{
|
|
if (type == TCommandLine::MainCommandLine) {
|
|
// This one does not need the name to be kept in a QSettings but we
|
|
// still need to retrieve the other setting:
|
|
auto saveCommands = static_cast<bool>(readProfileIniData(qsl("CommandLines/SaveHistory/main")).compare(qsl("false"), Qt::CaseInsensitive));
|
|
return {qsl("command_history_main"), saveCommands};
|
|
}
|
|
QString localName{name};
|
|
localName.replace(QRegularExpression(qsl("[\\/]")), qsl("_"));
|
|
// We use a '/' in the name as that denotes a grouping (section) within the
|
|
// QSetting's (INI) format with the left-most side of the first '/' as a
|
|
// section header. They actually get converted to `\\` (a single backslash)
|
|
// inside the actual file but are accessed correctly only if given as a
|
|
// forward slash in the code:
|
|
auto fileName = readProfileIniData(qsl("CommandLines/NameMapping/%1").arg(localName));
|
|
// We want this to default to true if the setting doesn't exist, so we will
|
|
// compare it to the opposite (which will be zero for a match) and convert
|
|
// it to a boolean - so that a missing value will give a non-zero value
|
|
// which becomes a true:
|
|
auto saveCommands = static_cast<bool>(readProfileIniData(qsl("CommandLines/SaveHistory/%1").arg(localName)).compare(qsl("false"), Qt::CaseInsensitive));
|
|
if (!fileName.isEmpty()) {
|
|
// Ah, we've used this name before, so return the details:
|
|
return {fileName, saveCommands};
|
|
}
|
|
|
|
// Else the name is not in the settings so we will have to create one:
|
|
// Get the highest number used so far:
|
|
bool isOk = false;
|
|
auto usedIndex = readProfileIniData(qsl("CommandLines/UsedIndexes")).toInt(&isOk);
|
|
if (!isOk || !usedIndex) {
|
|
// The value was not found / is null - so force it to be the right one
|
|
// to start with, remembering that it will be incremented before use:
|
|
usedIndex = 0;
|
|
}
|
|
// Increment it and save the new value
|
|
writeProfileIniData(qsl("CommandLines/UsedIndexes"), QString::number(++usedIndex));
|
|
// Generate the name
|
|
fileName = qsl("command_history_%1").arg(usedIndex, 2, 10, QLatin1Char('0'));
|
|
// Save it:
|
|
writeProfileIniData(qsl("CommandLines/NameMapping/%1").arg(localName), fileName);
|
|
// And a default setting:
|
|
writeProfileIniData(qsl("CommandLines/SaveHistory/%1").arg(localName), saveCommands ? qsl("true") : qsl("false"));
|
|
// And return it - with the defaulted other setting:
|
|
return {fileName, saveCommands};
|
|
}
|
|
|
|
void Host::setCmdLineSettings(const TCommandLine::CommandLineType type, const bool saveCommands, const QString& name)
|
|
{
|
|
if (type == TCommandLine::MainCommandLine) {
|
|
writeProfileIniData(qsl("CommandLines/SaveHistory/main"), saveCommands ? qsl("true") : qsl("false"));
|
|
return;
|
|
}
|
|
QString localName{name};
|
|
localName.replace(QRegularExpression(qsl("[\\/]")), qsl("_"));
|
|
// We use a '/' in the name as that donotes a grouping (section) within the
|
|
// QSetting's (INI) format with the left-most side of the first '/' as a
|
|
// section header. They actually get converted to `\\` (a single backslash)
|
|
// inside the actual file but are accessed correctly only if given as a
|
|
// forward slash in the code:
|
|
writeProfileIniData(qsl("CommandLines/SaveHistory/%1").arg(localName), saveCommands ? qsl("true") : qsl("false"));
|
|
}
|
|
|
|
// Derived from the one in dlgConnectionProfile class - but it does not need a
|
|
// host name argument...
|
|
QPair<bool, QString> Host::writeProfileData(const QString& item, const QString& what)
|
|
{
|
|
QSaveFile file(mudlet::getMudletPath(enums::profileDataItemPath, getName(), 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() << "Host::writeProfileData: writing host data: " << file.errorString();
|
|
}
|
|
}
|
|
|
|
if (file.error() == QFile::NoError) {
|
|
return qMakePair(true, QString());
|
|
}
|
|
return qMakePair(false, file.errorString());
|
|
}
|
|
|
|
// Similar to the above, a convenience for reading profile data for this host.
|
|
QString Host::readProfileData(const QString& item)
|
|
{
|
|
QFile file(mudlet::getMudletPath(enums::profileDataItemPath, getName(), 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;
|
|
}
|
|
|
|
// makes fonts in a given package/module be available for Mudlet scripting
|
|
// does not install font system-wide
|
|
void Host::installPackageFonts(const QString& packageName)
|
|
{
|
|
auto packagePath = mudlet::getMudletPath(enums::profilePackagePath, getName(), packageName);
|
|
|
|
QDirIterator it(packagePath, QDirIterator::Subdirectories);
|
|
while (it.hasNext()) {
|
|
auto filePath = it.next();
|
|
|
|
if (filePath.endsWith(QLatin1String(".otf"), Qt::CaseInsensitive) || filePath.endsWith(QLatin1String(".ttf"), Qt::CaseInsensitive)
|
|
|| filePath.endsWith(QLatin1String(".ttc"), Qt::CaseInsensitive) || filePath.endsWith(QLatin1String(".otc"), Qt::CaseInsensitive)) {
|
|
mudlet::self()->mFontManager.loadFont(filePath, getName(), packageName);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ensures fonts from all installed packages are loaded in Mudlet
|
|
void Host::refreshPackageFonts()
|
|
{
|
|
for (const auto& package : std::as_const(mInstalledPackages)) {
|
|
installPackageFonts(package);
|
|
}
|
|
}
|
|
|
|
void Host::setEnableBlinkText(const bool enable)
|
|
{
|
|
if (mEnableBlinkText != enable) {
|
|
mEnableBlinkText = enable;
|
|
emit signal_changeEnableBlinkText(enable);
|
|
}
|
|
}
|
|
|
|
void Host::setWideAmbiguousEAsianGlyphs(const Qt::CheckState state)
|
|
{
|
|
bool localState = false;
|
|
bool needToEmit = false;
|
|
const QByteArray encoding(mTelnet.getEncoding());
|
|
|
|
if (state == Qt::PartiallyChecked) {
|
|
// Set things automatically
|
|
mAutoAmbigousWidthGlyphsSetting = true;
|
|
|
|
if (encoding == "GBK" || encoding == "GB18030" || encoding == "BIG5" || encoding == "BIG5-HKSCS" || encoding == "EUC-KR") {
|
|
// Need to use wide width for ambiguous characters
|
|
if (!mWideAmbigousWidthGlyphs) {
|
|
// But the last setting was narrow - so we need to change
|
|
mWideAmbigousWidthGlyphs = true;
|
|
localState = true;
|
|
needToEmit = true;
|
|
}
|
|
|
|
} else {
|
|
// Need to use narrow width for ambiguous characters
|
|
if (mWideAmbigousWidthGlyphs) {
|
|
// But the last setting was wide - so we need to change
|
|
mWideAmbigousWidthGlyphs = false;
|
|
localState = false;
|
|
needToEmit = true;
|
|
}
|
|
}
|
|
|
|
} else {
|
|
// Set things manually:
|
|
mAutoAmbigousWidthGlyphsSetting = false;
|
|
if (mWideAmbigousWidthGlyphs != (state == Qt::Checked)) {
|
|
// The last setting is the opposite to what we want:
|
|
|
|
mWideAmbigousWidthGlyphs = (state == Qt::Checked);
|
|
localState = (state == Qt::Checked);
|
|
needToEmit = true;
|
|
}
|
|
}
|
|
|
|
if (needToEmit) {
|
|
emit signal_changeIsAmbigousWidthGlyphsToBeWide(localState);
|
|
}
|
|
}
|
|
|
|
QColor Host::getAnsiColor(const int ansiCode, const bool isBackground) const
|
|
{
|
|
// clang-format off
|
|
switch (ansiCode) {
|
|
case 0: return mBlack;
|
|
case 1: return mRed;
|
|
case 2: return mGreen;
|
|
case 3: return mYellow;
|
|
case 4: return mBlue;
|
|
case 5: return mMagenta;
|
|
case 6: return mCyan;
|
|
case 7: return mWhite;
|
|
case 8: return mLightBlack;
|
|
case 9: return mLightRed;
|
|
case 10: return mLightGreen;
|
|
case 11: return mLightYellow;
|
|
case 12: return mLightBlue;
|
|
case 13: return mLightMagenta;
|
|
case 14: return mLightCyan;
|
|
case 15: return mLightWhite;
|
|
default:
|
|
if (ansiCode == TTrigger::scmIgnored) {
|
|
// No-op - corresponds to no setting or ignoring this aspect
|
|
return QColor();
|
|
} else if (ansiCode == TTrigger::scmDefault) {
|
|
return isBackground ? mBgColor : mFgColor;
|
|
} else if (ansiCode >= 16 && ansiCode <= 231) {
|
|
// because color 1-15 behave like normal ANSI colors we need to subtract 16
|
|
// 6x6 RGB color space
|
|
const int r = (ansiCode - 16) / 36;
|
|
const int g = (ansiCode - 16 - (r * 36)) / 6;
|
|
const int b = (ansiCode - 16 - (r * 36)) - (g * 6);
|
|
// Values are scaled according to the standard Xterm color palette
|
|
// http://jonasjacek.github.io/colors/
|
|
return QColor(r == 0 ? 0 : (r - 1) * 40 + 95,
|
|
g == 0 ? 0 : (g - 1) * 40 + 95,
|
|
b == 0 ? 0 : (b - 1) * 40 + 95);
|
|
} else if (ansiCode < 256) {
|
|
const int k = (ansiCode - 232) * 10 + 8;
|
|
return QColor(k, k, k);
|
|
} else {
|
|
return QColor(); // No-op
|
|
}
|
|
}
|
|
// clang-format on
|
|
}
|
|
|
|
// handles out of band (OOB) GMCP/MSDP data for Discord - called whenever GMCP
|
|
// Telnet sub-option comes in and starts with "External.Discord.(Status|Info)"
|
|
void Host::processDiscordGMCP(const QString& packageMessage, const QString& data)
|
|
{
|
|
auto document = QJsonDocument::fromJson(data.toUtf8());
|
|
if (!document.isObject()) {
|
|
return;
|
|
}
|
|
|
|
auto json = document.object();
|
|
if (json.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
if (packageMessage == QLatin1String("External.Discord.Status")) {
|
|
processGMCPDiscordStatus(json);
|
|
} else if (packageMessage == QLatin1String("External.Discord.Info")) {
|
|
processGMCPDiscordInfo(json);
|
|
}
|
|
}
|
|
|
|
void Host::processGMCPDiscordInfo(const QJsonObject& discordInfo)
|
|
{
|
|
if (mDiscordMode != DiscordShowGameDetails) {
|
|
return;
|
|
}
|
|
|
|
mudlet* pMudlet = mudlet::self();
|
|
bool hasInvite = false;
|
|
auto inviteUrl = discordInfo.value(qsl("inviteurl"));
|
|
// Will be of form: "https://discord.gg/#####"
|
|
if (inviteUrl != QJsonValue::Undefined && !inviteUrl.toString().isEmpty() && inviteUrl.toString() != qsl("0")) {
|
|
setDiscordInviteURL(inviteUrl.toString());
|
|
pMudlet->updateDiscordNamedIcon();
|
|
hasInvite = true;
|
|
}
|
|
|
|
bool hasApplicationId = false;
|
|
bool hasCustomAppID = false;
|
|
auto appID = discordInfo.value(qsl("applicationid"));
|
|
if (appID != QJsonValue::Undefined) {
|
|
hasApplicationId = true;
|
|
if (appID.toString() == Discord::mMudletApplicationId) {
|
|
pMudlet->mDiscord.setApplicationID(this, QString());
|
|
} else {
|
|
hasCustomAppID = true;
|
|
pMudlet->mDiscord.setApplicationID(this, appID.toString());
|
|
auto image = pMudlet->mDiscord.getLargeImage(this);
|
|
|
|
if (image.isEmpty() || image == QLatin1String("mudlet")) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetLargeIcon);
|
|
pMudlet->mDiscord.setLargeImage(this, qsl("server-icon"));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasInvite) {
|
|
if (hasCustomAppID) {
|
|
qDebug() << "Game using a custom Discord server. Invite URL:" << inviteUrl.toString();
|
|
} else if (hasApplicationId) {
|
|
qDebug() << "Game using Mudlet's Discord server. Invite URL:" << inviteUrl.toString();
|
|
} else {
|
|
qDebug() << "Discord invite URL: " << inviteUrl.toString();
|
|
}
|
|
} else {
|
|
if (hasCustomAppID) {
|
|
qDebug() << "Game is using custom server Discord application ID";
|
|
} else if (hasApplicationId) {
|
|
qDebug() << "Game is using Mudlet's Discord application ID";
|
|
}
|
|
}
|
|
}
|
|
|
|
void Host::processGMCPDiscordStatus(const QJsonObject& discordInfo)
|
|
{
|
|
if (mDiscordMode != DiscordShowGameDetails) {
|
|
return;
|
|
}
|
|
|
|
auto pMudlet = mudlet::self();
|
|
auto gameName = discordInfo.value(qsl("game"));
|
|
if (gameName != QJsonValue::Undefined) {
|
|
setDiscordGameName(gameName.toString());
|
|
pMudlet->updateDiscordNamedIcon();
|
|
QPair<bool, QString> const richPresenceSupported = pMudlet->mDiscord.gameIntegrationSupported(getUrl());
|
|
if (richPresenceSupported.first && pMudlet->mDiscord.usingMudletsDiscordID(this)) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetDetail);
|
|
pMudlet->mDiscord.setDetailText(this, tr("Playing %1").arg(richPresenceSupported.second));
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetLargeIcon);
|
|
pMudlet->mDiscord.setLargeImage(this, richPresenceSupported.second);
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetLargeIconText);
|
|
//: %1 is the game name and %2:%3 is game server address like: mudlet.org:23
|
|
pMudlet->mDiscord.setLargeImageText(this, tr("%1 at %2:%3").arg(gameName.toString(), getUrl(), QString::number(getPort())));
|
|
} else {
|
|
// We are using a custom application id, so the top line is
|
|
// likely to be saying "Playing MudName"
|
|
if (richPresenceSupported.first) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetDetail);
|
|
pMudlet->mDiscord.setDetailText(this, QString());
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetLargeIconText);
|
|
//: %1 is the game name and %2:%3 is game server address like: mudlet.org:23
|
|
pMudlet->mDiscord.setLargeImageText(this, tr("%1 at %2:%3").arg(gameName.toString(), getUrl(), QString::number(getPort())));
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetLargeIcon);
|
|
pMudlet->mDiscord.setLargeImage(this, qsl("server-icon"));
|
|
}
|
|
}
|
|
}
|
|
|
|
auto details = discordInfo.value(qsl("details"));
|
|
if (details != QJsonValue::Undefined) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetDetail);
|
|
pMudlet->mDiscord.setDetailText(this, details.toString());
|
|
}
|
|
|
|
auto state = discordInfo.value(qsl("state"));
|
|
if (state != QJsonValue::Undefined) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetState);
|
|
pMudlet->mDiscord.setStateText(this, state.toString());
|
|
}
|
|
|
|
auto largeImages = discordInfo.value(qsl("largeimage"));
|
|
if (largeImages != QJsonValue::Undefined) {
|
|
auto largeImage = largeImages.toArray().first();
|
|
if (largeImage != QJsonValue::Undefined) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetLargeIcon);
|
|
pMudlet->mDiscord.setLargeImage(this, largeImage.toString());
|
|
}
|
|
}
|
|
|
|
auto largeImageText = discordInfo.value(qsl("largeimagetext"));
|
|
if (largeImageText != QJsonValue::Undefined) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetLargeIconText);
|
|
pMudlet->mDiscord.setLargeImageText(this, largeImageText.toString());
|
|
}
|
|
|
|
auto smallImages = discordInfo.value(qsl("smallimage"));
|
|
if (smallImages != QJsonValue::Undefined) {
|
|
auto smallImage = smallImages.toArray().first();
|
|
if (smallImage != QJsonValue::Undefined) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetSmallIcon);
|
|
pMudlet->mDiscord.setSmallImage(this, smallImage.toString());
|
|
}
|
|
}
|
|
|
|
auto smallImageText = discordInfo.value(qsl("smallimagetext"));
|
|
if ((smallImageText != QJsonValue::Undefined)) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetSmallIconText);
|
|
pMudlet->mDiscord.setSmallImageText(this, smallImageText.toString());
|
|
}
|
|
|
|
int64_t timeStamp = -1;
|
|
auto endTimeStamp = discordInfo.value(qsl("endtime"));
|
|
if (endTimeStamp.isDouble()) {
|
|
timeStamp = static_cast<int64_t>(endTimeStamp.toDouble());
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetTimeInfo);
|
|
pMudlet->mDiscord.setEndTimeStamp(this, timeStamp);
|
|
} else if (endTimeStamp.isString()) {
|
|
timeStamp = endTimeStamp.toString().toLongLong();
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetTimeInfo);
|
|
pMudlet->mDiscord.setEndTimeStamp(this, timeStamp);
|
|
} else {
|
|
auto startTimeStamp = discordInfo.value(qsl("starttime"));
|
|
if (startTimeStamp.isDouble()) {
|
|
timeStamp = static_cast<int64_t>(startTimeStamp.toDouble());
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetTimeInfo);
|
|
pMudlet->mDiscord.setStartTimeStamp(this, timeStamp);
|
|
} else if (startTimeStamp.isString()) {
|
|
timeStamp = startTimeStamp.toString().toLongLong();
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetTimeInfo);
|
|
pMudlet->mDiscord.setStartTimeStamp(this, timeStamp);
|
|
}
|
|
}
|
|
|
|
int partySizeValue = -1;
|
|
int partyMaxValue = -1;
|
|
auto partyMax = discordInfo.value(qsl("partymax"));
|
|
auto partySize = discordInfo.value(qsl("partysize"));
|
|
if (partyMax != QJsonValue::Undefined || partySize != QJsonValue::Undefined) {
|
|
pMudlet->mDiscord.setServerOrigin(this, DiscordSetPartyInfo);
|
|
}
|
|
if (partyMax.isDouble()) {
|
|
partyMaxValue = static_cast<int>(partyMax.toDouble());
|
|
if (partyMaxValue > 0 && partySize.isDouble()) {
|
|
partySizeValue = static_cast<int>(partySize.toDouble());
|
|
pMudlet->mDiscord.setParty(this, partySizeValue, partyMaxValue);
|
|
} else {
|
|
pMudlet->mDiscord.setParty(this, 0, 0);
|
|
}
|
|
} else {
|
|
if (partySize.isDouble()) {
|
|
partySizeValue = static_cast<int>(partySize.toDouble());
|
|
pMudlet->mDiscord.setParty(this, partySizeValue);
|
|
} else {
|
|
pMudlet->mDiscord.setParty(this, 0, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
void Host::clearDiscordData()
|
|
{
|
|
mudlet* pMudlet = mudlet::self();
|
|
pMudlet->mDiscord.setDetailText(this, QString());
|
|
pMudlet->mDiscord.setStateText(this, QString());
|
|
pMudlet->mDiscord.setLargeImage(this, QString());
|
|
pMudlet->mDiscord.setLargeImageText(this, QString());
|
|
pMudlet->mDiscord.setSmallImage(this, QString());
|
|
pMudlet->mDiscord.setSmallImageText(this, QString());
|
|
pMudlet->mDiscord.setStartTimeStamp(this, 0);
|
|
pMudlet->mDiscord.setParty(this, 0, 0);
|
|
}
|
|
|
|
void Host::setDiscordMode(DiscordMode mode)
|
|
{
|
|
const DiscordMode oldMode = mDiscordMode;
|
|
mDiscordMode = mode;
|
|
|
|
writeProfileData(qsl("discordmode"), QString::number(static_cast<int>(mode)));
|
|
|
|
auto pMudlet = mudlet::self();
|
|
|
|
if (mode == DiscordShowGameDetails && oldMode != DiscordShowGameDetails) {
|
|
// Switching to full integration - advertise Discord support to server.
|
|
// Per spec: Hello triggers Info (app ID, invite URL),
|
|
// Get triggers Status (rich presence fields).
|
|
if (mTelnet.isGMCPEnabled()) {
|
|
mTelnet.sendGMCPSupportsAdd(qsl("External.Discord 1"));
|
|
mTelnet.sendDiscordHello();
|
|
mTelnet.sendDiscordGet();
|
|
}
|
|
} else if (mode != DiscordShowGameDetails && oldMode == DiscordShowGameDetails) {
|
|
// Switching away from full integration - retract Discord support
|
|
// and reset all Discord data including server-origin flags and custom app ID
|
|
if (mTelnet.isGMCPEnabled()) {
|
|
mTelnet.sendGMCPSupportsRemove(qsl("External.Discord 1"));
|
|
}
|
|
pMudlet->mDiscord.resetData(this);
|
|
return;
|
|
}
|
|
|
|
pMudlet->mDiscord.UpdatePresence();
|
|
}
|
|
|
|
|
|
void Host::processDiscordMSDP(const QString& variable, QString value)
|
|
{
|
|
if (mDiscordMode != DiscordShowGameDetails) {
|
|
return;
|
|
}
|
|
|
|
Q_UNUSED(variable)
|
|
Q_UNUSED(value)
|
|
// TODO:
|
|
// if (!(variable == QLatin1String("SERVER_ID") || variable == QLatin1String("AREA_NAME"))) {
|
|
// return;
|
|
// }
|
|
|
|
// // MSDP value comes padded with quotes - strip them (from the local copy of
|
|
// // the supplied argument):
|
|
// if (value.startsWith(QLatin1String("\""))) {
|
|
// value = value.mid(1);
|
|
// }
|
|
|
|
// if (value.endsWith(QLatin1String("\""))) {
|
|
// value.chop(1);
|
|
// }
|
|
|
|
// if (variable == QLatin1String("SERVER_ID")) {
|
|
// mudlet::self()->mDiscord.setGame(this, value);
|
|
// } else if (variable == QLatin1String("AREA_NAME")) {
|
|
// mudlet::self()->mDiscord.setArea(this, value);
|
|
// }
|
|
}
|
|
|
|
void Host::setDiscordApplicationID(const QString& s)
|
|
{
|
|
mDiscordApplicationID = s;
|
|
writeProfileData(qsl("discordApplicationId"), s);
|
|
}
|
|
|
|
const QString& Host::getDiscordApplicationID()
|
|
{
|
|
return mDiscordApplicationID;
|
|
}
|
|
|
|
void Host::setDiscordInviteURL(const QString& s)
|
|
{
|
|
mDiscordInviteURL = s;
|
|
writeProfileData(qsl("discordInviteURL"), s);
|
|
}
|
|
|
|
// Returns false if the profile is restricted to a specific Discord username
|
|
// and the currently logged-in Discord user doesn't match.
|
|
bool Host::discordUserIdMatch(const QString& userName) const
|
|
{
|
|
if (!userName.isEmpty() && !mRequiredDiscordUserName.isEmpty() && userName.toLower() != mRequiredDiscordUserName.toLower()) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
void Host::initMMCPServer()
|
|
{
|
|
if (mMMCPServer) {
|
|
return;
|
|
}
|
|
|
|
mMMCPServer = new MMCPServer(this);
|
|
}
|
|
|
|
|
|
// Return the MMCP chat name for this host
|
|
const QString& Host::getMMCPChatName() const
|
|
{
|
|
return mMMCPChatName;
|
|
}
|
|
|
|
// Validate and set the MMCP chat name, notify connected peers, and update GUI.
|
|
// Returns false if the name is invalid (contains ~ or ,).
|
|
bool Host::setMMCPChatName(const QString& name)
|
|
{
|
|
static const QRegularExpression rx(qsl("~|,"));
|
|
if (rx.match(name).hasMatch()) {
|
|
return false;
|
|
}
|
|
|
|
// If the name is unchanged, no need to update or emit signals
|
|
if (mMMCPChatName == name) {
|
|
return true;
|
|
}
|
|
|
|
mMMCPChatName = name;
|
|
|
|
if (mMMCPServer) {
|
|
mMMCPServer->chatName(name);
|
|
}
|
|
|
|
emit mmcpChatNameChanged(name);
|
|
return true;
|
|
}
|
|
|
|
quint16 Host::getMMCPPort()
|
|
{
|
|
return mMMCPChatPort;
|
|
}
|
|
|
|
const QString& Host::getMMCPChatPrefix() const
|
|
{
|
|
return mMMCPChatPrefix;
|
|
}
|
|
|
|
bool Host::getMMCPAutoStartServer()
|
|
{
|
|
return mMMCPAutostartServer;
|
|
}
|
|
|
|
bool Host::getMMCPAllowPeekRequests()
|
|
{
|
|
return mMMCPAllowPeekRequests;
|
|
}
|
|
|
|
bool Host::getMMCPPrefixEmotes()
|
|
{
|
|
return mMMCPPrefixEmotes;
|
|
}
|
|
|
|
bool Host::getMMCPAddChatMessageNewline()
|
|
{
|
|
return mMMCPAddChatMessageNewline;
|
|
}
|
|
|
|
bool Host::getMMCPAutoAcceptCalls()
|
|
{
|
|
return mMMCPAutoAcceptCalls;
|
|
}
|
|
|
|
bool Host::getMMCPShowSnoopInMainConsole()
|
|
{
|
|
return mMMCPShowSnoopInMainConsole;
|
|
}
|
|
|
|
QString Host::getSpellDic()
|
|
{
|
|
if (!mSpellDic.isEmpty()) {
|
|
return mSpellDic;
|
|
}
|
|
#if defined(Q_OS_OPENBSD)
|
|
// OpenBSD does not ship a USA dictionary so we will have to use
|
|
// a different starting one to try and locate system ones
|
|
return (qsl("en-GB"));
|
|
#else
|
|
return (qsl("en_US"));
|
|
#endif
|
|
}
|
|
|
|
void Host::setSpellDic(const QString& newDict)
|
|
{
|
|
bool isChanged = false;
|
|
if (!newDict.isEmpty() && mSpellDic != newDict) {
|
|
mSpellDic = newDict;
|
|
isChanged = true;
|
|
}
|
|
if (isChanged && mpConsole) {
|
|
mpConsole->setSystemSpellDictionary(newDict);
|
|
}
|
|
}
|
|
|
|
// When called from dlgProfilePreferences the second flag will only be changed
|
|
// if necessary:
|
|
// DISABLED: - Prevent "None" option for user dictionary - modified to prevent original useDictionary argument from being false:
|
|
void Host::setUserDictionaryOptions(const bool _useDictionary, const bool useShared)
|
|
{
|
|
Q_UNUSED(_useDictionary)
|
|
const bool useDictionary = true;
|
|
bool dictionaryChanged{};
|
|
// Copy the value while we have the lock:
|
|
const bool isSpellCheckingEnabled = mEnableSpellCheck;
|
|
if (mEnableUserDictionary != useDictionary) {
|
|
mEnableUserDictionary = useDictionary;
|
|
dictionaryChanged = true;
|
|
}
|
|
|
|
if (mUseSharedDictionary != useShared) {
|
|
mUseSharedDictionary = useShared;
|
|
dictionaryChanged = true;
|
|
}
|
|
|
|
if (!mpConsole) {
|
|
return;
|
|
}
|
|
|
|
if (dictionaryChanged) {
|
|
// This will propagate the changes in the two flags to the main
|
|
// TConsole's copies of them - although setProfileSpellDictionary() is
|
|
// also called in the main TConsole constructor:
|
|
mpConsole->setProfileSpellDictionary();
|
|
}
|
|
|
|
// This also needs to handle the spell checking against the system/mudlet
|
|
// bundled dictionary being switched on or off. Given that if it has
|
|
// been disabled the spell checking code won't run we need to clear any
|
|
// highlights in the TCommandLine instance that may have been present when
|
|
// spell checking is turned on or off:
|
|
if (isSpellCheckingEnabled) {
|
|
// Now enabled - so recheck the whole command line with whichever
|
|
// dictionaries are active:
|
|
mpConsole->mpCommandLine->recheckWholeLine();
|
|
} else {
|
|
// Or it is now disabled so clear any spelling marks:
|
|
mpConsole->mpCommandLine->clearMarksOnWholeLine();
|
|
}
|
|
}
|
|
|
|
// This does not take care of any QMaps or other containers that the mudlet
|
|
// and HostManager classes have that use the name of this profile as a key,
|
|
// however it should ensure that other classes get updated:
|
|
void Host::setName(const QString& name)
|
|
{
|
|
if (mHostName == name) {
|
|
return;
|
|
}
|
|
|
|
TDebug::changeHostName(this, name);
|
|
int currentPlayerRoom = 0;
|
|
if (mpMap) {
|
|
currentPlayerRoom = mpMap->mRoomIdHash.take(mHostName);
|
|
}
|
|
|
|
mHostName = name;
|
|
|
|
mTelnet.mProfileName = name;
|
|
if (mpMap) {
|
|
mpMap->mProfileName = name;
|
|
if (currentPlayerRoom) {
|
|
mpMap->mRoomIdHash.insert(name, currentPlayerRoom);
|
|
}
|
|
}
|
|
|
|
if (mpConsole) {
|
|
// If skipped they will be taken care of in the TMainConsole constructor:
|
|
mpConsole->setProperty("HostName", name);
|
|
mpConsole->setProfileName(name);
|
|
}
|
|
mTimerUnit.changeHostName(name);
|
|
}
|
|
|
|
void Host::removeAllNonPersistentStopWatches()
|
|
{
|
|
auto it = mStopWatchMap.begin();
|
|
while (it != mStopWatchMap.end()) {
|
|
if (!it->second || it->second->persistent()) {
|
|
++it;
|
|
continue;
|
|
}
|
|
it = mStopWatchMap.erase(it);
|
|
}
|
|
}
|
|
|
|
void Host::updateProxySettings(QNetworkAccessManager* manager)
|
|
{
|
|
if (mUseProxy && !mProxyAddress.isEmpty() && mProxyPort != 0) {
|
|
auto& proxy = getConnectionProxy();
|
|
manager->setProxy(*proxy);
|
|
} else {
|
|
manager->setProxy(QNetworkProxy::DefaultProxy);
|
|
}
|
|
}
|
|
|
|
std::unique_ptr<QNetworkProxy>& Host::getConnectionProxy()
|
|
{
|
|
if (!mpConnectionProxy) {
|
|
mpConnectionProxy = std::make_unique<QNetworkProxy>(QNetworkProxy::Socks5Proxy);
|
|
}
|
|
auto& proxy = mpConnectionProxy;
|
|
proxy->setHostName(mProxyAddress);
|
|
proxy->setPort(mProxyPort);
|
|
if (!mProxyUsername.isEmpty()) {
|
|
proxy->setUser(mProxyUsername);
|
|
}
|
|
if (!mProxyPassword.isEmpty()) {
|
|
proxy->setPassword(mProxyPassword);
|
|
}
|
|
|
|
return mpConnectionProxy;
|
|
}
|
|
|
|
void Host::loadSecuredPassword()
|
|
{
|
|
// Use async API for QtKeychain integration with file fallback
|
|
auto* credManager = new CredentialManager(this);
|
|
|
|
credManager->retrievePassword(getName(), "character", [this, credManager](bool success, const QString& password, const QString& errorMessage) {
|
|
if (success && !password.isEmpty()) {
|
|
setPass(password);
|
|
QString passwordCopy = password; // Make a copy for secure clearing
|
|
SecureStringUtils::secureStringClear(passwordCopy);
|
|
} else if (!success && !errorMessage.isEmpty()) {
|
|
qDebug() << "Host::loadSecuredPassword() - Failed to retrieve password:" << errorMessage;
|
|
}
|
|
|
|
// Clean up the credential manager
|
|
credManager->deleteLater();
|
|
});
|
|
}
|
|
|
|
// Only needed for places outside of this class:
|
|
void Host::updateAnsi16ColorsInTable()
|
|
{
|
|
mLuaInterpreter.updateAnsi16ColorsInTable();
|
|
}
|
|
|
|
void Host::updateExtendedAnsiColorsInTable()
|
|
{
|
|
mLuaInterpreter.updateExtendedAnsiColorsInTable();
|
|
}
|
|
|
|
void Host::setPlayerRoomStyleDetails(const quint8 styleCode, const quint8 outerDiameter, const quint8 innerDiameter, const QColor& outerColor, const QColor& innerColor)
|
|
{
|
|
mPlayerRoomStyle = styleCode;
|
|
mPlayerRoomOuterDiameterPercentage = outerDiameter;
|
|
mPlayerRoomInnerDiameterPercentage = innerDiameter;
|
|
mPlayerRoomOuterColor = outerColor;
|
|
mPlayerRoomInnerColor = innerColor;
|
|
}
|
|
|
|
void Host::getPlayerRoomStyleDetails(quint8& styleCode, quint8& outerDiameter, quint8& innerDiameter, QColor& primaryColor, QColor& secondaryColor)
|
|
{
|
|
styleCode = mPlayerRoomStyle;
|
|
outerDiameter = mPlayerRoomOuterDiameterPercentage;
|
|
innerDiameter = mPlayerRoomInnerDiameterPercentage;
|
|
primaryColor = mPlayerRoomOuterColor;
|
|
secondaryColor = mPlayerRoomInnerColor;
|
|
}
|
|
|
|
// Used to set the searchOptions here and the one in the editor if present, for
|
|
// use by the XMLimporter class:
|
|
void Host::setSearchOptions(const dlgTriggerEditor::SearchOptions optionsState)
|
|
{
|
|
mSearchOptions = optionsState;
|
|
if (mpEditorDialog) {
|
|
mpEditorDialog->setSearchOptions(optionsState);
|
|
}
|
|
}
|
|
|
|
void Host::setBufferSearchOptions(const TConsole::SearchOptions optionsState)
|
|
{
|
|
mBufferSearchOptions = optionsState;
|
|
}
|
|
|
|
// The single answer to "does this profile have a map widget on screen right
|
|
// now" - null both for a profile that has never opened one and for one that put
|
|
// it away again, which a script cannot tell apart and does not need to.
|
|
//
|
|
// isHidden() rather than a flag of our own, because the dock gets hidden by
|
|
// paths that would never think to update one: its own title bar close button,
|
|
// mudlet::slot_showMapperDialog() handing the map over to a main window dock,
|
|
// and QMainWindow::restoreState() replaying a saved layout. It is also not
|
|
// !isVisible(), which would additionally answer "no map widget" whenever the
|
|
// main window itself is hidden, e.g. minimised to the system tray.
|
|
QDockWidget* Host::mapWidget() const
|
|
{
|
|
if (!mpConsole || !mpConsole->mpDockableMapWidget || mpConsole->mpDockableMapWidget->isHidden()) {
|
|
return nullptr;
|
|
}
|
|
|
|
return mpConsole->mpDockableMapWidget;
|
|
}
|
|
|
|
std::pair<bool, QString> Host::setMapperTitle(const QString& title)
|
|
{
|
|
auto pM = mapWidget();
|
|
if (!pM) {
|
|
return {false, qsl("no floating/dockable type map window found")};
|
|
}
|
|
|
|
if (title.isEmpty()) {
|
|
pM->setWindowTitle(tr("Map - %1").arg(mHostName));
|
|
} else {
|
|
pM->setWindowTitle(title);
|
|
}
|
|
|
|
return {true, QString()};
|
|
}
|
|
|
|
std::optional<QString> Host::getMapperTitle() const
|
|
{
|
|
auto pM = mapWidget();
|
|
if (!pM) {
|
|
return {};
|
|
}
|
|
|
|
return {pM->windowTitle()};
|
|
}
|
|
|
|
std::pair<int, QString> Host::createMapView(int areaId)
|
|
{
|
|
if (!mpMap) {
|
|
return {0, qsl("no map present or loaded")};
|
|
}
|
|
|
|
auto* viewManager = mpMap->getViewManager();
|
|
if (!viewManager) {
|
|
return {0, qsl("no view manager available")};
|
|
}
|
|
|
|
return viewManager->createView(areaId);
|
|
}
|
|
|
|
std::pair<bool, QString> Host::closeMapView(int viewId)
|
|
{
|
|
if (!mpMap) {
|
|
return {false, qsl("no map present or loaded")};
|
|
}
|
|
|
|
auto* viewManager = mpMap->getViewManager();
|
|
if (!viewManager) {
|
|
return {false, qsl("no view manager available")};
|
|
}
|
|
|
|
return viewManager->closeView(viewId);
|
|
}
|
|
|
|
std::pair<int, QString> Host::closeAllMapViews()
|
|
{
|
|
if (!mpMap) {
|
|
return {0, qsl("no map present or loaded")};
|
|
}
|
|
|
|
auto* viewManager = mpMap->getViewManager();
|
|
if (!viewManager) {
|
|
return {0, qsl("no view manager available")};
|
|
}
|
|
|
|
return {viewManager->closeAllViews(), QString()};
|
|
}
|
|
|
|
QList<int> Host::getMapViewIds() const
|
|
{
|
|
if (!mpMap) {
|
|
qWarning() << "Host::getMapViewIds() - no map present or loaded";
|
|
return {};
|
|
}
|
|
|
|
auto* viewManager = mpMap->getViewManager();
|
|
if (!viewManager) {
|
|
qWarning() << "Host::getMapViewIds() - no view manager available";
|
|
return {};
|
|
}
|
|
|
|
return viewManager->getViewIds();
|
|
}
|
|
|
|
void Host::setDebugShowAllProblemCodepoints(const bool state)
|
|
{
|
|
if (mDebugShowAllProblemCodepoints != state) {
|
|
mDebugShowAllProblemCodepoints = state;
|
|
emit signal_changeDebugShowAllProblemCodepoints(state);
|
|
}
|
|
}
|
|
|
|
void Host::setCompactInputLine(const bool state)
|
|
{
|
|
if (mCompactInputLine != state) {
|
|
mCompactInputLine = state;
|
|
// When the profile is being loaded and the previously saved data is
|
|
// read from the XML file the main TConsole has not been instatiated
|
|
// yet - so must check for it existing first - and ensure the read
|
|
// setting is applied in the constructor for it:
|
|
if (mpConsole && mpConsole->mpButtonMainLayer) {
|
|
mpConsole->mpButtonMainLayer->setVisible(!state);
|
|
}
|
|
}
|
|
}
|
|
|
|
QPointer<TConsole> Host::findConsole(QString name)
|
|
{
|
|
if (!mpConsole) {
|
|
qWarning() << "Host::findConsole() ERROR: main console not initialized";
|
|
return nullptr;
|
|
}
|
|
|
|
if (name.isEmpty() or name == qsl("main")) {
|
|
// Reason for the deref-plus-ref in the next line: `QPointer`s do not
|
|
// follow inheritance. See https://bugreports.qt.io/browse/QTBUG-2258
|
|
return &*mpConsole;
|
|
}
|
|
return mpConsole->mSubConsoleMap.value(name);
|
|
}
|
|
|
|
QPair<bool, QStringList> Host::getLines(const QString& windowName, const int lineFrom, const int lineTo)
|
|
{
|
|
if (!mpConsole) {
|
|
QStringList failMessage;
|
|
failMessage << qsl("internal error: no main TConsole - please report").arg(windowName);
|
|
return qMakePair(false, failMessage);
|
|
}
|
|
|
|
if (windowName.isEmpty() || windowName == QLatin1String("main")) {
|
|
return qMakePair(true, mpConsole->getLines(lineFrom, lineTo));
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(windowName);
|
|
if (!pC) {
|
|
QStringList failMessage;
|
|
failMessage << qsl("mini console, user window or buffer '%1' not found").arg(windowName);
|
|
return qMakePair(false, failMessage);
|
|
}
|
|
return qMakePair(true, pC->getLines(lineFrom, lineTo));
|
|
}
|
|
|
|
std::pair<bool, QString> Host::openWindow(const QString& name, bool loadLayout, bool autoDock, const QString& area)
|
|
{
|
|
if (!mpConsole) {
|
|
return {false, QString()};
|
|
}
|
|
|
|
if (name.isEmpty()) {
|
|
return {false, QLatin1String("an userwindow cannot have an empty string as its name")};
|
|
}
|
|
|
|
//Dont create Userwindow if there is a Label with the same name already. It breaks the UserWindow
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
return {false, qsl("label with the name '%1' already exists").arg(name)};
|
|
}
|
|
|
|
auto hostName(getName());
|
|
auto console = mpConsole->mSubConsoleMap.value(name);
|
|
auto dockwidget = mpConsole->mDockWidgetMap.value(name);
|
|
|
|
if (!console && !dockwidget) {
|
|
// The name is not used in either the QMaps of all user created TConsole
|
|
// or TDockWidget instances - so we can make a NEW one:
|
|
dockwidget = new TDockWidget(this, name);
|
|
dockwidget->setObjectName(qsl("dockWindow_%1_%2").arg(hostName, name));
|
|
dockwidget->setContentsMargins(0, 0, 0, 0);
|
|
dockwidget->setWindowTitle(name);
|
|
mpConsole->mDockWidgetMap.insert(name, dockwidget);
|
|
// It wasn't obvious but the parent passed to the TConsole constructor
|
|
// is sliced down to a QWidget and is NOT a TDockWidget pointer:
|
|
console = new TConsole(this, name, TConsole::UserWindow, dockwidget->widget());
|
|
console->setObjectName(qsl("dockWindowConsole_%1_%2").arg(hostName, name));
|
|
console->setContentsMargins(0, 0, 0, 0);
|
|
dockwidget->setTConsole(console);
|
|
console->layerCommandLine->hide();
|
|
console->setScrollBarVisible(false);
|
|
mpConsole->mSubConsoleMap.insert(name, console);
|
|
dockwidget->setStyleSheet(mProfileStyleSheet);
|
|
mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, dockwidget);
|
|
console->setFontSize(10);
|
|
}
|
|
if (!console || !dockwidget) {
|
|
return {false, qsl("userwindow '%1' already exists").arg(name)};
|
|
}
|
|
|
|
// The name is used in BOTH the QMaps of all user created TConsole
|
|
// and TDockWidget instances - so we HAVE an existing user window,
|
|
// Lets confirm this:
|
|
Q_ASSERT_X(console->getType() == TConsole::UserWindow, "host::openWindow(...)", "An existing TConsole was expected to be marked as a User Window type but it isn't");
|
|
dockwidget->update();
|
|
|
|
if (loadLayout && !dockwidget->hasLayoutAlready) {
|
|
mudlet::self()->loadWindowLayout();
|
|
dockwidget->hasLayoutAlready = true;
|
|
}
|
|
dockwidget->show();
|
|
dockwidget->setAllowedAreas(autoDock ? Qt::AllDockWidgetAreas : Qt::NoDockWidgetArea);
|
|
|
|
if (area.isEmpty()) {
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (area == QLatin1String("f") || area == QLatin1String("floating")) {
|
|
if (!dockwidget->isFloating()) {
|
|
dockwidget->setFloating(true);
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
if (area == QLatin1String("r") || area == QLatin1String("right")) {
|
|
dockwidget->setFloating(false);
|
|
mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, dockwidget);
|
|
return {true, QString()};
|
|
}
|
|
if (area == QLatin1String("l") || area == QLatin1String("left")) {
|
|
dockwidget->setFloating(false);
|
|
mudlet::self()->addDockWidget(Qt::LeftDockWidgetArea, dockwidget);
|
|
return {true, QString()};
|
|
}
|
|
if (area == QLatin1String("t") || area == QLatin1String("top")) {
|
|
dockwidget->setFloating(false);
|
|
mudlet::self()->addDockWidget(Qt::TopDockWidgetArea, dockwidget);
|
|
return {true, QString()};
|
|
}
|
|
if (area == QLatin1String("b") || area == QLatin1String("bottom")) {
|
|
dockwidget->setFloating(false);
|
|
mudlet::self()->addDockWidget(Qt::BottomDockWidgetArea, dockwidget);
|
|
return {true, QString()};
|
|
}
|
|
return {false, qsl(R"("docking option "%1" not available. available docking options are "t" top, "b" bottom, "r" right, "l" left and "f" floating")").arg(area)};
|
|
}
|
|
|
|
std::pair<bool, QString> Host::createMiniConsole(const QString& windowname, const QString& name, int x, int y, int width, int height)
|
|
{
|
|
if (!mpConsole) {
|
|
return {false, QString()};
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
auto pW = mpConsole->mDockWidgetMap.value(name);
|
|
if (!pC) {
|
|
pC = mpConsole->createMiniConsole(windowname, name, x, y, width, height);
|
|
if (pC) {
|
|
pC->setFontSize(12);
|
|
return {true, QString()};
|
|
}
|
|
} else if (pC) {
|
|
// CHECK: The absence of an explicit return statement in this block means that
|
|
// reusing an existing mini console causes the lua function to seem to
|
|
// fail - is this as per Wiki?
|
|
// This part was causing problems with UserWindows
|
|
if (!pW) {
|
|
pC->resize(width, height);
|
|
pC->move(x, y);
|
|
return {false, qsl("miniconsole '%1' already exists, moving/resizing '%1'").arg(name)};
|
|
}
|
|
}
|
|
return {false, qsl("miniconsole/userwindow '%1' already exists").arg(name)};
|
|
}
|
|
|
|
std::pair<bool, QString> Host::createScrollBox(const QString& windowname, const QString& name, int x, int y, int width, int height) const
|
|
{
|
|
if (!mpConsole) {
|
|
return {false, QString()};
|
|
}
|
|
|
|
auto pS = mpConsole->mScrollBoxMap.value(name);
|
|
if (!pS) {
|
|
pS = mpConsole->createScrollBox(windowname, name, x, y, width, height);
|
|
if (pS) {
|
|
return {true, QString()};
|
|
}
|
|
} else if (pS) {
|
|
pS->resize(width, height);
|
|
pS->move(x, y);
|
|
return {false, qsl("scrollBox '%1' already exists, moving/resizing '%1'").arg(name)};
|
|
}
|
|
return {false, qsl("scrollBox '%1' already exists").arg(name)};
|
|
}
|
|
|
|
std::pair<bool, QString> Host::createLabel(const QString& windowname, const QString& name, int x, int y, int width, int height, bool fillBg, bool clickthrough)
|
|
{
|
|
if (!mpConsole) {
|
|
return {false, QString()};
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (!pL && !pC) {
|
|
pL = mpConsole->createLabel(windowname, name, x, y, width, height, fillBg, clickthrough);
|
|
if (pL) {
|
|
return {true, QString()};
|
|
}
|
|
} else if (pL) {
|
|
return {false, qsl("label '%1' already exists").arg(name)};
|
|
} else if (pC) {
|
|
return {false, qsl("a miniconsole/userwindow with the name '%1' already exists").arg(name)};
|
|
}
|
|
return {false, QString()};
|
|
}
|
|
|
|
bool Host::setClickthrough(const QString& name, bool clickthrough)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setClickThrough(clickthrough);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLabelStyleSheet(const QString& name, const QString& styleSheet)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setStyleSheet(styleSheet);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLinkStyle(const QString& name, const QString& linkColor, const QString& linkVisitedColor, bool underline)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setLinkStyle(linkColor, linkVisitedColor, underline);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Host::resetLinkStyle(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->resetLinkStyle();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Host::clearVisitedLinks(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->clearVisitedLinks();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
void Host::hideMudletsVariables()
|
|
{
|
|
auto varUnit = getLuaInterface()->getVarUnit();
|
|
|
|
// remember which users' saved variables shouldn't be hidden
|
|
QVector<QString> unhideSavedVars;
|
|
for (const auto& variable : std::as_const(varUnit->savedVars)) {
|
|
if (!varUnit->isHidden(variable)) {
|
|
unhideSavedVars.append(variable);
|
|
}
|
|
}
|
|
|
|
// mark all currently loaded Lua variables as hidden
|
|
// this covers entirety of Mudlets API (good!) and but unfortunately
|
|
// user's saved variables as well
|
|
LuaInterface* lI = getLuaInterface();
|
|
lI->getVars(true);
|
|
|
|
// unhide user's saved variables
|
|
for (const auto& variable : std::as_const(unhideSavedVars)) {
|
|
varUnit->removeHidden(variable);
|
|
}
|
|
}
|
|
|
|
bool Host::createBuffer(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (!pC) {
|
|
pC = mpConsole->createBuffer(name);
|
|
if (pC) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Doesn't work on the errors or central debug consoles:
|
|
bool Host::clearWindow(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
return mpConsole->clear(name);
|
|
}
|
|
|
|
bool Host::showWindow(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
auto pN = mpConsole->mSubCommandLineMap.value(name);
|
|
auto pS = mpConsole->mScrollBoxMap.value(name);
|
|
auto pT = mpConsole->mTextBoxMap.value(name);
|
|
// check labels first as they are shown/hidden more often
|
|
if (pL) {
|
|
pL->show();
|
|
return true;
|
|
}
|
|
|
|
if (pC) {
|
|
auto pD = mpConsole->mDockWidgetMap.value(name);
|
|
if (pD) {
|
|
pD->update();
|
|
pD->show();
|
|
// TODO: conside refactoring TConsole::showWindow(name) so that there is a TConsole::showWindow() that can be called directly on the TConsole concerned?
|
|
return mpConsole->showWindow(name);
|
|
}
|
|
return mpConsole->showWindow(name);
|
|
}
|
|
|
|
if (pS) {
|
|
pS->show();
|
|
return true;
|
|
}
|
|
|
|
if (pN) {
|
|
pN->show();
|
|
return true;
|
|
}
|
|
|
|
if (pT) {
|
|
pT->show();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Host::hideWindow(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
auto pN = mpConsole->mSubCommandLineMap.value(name);
|
|
auto pS = mpConsole->mScrollBoxMap.value(name);
|
|
auto pT = mpConsole->mTextBoxMap.value(name);
|
|
|
|
// check labels first as they are shown/hidden more often
|
|
if (pL) {
|
|
pL->hide();
|
|
return true;
|
|
}
|
|
|
|
if (pC) {
|
|
auto pD = mpConsole->mDockWidgetMap.value(name);
|
|
if (pD) {
|
|
pD->hide();
|
|
pD->update();
|
|
}
|
|
return mpConsole->hideWindow(name);
|
|
}
|
|
|
|
if (pS) {
|
|
pS->hide();
|
|
return true;
|
|
}
|
|
|
|
if (pN) {
|
|
pN->hide();
|
|
return true;
|
|
}
|
|
|
|
if (pT) {
|
|
pT->hide();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Host::resizeWindow(const QString& name, int x1, int y1)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
auto pD = mpConsole->mDockWidgetMap.value(name);
|
|
auto pN = mpConsole->mSubCommandLineMap.value(name);
|
|
auto pS = mpConsole->mScrollBoxMap.value(name);
|
|
auto pT = mpConsole->mTextBoxMap.value(name);
|
|
|
|
if (pL) {
|
|
pL->resize(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pC && !pD) {
|
|
// NOT a floatable/dockable "user window"
|
|
pC->resize(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pC && pD) {
|
|
if (!pD->isFloating()) {
|
|
// Undock a docked window
|
|
pD->setFloating(true);
|
|
}
|
|
|
|
pD->resize(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pS) {
|
|
pS->resize(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pN) {
|
|
pN->resize(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pT) {
|
|
pT->resize(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Host::moveWindow(const QString& name, int x1, int y1)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
auto pD = mpConsole->mDockWidgetMap.value(name);
|
|
auto pN = mpConsole->mSubCommandLineMap.value(name);
|
|
auto pS = mpConsole->mScrollBoxMap.value(name);
|
|
auto pT = mpConsole->mTextBoxMap.value(name);
|
|
|
|
if (pL) {
|
|
pL->move(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pC && !pD) {
|
|
// NOT a floatable/dockable "user window"
|
|
pC->move(x1, y1);
|
|
pC->mOldX = x1;
|
|
pC->mOldY = y1;
|
|
return true;
|
|
}
|
|
|
|
if (pC && pD) {
|
|
if (!pD->isFloating()) {
|
|
// Undock a docked window
|
|
pD->setFloating(true);
|
|
}
|
|
|
|
pD->move(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pS) {
|
|
pS->move(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pN) {
|
|
pN->move(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
if (pT) {
|
|
pT->move(x1, y1);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
std::pair<bool, QString> Host::setWindow(const QString& windowname, const QString& name, int x1, int y1, bool show)
|
|
{
|
|
if (!mpConsole) {
|
|
return {false, QString()};
|
|
}
|
|
//checks - for reasons why the indicated thing might not be moved to the indicated destination
|
|
auto pDCheck = mpConsole->mDockWidgetMap.value(name);
|
|
if (pDCheck) {
|
|
return {false, qsl("element '%1' is the base of a floating/dockable user window and may not be moved").arg(name)};
|
|
}
|
|
if (mpConsole->mpDockableMapWidget) {
|
|
if (!name.compare(QLatin1String("mapper"), Qt::CaseInsensitive)) {
|
|
return {false, qsl("element '%1' is the map in a floating/dockable window and may not be moved").arg(name)};
|
|
}
|
|
if (!windowname.compare(QLatin1String("mapper"), Qt::CaseInsensitive)) {
|
|
return {false, qsl("window '%1' is the map in a floating/dockable window and may not receive other elements").arg(windowname)};
|
|
}
|
|
}
|
|
|
|
//children
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
auto pM = mpConsole->mpMapper;
|
|
auto pN = mpConsole->mSubCommandLineMap.value(name);
|
|
auto pS = mpConsole->mScrollBoxMap.value(name);
|
|
auto pT = mpConsole->mTextBoxMap.value(name);
|
|
//parents
|
|
auto pW = mpConsole->mpMainFrame;
|
|
auto pD = mpConsole->mDockWidgetMap.value(windowname);
|
|
auto pSW = mpConsole->mScrollBoxMap.value(windowname);
|
|
|
|
if (!pSW && !pD && windowname.compare(QLatin1String("main"), Qt::CaseInsensitive)) {
|
|
// Third argument is non-zero (i.e. true) if the window name is NOT
|
|
// the given string:
|
|
return {false, qsl("window '%1' not found").arg(windowname)};
|
|
}
|
|
|
|
if (pD) {
|
|
pW = pD->widget();
|
|
}
|
|
|
|
if (pSW) {
|
|
pW = pSW->widget();
|
|
}
|
|
|
|
if (pL) {
|
|
pL->setParent(pW);
|
|
pL->move(x1, y1);
|
|
if (show) {
|
|
pL->show();
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (pC) {
|
|
pC->setParent(pW);
|
|
pC->move(x1, y1);
|
|
pC->mOldX = x1;
|
|
pC->mOldY = y1;
|
|
if (show) {
|
|
pC->show();
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (pS) {
|
|
pS->setParent(pW);
|
|
pS->move(x1, y1);
|
|
if (show) {
|
|
pS->show();
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (pN) {
|
|
pN->setParent(pW);
|
|
pN->move(x1, y1);
|
|
if (show) {
|
|
pN->show();
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (pT) {
|
|
pT->setParent(pW);
|
|
pT->move(x1, y1);
|
|
if (show) {
|
|
pT->show();
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (pM && !name.compare(QLatin1String("mapper"), Qt::CaseInsensitive)) {
|
|
pM->setParent(pW);
|
|
pM->move(x1, y1);
|
|
if (show) {
|
|
pM->show();
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
|
|
return {false, qsl("element '%1' not found").arg(name)};
|
|
}
|
|
|
|
std::pair<bool, QString> Host::openMapWidget(const QString& area, int x, int y, int width, int height)
|
|
{
|
|
if (!mpConsole) {
|
|
return {false, qsl("no console for this profile - it may be closing")};
|
|
}
|
|
|
|
auto pM = mpConsole->mpDockableMapWidget;
|
|
auto pMapper = mpMap.data()->mpMapper;
|
|
if (!pM && !pMapper) {
|
|
showHideOrCreateMapper(true);
|
|
pM = mpConsole->mpDockableMapWidget;
|
|
}
|
|
if (!pM) {
|
|
return {false, qsl("cannot create map widget. Do you already use an embedded mapper?")};
|
|
}
|
|
pM->show();
|
|
if (area.isEmpty()) {
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (area == QLatin1String("f") || area == QLatin1String("floating")) {
|
|
if (!pM->isFloating()) {
|
|
// Undock a docked window
|
|
// Change of position or size is only possible when floating
|
|
pM->setFloating(true);
|
|
}
|
|
if ((x != -1) && (y != -1)) {
|
|
pM->move(x, y);
|
|
}
|
|
if ((width != -1) && (height != -1)) {
|
|
pM->resize(width, height);
|
|
}
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (area == QLatin1String("r") || area == QLatin1String("right")) {
|
|
pM->setFloating(false);
|
|
mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, pM);
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (area == QLatin1String("l") || area == QLatin1String("left")) {
|
|
pM->setFloating(false);
|
|
mudlet::self()->addDockWidget(Qt::LeftDockWidgetArea, pM);
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (area == QLatin1String("t") || area == QLatin1String("top")) {
|
|
pM->setFloating(false);
|
|
mudlet::self()->addDockWidget(Qt::TopDockWidgetArea, pM);
|
|
return {true, QString()};
|
|
}
|
|
|
|
if (area == QLatin1String("b") || area == QLatin1String("bottom")) {
|
|
pM->setFloating(false);
|
|
mudlet::self()->addDockWidget(Qt::BottomDockWidgetArea, pM);
|
|
return {true, QString()};
|
|
}
|
|
|
|
return {false, qsl(R"("docking option "%1" not available. available docking options are "t" top, "b" bottom, "r" right, "l" left and "f" floating")").arg(area)};
|
|
}
|
|
|
|
// The inverse of moveMapWidget()/resizeMapWidget(), which reach the dock widget
|
|
// through openMapWidget(). pos()/size() rather than geometry() for the same
|
|
// reason as Host::windowGeometry(): they are what move()/resize() were given,
|
|
// while a floating dock's geometry() reports the client area instead.
|
|
std::optional<QRect> Host::mapWidgetGeometry() const
|
|
{
|
|
auto pM = mapWidget();
|
|
if (!pM) {
|
|
return {};
|
|
}
|
|
|
|
return {QRect(pM->pos(), pM->size())};
|
|
}
|
|
|
|
std::pair<bool, QString> Host::closeMapWidget()
|
|
{
|
|
if (!mpConsole) {
|
|
return {false, qsl("no console for this profile - it may be closing")};
|
|
}
|
|
|
|
// Test the raw pointer first so that a profile which never made a map widget
|
|
// is told apart from one that has put its widget away.
|
|
if (!mpConsole->mpDockableMapWidget) {
|
|
return {false, qsl("no map widget found to close")};
|
|
}
|
|
if (!mapWidget()) {
|
|
return {false, qsl("map widget already closed")};
|
|
}
|
|
mpConsole->mpDockableMapWidget->hide();
|
|
return {true, QString()};
|
|
}
|
|
|
|
bool Host::closeWindow(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (pC) {
|
|
auto pD = mpConsole->mDockWidgetMap.value(name);
|
|
if (pD) {
|
|
pD->hide();
|
|
pD->update();
|
|
}
|
|
return mpConsole->hideWindow(name);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::echoWindow(const QString& name, const QString& text)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (pC) {
|
|
pC->print(text);
|
|
return true;
|
|
}
|
|
|
|
if (pL) {
|
|
pL->setText(text);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::pasteWindow(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (pC) {
|
|
pC->pasteWindow(mpConsole->mClipboard);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setCmdLineAction(const QString& name, const int func)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
auto pN = mpConsole->mSubCommandLineMap.value(name);
|
|
if (pN) {
|
|
pN->setAction(func);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::resetCmdLineAction(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
auto pN = mpConsole->mSubCommandLineMap.value(name);
|
|
if (pN) {
|
|
pN->resetAction();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLabelClickCallback(const QString& name, const int func)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setClick(func);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLabelDoubleClickCallback(const QString& name, const int func)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setDoubleClick(func);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLabelReleaseCallback(const QString& name, const int func)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setRelease(func);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLabelMoveCallback(const QString& name, const int func)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setMove(func);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLabelWheelCallback(const QString& name, const int func)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setWheel(func);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLabelOnEnter(const QString& name, const int func)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setEnter(func);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setLabelOnLeave(const QString& name, const int func)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->setLeave(func);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::pair<bool, QString> Host::setMovie(const QString& name, const QString& moviePath)
|
|
{
|
|
if (!mpConsole) {
|
|
return {false, QString()};
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (!pL) {
|
|
return {false, qsl("label '%1' does not exist").arg(name)};
|
|
}
|
|
|
|
auto myMovie = pL->mpMovie;
|
|
if (!myMovie) {
|
|
myMovie = new QMovie();
|
|
mGifTracker.registerGif(myMovie);
|
|
myMovie->setCacheMode(QMovie::CacheAll);
|
|
pL->mpMovie = myMovie;
|
|
myMovie->setParent(pL);
|
|
}
|
|
|
|
myMovie->setFileName(moviePath);
|
|
|
|
if (!myMovie->isValid()) {
|
|
return {false, qsl("no valid movie found at '%1'").arg(moviePath)};
|
|
}
|
|
|
|
myMovie->stop();
|
|
pL->setMovie(myMovie);
|
|
myMovie->start();
|
|
return {true, QString()};
|
|
}
|
|
|
|
QSize Host::calcFontSize(const QString& windowName)
|
|
{
|
|
if (!mpConsole) {
|
|
return QSize(-1, -1);
|
|
}
|
|
|
|
if (windowName.isEmpty() || windowName.compare(qsl("main"), Qt::CaseSensitive) == 0) {
|
|
QFontMetrics fontMetrics(mpConsole->mUpperPane->fontMetrics());
|
|
return QSize(fontMetrics.horizontalAdvance(QChar('W')), fontMetrics.height());
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(windowName);
|
|
if (!pC) {
|
|
return QSize(-1, -1);
|
|
}
|
|
|
|
Q_ASSERT_X(pC->mUpperPane, "calcFontSize", "located console does not have the upper pane available");
|
|
QFontMetrics fontMetrics(pC->mUpperPane->fontMetrics());
|
|
return QSize(fontMetrics.horizontalAdvance(QChar('W')), fontMetrics.height());
|
|
}
|
|
|
|
bool Host::setProfileStyleSheet(const QString& styleSheet)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
mProfileStyleSheet = styleSheet;
|
|
mpConsole->setStyleSheet(styleSheet);
|
|
if (mpEditorDialog) {
|
|
mpEditorDialog->setStyleSheet(styleSheet);
|
|
}
|
|
|
|
if (mpDlgProfilePreferences) {
|
|
mpDlgProfilePreferences->setStyleSheet(styleSheet);
|
|
}
|
|
if (mpNotePad) {
|
|
mpNotePad->setStyleSheet(styleSheet);
|
|
mpNotePad->setTabsStyleSheet(styleSheet);
|
|
}
|
|
if (mpConsole->mpDockableMapWidget) {
|
|
mpConsole->mpDockableMapWidget->setStyleSheet(styleSheet);
|
|
}
|
|
|
|
for (auto& dockWidget : mpConsole->mDockWidgetMap) {
|
|
dockWidget->setStyleSheet(styleSheet);
|
|
}
|
|
if (this == mudlet::self()->mpCurrentActiveHost) {
|
|
mudlet::self()->setGlobalStyleSheet(styleSheet);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
|
|
bool Host::setBackgroundColor(const QString& name, int r, int g, int b, int alpha)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pC) {
|
|
pC->setConsoleBgColor(r, g, b, alpha);
|
|
return true;
|
|
}
|
|
|
|
if (pL) {
|
|
QString styleSheet = pL->styleSheet();
|
|
QString newColor = QString("background-color: rgba(%1, %2, %3, %4);").arg(r).arg(g).arg(b).arg(alpha);
|
|
if (styleSheet.contains(qsl("background-color"))) {
|
|
QRegularExpression re("background-color:[^;]*;");
|
|
styleSheet.replace(re, newColor);
|
|
} else {
|
|
if (!styleSheet.isEmpty() && !styleSheet.endsWith('\n')) {
|
|
styleSheet.append('\n');
|
|
}
|
|
styleSheet.append(newColor);
|
|
}
|
|
|
|
pL->setStyleSheet(styleSheet);
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
std::optional<QColor> Host::getBackgroundColor(const QString& name) const
|
|
{
|
|
if (!mpConsole) {
|
|
return {};
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pC) {
|
|
return {pC->mBgColor};
|
|
}
|
|
|
|
if (pL) {
|
|
return {pL->palette().color(QPalette::Window)};
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
bool Host::setBackgroundImage(const QString& name, QString& imgPath, int mode, bool fullWindow)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
// Mode 4 ('style') is a raw QSS fragment (e.g. a gradient), not a literal file
|
|
// path - normalising separators would corrupt any url()/gradient syntax in it:
|
|
if (mode != 4) {
|
|
if (QDir::homePath().contains('\\')) {
|
|
imgPath.replace('/', R"(\)");
|
|
} else {
|
|
imgPath.replace('\\', "/");
|
|
}
|
|
}
|
|
|
|
if (fullWindow) {
|
|
return mpConsole->setWindowBackgroundImage(imgPath, mode);
|
|
}
|
|
|
|
if (name.isEmpty() || name.compare(qsl("main"), Qt::CaseSensitive) == 0) {
|
|
mpConsole->setConsoleBackgroundImage(imgPath, mode);
|
|
return true;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
const QPixmap bgPixmap(imgPath);
|
|
pL->setPixmap(bgPixmap);
|
|
return true;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (pC) {
|
|
pC->setConsoleBackgroundImage(imgPath, mode);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::resetBackgroundImage(const QString& name, bool fullWindow)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
if (fullWindow) {
|
|
return mpConsole->resetWindowBackgroundImage();
|
|
}
|
|
|
|
if (name.isEmpty() || name.compare(qsl("main"), Qt::CaseSensitive) == 0) {
|
|
mpConsole->resetConsoleBackgroundImage();
|
|
return true;
|
|
}
|
|
|
|
auto pL = mpConsole->mLabelMap.value(name);
|
|
if (pL) {
|
|
pL->clear();
|
|
return true;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (pC) {
|
|
pC->resetConsoleBackgroundImage();
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
bool Host::setCommandBackgroundColor(const QString& name, int r, int g, int b, int alpha)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (pC) {
|
|
pC->setCommandBgColor(r, g, b, alpha);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool Host::setCommandForegroundColor(const QString& name, int r, int g, int b, int alpha)
|
|
{
|
|
if (!mpConsole) {
|
|
return false;
|
|
}
|
|
|
|
auto pC = mpConsole->mSubConsoleMap.value(name);
|
|
if (pC) {
|
|
pC->setCommandFgColor(r, g, b, alpha);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Needed to extract into a separate method from mudlet::slot_mapper() so that
|
|
// we can use it WITHOUT loading a file - at least for the
|
|
// TConsole::importMap(...) case that may need to create a map widget before it
|
|
// loads/imports a non-default (last saved map in profile's map directory).
|
|
void Host::showHideOrCreateMapper(const bool loadDefaultMap)
|
|
{
|
|
auto pMap = mpMap.data();
|
|
if (pMap->mpMapper) {
|
|
toggleMapperVisibility();
|
|
return;
|
|
}
|
|
|
|
createMapper(loadDefaultMap);
|
|
}
|
|
|
|
void Host::toggleMapperVisibility()
|
|
{
|
|
auto pMap = mpMap.data();
|
|
if (pMap->mpMapper->isFloatAndDockable()) {
|
|
// If we are using a floating/dockable widget we must show/hide that
|
|
// only and not the mapper widget (otherwise it messes up {shrinks
|
|
// to a minimal size} the mapper inside the container dock widget). This
|
|
// is the same as the case for a TConsole inside a TDockWidget in
|
|
// (void) TDockWidget::setVisible(bool).
|
|
// When in a dock widget, check the parent's visibility, not the child's,
|
|
// to correctly handle the case where the dock widget was closed via X button.
|
|
const bool isCurrentlyVisible = pMap->mpMapper->parentWidget()->isVisible();
|
|
if (isCurrentlyVisible) {
|
|
pMap->mpMapper->parentWidget()->setVisible(false);
|
|
} else {
|
|
// When showing, show child first then parent - same pattern as TDockWidget
|
|
pMap->mpMapper->show();
|
|
pMap->mpMapper->parentWidget()->setVisible(true);
|
|
}
|
|
} else {
|
|
const bool visStatus = pMap->mpMapper->isVisible();
|
|
pMap->mpMapper->setVisible(!visStatus);
|
|
}
|
|
}
|
|
|
|
void Host::createMapper(const bool loadDefaultMap)
|
|
{
|
|
// The console owns the map dock; bail if the profile has no console yet or is
|
|
// already being torn down.
|
|
if (!mpConsole) {
|
|
return;
|
|
}
|
|
auto pMap = mpMap.data();
|
|
auto hostName(getName());
|
|
mpConsole->createMapperDock(tr("Map - %1").arg(hostName), qsl("dockMap_%1").arg(hostName));
|
|
// Arrange for TMap member values to be copied from the Host masters so they
|
|
// are in place when the 2D mapper is created:
|
|
getPlayerRoomStyleDetails(pMap->mPlayerRoomStyle, pMap->mPlayerRoomOuterDiameterPercentage, pMap->mPlayerRoomInnerDiameterPercentage, pMap->mPlayerRoomOuterColor, pMap->mPlayerRoomInnerColor);
|
|
|
|
pMap->mpMapper = new dlgMapper(mpConsole->mpDockableMapWidget, this, pMap); //FIXME: mpHost definieren
|
|
pMap->mpMapper->setStyleSheet(mProfileStyleSheet);
|
|
mpConsole->mpDockableMapWidget->setWidget(pMap->mpMapper);
|
|
|
|
if (loadDefaultMap && pMap->mpRoomDB->isEmpty()) {
|
|
qDebug() << "Host::create_mapper() - restore map case 3.";
|
|
pMap->pushErrorMessagesToFile(tr("Pre-Map loading(3) report"), true);
|
|
const QDateTime now(QDateTime::currentDateTime());
|
|
if (pMap->restore(QString())) {
|
|
pMap->audit();
|
|
pMap->mpMapper->mp2dMap->init();
|
|
pMap->mpMapper->updateAreaComboBox();
|
|
pMap->mpMapper->resetAreaComboBoxToPlayerRoomArea();
|
|
pMap->mpMapper->show();
|
|
}
|
|
|
|
pMap->pushErrorMessagesToFile(tr("Loading map(3) at %1 report").arg(now.toString(Qt::ISODate)), true);
|
|
|
|
} else {
|
|
if (pMap->mpMapper) {
|
|
// Needed to set the area selector widget to right area when map is
|
|
// loaded by clicking on Map main toolbar button:
|
|
pMap->mpMapper->updateAreaComboBox();
|
|
pMap->mpMapper->resetAreaComboBoxToPlayerRoomArea();
|
|
pMap->mpMapper->show();
|
|
}
|
|
}
|
|
mudlet::self()->addDockWidget(Qt::RightDockWidgetArea, mpConsole->mpDockableMapWidget);
|
|
|
|
// XXX: should this be called multiple times?
|
|
mudlet::self()->loadWindowLayout();
|
|
|
|
// Ensure the mapper is visible after creation - loadWindowLayout() may have
|
|
// restored a previous hidden state, but when first creating the mapper, we
|
|
// always want it to be visible.
|
|
pMap->mpMapper->show();
|
|
mpConsole->mpDockableMapWidget->show();
|
|
pMap->mpMapper->updateEmptyStateOverlay();
|
|
|
|
check_for_mappingscript();
|
|
TEvent mapOpenEvent{};
|
|
mapOpenEvent.mArgumentList.append(QLatin1String("mapOpenEvent"));
|
|
mapOpenEvent.mArgumentTypeList.append(ARGUMENT_TYPE_STRING);
|
|
raiseEvent(mapOpenEvent);
|
|
}
|
|
|
|
void Host::setDockLayoutUpdated(const QString& name)
|
|
{
|
|
if (!mpConsole) {
|
|
return;
|
|
}
|
|
|
|
auto pD = mpConsole->mDockWidgetMap.value(name);
|
|
if (Q_LIKELY(pD) && !mDockLayoutChanges.contains(name)) {
|
|
pD->setProperty("layoutChanged", QVariant(true));
|
|
mDockLayoutChanges.append(name);
|
|
}
|
|
}
|
|
|
|
void Host::setToolbarLayoutUpdated(TToolBar* pTB)
|
|
{
|
|
if (!mToolbarLayoutChanges.contains(pTB)) {
|
|
pTB->setProperty("layoutChanged", QVariant(true));
|
|
mToolbarLayoutChanges.append(pTB);
|
|
}
|
|
}
|
|
|
|
bool Host::commitLayoutUpdates(bool flush)
|
|
{
|
|
bool updated = false;
|
|
if (mpConsole && !flush) {
|
|
// commit changes (or rather clear the layout changed flags) for dockwidget
|
|
// consoles (user windows)
|
|
for (const auto& dockedConsoleName : std::as_const(mDockLayoutChanges)) {
|
|
auto pD = mpConsole->mDockWidgetMap.value(dockedConsoleName);
|
|
if (Q_LIKELY(pD) && pD->property("layoutChanged").toBool()) {
|
|
pD->setProperty("layoutChanged", QVariant(false));
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
mDockLayoutChanges.clear();
|
|
|
|
// commit changes (or rather clear the layout changed flags) for
|
|
// dockable/floating toolbars across all profiles:
|
|
if (!flush) {
|
|
for (const auto& pToolBar : std::as_const(mToolbarLayoutChanges)) {
|
|
if (!pToolBar || pToolBar.isNull()) {
|
|
// This can happen when a TToolBar is deleted
|
|
continue;
|
|
}
|
|
if (Q_UNLIKELY(!pToolBar->property("layoutChanged").isValid())) {
|
|
qWarning().nospace().noquote() << "host::commitLayoutUpdates() WARNING - was about to check for \"layoutChanged\" meta-property on a toolbar without that property!";
|
|
} else if (pToolBar->property("layoutChanged").toBool()) {
|
|
pToolBar->setProperty("layoutChanged", QVariant(false));
|
|
updated = true;
|
|
}
|
|
}
|
|
}
|
|
mToolbarLayoutChanges.clear();
|
|
return updated;
|
|
}
|
|
|
|
void Host::setupIreDriverBugfix()
|
|
{
|
|
// IRE games suffer from unnecessary linebreaks across split packets
|
|
// but other games implementing GA don't. Thus, only enable the workaround
|
|
// for the former only
|
|
|
|
const QStringList ireGameUrls{"achaea.com", "lusternia.com", "imperian.com", "aetolia.com", "starmourn.com"};
|
|
if (ireGameUrls.contains(getUrl(), Qt::CaseInsensitive)) {
|
|
set_USE_IRE_DRIVER_BUGFIX(true);
|
|
}
|
|
}
|
|
|
|
void Host::setControlCharacterMode(const ControlCharacterMode mode)
|
|
{
|
|
if (Q_UNLIKELY(!(mode == ControlCharacterMode::AsIs || mode == ControlCharacterMode::Picture || mode == ControlCharacterMode::OEM))) {
|
|
return;
|
|
}
|
|
|
|
if (mControlCharacter != mode) {
|
|
mControlCharacter = mode;
|
|
emit signal_controlCharacterHandlingChanged(mode);
|
|
}
|
|
}
|
|
|
|
std::optional<QString> Host::windowType(const QString& name) const
|
|
{
|
|
if (Q_UNLIKELY(name == QLatin1String("main"))) {
|
|
return {QLatin1String("main")};
|
|
}
|
|
|
|
if (mpConsole->mLabelMap.contains(name)) {
|
|
return {qsl("label")};
|
|
}
|
|
|
|
auto pWindow = mpConsole->mSubConsoleMap.value(name);
|
|
if (pWindow) {
|
|
switch (pWindow->getType()) {
|
|
case TConsole::UserWindow:
|
|
return {qsl("userwindow")};
|
|
case TConsole::Buffer:
|
|
return {qsl("buffer")};
|
|
case TConsole::SubConsole:
|
|
return {qsl("miniconsole")};
|
|
case TConsole::UnknownType:
|
|
[[fallthrough]];
|
|
case TConsole::CentralDebugConsole:
|
|
[[fallthrough]];
|
|
case TConsole::ErrorConsole:
|
|
[[fallthrough]];
|
|
case TConsole::MainConsole:
|
|
[[fallthrough]];
|
|
default:
|
|
Q_UNREACHABLE();
|
|
return {};
|
|
}
|
|
}
|
|
|
|
if (mpConsole->mScrollBoxMap.contains(name)) {
|
|
return {qsl("scrollbox")};
|
|
}
|
|
|
|
if (mpConsole->mSubCommandLineMap.contains(name)) {
|
|
return {qsl("commandline")};
|
|
}
|
|
|
|
if (mpConsole->mTextBoxMap.contains(name)) {
|
|
return {qsl("textedit")};
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
// Returns the position and size of a window element, matching what
|
|
// moveWindow()/resizeWindow() set and mirroring their widget dispatch, so user
|
|
// windows are read from their dock widget rather than their console.
|
|
// pos()/size() rather than geometry(): for a floating dock move() targets the
|
|
// frame origin while geometry() would report the client area.
|
|
std::optional<QRect> Host::windowGeometry(const QString& name) const
|
|
{
|
|
if (!mpConsole) {
|
|
return {};
|
|
}
|
|
|
|
if (name.isEmpty() || name == QLatin1String("main")) {
|
|
// 0,0 rather than the console's pos(), which under multi-view is an
|
|
// offset within the split; the size is getMainWindowSize()'s so the two
|
|
// functions cannot disagree.
|
|
return {QRect(QPoint(0, 0), mpConsole->getMainWindowSize())};
|
|
}
|
|
if (auto pL = mpConsole->mLabelMap.value(name)) {
|
|
return {QRect(pL->pos(), pL->size())};
|
|
}
|
|
if (auto pC = mpConsole->mSubConsoleMap.value(name)) {
|
|
if (auto pD = mpConsole->mDockWidgetMap.value(name)) {
|
|
return {QRect(pD->pos(), pD->size())};
|
|
}
|
|
return {QRect(pC->pos(), pC->size())};
|
|
}
|
|
if (auto pS = mpConsole->mScrollBoxMap.value(name)) {
|
|
return {QRect(pS->pos(), pS->size())};
|
|
}
|
|
if (auto pN = mpConsole->mSubCommandLineMap.value(name)) {
|
|
return {QRect(pN->pos(), pN->size())};
|
|
}
|
|
if (auto pT = mpConsole->mTextBoxMap.value(name)) {
|
|
return {QRect(pT->pos(), pT->size())};
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
// Returns whether a window element is currently visible, mirroring the widget
|
|
// dispatch of hideWindow()/showWindow() - user windows report their dock's
|
|
// visibility, which is what those toggle. Answered relative to the profile's
|
|
// own console: a child of a hidden user window still reads hidden, but a
|
|
// profile that is merely not the front tab does not.
|
|
std::optional<bool> Host::windowVisible(const QString& name) const
|
|
{
|
|
if (!mpConsole) {
|
|
return {};
|
|
}
|
|
|
|
if (name.isEmpty() || name == QLatin1String("main")) {
|
|
// only the tab machinery hides the main console, and that is the hiding
|
|
// this function looks past
|
|
return {true};
|
|
}
|
|
if (auto pL = mpConsole->mLabelMap.value(name)) {
|
|
return {pL->isVisibleTo(mpConsole)};
|
|
}
|
|
if (auto pC = mpConsole->mSubConsoleMap.value(name)) {
|
|
if (auto pD = mpConsole->mDockWidgetMap.value(name)) {
|
|
return {pD->isVisibleTo(mpConsole)};
|
|
}
|
|
return {pC->isVisibleTo(mpConsole)};
|
|
}
|
|
if (auto pS = mpConsole->mScrollBoxMap.value(name)) {
|
|
return {pS->isVisibleTo(mpConsole)};
|
|
}
|
|
if (auto pN = mpConsole->mSubCommandLineMap.value(name)) {
|
|
return {pN->isVisibleTo(mpConsole)};
|
|
}
|
|
if (auto pT = mpConsole->mTextBoxMap.value(name)) {
|
|
return {pT->isVisibleTo(mpConsole)};
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
void Host::setLargeAreaExitArrows(const bool state)
|
|
{
|
|
if (mLargeAreaExitArrows != state) {
|
|
mLargeAreaExitArrows = state;
|
|
if (mpMap && mpMap->mpMapper && mpMap->mpMapper->mp2dMap) {
|
|
mpMap->mpMapper->mp2dMap->mLargeAreaExitArrows = state;
|
|
mpMap->mpMapper->mp2dMap->update();
|
|
}
|
|
}
|
|
}
|
|
|
|
void Host::setEditorShowBidi(const bool state)
|
|
{
|
|
if (mEditorShowBidi != state) {
|
|
mEditorShowBidi = state;
|
|
if (mpEditorDialog) {
|
|
mpEditorDialog->setEditorShowBidi(state);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool Host::caretEnabled() const
|
|
{
|
|
return mCaretEnabled;
|
|
}
|
|
|
|
void Host::setCaretEnabled(bool enabled)
|
|
{
|
|
mCaretEnabled = enabled;
|
|
mpConsole->setCaretMode(enabled);
|
|
}
|
|
|
|
// Whether this key press is the one selected in the accessibility preferences
|
|
// to toggle caret mode - such a press must reach the caret-toggling key
|
|
// handlers instead of being swallowed by an application-wide QShortcut,
|
|
// which the user could have remapped onto any of these keys:
|
|
bool Host::caretShortcutMatches(const QKeyEvent* ke) const
|
|
{
|
|
constexpr Qt::KeyboardModifiers allModifiers = Qt::ShiftModifier | Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier | Qt::KeypadModifier | Qt::GroupSwitchModifier;
|
|
switch (mCaretShortcut) {
|
|
case CaretShortcut::None:
|
|
return false;
|
|
case CaretShortcut::Tab:
|
|
return ke->key() == Qt::Key_Tab && !(ke->modifiers() & Qt::ControlModifier);
|
|
case CaretShortcut::CtrlTab:
|
|
return ke->key() == Qt::Key_Tab && (ke->modifiers() & Qt::ControlModifier);
|
|
case CaretShortcut::F6:
|
|
return ke->key() == Qt::Key_F6 && (ke->modifiers() & allModifiers) == Qt::NoModifier;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void Host::setFocusOnHostActiveCommandLine()
|
|
{
|
|
if (mFocusTimerRunning) {
|
|
return;
|
|
}
|
|
|
|
mFocusTimerRunning = true;
|
|
|
|
// Lambda to set focus on command line
|
|
auto setCommandLineFocus = [this]() {
|
|
auto pCommandLine = activeCommandLine();
|
|
TCommandLine* targetCommandLine = nullptr;
|
|
|
|
if (pCommandLine) {
|
|
pCommandLine->activateWindow();
|
|
pCommandLine->console()->show();
|
|
pCommandLine->console()->raise();
|
|
pCommandLine->console()->repaint();
|
|
targetCommandLine = pCommandLine;
|
|
} else {
|
|
mpConsole->mpCommandLine->activateWindow();
|
|
mpConsole->show();
|
|
mpConsole->raise();
|
|
mpConsole->repaint();
|
|
targetCommandLine = mpConsole->mpCommandLine;
|
|
}
|
|
|
|
if (targetCommandLine) {
|
|
targetCommandLine->setFocus(Qt::OtherFocusReason);
|
|
|
|
// For Steam Deck and other environments where focus might be unreliable,
|
|
// add additional focus attempts with slight delays
|
|
QTimer::singleShot(10ms, this, [targetCommandLine]() {
|
|
if (targetCommandLine && !targetCommandLine->hasFocus()) {
|
|
targetCommandLine->setFocus(Qt::OtherFocusReason);
|
|
}
|
|
});
|
|
|
|
QTimer::singleShot(50ms, this, [targetCommandLine]() {
|
|
if (targetCommandLine && !targetCommandLine->hasFocus()) {
|
|
targetCommandLine->setFocus(Qt::OtherFocusReason);
|
|
}
|
|
});
|
|
}
|
|
|
|
mFocusTimerRunning = false;
|
|
};
|
|
|
|
QTimer::singleShot(0ms, this, setCommandLineFocus);
|
|
}
|
|
|
|
void Host::recordActiveCommandLine(TCommandLine* pCommandLine)
|
|
{
|
|
if (!pCommandLine || mIsClosingDown) {
|
|
return;
|
|
}
|
|
mpLastCommandLineUsed.removeAll(QPointer<TCommandLine>(pCommandLine));
|
|
mpLastCommandLineUsed.push(QPointer<TCommandLine>(pCommandLine));
|
|
}
|
|
|
|
void Host::forgetCommandLine(TCommandLine* pCommandLine)
|
|
{
|
|
if (pCommandLine && !mIsClosingDown) {
|
|
mpLastCommandLineUsed.removeAll(QPointer<TCommandLine>(pCommandLine));
|
|
}
|
|
}
|
|
|
|
// Returns a pointer to the last used TCommandLine for this profile:
|
|
TCommandLine* Host::activeCommandLine()
|
|
{
|
|
TCommandLine* pCommandLine = nullptr;
|
|
if (mIsClosingDown || mpLastCommandLineUsed.isEmpty()) {
|
|
return nullptr;
|
|
}
|
|
|
|
do {
|
|
pCommandLine = mpLastCommandLineUsed.top();
|
|
if (!pCommandLine) {
|
|
mpLastCommandLineUsed.pop();
|
|
}
|
|
} while (!mpLastCommandLineUsed.isEmpty() && !pCommandLine);
|
|
|
|
return pCommandLine;
|
|
}
|
|
|
|
QPointer<TConsole> Host::parentTConsole(QObject* start) const
|
|
{
|
|
QPointer<TConsole> result;
|
|
auto ptr = start;
|
|
if (!ptr) {
|
|
// Handle pathalogical case:
|
|
return result;
|
|
}
|
|
do {
|
|
ptr = ptr->parent();
|
|
} while (ptr && !ptr->inherits("TConsole"));
|
|
// QObject::inherits(...) uses a const char* - so no need to wrap raw string literal!
|
|
if (!ptr) {
|
|
// Handle not found case:
|
|
return result;
|
|
}
|
|
return qobject_cast<TConsole*>(ptr);
|
|
}
|
|
|
|
void Host::setBorders(QMargins borders)
|
|
{
|
|
auto original = mBorders;
|
|
if (borders == original) {
|
|
return;
|
|
}
|
|
mBorders = borders;
|
|
if (mpConsole.isNull()) {
|
|
return;
|
|
}
|
|
auto x = mpConsole->width();
|
|
auto y = mpConsole->height();
|
|
const QSize s = QSize(x, y);
|
|
QResizeEvent event(s, s);
|
|
QCoreApplication::sendEvent(mpConsole, &event);
|
|
mpConsole->raiseMudletSysWindowResizeEvent(x, y);
|
|
}
|
|
|
|
void Host::setUserBorders(const QMargins borders)
|
|
{
|
|
mUserBorders = borders;
|
|
setBorders(mUserBorders + mMxpBorders);
|
|
}
|
|
|
|
void Host::setMxpBorders(const QMargins borders)
|
|
{
|
|
mMxpBorders = borders;
|
|
setBorders(mUserBorders + mMxpBorders);
|
|
}
|
|
|
|
void Host::setCommandLineHistorySaveSize(const int lines)
|
|
{
|
|
if (mCommandLineHistorySaveSize != lines) {
|
|
mCommandLineHistorySaveSize = lines;
|
|
}
|
|
}
|
|
|
|
QString Host::getEditorTheme() const
|
|
{
|
|
if (mudlet::self()->inDarkMode() && !mEditorThemeDark.isEmpty() && !mEditorThemeFileDark.isEmpty()) {
|
|
return mEditorThemeDark;
|
|
}
|
|
return mEditorTheme;
|
|
}
|
|
|
|
QString Host::getEditorThemeFile() const
|
|
{
|
|
if (mudlet::self()->inDarkMode() && !mEditorThemeDark.isEmpty() && !mEditorThemeFileDark.isEmpty()) {
|
|
return mEditorThemeFileDark;
|
|
}
|
|
return mEditorThemeFile;
|
|
}
|
|
|
|
void Host::editorThemeChanged()
|
|
{
|
|
emit signal_editorThemeChanged();
|
|
}
|
|
|
|
void Host::sendCmdLine(const QString& cmd)
|
|
{
|
|
if (!mpConsole || !mpConsole->mpCommandLine) {
|
|
qWarning() << "Host::sendCmdLine(...) ERROR - No active command line available.";
|
|
return;
|
|
}
|
|
|
|
// Set the command in the active command line
|
|
mpConsole->mpCommandLine->setPlainText(cmd);
|
|
mpConsole->mpCommandLine->selectAll();
|
|
}
|
|
|
|
void Host::setRemoteEchoingActive(bool active)
|
|
{
|
|
if (mIsRemoteEchoingActive != active) {
|
|
mIsRemoteEchoingActive = active;
|
|
emit signal_remoteEchoChanged(active);
|
|
}
|
|
}
|
|
|
|
QFont Host::getDisplayFont()
|
|
{
|
|
if (mpConsole) {
|
|
return mpConsole->font();
|
|
}
|
|
|
|
qDebug().noquote().nospace() << "Host::getDisplayFont() INFO - No TMainConsole to get font from - faking it";
|
|
|
|
if (!mTempDisplayFontAttributes.has_value() || !mTempDisplayFont.has_value()) {
|
|
mTempDisplayFontAttributes = TFontAttributes(!mNoAntiAlias);
|
|
mTempDisplayFont = mTempDisplayFontAttributes.value().makeFont();
|
|
}
|
|
|
|
return mTempDisplayFont.value();
|
|
}
|
|
|
|
QFont Host::getAndClearTempDisplayFont()
|
|
{
|
|
if (!mTempDisplayFontAttributes.has_value() || !mTempDisplayFont.has_value()) {
|
|
qWarning() << "Host::getAndClearTempDisplayFont() WARNING - no temp font was set, using default";
|
|
mTempDisplayFontAttributes = TFontAttributes(!mNoAntiAlias);
|
|
mTempDisplayFont = mTempDisplayFontAttributes.value().makeFont();
|
|
}
|
|
|
|
QFont tempFont = mTempDisplayFont.value();
|
|
mTempDisplayFont.reset();
|
|
mTempDisplayFontAttributes.reset();
|
|
return tempFont;
|
|
}
|
|
|
|
// Static whitelist of valid experiments
|
|
const QSet<QString> Host::mValidExperiments = {qsl("experiment.rendering.originalish"),
|
|
qsl("experiment.rendering.more-transparent"),
|
|
qsl("experiment.3dmap.modernmapper"),
|
|
qsl("experiment.render-in-out-exits"),
|
|
qsl("experiment.3d-player-icon")};
|
|
|
|
bool Host::experimentEnabled(const QString& experimentKey) const
|
|
{
|
|
return mExperiments.value(experimentKey, false);
|
|
}
|
|
|
|
std::pair<bool, QString> Host::setExperimentEnabled(const QString& experimentKey, bool enabled)
|
|
{
|
|
// Validate experiment key against whitelist
|
|
if (!mValidExperiments.contains(experimentKey)) {
|
|
return {false, qsl("Invalid experiment name: %1").arg(experimentKey)};
|
|
}
|
|
|
|
if (enabled) {
|
|
// Check if this is a grouped experiment (contains dots beyond "experiment.")
|
|
if (experimentKey.count('.') >= 2) {
|
|
// Extract group (e.g., "experiment.rendering" from "experiment.rendering.originalish")
|
|
QString group = experimentKey.section('.', 0, 1);
|
|
|
|
// Disable all other experiments in the same group
|
|
for (auto it = mExperiments.begin(); it != mExperiments.end(); ++it) {
|
|
if (it.key() != experimentKey && it.key().startsWith(group + ".")) {
|
|
it.value() = false;
|
|
}
|
|
}
|
|
}
|
|
mExperiments[experimentKey] = true;
|
|
} else {
|
|
mExperiments[experimentKey] = false;
|
|
}
|
|
|
|
#if defined(INCLUDE_3DMAPPER)
|
|
// Refresh maps if any experiments changed the 3D map
|
|
if (mpMap && mpMap->mpMapper && mpMap->mpMapper->mp2dMap) {
|
|
mpMap->mpMapper->mp2dMap->update();
|
|
}
|
|
if (mpMap && mpMap->mpM) {
|
|
mpMap->mpM->update();
|
|
}
|
|
#endif
|
|
|
|
return {true, QString()};
|
|
}
|
|
|
|
QString Host::getActiveExperimentInGroup(const QString& group) const
|
|
{
|
|
QString groupPrefix = group + ".";
|
|
for (auto it = mExperiments.constBegin(); it != mExperiments.constEnd(); ++it) {
|
|
if (it.key().startsWith(groupPrefix) && it.value()) {
|
|
// Return just the experiment name without the group prefix
|
|
return it.key().mid(groupPrefix.length());
|
|
}
|
|
}
|
|
return QString(); // No active experiment in this group
|
|
}
|
|
|
|
QStringList Host::getAllExperiments() const
|
|
{
|
|
QStringList result;
|
|
for (auto it = mExperiments.constBegin(); it != mExperiments.constEnd(); ++it) {
|
|
if (it.value()) {
|
|
result << it.key();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QStringList Host::getValidExperiments() const
|
|
{
|
|
return QStringList(mValidExperiments.constBegin(), mValidExperiments.constEnd());
|
|
}
|
|
|
|
bool Host::shouldStripOscHyperlinkConfigParam()
|
|
{
|
|
return mTelnet.oscHyperlinkConfigFeatureEnabled();
|
|
}
|
|
|
|
bool Host::shouldStripOscHyperlinkPresetParam()
|
|
{
|
|
return mTelnet.oscHyperlinkPresetsEnabled();
|
|
}
|