diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/m_email/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/m_email/EMailAccountSettingsPage.cpp | 14 | ||||
| -rw-r--r-- | src/m_email/EMailAccountSettingsPage.h | 17 | ||||
| -rw-r--r-- | src/m_email/EMailAccountStore.cpp | 67 | ||||
| -rw-r--r-- | src/m_email/EMailImapController.h | 4 | ||||
| -rw-r--r-- | src/m_email/EMailModule.cpp | 524 | ||||
| -rw-r--r-- | src/m_email/EMailModule.h | 8 | ||||
| -rw-r--r-- | src/m_email/EMailPageView.cpp | 283 | ||||
| -rw-r--r-- | src/m_email/EMailPageView.h | 42 | ||||
| -rw-r--r-- | src/m_email/EMailStructureView.cpp | 3 | ||||
| -rw-r--r-- | src/m_email/EMailViewLayout.h | 6 | ||||
| -rw-r--r-- | src/m_email/EMailViewStyle.h | 24 | ||||
| -rw-r--r-- | src/m_email/module.json | 9 | ||||
| -rw-r--r-- | src/m_email/test/EMailSdkStubs.cpp | 41 | ||||
| -rw-r--r-- | src/m_email/ui/main.lua | 37 |
15 files changed, 430 insertions, 650 deletions
diff --git a/src/m_email/CMakeLists.txt b/src/m_email/CMakeLists.txt index ff155fe..a145222 100644 --- a/src/m_email/CMakeLists.txt +++ b/src/m_email/CMakeLists.txt @@ -66,6 +66,7 @@ gf_add_module( NAME email QT Core Widgets Concurrent Network LINK vmime-static + LUA_SCRIPTS ui/main.lua INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/vmime/src" "${CMAKE_BINARY_DIR}/src/vmime" diff --git a/src/m_email/EMailAccountSettingsPage.cpp b/src/m_email/EMailAccountSettingsPage.cpp index fbf3bef..6a9c5d2 100644 --- a/src/m_email/EMailAccountSettingsPage.cpp +++ b/src/m_email/EMailAccountSettingsPage.cpp @@ -104,7 +104,7 @@ void SelectSecurity(QComboBox* combo, MailTlsMode mode) { EMailAccountSettingsPage::EMailAccountSettingsPage(QWidget* parent) : QWidget(parent) { build_ui(); - SetSettings(); + LoadSettings(); } namespace { @@ -394,7 +394,7 @@ void EMailAccountSettingsPage::build_ui() { apply_colors(); } -void EMailAccountSettingsPage::SetSettings() { +void EMailAccountSettingsPage::LoadSettings() { loading_ = true; // Checked rather than plain: this page WRITES what it reads, and an empty @@ -418,7 +418,7 @@ void EMailAccountSettingsPage::SetSettings() { refresh_enabled_state(); } -void EMailAccountSettingsPage::ApplySettings() { +auto EMailAccountSettingsPage::ApplySettings() -> bool { // Refused outright when the stored list could not be read. Writing here // would replace a newer build's accounts -- or a list this build simply // could not parse -- with an empty one, which is exactly what refusing to @@ -436,7 +436,7 @@ void EMailAccountSettingsPage::ApplySettings() { "has not changed them.\n\nNothing has been lost. If this " "keeps happening, the stored list may need to be removed by " "hand.")); - return; + return false; } store_selected(); @@ -445,7 +445,7 @@ void EMailAccountSettingsPage::ApplySettings() { // whole account list and re-save every stored password on every OK press, // whichever settings page the user had actually come for. if (!dirty_ && pending_passwords_.isEmpty() && pending_removals_.isEmpty()) { - return; + return false; } EMailAccountStore::Store(accounts_, default_id_); @@ -467,6 +467,7 @@ void EMailAccountSettingsPage::ApplySettings() { } wipe_pending_passwords(); + return true; } auto EMailAccountSettingsPage::selected_index() const -> int { @@ -1255,6 +1256,3 @@ void EMailAccountSettingsPage::finish_probe(const std::shared_ptr<Probe>& probe, report_probe_error(probe->error, probe->imap, status); } -auto EMailAccountSettingsPageFactory(void* /*data*/) -> void* { - return new EMailAccountSettingsPage(nullptr); -} diff --git a/src/m_email/EMailAccountSettingsPage.h b/src/m_email/EMailAccountSettingsPage.h index 1260d5b..e196e13 100644 --- a/src/m_email/EMailAccountSettingsPage.h +++ b/src/m_email/EMailAccountSettingsPage.h @@ -29,6 +29,8 @@ #pragma once #include <QList> +#include <GFModule.h> + #include <QWidget> #include <memory> #include <mutex> @@ -61,7 +63,7 @@ class QSpinBox; * configurable and must not gain controls here; what a user can legitimately * want to vary is which server they use and how much of a mailbox to show. */ -class EMailAccountSettingsPage : public QWidget { +class EMailAccountSettingsPage : public QWidget, public gf::ui::SettingsWidget { Q_OBJECT public: @@ -70,9 +72,9 @@ class EMailAccountSettingsPage : public QWidget { public slots: /// Load the stored accounts, discarding anything staged. - void SetSettings(); + void LoadSettings() override; /// Persist the staged accounts. - void ApplySettings(); + bool ApplySettings() override; // NOLINT private slots: void slot_add_account(); @@ -121,7 +123,7 @@ class EMailAccountSettingsPage : public QWidget { [[nodiscard]] auto selected_index() const -> int; /// Staged, not stored: the dialog's Cancel throws these away by calling - /// SetSettings() again. + /// LoadSettings() again. QList<MailAccountConfig> accounts_; QString default_id_; /// Passwords the user typed this session, by account id. Only written to the @@ -245,10 +247,3 @@ class EMailAccountSettingsPage : public QWidget { QWidget* editor_{}; }; -/** - * @brief Build a page for the host's Settings dialog. - * - * Matches QObjectFactory. A fresh page every call: the dialog is rebuilt each - * time it opens and takes ownership of what it is given. - */ -auto EMailAccountSettingsPageFactory(void* data) -> void*; diff --git a/src/m_email/EMailAccountStore.cpp b/src/m_email/EMailAccountStore.cpp index 7216bac..9def5f3 100644 --- a/src/m_email/EMailAccountStore.cpp +++ b/src/m_email/EMailAccountStore.cpp @@ -40,18 +40,26 @@ namespace { -constexpr auto kSchemaVersionKey = "email/schema_version"; -constexpr auto kAccountsKey = "email/accounts"; -constexpr auto kDefaultAccountKey = "email/default_account"; +// Relative: the Host places them in this module's own settings group, which +// is where they always were ("email/..."), so no account moves. +constexpr auto kSchemaVersionKey = "schema_version"; +constexpr auto kAccountsKey = "accounts"; +constexpr auto kDefaultAccountKey = "default_account"; constexpr auto kSchemaVersion = 1; -/// The settings object is shared with the host, and QSettings is only -/// reentrant, so every read-modify-write of our keys has to be serialized. +/// A read-modify-write of the account list is several calls; the mutex keeps +/// this module's own threads from interleaving them. No settings object is +/// shared: every call reaches the Host's settings through the storage +/// capability. Q_GLOBAL_STATIC(QMutex, settings_mutex) -auto GlobalSettings() -> QSettings* { - return qobject_cast<QSettings*>( - static_cast<QObject*>(GFStorageSettingsRoot(GFModuleSdkContext()))); +auto Get(const char* key, const QVariant& fallback = {}) -> QVariant { + return gf::sdk::Setting(GFModuleSdkContext(), GF_SETTING_MODULE, key, + fallback); +} + +void Put(const char* key, const QVariant& value) { + gf::sdk::SetSetting(GFModuleSdkContext(), GF_SETTING_MODULE, key, value); } } // namespace @@ -63,20 +71,7 @@ auto Load() -> QList<MailAccountConfig> { return LoadChecked().accounts; } auto LoadChecked() -> LoadResult { QMutexLocker locker(settings_mutex()); - auto* settings = GlobalSettings(); - if (settings == nullptr) { - LOG_ERROR("global settings unavailable, no mail accounts loaded"); - // Not kOK: nothing was read, so nothing may be written back over whatever - // is actually there. - return {{}, LoadOutcome::kUNREADABLE}; - } - - // The host writes through its own short-lived QSettings objects, and this one - // outlives them all; without a sync it would keep serving whatever it read at - // startup. - settings->sync(); - - const auto version = settings->value(kSchemaVersionKey, 0).toInt(); + const auto version = Get(kSchemaVersionKey, 0).toInt(); if (version > kSchemaVersion) { // Written by a newer build. Refusing to read it is the honest answer: // silently reinterpreting fields we do not understand could downgrade a @@ -85,7 +80,7 @@ auto LoadChecked() -> LoadResult { return {{}, LoadOutcome::kNEWER}; } - const auto raw = settings->value(kAccountsKey).toString(); + const auto raw = Get(kAccountsKey).toString(); const auto document = QJsonDocument::fromJson(raw.toUtf8()); if (!document.isArray()) { // An absent value is not a corrupt one: a profile that has never had a @@ -109,22 +104,15 @@ void Store(const QList<MailAccountConfig>& accounts, const QString& default_id) { QMutexLocker locker(settings_mutex()); - auto* settings = GlobalSettings(); - // Refused here, not only in the settings page. A newer build may have // written a schema this one cannot represent, and stamping kSchemaVersion // over it would discard those accounts irreversibly. The page checks too and // gives the user a reason; this is what makes the rule hold for any future // caller that forgets to. - if (settings != nullptr && - settings->value(kSchemaVersionKey, 0).toInt() > kSchemaVersion) { + if (Get(kSchemaVersionKey, 0).toInt() > kSchemaVersion) { LOG_WARN("refusing to overwrite mail accounts written by a newer version"); return; } - if (settings == nullptr) { - LOG_ERROR("global settings unavailable, mail accounts not stored"); - return; - } QJsonArray array; QStringList ids; @@ -140,15 +128,10 @@ void Store(const QList<MailAccountConfig>& accounts, if (!ids.contains(resolved)) resolved = ids.isEmpty() ? QString() : ids.first(); - settings->setValue(kSchemaVersionKey, kSchemaVersion); - settings->setValue( - kAccountsKey, - QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact))); - settings->setValue(kDefaultAccountKey, resolved); - - // Our QSettings is a different object from the host's, so without this the - // host would keep reading the previous values. - settings->sync(); + Put(kSchemaVersionKey, kSchemaVersion); + Put(kAccountsKey, QString::fromUtf8( + QJsonDocument(array).toJson(QJsonDocument::Compact))); + Put(kDefaultAccountKey, resolved); } auto DefaultAccount() -> MailAccountConfig { @@ -158,9 +141,7 @@ auto DefaultAccount() -> MailAccountConfig { QString default_id; { QMutexLocker locker(settings_mutex()); - if (auto* settings = GlobalSettings(); settings != nullptr) { - default_id = settings->value(kDefaultAccountKey).toString(); - } + default_id = Get(kDefaultAccountKey).toString(); } for (const auto& account : accounts) { diff --git a/src/m_email/EMailImapController.h b/src/m_email/EMailImapController.h index 7c92b0b..6985355 100644 --- a/src/m_email/EMailImapController.h +++ b/src/m_email/EMailImapController.h @@ -29,6 +29,8 @@ #pragma once #include <QDateTime> +#include <GFModule.h> + #include <QDialog> #include <QHash> #include <QList> @@ -62,7 +64,7 @@ class QToolButton; * SignalMessageChosen() and the module decides what to do with it, which is * what keeps the picker from growing into a mail client. */ -class EMailImapController : public QDialog { +class EMailImapController : public QDialog, public gf::ui::DialogWidget { Q_OBJECT public: diff --git a/src/m_email/EMailModule.cpp b/src/m_email/EMailModule.cpp index a507e1f..2d5988d 100644 --- a/src/m_email/EMailModule.cpp +++ b/src/m_email/EMailModule.cpp @@ -37,6 +37,7 @@ #include "EMailSecret.h" #include "EMailSendDialog.h" #include "GFModuleIdentity.h" +#include "GFSDKHostCommands.hpp" // qt #include <QApplication> @@ -73,6 +74,7 @@ #include <vmime/contentTypeField.hpp> #include "GFModule.h" +#include "GFSDKHostCommands.hpp" // #include "EMailBasicGpgOpera.h" @@ -392,8 +394,10 @@ auto BuildResultCardsParam(const QString& operation, } // namespace /// Identifier of the settings page this module owns. -constexpr auto kMailSettingsPageId = - "com.bktus.gpgfrontend.module.email.accounts"; +namespace { +auto OpenRawAsEMailTab(const QByteArray& raw, const QString& title) -> bool; +auto TitleForImported(const QByteArray& raw) -> QString; +} // namespace auto OnActivate() -> GFResult { LOG_INFO("email module registering"); @@ -401,15 +405,6 @@ auto OnActivate() -> GFResult { // The MIME code carries no SDK symbol of its own so it can be unit-tested // without a module host; this is what gives it a logger at runtime. SetMimeLogSink([](const QString& m) { MLogDebug(m); }); - // The message view of an e-mail tab. The host still owns the page and its - // document -- this only supplies the widget shown on top of it, with the raw - // MIME still one click away. - gf::sdk::RegisterTabPageView( - GFModuleSdkContext(), "EMAIL", - [](void*) -> void* { return new EMailPageView(nullptr); }, nullptr); - - // register file extension handler - GFUIRegisterFileExtension(GFModuleSdkContext(), "eml", "EMAIL"); // These cross thread boundaries as queued signal arguments, so Qt has to // know how to copy them before the first connection is made. qRegisterMetaType<MailAccountConfig>("MailAccountConfig"); @@ -434,24 +429,37 @@ auto OnActivate() -> GFResult { // before any worker exists. vmime::platform::getHandler(); - const auto keywords = - QStringList{GC_TR("mail"), GC_TR("email"), GC_TR("imap"), - GC_TR("smtp"), GC_TR("account"), GC_TR("send")} - .join('\n'); - gf::sdk::RegisterSettingsPage(GFModuleSdkContext(), kMailSettingsPageId, - "features", GC_TR("Mail Accounts"), - keywords.toUtf8().constData(), - EMailAccountSettingsPageFactory, nullptr); - - return GFResult::Ok(); -} + // The module's own widgets, which ui/main.lua mounts: the message view of + // an e-mail document (one per tab; the Host owns the page, its document + // and the raw source beside it), the mail accounts settings page, and the + // IMAP controller dialog. Presentation is untranslated: the Host + // translates it when shown, after the module translators are installed. + const bool editor = gf::ui::RegisterNativeWidgetFactory<EMailPageView>( + "editor", + {GC_TR("E-Mail"), "", "eml", GC_TR("E-Mail Message (*.eml);;All Files (*)"), + ":/icons/email.png", 0, 0}, + [](const QCborMap& /*args*/) { return new EMailPageView(); }); + const bool settings = gf::ui::RegisterNativeWidget<EMailAccountSettingsPage>( + "settings", + {GC_TR("Mail Accounts"), + GC_TR("mail,email,imap,smtp,account,send"), "", "", "", 0, 0}, + [](const QCborMap& /*args*/) { return new EMailAccountSettingsPage(); }); + const bool imap = gf::ui::RegisterNativeWidget<EMailImapController>( + "imap", {GC_TR("IMAP Controller"), "", "", "", "", 0, 0}, + [](const QCborMap& /*args*/) { + auto* controller = new EMailImapController(); + // The narrow boundary: raw bytes in, a document out. The controller + // never touches a tab itself. + QObject::connect(controller, &EMailImapController::SignalMessageChosen, + controller, [](const QByteArray& raw) { + OpenRawAsEMailTab(raw, TitleForImported(raw)); + }); + return controller; + }); -auto OnDeactivate() -> GFResult { - // A factory pointing into an unloaded shared object would crash the next - // time an e-mail tab is opened. - GFUIUnregisterTabPageView(GFModuleSdkContext(), "EMAIL"); - GFUIUnregisterSettingsPage(GFModuleSdkContext(), kMailSettingsPageId); - return GFResult::Ok(); + return editor && settings && imap + ? GFResult::Ok() + : GFResult::Fail("the mail widgets were not registered"); } auto OnUnload() -> void { LOG_INFO("email module unregistering"); } @@ -521,6 +529,15 @@ auto ConfirmExport(QWidget* parent, const EMailExportCheck& check) -> bool { return accepted; } +} // namespace + +auto EMailConfirmExport(QWidget* parent, const EMailExportCheck& check) + -> bool { + return ConfirmExport(parent, check); +} + +namespace { + // Where a Save dialog should open. Falls back to the home directory only if // the host cannot answer, which it always can in practice. auto default_save_dir() -> QString { @@ -617,35 +634,14 @@ namespace { * Must run on the GUI thread. */ auto OpenRawAsEMailTab(const QByteArray& raw, const QString& title) -> bool { - auto* edit = GFUIObject<QWidget>("main_window_edit"); - if (edit == nullptr) { - LOG_ERROR("main_window_edit handle invalid or not QWidget"); - return false; - } - - QWidget* page = nullptr; - auto ok = QMetaObject::invokeMethod( - edit, "SlotNewCustomTab", Qt::DirectConnection, - Q_RETURN_ARG(QWidget*, page), Q_ARG(QString, "email"), - Q_ARG(QString, title), Q_ARG(QIcon, QIcon(":/icons/email.png")), - Q_ARG(QString, ":/icons/email.png")); - - if (!ok || page == nullptr) { - LOG_ERROR("create new email tab page failed"); - return false; - } - - // Bytes, not text: the page records which line endings the message arrived - // with, and a PGP/MIME signature covers the exact octets. Going through - // setPlainText would destroy every signature before anything could check - // one. - if (!QMetaObject::invokeMethod(page, "SetContentFromBytes", - Qt::DirectConnection, - Q_ARG(QByteArray, raw))) { - LOG_ERROR("host does not support SetContentFromBytes"); - return false; - } - return true; + // Bytes, as a Blob: the Host records which line endings the message + // arrived with, and a PGP/MIME signature covers the exact octets. + return Commands() + .Invoke<gf::cmd::host::DocumentOpen>( + {QStringLiteral("email"), title, QString(), + gf::cmd::MakeBlob(raw.constData(), static_cast<size_t>(raw.size())), + false, false}) + .Ok(); } /// A tab title for an imported message: its subject, or a neutral fallback. @@ -667,121 +663,6 @@ auto TitleForImported(const QByteArray& raw) -> QString { } // namespace -auto OnMainwindowMenuMounted(const GFEvent& event) -> GFEventResult { - LOG_DEBUG("main window menu mounted event: processing"); - - if (!event.Has("main_window")) { - LOG_DEBUG("main window menu mounted event: no main_window found"); - return GFEventResult::Bad("no main_window found"); - } - - auto* main_window = GFUIObject<QMainWindow>(event.Str("main_window")); - if (!main_window) { - LOG_ERROR( - "main window menu mounted: main_window handle invalid or not " - "QMainWindow"); - return GFEventResult::Bad("main_window handle invalid or not QMainWindow"); - } - - if (!event.Has("import_key_menu")) { - LOG_DEBUG("main window menu mounted event: no import_key_menu found"); - return GFEventResult::Bad("no import_key_menu found"); - } - - // Importing over the network is a different kind of act from opening a new - // editor, so it belongs in Advanced rather than beside "Mail Editor" in the - // workspace menu. - auto* advance_menu = GFUIObject<QMenu>(event.Str("advance_menu")); - if (advance_menu == nullptr) { - LOG_ERROR("advance_menu handle invalid or not QMenu"); - } - - auto* workspace_menu = GFUIObject<QMenu>(event.Str("file_workspace_menu")); - if (!workspace_menu) { - LOG_ERROR( - "main window menu mounted: workspace_menu handle invalid or not " - "QMenu"); - return GFEventResult::Bad("workspace_menu handle invalid or not QMenu"); - } - - LOG_DEBUG("adding key server sync actions to import key menu"); - - auto* edit = GFUIObject<QWidget>("main_window_edit"); - if (!edit) { - LOG_ERROR( - "main window menu mounted: main_window_edit handle invalid or not " - "QWidget"); - return GFEventResult::Bad("main_window_edit handle invalid or not QWidget"); - } - - QMetaObject::invokeMethod( - QApplication::instance(), - [&, advance_menu]() -> void { - QWidget* parent = - qobject_cast<QWidget*>(static_cast<QObject*>(main_window)); - auto* action = new QAction( - QCoreApplication::translate("GTrC", "Mail Editor"), parent); - - action->setToolTip(QCoreApplication::translate( - "GTrC", "Open a new text editor for email.")); - action->setIcon(QIcon(":/icons/email.png")); - bool ok = - QObject::connect(action, &QAction::triggered, parent, [edit]() { - QMetaObject::invokeMethod( - edit, "SlotNewCustomTab", Qt::DirectConnection, - Q_ARG(QString, "email"), Q_ARG(QString, "untitled.eml"), - Q_ARG(QIcon, QIcon(":/icons/email.png")), - Q_ARG(QString, ":/icons/email.png")); - }); - - if (!ok) { - LOG_ERROR("connecting mail editor action failed"); - } - - workspace_menu->addAction(action); - - // Import from IMAP. Shown only when an account could actually be - // browsed: an entry that always fails teaches people to ignore it. - // Named and dressed like its neighbours in this menu ("Open Smart - // Card Controller", "Open Module Controller"): it opens a controller - // rather than prompting for anything, so it takes no ellipsis. - auto* import_action = new QAction( - QCoreApplication::translate("GTrC", "Open IMAP Controller"), - parent); - import_action->setIcon(QIcon(":/icons/receive_email.png")); - import_action->setToolTip( - QCoreApplication::translate("GTrC", "Open IMAP Controller Dialog")); - QObject::connect( - import_action, &QAction::triggered, parent, [parent]() { - if (!EMailImapController::HasUsableAccount()) { - QMessageBox::information( - parent, - QCoreApplication::translate("GTrC", "No mail account"), - QCoreApplication::translate( - "GTrC", - "Configure a mail account with IMAP enabled in " - "Settings first.")); - return; - } - - auto* controller = new EMailImapController(parent); - controller->setAttribute(Qt::WA_DeleteOnClose); - - // The narrow boundary: raw bytes in, a tab out. The controller - // never touches the tab widget itself. - QObject::connect(controller, - &EMailImapController::SignalMessageChosen, - parent, [](const QByteArray& raw) { - OpenRawAsEMailTab(raw, TitleForImported(raw)); - }); - controller->show(); - }); - if (advance_menu != nullptr) advance_menu->addAction(import_action); - }, - Qt::BlockingQueuedConnection); - return GFEventResult::Ok(); -} - namespace { /// What the status board should make of a verification. @@ -1830,274 +1711,87 @@ auto OnEditTabTypeEmailOpDecryptVerify(const GFEvent& event) -> GFEventResult { return GFEventResult::Deferred(); } -auto OnEditTabTypeEmailOpSaveFile(const GFEvent& event) -> GFEventResult { - if (event.Str("page").isEmpty()) return GFEventResult::Bad("page is empty"); - - auto* page = GFUIObject<QWidget>(event.Str("page")); - if (!page) { - LOG_ERROR("page handler is not a QWidget"); - return GFEventResult::Bad("page handle invalid or not QMainWindow"); - } - - auto* tab_widget = GFUIObject<QTabWidget>(event.Str("tab_widget")); - if (!tab_widget) { - LOG_ERROR("tab widget handler is not a QTabWidget"); - return GFEventResult::Bad("main_window handle invalid or not QMainWindow"); - } - - QString filename; - - auto ok = QMetaObject::invokeMethod(page, "GetFilePath", - Qt::BlockingQueuedConnection, - Q_RETURN_ARG(QString, filename)); - - if (!ok) { - LOG_ERROR("invoke GetFilePath failed"); - return GFEventResult::Bad("invoke GetFilePath failed"); - } - - if (filename.isEmpty()) { - auto ok = QMetaObject::invokeMethod( - QCoreApplication::instance(), - [&]() -> void { - // Named after the message rather than left blank. The view is - // the only thing that knows the subject, and it is reached the - // same way the rest of this module reaches it. - QString suggested; - if (auto* view = page->findChild<EMailPageView*>(); view != nullptr) { - suggested = view->SuggestedFileName(); - } - if (suggested.isEmpty()) suggested = QStringLiteral("untitled.eml"); - - filename = QFileDialog::getSaveFileName( - page, QApplication::translate("EMailModule", "Save file"), - QDir(default_save_dir()).filePath(suggested), - QApplication::translate("EMailModule", - "E-Mail Message (*.eml);;All Files " - "(*)")); - }, - Qt::BlockingQueuedConnection); - - if (!ok) { - LOG_ERROR("invoke getSaveFileName failed"); - return GFEventResult::Bad("invoke getSaveFileName failed"); - } - } - - if (filename.isEmpty()) { - LOG_INFO("user cancelled to select file to save"); - return GFEventResult::Ok(); - } - - QFileInfo file_info(filename); - if (file_info.suffix().toLower() != "eml") { - file_info.setFile(file_info.path(), file_info.completeBaseName() + ".eml"); - filename = file_info.absoluteFilePath(); - FLOG_DEBUG("append .eml suffix to filename: %1", filename); - } - - QPlainTextEdit* text_edit = nullptr; - ok = QMetaObject::invokeMethod(page, "GetTextPage", - Qt::BlockingQueuedConnection, - Q_RETURN_ARG(QPlainTextEdit*, text_edit)); - if (!ok || text_edit == nullptr) { - LOG_ERROR("invoke GetTextPage failed"); - return GFEventResult::Bad("invoke GetTextPage failed"); - } - - // Reading a QTextDocument is a GUI-thread operation, and the user may - // be typing into this one. Only the read hops over; everything done to - // the text afterwards is ordinary work on a copy. - QString text; - RunOnGui([&]() { text = text_edit->toPlainText(); }); - - // Normalize to LF first: the editor may already hold CRLF, and blindly - // expanding every "\n" then turns each of those into CRCRLF. - text.replace("\r\n", "\n"); - text.replace("\n", "\r\n"); - - const auto bytes = text.toUtf8(); - - // Last look before the bytes leave. Deliberately after the content is - // assembled and before anything is written, so what is checked is - // exactly what would be saved. - // - // The inspection runs HERE, on the module thread: it parses and walks a - // message of whatever size the user has composed, and that is not work - // to hand the GUI thread. Only the question that follows is a dialog. - const auto check = CheckBeforeExport(bytes); - if (!ConfirmExport(page, check)) { - LOG_INFO("user cancelled the save after the export check"); - return GFEventResult::Ok(); - } - - // Written binary and through QSaveFile: QIODevice::Text would translate - // the line endings a second time on Windows, and a plain QFile leaves a - // truncated .eml behind if the write fails halfway. - QSaveFile file(filename); - if (!file.open(QIODevice::WriteOnly)) { - WarnOnGui(page, QApplication::translate("EMailModule", - "Cannot write file %1:\n%2.") - .arg(filename) - .arg(file.errorString())); - return GFEventResult::Bad("cannot open file for writing"); - } - - RunOnGui([]() { QApplication::setOverrideCursor(Qt::WaitCursor); }); - const bool written = file.write(bytes) == bytes.size() && file.commit(); - RunOnGui([]() { QApplication::restoreOverrideCursor(); }); - - if (!written) { - WarnOnGui(page, QApplication::translate("EMailModule", - "Cannot write file %1:\n%2.") - .arg(filename) - .arg(file.errorString())); - return GFEventResult::Bad("writing file failed"); - } +namespace { - // The document's modified flag and the tab's label are widget state, and - // the two belong together: a tab renamed without its flag cleared, or the - // reverse, is a half-saved file as far as the user can see. - const auto shown = QFileInfo(filename).fileName(); - RunOnGui([&]() { - text_edit->document()->setModified(false); - tab_widget->setTabText(tab_widget->currentIndex(), shown); - }); +/// File > Workspace > Mail Editor: an empty message, in a tab of its own. +struct NewMessage { + static constexpr gf::cmd::Meta kMeta{ + GF_MODULE_ID ".new_message", GC_TR("Mail Editor"), + GC_TR("Open a new text editor for email."), "", 0, + gf::cmd::kNeedsGuiThread}; + using Args = gf::cmd::Unit; + using Result = gf::cmd::Unit; +}; - QMetaObject::invokeMethod(page, "SetFilePath", Qt::BlockingQueuedConnection, - Q_ARG(QString, filename)); - QMetaObject::invokeMethod(page, "NotifyFileSaved", - Qt::BlockingQueuedConnection); - return GFEventResult::Deferred(); +auto DoNewMessage(const gf::cmd::CommandContext& /*ctx*/, + const gf::cmd::Unit& /*args*/) + -> gf::cmd::Outcome<gf::cmd::Unit> { + Commands().Invoke<gf::cmd::host::DocumentNew>( + {QStringLiteral("email"), QStringLiteral("untitled.eml")}); + return gf::cmd::Outcome<gf::cmd::Unit>::Success({}); } -auto OnFileExtEmailOpOpenFile(const GFEvent& event) -> GFEventResult { - if (event.Str("file_path").isEmpty()) - return GFEventResult::Bad("file_path is empty"); - - auto file_path = event.Str("file_path"); - - // Read HERE, on the module thread, and through the one helper that knows - // the guards. This is file IO, which is precisely the work not to hand - // the GUI thread -- and the kind of file has to be settled before the - // read, because a FIFO, a device node or a /proc entry reports a size of - // 0 and would then block forever or grow without bound inside it. - QByteArray raw; - QString read_error; - qint64 file_size = 0; - const auto admission = - ReadFileWithin(file_path, kMaxEMLFileSize, raw, read_error, file_size); - - if (admission == EMailFileAdmission::kNOT_REGULAR) { - WarnOnGui(nullptr, - QApplication::translate( - "EMailModule", - "%1 is not an ordinary file, so it cannot be opened as a " - "message.") - .arg(file_path)); - return GFEventResult::Bad("not a regular file"); - } - - if (admission == EMailFileAdmission::kTOO_LARGE) { - WarnOnGui(nullptr, - QApplication::translate( - "EMailModule", - "The file %1 is too large (%2) to be opened. The maximum " - "allowed size is %3.") - .arg(file_path) - .arg(QLocale().formattedDataSize(file_size)) - .arg(QLocale().formattedDataSize(kMaxEMLFileSize))); - return GFEventResult::Bad("file too large"); - } - - if (admission != EMailFileAdmission::kOK) { - WarnOnGui(nullptr, QApplication::translate("EMailModule", - "Cannot read file %1:\n%2.") - .arg(file_path) - .arg(read_error)); - return GFEventResult::Bad("cannot read file"); - } +/// Advanced > Open IMAP Controller. +struct OpenImapController { + static constexpr gf::cmd::Meta kMeta{ + GF_MODULE_ID ".open_imap_controller", GC_TR("Open IMAP Controller"), + GC_TR("Open IMAP Controller Dialog"), "", 0, gf::cmd::kNeedsGuiThread}; + using Args = gf::cmd::Unit; + using Result = gf::cmd::Unit; +}; - auto* edit = GFUIObject<QWidget>("main_window_edit"); - if (!edit) { - LOG_ERROR( - "main window menu mounted: main_window_edit " - "handle invalid or not " - "QWidget"); - return GFEventResult::Bad( - "main_window_edit handle invalid or not " - "QWidget"); +auto DoOpenImapController(const gf::cmd::CommandContext& /*ctx*/, + const gf::cmd::Unit& /*args*/) + -> gf::cmd::Outcome<gf::cmd::Unit> { + // An entry that always fails teaches people to ignore it, so an account + // that cannot be browsed gets told why instead of an empty controller. + if (!EMailImapController::HasUsableAccount()) { + Commands().Invoke<gf::cmd::host::AppMessage>( + {gf::cmd::host::AppMessage::Severity::kInfo, + QCoreApplication::translate("GTrC", "No mail account"), + QCoreApplication::translate( + "GTrC", + "Configure a mail account with IMAP enabled in Settings first.")}); + return gf::cmd::Outcome<gf::cmd::Unit>::Success({}); } - - // Only the tab is built on the GUI thread now: the bytes are already in - // hand, so nothing here reads a file or blocks on one. - QMetaObject::invokeMethod(QCoreApplication::instance(), [=]() -> void { - QWidget* page = nullptr; - - auto ok = QMetaObject::invokeMethod( - edit, "SlotNewCustomTab", Qt::DirectConnection, - Q_RETURN_ARG(QWidget*, page), Q_ARG(QString, "email"), - Q_ARG(QString, QFileInfo(file_path).fileName()), - Q_ARG(QIcon, QIcon(":/icons/email.png")), - Q_ARG(QString, ":/icons/email.png")); - - if (!ok || !page) { - LOG_ERROR("create new email tab page failed"); - event.Answer().Fail("create new email tab page failed"); - return; - } - - QPlainTextEdit* text_edit = nullptr; - ok = QMetaObject::invokeMethod(page, "GetTextPage", Qt::DirectConnection, - Q_RETURN_ARG(QPlainTextEdit*, text_edit)); - if (!ok || text_edit == nullptr) { - LOG_ERROR("invoke GetTextPage failed"); - event.Answer().Fail("invoke GetTextPage failed"); - return; - } - - // Handed over as bytes so the page can record which line endings the - // message arrived with and reproduce them when it is read back or - // saved. Falls back to the old path on a host that does not offer - // this, where the endings are lost exactly as they were before. - if (!QMetaObject::invokeMethod(page, "SetContentFromBytes", - Qt::DirectConnection, - Q_ARG(QByteArray, raw))) { - text_edit->setPlainText(QString::fromUtf8(raw)); - text_edit->document()->setModified(false); - } - - QMetaObject::invokeMethod(page, "SetFilePath", Qt::DirectConnection, - Q_ARG(QString, file_path)); - }); - return GFEventResult::Deferred(); + Commands().Invoke<gf::cmd::host::ViewOpen>( + {gf::cmd::ViewRef{QStringLiteral(GF_MODULE_ID ".imap")}}); + return gf::cmd::Outcome<gf::cmd::Unit>::Success({}); } +} // namespace + // The module's whole framework surface. -constexpr GFEventBinding kEvents[] = { +// The crypto operations stay events: the Host hands the module an e-mail +// document's bytes and takes the result back, which is data, not UI. +constexpr std::array<GFEventBinding, 7> kEvents = {{ {"EDIT_TAB_TYPE_EMAIL_OP_DECRYPT", &OnEditTabTypeEmailOpDecrypt}, {"EDIT_TAB_TYPE_EMAIL_OP_DECRYPT_VERIFY", &OnEditTabTypeEmailOpDecryptVerify}, {"EDIT_TAB_TYPE_EMAIL_OP_ENCRYPT", &OnEditTabTypeEmailOpEncrypt}, {"EDIT_TAB_TYPE_EMAIL_OP_ENCRYPT_SIGN", &OnEditTabTypeEmailOpEncryptSign}, - {"EDIT_TAB_TYPE_EMAIL_OP_SAVE_FILE", &OnEditTabTypeEmailOpSaveFile}, {"EDIT_TAB_TYPE_EMAIL_OP_SIGN", &OnEditTabTypeEmailOpSign}, {"EDIT_TAB_TYPE_EMAIL_OP_VERIFY", &OnEditTabTypeEmailOpVerify}, - {"FILE_EXT_EMAIL_OP_OPEN_FILE", &OnFileExtEmailOpOpenFile}, {"KEY_DATABASE_REFRESH_DONE", &OnKeyDatabaseRefreshDone}, - {"MAINWINDOW_MENU_MOUNTED", &OnMainwindowMenuMounted}, +}}; + +const std::array<gf::cmd::Binding, 2> kCommands = { + gf::cmd::Bind<NewMessage, &DoNewMessage>(), + gf::cmd::Bind<OpenImapController, &DoOpenImapController>(), }; -constexpr GFModuleHooks kHooks = { +const GFModuleHooks kHooks = { sizeof(GFModuleHooks), GF_MODULE_ID, GF_MODULE_VERSION, GF_MODULE_TRANSLATION_CONTEXT, &OnActivate, - &OnDeactivate, + nullptr, // the Host withdraws the commands, widgets and script itself &OnUnload, - kEvents, - std::size(kEvents), + kEvents.data(), + kEvents.size(), + kCommands.data(), + kCommands.size(), }; extern "C" GF_MODULE_EXPORT auto GFModuleGetApi(uint32_t abi) diff --git a/src/m_email/EMailModule.h b/src/m_email/EMailModule.h index a8bd687..fc7dc8a 100644 --- a/src/m_email/EMailModule.h +++ b/src/m_email/EMailModule.h @@ -29,3 +29,11 @@ #pragma once #include "GFModule.h" +#include "EMailHelper.h" + +class QWidget; + +/// The pre-save look at a message, asked of the user when there is +/// something worth saying. false: they chose not to write the file. +auto EMailConfirmExport(QWidget* parent, const EMailExportCheck& check) + -> bool; diff --git a/src/m_email/EMailPageView.cpp b/src/m_email/EMailPageView.cpp index 9330763..8351b01 100644 --- a/src/m_email/EMailPageView.cpp +++ b/src/m_email/EMailPageView.cpp @@ -28,6 +28,10 @@ #include "EMailPageView.h" +#include <GFSDKHostCommands.hpp> + +#include "EMailModule.h" + #include <GFSDKGpg.h> #include <GFSDKGpgList.h> @@ -300,7 +304,42 @@ auto GuessMimeType(const QString& path) -> QString { } // namespace +namespace { + +/// Every message view alive, touched only on the GUI thread. The keyring +/// notice goes to these and to nothing else in the process. A list rather +/// than a set: a QPointer turns null as its object dies, which would change +/// its hash while it sat in a set. +auto LiveViews() -> QList<QPointer<EMailPageView>>& { + static QList<QPointer<EMailPageView>> views; + return views; +} + +} // namespace + EMailPageView::EMailPageView(QWidget* parent) : QWidget(parent) { + LiveViews().removeAll(QPointer<EMailPageView>()); + LiveViews().append(this); + + // The typed interface's notifiers, fed from the signals this view has + // always emitted internally. + connect(this, &EMailPageView::SignalContentModified, this, + [this]() { NotifyModified(); }); + connect(this, &EMailPageView::SignalCryptoOperationsChanged, this, + [this]() { NotifyOpsChanged(); }); + connect(this, &EMailPageView::SignalCryptoOperationRequested, this, + [this](const QString& op) { + static const QHash<QString, uint32_t> kOps = { + {"encrypt", GF_CRYPTO_OP_ENCRYPT}, + {"decrypt", GF_CRYPTO_OP_DECRYPT}, + {"sign", GF_CRYPTO_OP_SIGN}, + {"verify", GF_CRYPTO_OP_VERIFY}, + {"encrypt_sign", GF_CRYPTO_OP_ENCRYPT_SIGN}, + {"decrypt_verify", GF_CRYPTO_OP_DECRYPT_VERIFY}, + }; + if (kOps.contains(op)) RequestCrypto(kOps.value(op)); + }); + build_ui(); // Files can be dropped anywhere on the message to attach them, which is // what people try before they look for a button. @@ -416,19 +455,13 @@ void EMailPageView::build_details_dialog() { // Restored before the window is ever shown, so it opens where it was left // rather than jumping into place afterwards. - auto* settings = EMailViewSettings(); - if (settings == nullptr) { - details_dialog_->resize(kEMailDetailsDefaultWidth, - kEMailDetailsDefaultHeight); - return; - } - - const auto size = - EMailClampDetailsSize(settings->value(kEMailDetailsWidthKey, 0).toInt(), - settings->value(kEMailDetailsHeightKey, 0).toInt()); + const auto size = EMailClampDetailsSize( + EMailViewSetting(kEMailDetailsWidthKey, 0).toInt(), + EMailViewSetting(kEMailDetailsHeightKey, 0).toInt()); details_dialog_->resize(size.first, size.second); details_tabs_->setCurrentIndex(EMailClampDetailsTab( - settings->value(kEMailDetailsTabKey, 0).toInt(), details_tabs_->count())); + EMailViewSetting(kEMailDetailsTabKey, 0).toInt(), + details_tabs_->count())); } void EMailPageView::apply_colors() { @@ -1026,13 +1059,11 @@ void EMailPageView::schedule_persist() { } void EMailPageView::persist_details_state() { - auto* settings = EMailViewSettings(); - if (settings == nullptr || details_dialog_ == nullptr) return; + if (details_dialog_ == nullptr) return; - settings->setValue(kEMailDetailsWidthKey, details_dialog_->width()); - settings->setValue(kEMailDetailsHeightKey, details_dialog_->height()); - settings->setValue(kEMailDetailsTabKey, details_tabs_->currentIndex()); - settings->sync(); + SetEMailViewSetting(kEMailDetailsWidthKey, details_dialog_->width()); + SetEMailViewSetting(kEMailDetailsHeightKey, details_dialog_->height()); + SetEMailViewSetting(kEMailDetailsTabKey, details_tabs_->currentIndex()); } void EMailPageView::set_source_mode(bool on) { @@ -1423,62 +1454,6 @@ void EMailPageView::refresh_structure() { header_view_->SetMessage(tree_root_, last_source_); } -void EMailPageView::AdoptSourceView(QWidget* source) { - if (source == nullptr || source_view_ != nullptr) return; - - source_view_ = source; - - // The editor is not shown bare: it gets a row above it saying whether it may - // be written to, and the control that changes that. - raw_tab_ = new QWidget(this); - auto* layout = new QVBoxLayout(raw_tab_); - layout->setContentsMargins(0, 4, 0, 0); - layout->setSpacing(4); - - auto* bar = new QHBoxLayout(); - bar->setContentsMargins(0, 0, 0, 0); - - raw_notice_ = new QLabel(raw_tab_); - raw_notice_->setWordWrap(true); - { - auto font = raw_notice_->font(); - font.setPointSizeF(font.pointSizeF() * 0.92); - raw_notice_->setFont(font); - } - bar->addWidget(raw_notice_, 1); - - raw_unlock_button_ = new QToolButton(raw_tab_); - raw_unlock_button_->setCheckable(true); - raw_unlock_button_->setAutoRaise(true); - raw_unlock_button_->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); - bar->addWidget(raw_unlock_button_); - - layout->addLayout(bar); - layout->addWidget(source, 1); - - connect(raw_unlock_button_, &QToolButton::toggled, this, - &EMailPageView::slot_toggle_raw_edit); - - // A page of the body stack rather than a place of its own, so the action row - // above it -- and with it the switcher back -- stays on screen while the raw - // document is being read. Appended, so the indices the rest of this file - // uses for the editor, the rendered body and the locked panel do not move. - body_stack_->addWidget(raw_tab_); - - // There is a second mode now, so the switch to it becomes reachable. - view_switcher_->setVisible(true); - - // Read-only by default, and not only in forensic mode: these are the exact - // octets a signature covers, and an accidental keystroke here is - // indistinguishable from a forgery to whoever verifies the message next. - // Set through the property system because the page hands over a plain - // QWidget; an editor that does not carry the property simply does not gain a - // way to be written to. - raw_unlocked_ = false; - source_view_->setProperty("readOnly", true); - refresh_raw_lock_ui(); -} - void EMailPageView::slot_toggle_raw_edit(bool on) { if (!on) { raw_unlocked_ = false; @@ -1561,15 +1536,17 @@ void EMailPageView::ApplyEditorFont(const QFont& font) { if (body_view_ != nullptr) body_view_->setFont(font); } + void EMailNotifyKeyringChanged() { // The keyring is refreshed on a worker thread, so this can arrive from one; // everything below touches widgets and must not. QMetaObject::invokeMethod( qApp, []() { - for (auto* widget : QApplication::allWidgets()) { - auto* view = qobject_cast<EMailPageView*>(widget); - if (view != nullptr) view->NotifyKeyringChanged(); + auto& views = LiveViews(); + views.removeAll(QPointer<EMailPageView>()); + for (const auto& view : views) { + if (!view.isNull()) view->NotifyKeyringChanged(); } }, Qt::QueuedConnection); @@ -1748,25 +1725,6 @@ void EMailPageView::slot_derive_message(int mode) { // action on a message they care about not happen. const auto intact = tr("\n\nThis message itself has not been changed."); - // Walk up to the tab widget that owns this page. The module cannot link the - // UI library, so the host is reached by invokable name rather than by type. - QObject* edit = parent(); - while (edit != nullptr && - edit->metaObject()->indexOfMethod( - "SlotNewCustomTab(QString,QString,QIcon,QString)") < 0) { - edit = edit->parent(); - } - - if (edit == nullptr) { - MLogWarn("cannot derive a message: no tab host above this view"); - QMessageBox::warning( - this, what, - tr("A new message cannot be opened from here, because this view is " - "not inside a window that holds tabs.") + - intact); - return; - } - // Whichever of our own addresses the message was sent to; used to keep the // user out of their own reply-all. QString self; @@ -1802,37 +1760,23 @@ void EMailPageView::slot_derive_message(int mode) { auto title = TabTitleForSubject(derived.subject); if (title.isEmpty()) title = tr("untitled.eml"); - QWidget* page = nullptr; - QMetaObject::invokeMethod(edit, "SlotNewCustomTab", Qt::DirectConnection, - Q_RETURN_ARG(QWidget*, page), - Q_ARG(QString, "email"), Q_ARG(QString, title), - Q_ARG(QIcon, QIcon(":/icons/email.png")), - Q_ARG(QString, ":/icons/email.png")); - - if (page == nullptr) { - MLogWarn("the host did not create a tab for the derived message"); - QMessageBox::warning( - this, what, - tr("A tab for the new message could not be opened.") + intact); - return; - } - - // The new tab mounts a view of this same type, so it can be addressed - // directly rather than through the host's document. - auto* view = page->findChild<EMailPageView*>(); - if (view == nullptr) { - MLogWarn("the new tab has no message view to fill"); - QMessageBox::warning( - this, what, - tr("The new tab opened, but it is not showing a message view, so " - "there is nowhere to put the reply. The tab can be closed.") + - intact); - return; - } - - view->LoadFromSource(eml); - // It is a draft the user has not saved, and the tab should say so. - view->mark_dirty(); + // The Host opens the tab; a document of type "email" gets a view of this + // same kind, which loads the message. It is a draft nobody has saved, and + // the tab says so. + QPointer<EMailPageView> guard(this); + Commands().Invoke<gf::cmd::host::DocumentOpen>( + {QStringLiteral("email"), title, QString(), + gf::cmd::MakeBlob(eml.constData(), static_cast<size_t>(eml.size())), + false, true}, + this, [guard, what, intact](const auto& r) { + if (r.Ok() || guard.isNull()) return; + MLogWarn("the host did not open a tab for the derived message"); + QMessageBox::warning( + guard, what, + EMailPageView::tr("A tab for the new message could not be " + "opened.") + + intact); + }); } auto EMailPageView::build_locked_panel() -> QWidget* { @@ -2077,7 +2021,7 @@ void EMailPageView::refresh_locked_capability_by_address( : tr("You hold private keys for %1.").arg(matched.join(", "))); } -auto EMailPageView::AttachPublicKey(const QByteArray& key_data, +auto EMailPageView::AttachPublicKeyToMessage(const QByteArray& key_data, const QString& suggested_name) -> int { if (key_data.isEmpty()) return kEMAIL_ADD_NOT_HANDLED; @@ -2950,7 +2894,7 @@ void EMailPageView::report_attachment_status(const QString& note) { QTimer::singleShot(kStatusNoteMs, this, [this]() { refresh_attachments(); }); } -auto EMailPageView::SuggestedFileName() -> QString { +auto EMailPageView::SuggestedFileName() const -> QString { // The subject as it is on screen, not as it was loaded: a draft being // written has a subject in the field and nothing in message_ until something // serializes it, and the name offered should follow what the user typed. @@ -3029,7 +2973,7 @@ void EMailPageView::slot_send_message() { dialog->show(); } -auto EMailPageView::IsDirty() -> bool { return dirty_; } +auto EMailPageView::IsDirty() const -> bool { return dirty_; } auto EMailPageView::BuildOutgoing(EMailOutgoingMessage& out) -> bool { collect_fields(); @@ -3335,8 +3279,7 @@ void EMailPageView::import_message_keys() { // user already asked for would be a dialog per part. for (const auto* part : keys) { gf::sdk::ImportKeys(GFModuleSdkContext(), - GFGpgCurrentChannel(GFModuleSdkContext()), this, - part->data); + GFGpgCurrentChannel(GFModuleSdkContext()), part->data); } // The keyring is what the Security tab was reporting against, so it has to @@ -3351,8 +3294,7 @@ void EMailPageView::import_attachment_key(const EMailAttachment& att) { // came of it: this module has no business inventing a second report of the // same operation. gf::sdk::ImportKeys(GFModuleSdkContext(), - GFGpgCurrentChannel(GFModuleSdkContext()), this, - att.data); + GFGpgCurrentChannel(GFModuleSdkContext()), att.data); } void EMailPageView::open_attachment(QTreeWidgetItem* item) { @@ -3588,3 +3530,78 @@ void EMailPageView::save_attachments(const QList<EMailAttachment>& chosen) { ? tr("Saved 1 file.") : tr("Saved %1 files.").arg(written.size())); } + +// --- gf::ui::DocumentWidget ------------------------------------------------ + +void EMailPageView::Load(const QByteArray& bytes) { LoadFromSource(bytes); } + +auto EMailPageView::Save() -> std::optional<QByteArray> { + return SaveToSource(); +} + +auto EMailPageView::CryptoOperations() const -> std::optional<uint32_t> { + static const QHash<QString, uint32_t> kOps = { + {"encrypt", GF_CRYPTO_OP_ENCRYPT}, + {"decrypt", GF_CRYPTO_OP_DECRYPT}, + {"sign", GF_CRYPTO_OP_SIGN}, + {"verify", GF_CRYPTO_OP_VERIFY}, + {"encrypt_sign", GF_CRYPTO_OP_ENCRYPT_SIGN}, + {"decrypt_verify", GF_CRYPTO_OP_DECRYPT_VERIFY}, + }; + uint32_t bits = 0; + // Only reads what the message is; not const merely by history. + for (const auto& op : + const_cast<EMailPageView*>(this)->AvailableCryptoOperations()) { + bits |= kOps.value(op, 0); + } + return bits; +} + +void EMailPageView::ApplyVerification(const QByteArray& json) { + ApplyVerificationResult(json); +} + +auto EMailPageView::AppendText(const QString& text) -> bool { + return AppendBodyText(text) != kEMAIL_ADD_NOT_HANDLED; +} + +auto EMailPageView::AttachPublicKey(const QByteArray& key, + const QString& name) -> bool { + return AttachPublicKeyToMessage(key, name) != kEMAIL_ADD_NOT_HANDLED; +} + +void EMailPageView::ApplyFont(const QFont& font) { ApplyEditorFont(font); } + +auto EMailPageView::PrepareSave(const QByteArray& bytes) -> SaveDecision { + SaveDecision decision; + + // A message is CRLF on disk. Normalised to LF first: the document may + // already hold CRLF, and expanding every "\n" then makes CRCRLF. + auto text = bytes; + text.replace("\r\n", "\n"); + text.replace("\n", "\r\n"); + + // The last look before the bytes leave, at exactly what would be written. + const auto check = CheckBeforeExport(text); + if (!EMailConfirmExport(this, check)) { + decision.cancelled = true; + return decision; + } + decision.bytes = text; + return decision; +} + +auto EMailPageView::SourceLockReason() const -> std::optional<QString> { + switch (content_lock()) { + case EMailLockReason::kFORENSIC: + return tr("This message is locked for inspection. Its source cannot be " + "edited."); + case EMailLockReason::kPROTECTED: + return tr("These bytes are covered by a signature, or are ciphertext. " + "Remove the signature, or decrypt the message, before editing " + "its source."); + case EMailLockReason::kNONE: + break; + } + return std::nullopt; +} diff --git a/src/m_email/EMailPageView.h b/src/m_email/EMailPageView.h index 34f47a4..94500ae 100644 --- a/src/m_email/EMailPageView.h +++ b/src/m_email/EMailPageView.h @@ -28,6 +28,8 @@ #pragma once +#include <GFModule.h> + #include <QHash> #include <QStringList> #include <QTemporaryDir> @@ -104,7 +106,7 @@ enum EMailAddContentResult : int { kEMAIL_ADD_REFUSED = 2, }; -class EMailPageView : public QWidget { +class EMailPageView : public QWidget, public gf::ui::DocumentWidget { Q_OBJECT public: @@ -144,19 +146,6 @@ class EMailPageView : public QWidget { */ void ApplyVerificationResult(const QByteArray& payload); // NOLINT - /** - * @brief Takes the host's raw document editor and presents it as a mode. - * - * Optional half of the page/view contract: a page that finds this member - * hands over its own editor instead of putting a Message / Raw Source - * switcher above this widget, so the switch lives inside the message rather - * than in a strip above it. - * - * The widget passed in is the real editor over the real document, not a - * copy. Editing the raw source therefore keeps working, and the document - * stays the one canonical content of the tab. - */ - void AdoptSourceView(QWidget* source); // NOLINT /** * @brief Uses the editor font the application was configured with. @@ -183,8 +172,8 @@ class EMailPageView : public QWidget { * * @return one of EMailAddContentResult */ - int AttachPublicKey(const QByteArray& key_data, // NOLINT - const QString& suggested_name); + int AttachPublicKeyToMessage(const QByteArray& key_data, // NOLINT + const QString& suggested_name); /** * @brief Appends text to the message the view is presenting. @@ -217,7 +206,7 @@ class EMailPageView : public QWidget { * only this view knows the message's subject, which is the thing a message * actually calls itself. Already safe to use as a single path component. */ - QString SuggestedFileName(); // NOLINT + QString SuggestedFileName() const override; // NOLINT /// Optional half of the page/view contract: the save dialog's filter. QString FileTypeFilter(); // NOLINT @@ -251,7 +240,7 @@ class EMailPageView : public QWidget { /** * @brief Whether the user has edited the view since it was last loaded. */ - bool IsDirty(); // NOLINT + bool IsDirty() const override; // NOLINT /** * @brief The sendable, frozen form of this message. @@ -277,7 +266,22 @@ class EMailPageView : public QWidget { * Covers the attachment buffers as well as the body: a decrypted attachment * living on in a QByteArray would defeat the host's wipe-on-close. */ - void WipeContent(); // NOLINT + void WipeContent() override; // NOLINT + + public: + // --- gf::ui::DocumentWidget: what the Host asks of a document view --- + + void Load(const QByteArray& bytes) override; + std::optional<QByteArray> Save() override; + std::optional<uint32_t> CryptoOperations() const override; + void ApplyVerification(const QByteArray& json) override; + bool AppendText(const QString& text) override; + bool AttachPublicKey(const QByteArray& key, const QString& name) override; + void ApplyFont(const QFont& font) override; + /// CRLF line endings, and a last look before the message leaves. + SaveDecision PrepareSave(const QByteArray& bytes) override; + /// Signed bytes and ciphertext are not edited by accident. + std::optional<QString> SourceLockReason() const override; signals: /** diff --git a/src/m_email/EMailStructureView.cpp b/src/m_email/EMailStructureView.cpp index d470556..283c88e 100644 --- a/src/m_email/EMailStructureView.cpp +++ b/src/m_email/EMailStructureView.cpp @@ -191,8 +191,7 @@ void EMailStructureView::show_part_menu(const QPoint& pos) { } else if (import != nullptr && chosen == import) { // The host owns the import and whatever it reports about the outcome. gf::sdk::ImportKeys(GFModuleSdkContext(), - GFGpgCurrentChannel(GFModuleSdkContext()), this, - part->data); + GFGpgCurrentChannel(GFModuleSdkContext()), part->data); } else if (chosen == copy_type) { clipboard->setText(part->content_type); } else if (chosen == copy_name) { diff --git a/src/m_email/EMailViewLayout.h b/src/m_email/EMailViewLayout.h index 86ac74d..3dfde88 100644 --- a/src/m_email/EMailViewLayout.h +++ b/src/m_email/EMailViewLayout.h @@ -58,9 +58,9 @@ constexpr int kEMailCompactActionsWidth = 640; constexpr int kEMailRoomyActionsWidth = 700; /// The settings keys the details window is remembered under. -constexpr auto kEMailDetailsWidthKey = "email/view/details_width"; -constexpr auto kEMailDetailsHeightKey = "email/view/details_height"; -constexpr auto kEMailDetailsTabKey = "email/view/details_tab"; +constexpr auto kEMailDetailsWidthKey = "view/details_width"; +constexpr auto kEMailDetailsHeightKey = "view/details_height"; +constexpr auto kEMailDetailsTabKey = "view/details_tab"; /** * @brief What size the details window may actually open at. diff --git a/src/m_email/EMailViewStyle.h b/src/m_email/EMailViewStyle.h index 3968b98..63120eb 100644 --- a/src/m_email/EMailViewStyle.h +++ b/src/m_email/EMailViewStyle.h @@ -62,7 +62,9 @@ /// One colour, by role. Five host entry points became one taking a role, so /// the function pointer this used to take is now an int. inline auto EMailThemeColor(QWidget* w, int role) -> QColor { - const auto rgba = GFUIThemeColor(GFModuleSdkContext(), role, w); + // From the application palette, by role: this module's widgets carry the + // same palette, and no widget has to cross to the Host to ask. + const auto rgba = GFUIThemeColorForRole(GFModuleSdkContext(), role); return rgba == 0 ? w->palette().color(QPalette::WindowText) : QColor::fromRgba(rgba); } @@ -331,16 +333,16 @@ inline auto EMailTintedBanner(QWidget* parent, const QColor& tint, return banner; } -/** - * @brief The settings the host keeps for the whole application. - * - * Same object the account store writes to, reached the same way. Null when the - * host is not there to ask, which every caller has to be able to live with: - * nothing here is worth refusing to build a widget over. - */ -inline auto EMailViewSettings() -> QSettings* { - return qobject_cast<QSettings*>( - static_cast<QObject*>(GFStorageSettingsRoot(GFModuleSdkContext()))); +/// One of this module's own settings -- the Host keeps them in the module's +/// group -- or @p fallback when it was never stored. +inline auto EMailViewSetting(const char* key, const QVariant& fallback) + -> QVariant { + return gf::sdk::Setting(GFModuleSdkContext(), GF_SETTING_MODULE, key, + fallback); +} + +inline void SetEMailViewSetting(const char* key, const QVariant& value) { + gf::sdk::SetSetting(GFModuleSdkContext(), GF_SETTING_MODULE, key, value); } /** diff --git a/src/m_email/module.json b/src/m_email/module.json index a0079e8..8dc63c3 100644 --- a/src/m_email/module.json +++ b/src/m_email/module.json @@ -17,12 +17,13 @@ "EDIT_TAB_TYPE_EMAIL_OP_DECRYPT_VERIFY", "EDIT_TAB_TYPE_EMAIL_OP_ENCRYPT", "EDIT_TAB_TYPE_EMAIL_OP_ENCRYPT_SIGN", - "EDIT_TAB_TYPE_EMAIL_OP_SAVE_FILE", "EDIT_TAB_TYPE_EMAIL_OP_SIGN", "EDIT_TAB_TYPE_EMAIL_OP_VERIFY", - "FILE_EXT_EMAIL_OP_OPEN_FILE", - "KEY_DATABASE_REFRESH_DONE", - "MAINWINDOW_MENU_MOUNTED" + "KEY_DATABASE_REFRESH_DONE" + ], + "commands": [ + "com.bktus.gpgfrontend.module.email.new_message", + "com.bktus.gpgfrontend.module.email.open_imap_controller" ], "translation_context": "ModuleEMail" } diff --git a/src/m_email/test/EMailSdkStubs.cpp b/src/m_email/test/EMailSdkStubs.cpp index 5b4e3ef..c6e4ca0 100644 --- a/src/m_email/test/EMailSdkStubs.cpp +++ b/src/m_email/test/EMailSdkStubs.cpp @@ -16,6 +16,7 @@ #include <GFSDKContext.h> #include <GFSDKHostApi.h> +#include <QCborValue> #include <QSettings> #include <QString> #include <cstdio> @@ -183,9 +184,14 @@ auto UiUnreg(GFHostContextRef, const char*) -> int { return 0; } auto UiRegTab(GFHostContextRef, const GFUITabViewSpec*) -> int { return 0; } auto UiRegExt(GFHostContextRef, const char*, const char*) -> int { return 0; } +auto ThemeColorRole(GFHostContextRef ctx, int role) -> uint32_t { + return ThemeColor(ctx, role, nullptr); +} + const GFHostUiApi kUi = { sizeof(GFHostUiApi), &UiCreate, &UiNull, &UiShow, &ThemeColor, &UiPath, &UiRegSettings, &UiUnreg, &UiRegTab, &UiUnreg, &UiRegExt, + &ThemeColorRole, }; /* --- storage -------------------------------------------------------------- */ @@ -198,6 +204,38 @@ auto SettingsRoot(GFHostContextRef) -> void* { return settings; } +/// Settings by key, kept in the same file the old settings root used, under +/// the group the Host would give this module. +auto SettingKey(const char* key) -> QString { + return QStringLiteral("email/") + QString::fromUtf8(key); +} + +auto SettingGet(GFHostContextRef ctx, int, const char* key, GFBufferRef* out) + -> int { + if (out != nullptr) *out = nullptr; + auto* settings = static_cast<QSettings*>(SettingsRoot(ctx)); + if (!settings->contains(SettingKey(key))) return -1; + const auto bytes = + QCborValue::fromVariant(settings->value(SettingKey(key))).toCbor(); + *out = BufNew(ctx, bytes.constData(), static_cast<size_t>(bytes.size())); + return 0; +} + +auto SettingSet(GFHostContextRef ctx, int, const char* key, GFBufferView cbor) + -> int { + auto* settings = static_cast<QSettings*>(SettingsRoot(ctx)); + const auto bytes = + QByteArray(static_cast<const char*>(BufData(ctx, cbor)), + static_cast<int>(BufSize(ctx, cbor))); + settings->setValue(SettingKey(key), QCborValue::fromCbor(bytes).toVariant()); + return 0; +} + +auto SettingRemove(GFHostContextRef ctx, int, const char* key) -> int { + static_cast<QSettings*>(SettingsRoot(ctx))->remove(SettingKey(key)); + return 0; +} + auto CacheGet(GFHostContextRef, int, const char*, GFBufferRef* out) -> int { if (out != nullptr) *out = nullptr; return -1; // nothing stored: absence is reportable now @@ -246,6 +284,9 @@ const GFHostStorageApi kStorage = { &StateGetBool, &StateSetBool, &StateChildren, + &SettingGet, + &SettingSet, + &SettingRemove, }; /* --- the table ------------------------------------------------------------ */ diff --git a/src/m_email/ui/main.lua b/src/m_email/ui/main.lua new file mode 100644 index 0000000..c298ea9 --- /dev/null +++ b/src/m_email/ui/main.lua @@ -0,0 +1,37 @@ +-- E-mail: where it is offered. +-- +-- The message view is the module's own widget, mounted for every document of +-- type "email" and for .eml files, which the Host opens and saves itself. +-- The settings page and the IMAP controller are the module's too. Titles +-- belong to the commands. + +local new_message = commands.get("com.bktus.gpgfrontend.module.email.new_message") +local open_imap = commands.get("com.bktus.gpgfrontend.module.email.open_imap_controller") + +ui.mount { + id = "editor", + anchor = ui.anchor.editor { document_type = "email", extensions = { "eml" } }, + widget = native.factory("editor"), +} + +ui.mount { + id = "settings", + anchor = ui.anchor.settings { section = "features" }, + widget = native.widget("settings"), +} + +ui.mount { id = "imap", anchor = ui.anchor.dialog {}, widget = native.widget("imap") } + +ui.action { + id = "new", + anchor = ui.anchor("main.menu.file.workspace"), + command = new_message, + icon = ":/icons/email.png", +} + +ui.action { + id = "imap", + anchor = ui.anchor("main.menu.advanced"), + command = open_imap, + icon = ":/icons/receive_email.png", +} |
