aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsaturneric <[email protected]>2026-09-23 23:19:37 +0200
committersaturneric <[email protected]>2026-09-23 23:19:37 +0200
commit720beacdad29d2056a88411e2a83447f4984af57 (patch)
tree5b98855af9fea9ba6d9c1d049b3631a604d93e51 /src
parentfix(key-server-sync): update key import calls (diff)
downloadmodules-720beacdad29d2056a88411e2a83447f4984af57.tar.gz
modules-720beacdad29d2056a88411e2a83447f4984af57.zip
feat(email): migrate UI to host command model
- register native document, settings, and IMAP widgets through the SDK - add Lua mounts and commands for email actions and navigation - move document opening, saving, and message creation to host commands - use module-scoped settings APIs for account and view state - implement the document widget interface for email message handling - update SDK stubs and module metadata for the new integrations
Diffstat (limited to 'src')
-rw-r--r--src/m_email/CMakeLists.txt1
-rw-r--r--src/m_email/EMailAccountSettingsPage.cpp14
-rw-r--r--src/m_email/EMailAccountSettingsPage.h17
-rw-r--r--src/m_email/EMailAccountStore.cpp67
-rw-r--r--src/m_email/EMailImapController.h4
-rw-r--r--src/m_email/EMailModule.cpp524
-rw-r--r--src/m_email/EMailModule.h8
-rw-r--r--src/m_email/EMailPageView.cpp283
-rw-r--r--src/m_email/EMailPageView.h42
-rw-r--r--src/m_email/EMailStructureView.cpp3
-rw-r--r--src/m_email/EMailViewLayout.h6
-rw-r--r--src/m_email/EMailViewStyle.h24
-rw-r--r--src/m_email/module.json9
-rw-r--r--src/m_email/test/EMailSdkStubs.cpp41
-rw-r--r--src/m_email/ui/main.lua37
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",
+}