diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index fb5e0d99b..a7d05d2fd 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -298,6 +298,7 @@ ConstString KEY_TAB_COMPLETION_DICTIONARY_SIZE = "Tab completion dictionary size ConstString KEY_THEME = "Theme"; ConstString KEY_TLS_ENCRYPTION = "TLS encryption"; ConstString KEY_USE_INTERNAL_EDITOR = "Use internal editor"; +ConstString KEY_EDITOR_DIRECTORY = "Editor directory"; ConstString KEY_USE_TRILINEAR_FILTERING = "Use trilinear filtering"; ConstString KEY_WEATHER_ATMOSPHERE_INTENSITY = "weather.atmosphereIntensity"; ConstString KEY_WEATHER_PRECIPITATION_INTENSITY = "weather.precipitationIntensity"; @@ -573,6 +574,7 @@ void Configuration::reset() ConstString DEFAULT_MMAPPER_SUBDIR = "/MMapper"; ConstString DEFAULT_LOGS_SUBDIR = "/Logs"; +ConstString DEFAULT_EDITOR_SUBDIR = "/Editor"; ConstString DEFAULT_RESOURCES_SUBDIR = "/Resources"; NODISCARD static QString getDefaultDirectory() @@ -736,6 +738,11 @@ void Configuration::MumeClientProtocolSettings::read(const QSettings &conf) internalRemoteEditor = conf.value(KEY_USE_INTERNAL_EDITOR, true).toBool(); externalRemoteEditorCommand = conf.value(KEY_EXTERNAL_EDITOR_COMMAND, getPlatformEditor()) .toString(); + editorDirectory = conf.value(KEY_EDITOR_DIRECTORY, + getDefaultDirectory() + .append(DEFAULT_MMAPPER_SUBDIR) + .append(DEFAULT_EDITOR_SUBDIR)) + .toString(); } void Configuration::MumeNativeSettings::read(const QSettings &conf) @@ -930,6 +937,7 @@ void Configuration::MumeClientProtocolSettings::write(QSettings &conf) const { conf.setValue(KEY_USE_INTERNAL_EDITOR, internalRemoteEditor); conf.setValue(KEY_EXTERNAL_EDITOR_COMMAND, externalRemoteEditorCommand); + conf.setValue(KEY_EDITOR_DIRECTORY, editorDirectory); } void Configuration::PathMachineSettings::write(QSettings &conf) const diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index 6f174fd0a..d7241be38 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -117,6 +117,7 @@ class NODISCARD Configuration final { bool internalRemoteEditor = false; QString externalRemoteEditorCommand; + QString editorDirectory; private: SUBGROUP(); diff --git a/src/global/AsyncTasks.cpp b/src/global/AsyncTasks.cpp index 9dc043ddf..86950da9c 100644 --- a/src/global/AsyncTasks.cpp +++ b/src/global/AsyncTasks.cpp @@ -37,6 +37,8 @@ std::string get_type_name(const AsyncTaskTypeEnum type) return "IO Task"; case Task: return "Task"; + case RemoteEdit: + return "RemoteEdit"; } return "(error)"; } diff --git a/src/global/AsyncTasks.h b/src/global/AsyncTasks.h index 8a2b955e2..165475a69 100644 --- a/src/global/AsyncTasks.h +++ b/src/global/AsyncTasks.h @@ -10,7 +10,7 @@ class AnsiOstream; -enum class NODISCARD AsyncTaskTypeEnum : uint8_t { IO, Task }; +enum class NODISCARD AsyncTaskTypeEnum : uint8_t { IO, Task, RemoteEdit }; namespace async_tasks { diff --git a/src/mainwindow/TasksPanel.cpp b/src/mainwindow/TasksPanel.cpp index f744db3b0..4ad7981ef 100644 --- a/src/mainwindow/TasksPanel.cpp +++ b/src/mainwindow/TasksPanel.cpp @@ -8,6 +8,9 @@ #include "../global/PrintUtils.h" #include "../global/SendToUser.h" #include "../global/thread_utils.h" +#include "../mpi/remoteedit.h" +#include "../proxy/connectionlistener.h" +#include "../proxy/proxy.h" #include "AsyncTypes.h" #include "mainwindow.h" @@ -71,15 +74,27 @@ struct NODISCARD TasksPanel::ListItem final : public QWidget return QScopedPointer{progress.release()}; }); QScopedPointer m_cancelButton; + QScopedPointer m_actionButton; public: - explicit ListItem(async_tasks::AsyncTaskHandle moved_task) + explicit ListItem(async_tasks::AsyncTaskHandle moved_task, MainWindow &mainWindow) : m_task{std::move(moved_task)} , m_cancelButton{mmqt::makeQScopedPointer(m_task)} + , m_actionButton{mmqt::makeQScopedPointer()} { const auto layout = mmqt::makeQPointer(this); layout->addWidget(m_label.get()); layout->addWidget(m_progress.get()); + + if (m_task.getType() == AsyncTaskTypeEnum::RemoteEdit) { + auto btn = m_actionButton.get(); + btn->setText("Show Editor / View Draft"); + layout->addWidget(btn); + connect(btn, &QPushButton::clicked, this, [&mainWindow, task_id = m_task.getId()]() { + mainWindow.getRemoteEdit().slot_showDraft(task_id); + }); + } + layout->addWidget(m_cancelButton.get()); layout->insertStretch(-1); // must be after all the addWidget() calls updateProgress(); @@ -370,7 +385,7 @@ void TasksPanel::add_new_item(const TaskHandle &handle) { const auto &task = handle.task; // NOLINTNEXTLINE (no, this is not leaked; Qt manages it) - if (auto *const item = new ListItem(task)) { + if (auto *const item = new ListItem(task, m_mainWindow)) { getLayout().addWidget(item); getKnownTasks().emplace(task.getId(), task); } diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 3ef261245..a493725b6 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -27,6 +27,7 @@ #include "../media/AudioManager.h" #include "../media/DescriptionWidget.h" #include "../media/MediaLibrary.h" +#include "../mpi/remoteedit.h" #include "../pathmachine/mmapper2pathmachine.h" #include "../preferences/configdialog.h" #include "../proxy/connectionlistener.h" @@ -136,6 +137,8 @@ MainWindow::MainWindow() m_prespammedPath = new PrespammedPath(this); + m_remoteEdit = new RemoteEdit(this); + m_groupManager = new Mmapper2Group(this); m_groupManager->setObjectName("GroupManager"); @@ -401,6 +404,8 @@ MainWindow::MainWindow() readSettings(); g_mainWindow = this; + + QTimer::singleShot(0, this, [this]() { m_remoteEdit->recoverDrafts(); }); } void MainWindow::startServices() @@ -609,6 +614,27 @@ void MainWindow::wireConnections() &FindRoomsDlg::sig_editSelection, this, &MainWindow::slot_onEditRoomSelection); + + connect(m_listener, &ConnectionListener::sig_proxyCreated, this, [this](QPointer proxy) { + if (!proxy) + return; + + connect(m_remoteEdit, &RemoteEdit::sig_sendGmcp, proxy.data(), &Proxy::slot_sendGmcp); + connect(proxy.data(), &Proxy::sig_remoteEditRequested, m_remoteEdit, &RemoteEdit::slot_remoteEdit); + connect(proxy.data(), &Proxy::sig_remoteViewRequested, m_remoteEdit, &RemoteEdit::slot_remoteView); + connect(proxy.data(), + &Proxy::sig_remoteWriteResult, + m_remoteEdit, + &RemoteEdit::slot_remoteWriteResult); + connect(proxy.data(), + &Proxy::sig_remoteCancelResult, + m_remoteEdit, + &RemoteEdit::slot_remoteCancelResult); + }); + + deref(m_gameObserver).sig2_disconnected.connect(m_lifetime, [this]() { + m_remoteEdit->onDisconnected(); + }); } void MainWindow::slot_log(const QString &mod, const QString &message) diff --git a/src/mainwindow/mainwindow.h b/src/mainwindow/mainwindow.h index 40c7d0fdd..23b1e7883 100644 --- a/src/mainwindow/mainwindow.h +++ b/src/mainwindow/mainwindow.h @@ -66,6 +66,7 @@ class DescriptionWidget; class MediaLibrary; class TimerWidget; class MapDestination; +class RemoteEdit; struct MapLoadData; @@ -117,6 +118,7 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow DescriptionWidget *m_descriptionWidget = nullptr; TimerWidget *m_timerWidget = nullptr; std::unique_ptr m_hotkeyManager; + RemoteEdit *m_remoteEdit = nullptr; QPointer m_contextMenu; @@ -258,6 +260,7 @@ class NODISCARD_QOBJECT MainWindow final : public QMainWindow NODISCARD HotkeyManager &getHotkeyManager() const { return deref(m_hotkeyManager); } NODISCARD CTimers &getTimers() const { return deref(m_timers); } + NODISCARD RemoteEdit &getRemoteEdit() const { return deref(m_remoteEdit); } NODISCARD bool saveFile(const QString &fileName, SaveModeEnum mode, SaveFormatEnum format); void loadFile(std::shared_ptr source); diff --git a/src/mpi/remoteedit.cpp b/src/mpi/remoteedit.cpp index 088b4978c..95c786860 100644 --- a/src/mpi/remoteedit.cpp +++ b/src/mpi/remoteedit.cpp @@ -5,7 +5,9 @@ #include "remoteedit.h" #include "../configuration/configuration.h" +#include "../global/AsyncTasks.h" #include "../global/Consts.h" +#include "../global/io.h" #include "remoteeditsession.h" #include @@ -17,7 +19,10 @@ #include #include #include +#include +#include #include +#include using char_consts::C_NEWLINE; @@ -42,17 +47,18 @@ void RemoteEdit::addSession(const RemoteSessionId sessionId, const QString &body) { const auto internalId = RemoteInternalId{getInternalIdCount()}; - std::unique_ptr session; + const bool isEdit = (sessionId != REMOTE_VIEW_SESSION_ID); + std::shared_ptr session; if (getConfig().mumeClientProtocol.internalRemoteEditor) { - session = std::make_unique(internalId, + session = std::make_shared(internalId, sessionId, title, body, this); } else { #ifndef Q_OS_WASM - session = std::make_unique(internalId, + session = std::make_shared(internalId, sessionId, title, body, @@ -64,6 +70,36 @@ void RemoteEdit::addSession(const RemoteSessionId sessionId, return; #endif } + + if (isEdit) { + QString fileName = provisionDraftFile(sessionId, title, body); + session->setDraftFileName(fileName); + + std::weak_ptr weakSession = session; + auto handle = async_tasks::startAsyncTask( + AsyncTaskTypeEnum::RemoteEdit, + AllowCancelEnum::Allow, + mmqt::toStdStringUtf8(QString("RemoteEdit: %1").arg(title)), + [weakSession, title](ProgressCounter &pc) { + pc.setNewTask(ProgressMsg{QString("Editing %1").arg(title)}, 100); + while (true) { + auto pSession = weakSession.lock(); + if (!pSession || pSession->shouldStopTask()) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (pc.hasRequestedCancel()) { + QMetaObject::invokeMethod(pSession.get(), + "slot_onCancel", + Qt::QueuedConnection); + break; + } + } + }, + []() {}); + session->setAsyncTask(handle); + } + m_sessions.insert(std::make_pair(internalId, std::move(session))); m_greatestUsedId = internalId.asUint32(); // Increment internalId counter @@ -71,12 +107,15 @@ void RemoteEdit::addSession(const RemoteSessionId sessionId, void RemoteEdit::removeSession(const RemoteEditSession &session) { + const_cast(session).stopTask(); const auto internalId = session.getInternalId(); const auto search = m_sessions.find(internalId); if (search != m_sessions.end()) { qDebug() << "Destroying RemoteEditSession" << internalId.asUint32(); m_sessions.erase(search); + // Ensure drafts from closed disconnected sessions appear as recovered tasks immediately. + QTimer::singleShot(0, this, [this]() { recoverDrafts(); }); } else { qWarning() << "Unable to find" << internalId.asUint32() << "session to erase"; } @@ -86,19 +125,55 @@ void RemoteEdit::cancel(const RemoteEditSession *const pSession) { auto &session = deref(pSession); + bool explicitDiscard = false; + if (auto handle = session.getAsyncTask()) { + if (handle->getProgressCounter().hasRequestedCancel()) { + explicitDiscard = true; + } + } + if (session.isEditSession() && session.isConnected()) { qDebug() << "Cancelling session" << session.getSessionId().asInt32(); - emit sig_remoteEditCancel(session.getSessionId()); - } - removeSession(session); + QJsonObject obj; + obj["id"] = session.getSessionId().asInt32(); + QJsonDocument doc; + doc.setObject(obj); + GmcpJson json{QString::fromUtf8(doc.toJson())}; + GmcpMessage msg{GmcpMessageTypeEnum::MUME_CLIENT_CANCEL_EDIT, json}; + + if (auto handle = session.getAsyncTask()) { + handle->getProgressCounter().setCurrentTask(ProgressMsg{"Canceling edit..."}); + } + + emit sig_sendGmcp(msg); + + if (explicitDiscard) { + // Explicitly requested task deletion: be aggressive and remove now. + deleteDraft(session.getDraftFileName()); + removeSession(session); + } + } else if (session.isEditSession()) { + if (explicitDiscard) { + // FR-6.4: Explicit deletion command for recovered/disconnected task + deleteDraft(session.getDraftFileName()); + } + // Transition to recovered state by removing active session; recoverDrafts() trigger will pick it up. + removeSession(session); + } else { + // Not an edit session: just remove. + removeSession(session); + } } void RemoteEdit::save(const RemoteEditSession *const pSession) { auto &session = deref(pSession); trySave(session); - removeSession(session); + // We do not call removeSession here if connected; we wait for the server's confirmation. + if (!session.isConnected()) { + removeSession(session); + } } void RemoteEdit::trySave(const RemoteEditSession &session) @@ -129,7 +204,23 @@ void RemoteEdit::sendToMume(const RemoteEditSession &session) // (e.g. unicode transliteration, etc). auto latin1 = Latin1Bytes{ mmqt::toQByteArrayLatin1(session.getContent())}; // MPI is always Latin1 - emit sig_remoteEditSave(session.getSessionId(), latin1); + + QJsonObject obj; + obj["text"] = QString::fromLatin1(latin1.getQByteArray()); + obj["id"] = session.getSessionId().asInt32(); + QJsonDocument doc; + doc.setObject(obj); + GmcpJson json{QString::fromUtf8(doc.toJson())}; + GmcpMessage msg{GmcpMessageTypeEnum::MUME_CLIENT_WRITE, json}; + + if (auto handle = session.getAsyncTask()) { + handle->getProgressCounter().setCurrentTask(ProgressMsg{"Submitting changes..."}); + } + + emit sig_sendGmcp(msg); + + // FR-4.4: Upon confirmed delivery success, delete local temporary file and unregister task. + // Deletion is now handled in slot_remoteWriteResult. } void RemoteEdit::trySaveLocally(const RemoteEditSession &session) @@ -139,23 +230,20 @@ void RemoteEdit::trySaveLocally(const RemoteEditSession &session) } auto *dlg = new QMessageBox( - QMessageBox::Critical, + QMessageBox::Information, "MUME Disconnected", - "The connection to MUME was lost. Your unsaved changes will be lost unless you save the file locally now.", - QMessageBox::StandardButtons{QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel}, + "The connection to MUME was lost. Your changes have been preserved as a draft in the " + "MMapper/Editor directory and are available in the Tasks panel for recovery.", + QMessageBox::StandardButtons{QMessageBox::Ok}, nullptr); dlg->setAttribute(Qt::WA_DeleteOnClose); const auto id = session.getInternalId().asUint32(); - const auto body = session.getContent().toUtf8(); - connect(dlg, &QMessageBox::finished, this, [id, body](int result) { - if (result == QMessageBox::Save) { - qDebug() << "Session" << id << "was saved"; - QFileDialog::saveFileContent(body, QString("MMapper-Edit-%1.txt").arg(id)); - } - }); dlg->open(); - QGuiApplication::clipboard()->setText(body); - qWarning() << "Session" << id << "was copied to the clipboard"; + qWarning() << "Session" << id << "marked as disconnected - draft preserved"; + + if (auto handle = session.getAsyncTask()) { + handle->getProgressCounter().setCurrentTask(ProgressMsg{"Disconnected - Draft preserved"}); + } } void RemoteEdit::onDisconnected() @@ -166,6 +254,213 @@ void RemoteEdit::onDisconnected() if (session->isEditSession()) { qWarning() << "Session" << id.asUint32() << "marked as disconnected"; session->setDisconnected(); + if (auto handle = session->getAsyncTask()) { + handle->getProgressCounter().setCurrentTask( + ProgressMsg{"Disconnected - Draft preserved"}); + } + } + } +} + +void RemoteEdit::slot_showDraft(size_t taskId) +{ + if (auto session = getSessionByTaskId(taskId)) { + session->virt_show(); + } +} + +RemoteEditSession *RemoteEdit::getSessionByTaskId(size_t taskId) const +{ + for (const auto &pair : m_sessions) { + if (pair.second->isEditSession()) { + if (auto handle = pair.second->getAsyncTask()) { + if (handle->getId() == taskId) { + return pair.second.get(); + } + } + } + } + return nullptr; +} + +void RemoteEdit::slot_remoteWriteResult(const RemoteSessionId sessionId, + const bool success, + const QString &message) +{ + for (auto it = m_sessions.begin(); it != m_sessions.end(); ++it) { + if (it->second->getSessionId() == sessionId) { + if (success) { + qDebug() << "MUME.Client.Write success for session" << sessionId.asInt32(); + deleteDraft(it->second->getDraftFileName()); + removeSession(*(it->second)); + } else if (auto handle = it->second->getAsyncTask()) { + const QString errorMsg = message.isEmpty() ? "unknown error" : message; + handle->getProgressCounter().setCurrentTask( + ProgressMsg{QString("Submission failed: %1").arg(errorMsg)}); + } + break; + } + } +} + +void RemoteEdit::slot_remoteCancelResult(const RemoteSessionId sessionId, + const bool success, + const QString &message) +{ + for (auto it = m_sessions.begin(); it != m_sessions.end(); ++it) { + if (it->second->getSessionId() == sessionId) { + if (success) { + qDebug() << "MUME.Client.CancelEdit success for session" << sessionId.asInt32(); + deleteDraft(it->second->getDraftFileName()); + removeSession(*(it->second)); + } else if (auto handle = it->second->getAsyncTask()) { + const QString errorMsg = message.isEmpty() ? "Cancel failed" : message; + handle->getProgressCounter().setCurrentTask(ProgressMsg{errorMsg}); + } + break; + } + } +} + +void RemoteEdit::recoverDrafts() +{ + auto drafts = discoverDrafts(); + if (drafts.isEmpty()) { + return; + } + qInfo() << "Scanning for recovered drafts in" << getDraftDirectory(); + for (const auto &draft : drafts) { + // Check if this draft is already being managed by an active session + bool active = false; + for (const auto &pair : m_sessions) { + if (pair.second->getDraftFileName() == draft.fileName) { + active = true; + break; + } + } + + if (!active) { + qInfo() << "Recovering draft:" << draft.fileName << "title:" << draft.title; + + // FR-5.2: Discover files lacking active session must be registered as recovered tasks. + // FR-5.3: Recovered tasks must be strictly flagged as non-sendable from raw state. + // Register this as a "recovered" session in our local map. + const auto internalId = RemoteInternalId{getInternalIdCount()}; + auto session = std::make_shared(internalId, + draft.sessionId, + draft.title, + this); + std::weak_ptr weakSession = session; + + auto handle = async_tasks::startAsyncTask( + AsyncTaskTypeEnum::RemoteEdit, + AllowCancelEnum::Allow, + mmqt::toStdStringUtf8(QString("Recovered: %1").arg(draft.title)), + [weakSession, draft](ProgressCounter &pc) { + pc.setNewTask(ProgressMsg{QString("Recovered draft from %1") + .arg(draft.lastModified.toString())}, + 100); + while (true) { + auto pSession = weakSession.lock(); + if (!pSession || pSession->shouldStopTask()) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + if (pc.hasRequestedCancel()) { + QMetaObject::invokeMethod(pSession.get(), + "slot_onCancel", + Qt::QueuedConnection); + break; + } + } + }, + []() {}); + + session->setDraftFileName(draft.fileName); + session->setAsyncTask(handle); + session->setDisconnected(); // Recovered drafts are naturally disconnected + + m_sessions.insert(std::make_pair(internalId, std::move(session))); + m_greatestUsedId = internalId.asUint32(); + } + } +} + +QString RemoteEdit::getDraftDirectory() +{ + QString dir = getConfig().mumeClientProtocol.editorDirectory; + QDir().mkpath(dir); + return dir; +} + +QString RemoteEdit::encodeMetadata(RemoteSessionId sessionId, const QString &title) +{ + QString safeTitle = QUrl::toPercentEncoding(title).mid(0, 50); + return QString("draft_%1_%2.txt").arg(sessionId.asInt32()).arg(safeTitle); +} + +bool RemoteEdit::decodeMetadata(const QString &fileName, RemoteSessionId &sessionId, QString &title) +{ + static const QRegularExpression re("^draft_(-?\\d+)_(.*)\\.txt$"); + QRegularExpressionMatch match = re.match(fileName); + if (match.hasMatch()) { + sessionId = RemoteSessionId(match.captured(1).toInt()); + title = QUrl::fromPercentEncoding(match.captured(2).toUtf8()); + return true; + } + return false; +} + +QString RemoteEdit::provisionDraftFile(RemoteSessionId sessionId, + const QString &title, + const QString &content) +{ + QString dir = getDraftDirectory(); + QString fileName = encodeMetadata(sessionId, title); + QString fullPath = QDir(dir).absoluteFilePath(fileName); + + QFile file(fullPath); + if (file.open(QFile::WriteOnly | QFile::Text)) { + file.write(mmqt::toQByteArrayLatin1(content)); + file.flush(); + std::ignore = io::fsyncNoexcept(file); + file.close(); + return fileName; + } + return QString(); +} + +bool RemoteEdit::saveDraftAtomic(const QString &fileName, const QString &content) +{ + QString fullPath = QDir(getDraftDirectory()).absoluteFilePath(fileName); + QSaveFile file(fullPath); + if (file.open(QFile::WriteOnly | QFile::Text)) { + file.write(mmqt::toQByteArrayLatin1(content)); + return file.commit(); + } + return false; +} + +void RemoteEdit::deleteDraft(const QString &fileName) +{ + if (fileName.isEmpty()) + return; + QFile::remove(QDir(getDraftDirectory()).absoluteFilePath(fileName)); +} + +QList RemoteEdit::discoverDrafts() +{ + QList drafts; + QDir dir(getDraftDirectory()); + QStringList files = dir.entryList({"draft_*.txt"}, QDir::Files); + + for (const QString &fileName : files) { + RemoteSessionId sid; + QString title; + if (decodeMetadata(fileName, sid, title)) { + QFileInfo info(dir.absoluteFilePath(fileName)); + drafts.append({fileName, title, sid, info.lastModified()}); } } + return drafts; } diff --git a/src/mpi/remoteedit.h b/src/mpi/remoteedit.h index c0cceaa91..76266061b 100644 --- a/src/mpi/remoteedit.h +++ b/src/mpi/remoteedit.h @@ -4,6 +4,7 @@ // Author: Nils Schimmelmann (Jahara) #include "../global/macros.h" +#include "../proxy/GmcpMessage.h" #include "../proxy/TaggedBytes.h" #include "remoteeditsession.h" @@ -26,8 +27,17 @@ class NODISCARD_QOBJECT RemoteEdit final : public QObject private: friend class RemoteEditSession; +public: + struct DraftInfo + { + QString fileName; + QString title; + RemoteSessionId sessionId; + QDateTime lastModified; + }; + private: - std::map> m_sessions; + std::map> m_sessions; uint32_t m_greatestUsedId = 0; public: @@ -36,6 +46,15 @@ class NODISCARD_QOBJECT RemoteEdit final : public QObject public: void onDisconnected(); + void recoverDrafts(); + + static QString getDraftDirectory(); + static QString provisionDraftFile(RemoteSessionId sessionId, + const QString &title, + const QString &content); + static bool saveDraftAtomic(const QString &fileName, const QString &content); + static void deleteDraft(const QString &fileName); + static QList discoverDrafts(); protected: void cancel(const RemoteEditSession *); @@ -54,11 +73,25 @@ class NODISCARD_QOBJECT RemoteEdit final : public QObject void sendToMume(const RemoteEditSession &session); void trySaveLocally(const RemoteEditSession &session); + static QString encodeMetadata(RemoteSessionId sessionId, const QString &title); + static bool decodeMetadata(const QString &fileName, RemoteSessionId &sessionId, QString &title); + signals: - void sig_remoteEditCancel(const RemoteSessionId sessionId); - void sig_remoteEditSave(const RemoteSessionId sessionId, const Latin1Bytes &content); + void sig_sendGmcp(const GmcpMessage &msg); + +public: + NODISCARD RemoteEditSession *getSessionByTaskId(size_t taskId) const; public slots: - void slot_remoteView(const QString &, const QString &); - void slot_remoteEdit(const RemoteSessionId, const QString &, const QString &); + void slot_remoteView(const QString &title, const QString &body); + void slot_remoteEdit(const RemoteSessionId sessionId, + const QString &title, + const QString &body); + void slot_remoteWriteResult(const RemoteSessionId sessionId, + const bool success, + const QString &message); + void slot_remoteCancelResult(const RemoteSessionId sessionId, + const bool success, + const QString &message); + void slot_showDraft(size_t taskId); }; diff --git a/src/mpi/remoteeditprocess.cpp b/src/mpi/remoteeditprocess.cpp index b2daf09fb..a502d30f1 100644 --- a/src/mpi/remoteeditprocess.cpp +++ b/src/mpi/remoteeditprocess.cpp @@ -37,11 +37,13 @@ NODISCARD static std::string randomString(int length) RemoteEditProcess::RemoteEditProcess(const bool editSession, const QString &title, const QString &body, + const QString &fullPath, QObject *const parent) : QObject(parent) , m_title(title) , m_body(body) , m_editSession(editSession) + , m_fullPath(fullPath) { m_process.setProcessChannelMode(QProcess::MergedChannels); @@ -52,30 +54,28 @@ RemoteEditProcess::RemoteEditProcess(const bool editSession, &RemoteEditProcess::slot_onFinished); connect(&m_process, &QProcess::errorOccurred, this, &RemoteEditProcess::slot_onError); - // Set the file template - QString fileTemplate = QString("%1MMapper.%2.pid%3.%4") - .arg(QDir::tempPath() + QDir::separator()) // %1 - .arg(m_editSession ? "edit" : "view") // %2 - .arg(QCoreApplication::applicationPid()) // %3 - .arg(mmqt::toQStringLatin1(randomString(6))); // %4 // ASCII - QFile file(fileTemplate); - - // Try opening up the temporary file - if (!file.open(QFile::WriteOnly | QFile::Text)) { - qCritical() << "View session was unable to create a temporary file"; - throw std::runtime_error("failed to start"); + if (m_fullPath.isEmpty()) { + // Fallback for view mode if no draft was provisioned (though normally it is now) + m_fullPath = QDir::tempPath() + QDir::separator() + + QString("MMapper.view.%1.%2") + .arg(QCoreApplication::applicationPid()) + .arg(mmqt::toQStringLatin1(randomString(6))); } - m_fileName = file.fileName(); - qDebug() << "View session file template" << m_fileName; - file.write(mmqt::toQByteArrayLatin1(m_body)); // MPI is always Latin1 - file.flush(); + QFile file(m_fullPath); + if (!file.exists()) { + if (!file.open(QFile::WriteOnly | QFile::Text)) { + qCritical() << "View session was unable to create a temporary file" << m_fullPath; + throw std::runtime_error("failed to start"); + } + file.write(mmqt::toQByteArrayLatin1(m_body)); // MPI is always Latin1 + file.flush(); + std::ignore = io::fsyncNoexcept(file); + file.close(); + } - // REVISIT: check return value? - std::ignore = io::fsyncNoexcept(file); - file.close(); - m_previousTime = QFileInfo{m_fileName}.lastModified(); - qDebug() << "File written with last modified timestamp" << m_previousTime; + m_previousTime = QFileInfo{m_fullPath}.lastModified(); + qDebug() << "External editor using file" << m_fullPath << "with timestamp" << m_previousTime; // Set the TITLE environmental variable QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); @@ -87,7 +87,7 @@ RemoteEditProcess::RemoteEditProcess(const bool editSession, // Start the process! QStringList args = splitCommandLine(getConfig().mumeClientProtocol.externalRemoteEditorCommand); - args << m_fileName; + args << m_fullPath; const QString &program = args.takeFirst(); qDebug() << program << args; m_process.start(program, args); @@ -98,8 +98,13 @@ RemoteEditProcess::RemoteEditProcess(const bool editSession, RemoteEditProcess::~RemoteEditProcess() { qInfo() << "Destroyed RemoteEditProcess"; - QFile file(m_fileName); - file.remove(); + // We don't remove the file here anymore for edit sessions, + // as it is managed by the session/manager and must persist + // until success confirmation. + if (!m_editSession) { + QFile file(m_fullPath); + file.remove(); + } } void RemoteEditProcess::virt_onFinished(int exitCode, QProcess::ExitStatus status) @@ -117,9 +122,9 @@ void RemoteEditProcess::virt_onFinished(int exitCode, QProcess::ExitStatus statu return; } - QFile file(m_fileName); + QFile file(m_fullPath); if (!file.open(QFile::ReadOnly)) { - qWarning() << "Edit session unable to read file!"; + qWarning() << "Edit session unable to read file!" << m_fullPath; emit sig_cancel(); return; } diff --git a/src/mpi/remoteeditprocess.h b/src/mpi/remoteeditprocess.h index 0d00d4c8c..921c79ab4 100644 --- a/src/mpi/remoteeditprocess.h +++ b/src/mpi/remoteeditprocess.h @@ -28,16 +28,19 @@ class NODISCARD_QOBJECT RemoteEditProcess final : public QObject const bool m_editSession; QProcess m_process; - QString m_fileName; + QString m_fullPath; QDateTime m_previousTime; public: explicit RemoteEditProcess(bool editSession, const QString &title, const QString &body, + const QString &fullPath, QObject *parent); ~RemoteEditProcess() final; + NODISCARD bool isRunning() const { return m_process.state() == QProcess::Running; } + private: virtual void virt_onError(QProcess::ProcessError); virtual void virt_onFinished(int, QProcess::ExitStatus); diff --git a/src/mpi/remoteeditsession.cpp b/src/mpi/remoteeditsession.cpp index 3ac4362b5..4560d6872 100644 --- a/src/mpi/remoteeditsession.cpp +++ b/src/mpi/remoteeditsession.cpp @@ -21,9 +21,11 @@ RemoteEditSession::RemoteEditSession(const RemoteInternalId internalId, const RemoteSessionId sessionId, + QString title, RemoteEdit *const remoteEdit) : QObject(remoteEdit) , m_manager(remoteEdit) + , m_title(std::move(title)) , m_internalId(internalId) , m_sessionId(sessionId) { @@ -40,12 +42,38 @@ void RemoteEditSession::cancel() m_manager->cancel(this); } +void RemoteEditSession::virt_show() +{ + QString content = m_content; + if (content.isEmpty() && !m_draftFileName.isEmpty()) { + QFile file(getFullDraftPath()); + if (file.open(QFile::ReadOnly | QFile::Text)) { + content = QString::fromLatin1(file.readAll()); + file.close(); + } + } + + // Default implementation does nothing (for recovered raw sessions) + // We will show the content in a read-only dialog + auto *widget = new RemoteEditWidget(false, m_title, content, dynamic_cast(m_manager->parent())); + widget->setAttribute(Qt::WA_DeleteOnClose); + widget->show(); +} + +QString RemoteEditSession::getFullDraftPath() const +{ + if (m_draftFileName.isEmpty()) { + return QString(); + } + return QDir(RemoteEdit::getDraftDirectory()).absoluteFilePath(m_draftFileName); +} + RemoteEditInternalSession::RemoteEditInternalSession(const RemoteInternalId internalId, const RemoteSessionId sessionId, const QString &title, const QString &body, RemoteEdit *const parent) - : RemoteEditSession(internalId, sessionId, parent) + : RemoteEditSession(internalId, sessionId, title, parent) , m_widget( new RemoteEditWidget(isEditSession(), title, @@ -56,6 +84,28 @@ RemoteEditInternalSession::RemoteEditInternalSession(const RemoteInternalId inte const auto widget = m_widget.data(); connect(widget, &RemoteEditWidget::sig_save, this, &RemoteEditSession::slot_onSave); connect(widget, &RemoteEditWidget::sig_cancel, this, &RemoteEditSession::slot_onCancel); + connect(widget, + &RemoteEditWidget::sig_textModified, + this, + &RemoteEditInternalSession::slot_onTextModified); + + if (isEditSession()) { + m_debounceTimer = new QTimer(this); + m_debounceTimer->setSingleShot(true); + connect(m_debounceTimer, + &QTimer::timeout, + this, + &RemoteEditInternalSession::slot_performAutoSave); + + m_throttleTimer = new QTimer(this); + m_throttleTimer->setSingleShot(true); + connect(m_throttleTimer, + &QTimer::timeout, + this, + &RemoteEditInternalSession::slot_performAutoSave); + + m_lastWriteTimer.start(); + } } RemoteEditInternalSession::~RemoteEditInternalSession() @@ -67,15 +117,55 @@ RemoteEditInternalSession::~RemoteEditInternalSession() } } +void RemoteEditInternalSession::slot_onTextModified(const QString &content) +{ + m_content = content; + + // FR-3.2: 2000ms debounce + m_debounceTimer->start(2000); + + // FR-3.3: 15000ms max throttle + if (!m_throttleTimer->isActive()) { + m_throttleTimer->start(15000); + } +} + +void RemoteEditInternalSession::slot_performAutoSave() +{ + if (m_draftFileName.isEmpty()) { + return; + } + + if (RemoteEdit::saveDraftAtomic(m_draftFileName, m_content)) { + qDebug() << "Auto-save successful for" << m_draftFileName; + m_lastWriteTimer.restart(); + m_debounceTimer->stop(); + m_throttleTimer->stop(); + } else { + qWarning() << "Auto-save failed for" << m_draftFileName; + } +} + +void RemoteEditInternalSession::virt_show() +{ + if (m_widget) { + m_widget->show(); + m_widget->raise(); + m_widget->activateWindow(); + } else { + RemoteEditSession::virt_show(); + } +} + #ifndef Q_OS_WASM RemoteEditExternalSession::RemoteEditExternalSession(const RemoteInternalId internalId, const RemoteSessionId sessionId, const QString &title, const QString &body, RemoteEdit *const parent) - : RemoteEditSession(internalId, sessionId, parent) - , m_process(new RemoteEditProcess(isEditSession(), title, body, this)) + : RemoteEditSession(internalId, sessionId, title, parent) { + m_process = new RemoteEditProcess(isEditSession(), title, body, getFullDraftPath(), this); const auto proc = m_process.data(); connect(proc, &RemoteEditProcess::sig_save, this, &RemoteEditExternalSession::slot_onSave); connect(proc, &RemoteEditProcess::sig_cancel, this, &RemoteEditExternalSession::slot_onCancel); diff --git a/src/mpi/remoteeditsession.h b/src/mpi/remoteeditsession.h index 7f0d9e2ed..3d98d4221 100644 --- a/src/mpi/remoteeditsession.h +++ b/src/mpi/remoteeditsession.h @@ -8,6 +8,7 @@ #include #endif +#include "../global/AsyncTasks.h" #include "../global/TaggedInt.h" #include "../global/TaggedString.h" #include "../global/macros.h" @@ -62,12 +63,16 @@ class NODISCARD_QOBJECT RemoteEditSession : public QObject { Q_OBJECT -private: +protected: RemoteEdit *m_manager = nullptr; QString m_content; + QString m_title; const RemoteInternalId m_internalId{}; const RemoteSessionId m_sessionId = REMOTE_VIEW_SESSION_ID; bool m_connected = true; + bool m_stopTask = false; + QString m_draftFileName; + std::optional m_taskHandle; private: #ifndef Q_OS_WASM @@ -78,6 +83,7 @@ class NODISCARD_QOBJECT RemoteEditSession : public QObject public: explicit RemoteEditSession(RemoteInternalId internalId, RemoteSessionId sessionId, + QString title, RemoteEdit *remoteEdit); public: @@ -85,6 +91,7 @@ class NODISCARD_QOBJECT RemoteEditSession : public QObject NODISCARD auto getSessionId() const { return m_sessionId; } NODISCARD bool isEditSession() const { return m_sessionId != REMOTE_VIEW_SESSION_ID; } NODISCARD const QString &getContent() const { return m_content; } + NODISCARD const QString &getTitle() const { return m_title; } void setContent(QString content) { m_content = std::move(content); } void cancel(); void save(); @@ -92,6 +99,17 @@ class NODISCARD_QOBJECT RemoteEditSession : public QObject public: NODISCARD bool isConnected() const { return m_connected; } void setDisconnected() { m_connected = false; } + void setDraftFileName(const QString &fileName) { m_draftFileName = fileName; } + NODISCARD const QString &getDraftFileName() const { return m_draftFileName; } + NODISCARD QString getFullDraftPath() const; + void setAsyncTask(async_tasks::AsyncTaskHandle handle) { m_taskHandle = std::move(handle); } + NODISCARD std::optional getAsyncTask() const + { + return m_taskHandle; + } + void stopTask() { m_stopTask = true; } + NODISCARD bool shouldStopTask() const { return m_stopTask; } + virtual void virt_show(); protected slots: void slot_onCancel() { cancel(); } @@ -108,6 +126,9 @@ class NODISCARD_QOBJECT RemoteEditInternalSession final : public RemoteEditSessi private: QPointer m_widget; + QTimer *m_debounceTimer = nullptr; + QTimer *m_throttleTimer = nullptr; + QElapsedTimer m_lastWriteTimer; public: explicit RemoteEditInternalSession(RemoteInternalId internalId, @@ -116,6 +137,13 @@ class NODISCARD_QOBJECT RemoteEditInternalSession final : public RemoteEditSessi const QString &body, RemoteEdit *remoteEdit); ~RemoteEditInternalSession() final; + +public: + void virt_show() final; + +private slots: + void slot_onTextModified(const QString &content); + void slot_performAutoSave(); }; #ifndef Q_OS_WASM diff --git a/src/mpi/remoteeditwidget.cpp b/src/mpi/remoteeditwidget.cpp index a5c20e949..c9373fc5b 100644 --- a/src/mpi/remoteeditwidget.cpp +++ b/src/mpi/remoteeditwidget.cpp @@ -1074,6 +1074,11 @@ void RemoteEditWidget::addStatusBar(const Editor *const pTextEdit) this, &RemoteEditWidget::slot_updateStatusBar); connect(pTextEdit, &QPlainTextEdit::textChanged, this, &RemoteEditWidget::slot_updateStatusBar); + connect(pTextEdit, &QPlainTextEdit::textChanged, this, [this, pTextEdit]() { + if (m_editSession) { + emit sig_textModified(pTextEdit->toPlainText()); + } + }); } void RemoteEditWidget::slot_updateStatus(const QString &message_param) diff --git a/src/mpi/remoteeditwidget.h b/src/mpi/remoteeditwidget.h index 95af39735..7caee16d9 100644 --- a/src/mpi/remoteeditwidget.h +++ b/src/mpi/remoteeditwidget.h @@ -182,6 +182,7 @@ class NODISCARD_QOBJECT RemoteEditWidget : public QDialog signals: void sig_cancel(); void sig_save(const QString &); + void sig_textModified(const QString &); protected slots: void slot_cancelEdit(); diff --git a/src/observer/gameobserver.cpp b/src/observer/gameobserver.cpp index 88a17b368..ea0121d5e 100644 --- a/src/observer/gameobserver.cpp +++ b/src/observer/gameobserver.cpp @@ -11,6 +11,11 @@ void GameObserver::observeConnected() sig2_connected.invoke(); } +void GameObserver::observeDisconnected() +{ + sig2_disconnected.invoke(); +} + void GameObserver::observeSentToMud(const QString &input) { auto str = input; diff --git a/src/observer/gameobserver.h b/src/observer/gameobserver.h index eec57e187..46917aa1f 100644 --- a/src/observer/gameobserver.h +++ b/src/observer/gameobserver.h @@ -12,6 +12,7 @@ class NODISCARD GameObserver final { public: Signal2<> sig2_connected; + Signal2<> sig2_disconnected; Signal2 sig2_sentToMudString; // removes ANSI Signal2 sig2_sentToUserString; // removes ANSI @@ -39,6 +40,7 @@ class NODISCARD GameObserver final public: void observeConnected(); + void observeDisconnected(); void observeSentToMud(const QString &ba); void observeSentToUser(const QString &ba); void observeSentToUserGmcp(const GmcpMessage &m); diff --git a/src/proxy/MudTelnet.cpp b/src/proxy/MudTelnet.cpp index d0a4c68ea..8863bde3a 100644 --- a/src/proxy/MudTelnet.cpp +++ b/src/proxy/MudTelnet.cpp @@ -490,15 +490,13 @@ void MudTelnet::virt_receiveGmcpMessage(const GmcpMessage &msg) const auto optBool = obj.getBool("result"); const auto optString = obj.getString("result"); - if (optBool && optBool.value()) { - qDebug() << "[success] Successfully" << (msg.isMumeClientWrite() ? "sent" : "cancelled") - << "remote edit" << id; + const bool success = optBool.value_or(false); + const QString errmsg = optString.value_or(success ? "" : "unknown error"); + + if (msg.isMumeClientWrite()) { + m_outputs.onMumeClientWriteResult(RemoteSessionId{id}, success, errmsg); } else { - const auto action = (msg.isMumeClientWrite() ? "sending" : "canceling"); - const auto result = optString.value_or("missing text"); - qDebug() << "Failure" << action << "remote message" << id << result; - // Mume doesn't send anything, so we have to make our own message. - global::sendToUser(QString("Failure %1 remote message: %2\n").arg(action, result)); + m_outputs.onMumeClientCancelResult(RemoteSessionId{id}, success, errmsg); } return; } diff --git a/src/proxy/MudTelnet.h b/src/proxy/MudTelnet.h index f54df5dd9..758c9bb3d 100644 --- a/src/proxy/MudTelnet.h +++ b/src/proxy/MudTelnet.h @@ -42,6 +42,14 @@ struct NODISCARD MudTelnetOutputs virt_onMumeClientEdit(id, title, body); } void onMumeClientError(const QString &errmsg) { virt_onMumeClientError(errmsg); } + void onMumeClientWriteResult(const RemoteSessionId id, bool success, const QString &errmsg) + { + virt_onMumeClientWriteResult(id, success, errmsg); + } + void onMumeClientCancelResult(const RemoteSessionId id, bool success, const QString &errmsg) + { + virt_onMumeClientCancelResult(id, success, errmsg); + } private: virtual void virt_onAnalyzeMudStream(const RawBytes &, bool goAhead) = 0; @@ -57,8 +65,17 @@ struct NODISCARD MudTelnetOutputs const QString &body) = 0; virtual void virt_onMumeClientError(const QString &errmsg) = 0; + virtual void virt_onMumeClientWriteResult(const RemoteSessionId id, + bool success, + const QString &errmsg) + = 0; + virtual void virt_onMumeClientCancelResult(const RemoteSessionId id, + bool success, + const QString &errmsg) + = 0; }; + class NODISCARD MudTelnet final : public AbstractTelnet { private: diff --git a/src/proxy/connectionlistener.cpp b/src/proxy/connectionlistener.cpp index 8a6f704f2..e5b499ccd 100644 --- a/src/proxy/connectionlistener.cpp +++ b/src/proxy/connectionlistener.cpp @@ -11,6 +11,7 @@ #include "../global/Charset.h" #include "../global/MakeQPointer.h" #include "../global/TextUtils.h" +#include "../mpi/remoteedit.h" #include "TcpSocket.h" #include "proxy.h" @@ -128,6 +129,7 @@ void ConnectionListener::startClient(std::unique_ptr socket) m_gameOberver, std::move(socket), *this); + emit sig_proxyCreated(m_proxy); } else { log("New connection: rejected."); const auto msg = std::invoke([]() -> QByteArray { diff --git a/src/proxy/connectionlistener.h b/src/proxy/connectionlistener.h index 7c838f832..4875c474c 100644 --- a/src/proxy/connectionlistener.h +++ b/src/proxy/connectionlistener.h @@ -25,7 +25,7 @@ class Mmapper2PathMachine; class MumeClock; class CTimers; class PrespammedPath; -class Proxy; +#include "proxy.h" class QObject; class RoomManager; @@ -80,6 +80,7 @@ class NODISCARD_QOBJECT ConnectionListener final : public QObject public: void listen(); + NODISCARD Proxy *getProxy() const { return m_proxy.data(); } private: void log(const QString &msg) { emit sig_log("Listener", msg); } @@ -87,6 +88,7 @@ class NODISCARD_QOBJECT ConnectionListener final : public QObject signals: void sig_log(const QString &, const QString &); void sig_clientSuccessfullyConnected(); + void sig_proxyCreated(QPointer proxy); protected slots: void slot_onIncomingConnection(qintptr socketDescriptor); diff --git a/src/proxy/proxy.cpp b/src/proxy/proxy.cpp index 0d269daaa..bdd7d0945 100644 --- a/src/proxy/proxy.cpp +++ b/src/proxy/proxy.cpp @@ -207,12 +207,7 @@ Proxy::~Proxy() getUserSocket().disconnectFromHost(); } - { - auto &remoteEdit = deref(m_remoteEdit); - remoteEdit.onDisconnected(); - remoteEdit.disconnect(); // disconnect all signals - remoteEdit.deleteLater(); - } + {} destroyPipelineObjects(); } @@ -316,7 +311,6 @@ void Proxy::allocMudSocket() NODISCARD Proxy &getProxy() { return m_proxy; } NODISCARD MudTelnet &getMudTelnet() { return getProxy().getMudTelnet(); } NODISCARD MumeXmlParser &getMudParser() { return getProxy().getMudParser(); } - NODISCARD RemoteEdit &getRemoteEdit() { return getProxy().getRemoteEdit(); } NODISCARD UserTelnet &getUserTelnet() { return getProxy().getUserTelnet(); } NODISCARD Mmapper2Group &getGroupManager() { return getProxy().getGroupManager(); } NODISCARD MainWindow &getMainWindow() { return getProxy().getMainWindow(); } @@ -346,7 +340,7 @@ void Proxy::allocMudSocket() void virt_onSocketStatus(const QString &msg) final { - getProxy().sendStatusToUser(msg.toUtf8().toStdString()); + getProxy().sendStatusToUser(mmqt::toStdStringUtf8(msg)); } void virt_onDisconnected() final @@ -356,7 +350,6 @@ void Proxy::allocMudSocket() getMudParser().onReset(); getGroupManager().onReset(); getProxy().mudTerminatedConnection(); - getRemoteEdit().onDisconnected(); } void virt_onProcessMudStream(const TelnetIacBytes &bytes) final @@ -495,15 +488,14 @@ void Proxy::allocMudTelnet() void virt_onRelayGmcpFromMudToUser(const GmcpMessage &msg) final { - if (msg.isMumeClientView() || msg.isMumeClientEdit() || msg.isMumeClientCancelEdit() - || msg.isMumeClientError() || msg.isMumeClientWrite() || msg.isMumeClientXml()) { - // this is a private message between MUME and mmapper. - qWarning() << "MUME.Client message was almost sent to the user."; - return; - } + const bool isMumeClient = msg.isMumeClientView() || msg.isMumeClientEdit() + || msg.isMumeClientCancelEdit() || msg.isMumeClientError() + || msg.isMumeClientWrite() || msg.isMumeClientXml(); - // forwarded (to user) - getUserTelnet().onGmcpToUser(msg); + if (!isMumeClient) { + // forwarded (to user) + getUserTelnet().onGmcpToUser(msg); + } // REVISIT: should parser be first? getGroupManager().slot_parseGmcpInput(msg); @@ -548,6 +540,32 @@ void Proxy::allocMudTelnet() getProxy().sendToUser(SendToUserSourceEnum::FromMMapper, QString("MUME.Client protocol error: %1").arg(errmsg)); } + void virt_onMumeClientWriteResult(const RemoteSessionId id, + const bool success, + const QString &errmsg) final + { + if (success) { + qDebug() << "[success] Successfully sent remote edit" << id.asInt32(); + } else { + qDebug() << "Failure sending remote message" << id.asInt32() << errmsg; + getProxy().sendToUser(SendToUserSourceEnum::FromMMapper, + QString("Failure sending remote message: %1\n").arg(errmsg)); + } + emit getProxy().sig_remoteWriteResult(id, success, errmsg); + } + void virt_onMumeClientCancelResult(const RemoteSessionId id, + const bool success, + const QString &errmsg) final + { + if (success) { + qDebug() << "[success] Successfully cancelled remote edit" << id.asInt32(); + } else { + qDebug() << "Failure canceling remote message" << id.asInt32() << errmsg; + getProxy().sendToUser(SendToUserSourceEnum::FromMMapper, + QString("Failure canceling remote message: %1\n").arg(errmsg)); + } + emit getProxy().sig_remoteCancelResult(id, success, errmsg); + } }; auto &pipe = getPipeline(); @@ -789,7 +807,6 @@ void Proxy::allocMpiFilter() private: NODISCARD Proxy &getProxy() { return m_proxy; } NODISCARD MumeXmlParser &getMudParser() { return getProxy().getMudParser(); } - NODISCARD RemoteEdit &getRemoteEdit() { return getProxy().getRemoteEdit(); } private: void notifyUser(const std::string_view article, @@ -818,12 +835,12 @@ void Proxy::allocMpiFilter() const QString &body) final { notifyUser("an", "Editor", title); - getRemoteEdit().slot_remoteEdit(id, title, body); + emit getProxy().sig_remoteEditRequested(id, title, body); } void virt_onViewMessage(const QString &title, const QString &body) final { notifyUser("a", "Viewer", title); - getRemoteEdit().slot_remoteView(title, body); + emit getProxy().sig_remoteViewRequested(title, body); } void virt_onParseNewMudInput(const TelnetData &data) final { @@ -838,9 +855,6 @@ void Proxy::allocMpiFilter() void Proxy::allocRemoteEdit() { - // Caution: RemoteEdit outlives the proxy, since it manages windows. - m_remoteEdit = mmqt::makeQPointer(&m_mainWindow); - struct NODISCARD LocalMpiFilterToMud final : public MpiFilterToMud { private: @@ -860,19 +874,6 @@ void Proxy::allocRemoteEdit() auto &pipe = getPipeline(); pipe.mud.mpiFilterToMud = std::make_unique(*this); - - auto &remoteEdit = deref(m_remoteEdit); - QObject::connect(&remoteEdit, - &RemoteEdit::sig_remoteEditCancel, - this, - [this](const RemoteSessionId id) { getMpiFilterToMud().cancelRemoteEdit(id); }); - - QObject::connect(&remoteEdit, - &RemoteEdit::sig_remoteEditSave, - this, - [this](const RemoteSessionId id, const Latin1Bytes &content) { - getMpiFilterToMud().saveRemoteEdit(id, content); - }); } void Proxy::init() @@ -970,6 +971,7 @@ void Proxy::mudTerminatedConnection() getUserTelnet().onRelayEchoMode(true); log("Mud terminated connection ..."); + getGameObserver().observeDisconnected(); sendNewlineToUser(); sendStatusToUser("MUME closed the connection."); @@ -1202,7 +1204,17 @@ void Proxy::log(const QString &msg) getMainWindow().slot_log("Proxy", msg); } -RemoteEdit &Proxy::getRemoteEdit() +void Proxy::slot_remoteEditSave(const RemoteSessionId sessionId, const Latin1Bytes &content) +{ + getMpiFilterToMud().saveRemoteEdit(sessionId, content); +} + +void Proxy::slot_remoteEditCancel(const RemoteSessionId sessionId) +{ + getMpiFilterToMud().cancelRemoteEdit(sessionId); +} + +void Proxy::slot_sendGmcp(const GmcpMessage &msg) { - return deref(m_remoteEdit); + getMudTelnet().onSubmitGmcpMumeClient(msg); } diff --git a/src/proxy/proxy.h b/src/proxy/proxy.h index 04e7295db..f07dc0404 100644 --- a/src/proxy/proxy.h +++ b/src/proxy/proxy.h @@ -10,6 +10,7 @@ #include "../global/WeakHandle.h" #include "../global/io.h" #include "../group/GroupManagerApi.h" +#include "../mpi/remoteeditsession.h" #include "../observer/gameobserver.h" #include "../parser/SendToUserSourceEnum.h" #include "GmcpMessage.h" @@ -150,10 +151,6 @@ class NODISCARD_QOBJECT Proxy final : public QObject // because it's intended for sendXXX within the lifetime of this object. Signal2Lifetime m_lifetime; - // Technically we create this, but we don't "own" it; - // it outlives this object when the connection closes. - QPointer m_remoteEdit; - enum class NODISCARD ServerStateEnum { Initialized, Offline, @@ -311,7 +308,6 @@ class NODISCARD_QOBJECT Proxy final : public QObject { return deref(getPipeline().user.userTelnetFilter); } - NODISCARD RemoteEdit &getRemoteEdit(); NODISCARD MumeXmlParser &getMudParser() { return deref(getPipeline().mud.mudParser); } NODISCARD AbstractParser &getUserParser() { return deref(getPipeline().user.userParser); } NODISCARD PasswordConfig &getPasswordConfig() @@ -326,4 +322,21 @@ class NODISCARD_QOBJECT Proxy final : public QObject { return deref(getPipeline().user.userTelnet); } + +signals: + void sig_remoteEditRequested(const RemoteSessionId sessionId, + const QString &title, + const QString &body); + void sig_remoteViewRequested(const QString &title, const QString &body); + void sig_remoteWriteResult(const RemoteSessionId sessionId, + const bool success, + const QString &message); + void sig_remoteCancelResult(const RemoteSessionId sessionId, + const bool success, + const QString &message); + +public slots: + void slot_remoteEditSave(const RemoteSessionId sessionId, const Latin1Bytes &content); + void slot_remoteEditCancel(const RemoteSessionId sessionId); + void slot_sendGmcp(const GmcpMessage &msg); };