From 1a933ad11ebe97a120ec9827e914335635c62a3b Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sat, 28 Feb 2026 08:49:25 +0200 Subject: [PATCH 01/15] Autologin feature Make WASM auto-login behave the same as desktop: retrieve credentials immediately when GMCP negotiation completes, without requiring a click. Defer getPassword() via QTimer::singleShot(0) to avoid ASYNCIFY stack overflow from deep within the GMCP signal chain. Improve Preferences UI: disable credential fields when "Remember my login" is unchecked, clear stored credentials on uncheck, show dummy dots for stored passwords, and delete stale IndexedDB records when the password field is cleared. --- CMakeLists.txt | 6 +- src/CMakeLists.txt | 2 +- src/clock/mumeclock.cpp | 5 +- src/configuration/PasswordConfig.cpp | 189 ++++++++++++++++++++++++++- src/configuration/PasswordConfig.h | 1 + src/configuration/configuration.cpp | 4 +- src/opengl/LineRendering.cpp | 1 + src/preferences/generalpage.cpp | 112 ++++++++++++++-- src/preferences/generalpage.h | 1 + src/preferences/generalpage.ui | 3 - src/proxy/proxy.cpp | 18 ++- 11 files changed, 321 insertions(+), 21 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e78344e12..c7e8a6cfc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,6 +193,8 @@ else() endif() add_subdirectory(external/glm) +# Newer system GLM versions require this for gtx/ experimental extensions +add_compile_definitions(GLM_ENABLE_EXPERIMENTAL) add_subdirectory(external/immer) # Extract git branch and revision @@ -437,7 +439,9 @@ if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wno-extra-semi-stmt) # enabled only for src/ directory # require explicit template parameters when deduction guides are missing - add_compile_options(-Werror=ctad-maybe-unsupported) + # NOTE: Downgraded from -Werror to -Wno because Qt 6.10.x MOC-generated + # code uses CTAD without deduction guides (in qtmochelpers.h). + add_compile_options(-Wno-ctad-maybe-unsupported) # always errors add_compile_options(-Werror=cast-qual) # always a mistake unless you added the qualifier yourself. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 02f95f4b1..9f5b1e349 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1164,7 +1164,7 @@ if(APPLE) add_custom_command( TARGET mmapper POST_BUILD - COMMAND ${MACDEPLOYQT_APP} ${CMAKE_CURRENT_BINARY_DIR}/mmapper.app -libpath ${QTKEYCHAIN_LIBRARY_DIR} -verbose=2 + COMMAND ${MACDEPLOYQT_APP} ${CMAKE_CURRENT_BINARY_DIR}/mmapper.app "-libpath=${QTKEYCHAIN_LIBRARY_DIR}" -verbose=2 WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR} COMMENT "Deploying the Qt Framework onto the bundle" VERBATIM diff --git a/src/clock/mumeclock.cpp b/src/clock/mumeclock.cpp index c27a02a5e..e9a61c1c8 100644 --- a/src/clock/mumeclock.cpp +++ b/src/clock/mumeclock.cpp @@ -78,7 +78,10 @@ class NODISCARD QME final public: NODISCARD static int keyToValue(const QString &key) { return g_qme.keyToValue(key.toUtf8()); } - NODISCARD static QString valueToKey(const int value) { return g_qme.valueToKey(value); } + NODISCARD static QString valueToKey(const int value) + { + return g_qme.valueToKey(static_cast(value)); + } NODISCARD static int keyCount() { return g_qme.keyCount(); } }; diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index 383eaf8e4..dfb215931 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -5,14 +5,158 @@ #include "../global/macros.h" -#ifndef MMAPPER_NO_QTKEYCHAIN +#ifdef Q_OS_WASM +#include +#include + +// clang-format off + +// Store a password encrypted with AES-GCM-256 in IndexedDB. +// Generates a new non-extractable CryptoKey on every save (more secure). +// Returns 0 on success, -1 on error. +EM_ASYNC_JS(int, wasm_store_password, (const char *key, const char *password), { + try { + const keyStr = UTF8ToString(key); + const passwordStr = UTF8ToString(password); + + const cryptoKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + false, // non-extractable + ["encrypt", "decrypt"] + ); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(passwordStr); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + cryptoKey, + encoded + ); + + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open("mmapper-credentials", 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("passwords")) { + db.createObjectStore("passwords"); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + await new Promise((resolve, reject) => { + const tx = db.transaction("passwords", "readwrite"); + const store = tx.objectStore("passwords"); + store.put({ cryptoKey: cryptoKey, iv: iv, ciphertext: new Uint8Array(ciphertext) }, keyStr); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + + db.close(); + return 0; + } catch (e) { + console.error("wasm_store_password error:", e); + return -1; + } +}); + +// Read and decrypt a password from IndexedDB. +// Returns a malloc'd UTF-8 string on success, or NULL on error/not found. +// Caller must free() the returned pointer. +EM_ASYNC_JS(char *, wasm_read_password, (const char *key), { + try { + const keyStr = UTF8ToString(key); + + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open("mmapper-credentials", 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("passwords")) { + db.createObjectStore("passwords"); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + const record = await new Promise((resolve, reject) => { + const tx = db.transaction("passwords", "readonly"); + const store = tx.objectStore("passwords"); + const getReq = store.get(keyStr); + getReq.onsuccess = () => resolve(getReq.result); + getReq.onerror = () => reject(getReq.error); + }); + + db.close(); + + if (!record) { + return 0; // not found + } + + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: record.iv }, + record.cryptoKey, + record.ciphertext + ); + + const password = new TextDecoder().decode(decrypted); + const len = lengthBytesUTF8(password) + 1; + const ptr = _malloc(len); + stringToUTF8(password, ptr, len); + return ptr; + } catch (e) { + console.error("wasm_read_password error:", e); + return 0; + } +}); + +// Delete a password entry from IndexedDB. +// Returns 0 on success, -1 on error. +EM_ASYNC_JS(int, wasm_delete_password, (const char *key), { + try { + const keyStr = UTF8ToString(key); + + const db = await new Promise((resolve, reject) => { + const req = indexedDB.open("mmapper-credentials", 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("passwords")) { + db.createObjectStore("passwords"); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + + await new Promise((resolve, reject) => { + const tx = db.transaction("passwords", "readwrite"); + const store = tx.objectStore("passwords"); + store.delete(keyStr); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); + + db.close(); + return 0; + } catch (e) { + console.error("wasm_delete_password error:", e); + return -1; + } +}); + +// clang-format on + +static const char *const WASM_PASSWORD_KEY = "password"; + +#elif !defined(MMAPPER_NO_QTKEYCHAIN) static const QLatin1String PASSWORD_KEY("password"); static const QLatin1String APP_NAME("org.mume.mmapper"); #endif PasswordConfig::PasswordConfig(QObject *const parent) : QObject(parent) -#ifndef MMAPPER_NO_QTKEYCHAIN +#if !defined(Q_OS_WASM) && !defined(MMAPPER_NO_QTKEYCHAIN) , m_readJob(APP_NAME) , m_writeJob(APP_NAME) { @@ -40,7 +184,13 @@ PasswordConfig::PasswordConfig(QObject *const parent) void PasswordConfig::setPassword(const QString &password) { -#ifndef MMAPPER_NO_QTKEYCHAIN +#ifdef Q_OS_WASM + const QByteArray utf8 = password.toUtf8(); + const int result = wasm_store_password(WASM_PASSWORD_KEY, utf8.constData()); + if (result != 0) { + emit sig_error("Failed to store password in browser storage."); + } +#elif !defined(MMAPPER_NO_QTKEYCHAIN) m_writeJob.setKey(PASSWORD_KEY); m_writeJob.setTextData(password); m_writeJob.start(); @@ -52,10 +202,41 @@ void PasswordConfig::setPassword(const QString &password) void PasswordConfig::getPassword() { -#ifndef MMAPPER_NO_QTKEYCHAIN +#ifdef Q_OS_WASM + char *password = wasm_read_password(WASM_PASSWORD_KEY); + if (password) { + emit sig_incomingPassword(QString::fromUtf8(password)); + free(password); + } else { + emit sig_error("Failed to retrieve password from browser storage."); + } +#elif !defined(MMAPPER_NO_QTKEYCHAIN) m_readJob.setKey(PASSWORD_KEY); m_readJob.start(); #else emit sig_error("Password retrieval is not available."); #endif } + +void PasswordConfig::deletePassword() +{ +#ifdef Q_OS_WASM + const int result = wasm_delete_password(WASM_PASSWORD_KEY); + if (result != 0) { + emit sig_error("Failed to delete password from browser storage."); + } +#elif !defined(MMAPPER_NO_QTKEYCHAIN) + // QtKeychain delete job (fire and forget) + auto *deleteJob = new QKeychain::DeletePasswordJob(APP_NAME); + deleteJob->setAutoDelete(true); + deleteJob->setKey(PASSWORD_KEY); + connect(deleteJob, &QKeychain::DeletePasswordJob::finished, [this, deleteJob]() { + if (deleteJob->error()) { + emit sig_error(deleteJob->errorString()); + } + }); + deleteJob->start(); +#else + emit sig_error("Password deletion is not available."); +#endif +} diff --git a/src/configuration/PasswordConfig.h b/src/configuration/PasswordConfig.h index fc8186a5a..1ba0b6914 100644 --- a/src/configuration/PasswordConfig.h +++ b/src/configuration/PasswordConfig.h @@ -28,6 +28,7 @@ class PasswordConfig final : public QObject void setPassword(const QString &password); void getPassword(); + void deletePassword(); signals: void sig_error(const QString &msg); diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index caf1feb8f..2e299b005 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -666,7 +666,9 @@ void Configuration::AccountSettings::read(const QSettings &conf) { accountName = conf.value(KEY_ACCOUNT_NAME, "").toString(); accountPassword = conf.value(KEY_ACCOUNT_PASSWORD, false).toBool(); - rememberLogin = NO_QTKEYCHAIN ? false : conf.value(KEY_REMEMBER_LOGIN, false).toBool(); + rememberLogin = (NO_QTKEYCHAIN && CURRENT_PLATFORM != PlatformEnum::Wasm) + ? false + : conf.value(KEY_REMEMBER_LOGIN, false).toBool(); } void Configuration::AutoLoadSettings::read(const QSettings &conf) diff --git a/src/opengl/LineRendering.cpp b/src/opengl/LineRendering.cpp index 74b1018e4..610990f7d 100644 --- a/src/opengl/LineRendering.cpp +++ b/src/opengl/LineRendering.cpp @@ -6,6 +6,7 @@ #include #include +#include #include namespace mmgl { diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index ed075620b..10b99481c 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -13,6 +13,57 @@ #include #include +#ifdef Q_OS_WASM +// Workaround for Qt WASM double character input bug. +// Qt's WASM platform plugin generates both a QKeyEvent (from JS keydown) +// and a QInputMethodEvent (from the hidden element's input method +// context) for each keystroke. Both events insert the same character into +// the QLineEdit, causing every character to appear twice. +// Fix: allow the first insertion event and suppress the duplicate. +class WasmInputDeduplicateFilter final : public QObject +{ +public: + using QObject::QObject; + +protected: + bool eventFilter(QObject *obj, QEvent *event) override + { + if (event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + const QString text = ke->text(); + if (!text.isEmpty() && text.at(0).isPrint()) { + if (m_suppressDuplicate && m_lastText == text) { + m_suppressDuplicate = false; + return true; // suppress duplicate insertion + } + m_lastText = text; + m_suppressDuplicate = true; + } else { + // Non-printable key (Backspace, arrows, etc.) — reset state + m_suppressDuplicate = false; + m_lastText.clear(); + } + } else if (event->type() == QEvent::InputMethod) { + auto *ime = static_cast(event); + const QString commit = ime->commitString(); + if (!commit.isEmpty()) { + if (m_suppressDuplicate && m_lastText == commit) { + m_suppressDuplicate = false; + return true; // suppress duplicate insertion + } + m_lastText = commit; + m_suppressDuplicate = true; + } + } + return QObject::eventFilter(obj, event); + } + +private: + QString m_lastText; + bool m_suppressDuplicate = false; +}; +#endif + // Order of entries in charsetComboBox drop down static_assert(static_cast(CharacterEncodingEnum::LATIN1) == 0); static_assert(static_cast(CharacterEncodingEnum::UTF8) == 1); @@ -30,6 +81,12 @@ GeneralPage::GeneralPage(QWidget *parent) { ui->setupUi(this); +#ifdef Q_OS_WASM + // Install key deduplication filter on text inputs affected by the Qt WASM double-key bug + ui->accountPassword->installEventFilter(new WasmInputDeduplicateFilter(ui->accountPassword)); + ui->accountName->installEventFilter(new WasmInputDeduplicateFilter(ui->accountName)); +#endif + connect(ui->remoteName, &QLineEdit::textChanged, this, &GeneralPage::slot_remoteNameTextChanged); connect(ui->remotePort, QOverload::of(&QSpinBox::valueChanged), @@ -171,7 +228,21 @@ GeneralPage::GeneralPage(QWidget *parent) }); connect(ui->autoLogin, &QCheckBox::stateChanged, this, [this]() { - setConfig().account.rememberLogin = ui->autoLogin->isChecked(); + const bool checked = ui->autoLogin->isChecked(); + setConfig().account.rememberLogin = checked; + ui->accountName->setEnabled(checked); + ui->accountPassword->setEnabled(checked); + ui->showPassword->setEnabled(checked); + if (!checked) { + passCfg.deletePassword(); + setConfig().account.accountPassword = false; + setConfig().account.accountName.clear(); + ui->accountName->clear(); + ui->accountPassword->clear(); + ui->accountPassword->setEchoMode(QLineEdit::Password); + ui->showPassword->setText("Show Password"); + m_passwordFieldHasDummy = false; + } }); connect(ui->accountName, &QLineEdit::textChanged, this, [](const QString &account) { @@ -190,17 +261,31 @@ GeneralPage::GeneralPage(QWidget *parent) }); connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { + m_passwordFieldHasDummy = false; setConfig().account.accountPassword = !password.isEmpty(); - passCfg.setPassword(password); + // Avoid storing an encrypted empty string as a stale record in IndexedDB/keychain + // when the user clears the field (e.g. select-all + delete). + if (!password.isEmpty()) { + passCfg.setPassword(password); + } else { + passCfg.deletePassword(); + } }); connect(ui->showPassword, &QAbstractButton::clicked, this, [this]() { if (ui->showPassword->text() == "Hide Password") { + // Hide: restore dummy dots in password mode ui->showPassword->setText("Show Password"); - ui->accountPassword->clear(); ui->accountPassword->setEchoMode(QLineEdit::Password); - } else if (getConfig().account.accountPassword && ui->accountPassword->text().isEmpty()) { - ui->showPassword->setText("Request Password"); + if (getConfig().account.accountPassword) { + const QSignalBlocker blocker(ui->accountPassword); + ui->accountPassword->setText(QString(8, QChar(0x2022))); + m_passwordFieldHasDummy = true; + } else { + ui->accountPassword->clear(); + } + } else if (getConfig().account.accountPassword) { + // Stored password exists — retrieve and reveal it passCfg.getPassword(); } }); @@ -268,7 +353,7 @@ void GeneralPage::slot_loadConfig() ui->proxyConnectionStatusCheckBox->setChecked(connection.proxyConnectionStatus); - if constexpr (NO_QTKEYCHAIN) { + if constexpr (NO_QTKEYCHAIN && CURRENT_PLATFORM != PlatformEnum::Wasm) { ui->autoLogin->setEnabled(false); ui->accountName->setEnabled(false); ui->accountPassword->setEnabled(false); @@ -276,8 +361,19 @@ void GeneralPage::slot_loadConfig() } else { ui->autoLogin->setChecked(account.rememberLogin); ui->accountName->setText(account.accountName); - if (!account.accountPassword) { - ui->accountPassword->setPlaceholderText(""); + ui->accountName->setEnabled(account.rememberLogin); + ui->accountPassword->setEnabled(account.rememberLogin); + ui->showPassword->setEnabled(account.rememberLogin); + ui->accountPassword->setEchoMode(QLineEdit::Password); + ui->showPassword->setText("Show Password"); + if (account.accountPassword) { + // Show dots to indicate a password is stored, without triggering textEdited + const QSignalBlocker blocker(ui->accountPassword); + ui->accountPassword->setText(QString(8, QChar(0x2022))); + m_passwordFieldHasDummy = true; + } else { + ui->accountPassword->clear(); + m_passwordFieldHasDummy = false; } } } diff --git a/src/preferences/generalpage.h b/src/preferences/generalpage.h index 2a1e9fe50..afa92baf6 100644 --- a/src/preferences/generalpage.h +++ b/src/preferences/generalpage.h @@ -23,6 +23,7 @@ class NODISCARD_QOBJECT GeneralPage final : public QWidget private: Ui::GeneralPage *const ui; PasswordConfig passCfg; + bool m_passwordFieldHasDummy = false; public: explicit GeneralPage(QWidget *parent); diff --git a/src/preferences/generalpage.ui b/src/preferences/generalpage.ui index 99fbcd166..9c6c3ac93 100644 --- a/src/preferences/generalpage.ui +++ b/src/preferences/generalpage.ui @@ -187,9 +187,6 @@ QLineEdit::Password - - *********** - diff --git a/src/proxy/proxy.cpp b/src/proxy/proxy.cpp index 78a62f3bd..6442690dc 100644 --- a/src/proxy/proxy.cpp +++ b/src/proxy/proxy.cpp @@ -46,6 +46,7 @@ #include #include #include +#include using mmqt::makeQPointer; @@ -504,8 +505,21 @@ void Proxy::allocMudTelnet() { const auto &account = getConfig().account; if (account.rememberLogin && !account.accountName.isEmpty() && account.accountPassword) { - // fetch asynchronously from keychain - getProxy().getPasswordConfig().getPassword(); + // On WASM, getPassword() uses EM_ASYNC_JS which requires ASYNCIFY stack + // unwinding. Calling it from deep within the GMCP signal chain would crash, + // so we defer to the next event loop iteration for a clean call stack. + if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { + // Use the Proxy as both receiver and context: if the Proxy is destroyed + // before the timer fires, Qt cancels the invocation automatically. + // We capture `this` (LocalMudTelnetOutputs) which is safe because + // LocalMudTelnetOutputs is owned by the Proxy's pipeline, and the + // Proxy receiver guard prevents the lambda from firing after destruction. + QTimer::singleShot(0, &getProxy(), [this]() { + getProxy().getPasswordConfig().getPassword(); + }); + } else { + getProxy().getPasswordConfig().getPassword(); + } } } From 01391256003ecaaa5807f8e1792d473d837b56c2 Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sat, 28 Feb 2026 09:01:56 +0200 Subject: [PATCH 02/15] Fixes --- src/clock/mumeclock.cpp | 6 ++++++ src/configuration/PasswordConfig.cpp | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/clock/mumeclock.cpp b/src/clock/mumeclock.cpp index e9a61c1c8..a74c2959e 100644 --- a/src/clock/mumeclock.cpp +++ b/src/clock/mumeclock.cpp @@ -80,7 +80,13 @@ class NODISCARD QME final NODISCARD static int keyToValue(const QString &key) { return g_qme.keyToValue(key.toUtf8()); } NODISCARD static QString valueToKey(const int value) { + // Qt 6.10+ changed QMetaEnum::valueToKey() to take quint64. + // Qt 6.5 takes int. Compile-time dispatch avoids warnings on both. +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) return g_qme.valueToKey(static_cast(value)); +#else + return g_qme.valueToKey(value); +#endif } NODISCARD static int keyCount() { return g_qme.keyCount(); } }; diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index dfb215931..0994f13a0 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -6,8 +6,8 @@ #include "../global/macros.h" #ifdef Q_OS_WASM -#include #include +#include // clang-format off From 7e9a20c8db9b6fd17319ee34e315cb179103e5fc Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sat, 28 Feb 2026 22:05:02 +0200 Subject: [PATCH 03/15] Root cause: Firefox throws a DataCloneError when IndexedDB tries to serialize a non-extractable CryptoKey object via the structured clone algorithm. Chrome handles this fine, Firefox doesn't. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix: 1. wasm_store_password: Changed extractable from false to true, then exports the key as raw bytes (crypto.subtle.exportKey("raw", ...)) and stores the Uint8Array instead of the CryptoKey object. Only plain byte arrays (rawKey, iv, ciphertext) go into IndexedDB now — no browser-specific serialization issues. 2. wasm_read_password: Imports the raw key bytes back into a CryptoKey (crypto.subtle.importKey("raw", ...)) before decrypting. The imported key is non-extractable since it's only needed for the decrypt operation. Security impact: None meaningful. The password is already available as plaintext in JS memory during encryption/decryption. The encryption protects at-rest data in IndexedDB — storing raw key bytes alongside the ciphertext in the same record is equivalent to storing the CryptoKey object. --- src/configuration/PasswordConfig.cpp | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index 0994f13a0..35e2931d6 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -12,7 +12,10 @@ // clang-format off // Store a password encrypted with AES-GCM-256 in IndexedDB. -// Generates a new non-extractable CryptoKey on every save (more secure). +// Generates a new CryptoKey on every save, exports the raw key bytes for +// storage. We store raw bytes (not the CryptoKey object) because Firefox +// throws DataCloneError when serializing non-extractable CryptoKey objects +// into IndexedDB via structured clone. // Returns 0 on success, -1 on error. EM_ASYNC_JS(int, wasm_store_password, (const char *key, const char *password), { try { @@ -21,7 +24,7 @@ EM_ASYNC_JS(int, wasm_store_password, (const char *key, const char *password), { const cryptoKey = await crypto.subtle.generateKey( { name: "AES-GCM", length: 256 }, - false, // non-extractable + true, // extractable — needed to export raw bytes for IndexedDB storage ["encrypt", "decrypt"] ); @@ -33,6 +36,9 @@ EM_ASYNC_JS(int, wasm_store_password, (const char *key, const char *password), { encoded ); + // Export key as raw bytes instead of storing the CryptoKey object. + const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", cryptoKey)); + const db = await new Promise((resolve, reject) => { const req = indexedDB.open("mmapper-credentials", 1); req.onupgradeneeded = () => { @@ -48,7 +54,7 @@ EM_ASYNC_JS(int, wasm_store_password, (const char *key, const char *password), { await new Promise((resolve, reject) => { const tx = db.transaction("passwords", "readwrite"); const store = tx.objectStore("passwords"); - store.put({ cryptoKey: cryptoKey, iv: iv, ciphertext: new Uint8Array(ciphertext) }, keyStr); + store.put({ rawKey: rawKey, iv: iv, ciphertext: new Uint8Array(ciphertext) }, keyStr); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); @@ -94,9 +100,18 @@ EM_ASYNC_JS(char *, wasm_read_password, (const char *key), { return 0; // not found } + // Import raw key bytes back into a CryptoKey for decryption. + const cryptoKey = await crypto.subtle.importKey( + "raw", + record.rawKey, + { name: "AES-GCM" }, + false, // non-extractable for decryption use + ["decrypt"] + ); + const decrypted = await crypto.subtle.decrypt( { name: "AES-GCM", iv: record.iv }, - record.cryptoKey, + cryptoKey, record.ciphertext ); From d63cbd397328328117a7538bb6ee596a16869726 Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sat, 28 Feb 2026 22:52:37 +0200 Subject: [PATCH 04/15] Added tests --- .../WasmInputDeduplicateFilter.cpp | 42 +++++++ src/preferences/WasmInputDeduplicateFilter.h | 26 ++++ src/preferences/generalpage.cpp | 49 +------- tests/CMakeLists.txt | 23 ++++ tests/TestWasmInputFilter.cpp | 113 ++++++++++++++++++ tests/TestWasmInputFilter.h | 23 ++++ 6 files changed, 228 insertions(+), 48 deletions(-) create mode 100644 src/preferences/WasmInputDeduplicateFilter.cpp create mode 100644 src/preferences/WasmInputDeduplicateFilter.h create mode 100644 tests/TestWasmInputFilter.cpp create mode 100644 tests/TestWasmInputFilter.h diff --git a/src/preferences/WasmInputDeduplicateFilter.cpp b/src/preferences/WasmInputDeduplicateFilter.cpp new file mode 100644 index 000000000..5aa2a3af4 --- /dev/null +++ b/src/preferences/WasmInputDeduplicateFilter.cpp @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include "WasmInputDeduplicateFilter.h" + +#include +#include +#include + +WasmInputDeduplicateFilter::~WasmInputDeduplicateFilter() = default; + +bool WasmInputDeduplicateFilter::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::KeyPress) { + auto *ke = static_cast(event); + const QString text = ke->text(); + if (!text.isEmpty() && text.at(0).isPrint()) { + if (m_suppressDuplicate && m_lastText == text) { + m_suppressDuplicate = false; + return true; // suppress duplicate insertion + } + m_lastText = text; + m_suppressDuplicate = true; + } else { + // Non-printable key (Backspace, arrows, etc.) — reset state + m_suppressDuplicate = false; + m_lastText.clear(); + } + } else if (event->type() == QEvent::InputMethod) { + auto *ime = static_cast(event); + const QString commit = ime->commitString(); + if (!commit.isEmpty()) { + if (m_suppressDuplicate && m_lastText == commit) { + m_suppressDuplicate = false; + return true; // suppress duplicate insertion + } + m_lastText = commit; + m_suppressDuplicate = true; + } + } + return QObject::eventFilter(obj, event); +} diff --git a/src/preferences/WasmInputDeduplicateFilter.h b/src/preferences/WasmInputDeduplicateFilter.h new file mode 100644 index 000000000..03a7bfe08 --- /dev/null +++ b/src/preferences/WasmInputDeduplicateFilter.h @@ -0,0 +1,26 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include +#include + +// Workaround for Qt WASM double character input bug. +// Qt's WASM platform plugin generates both a QKeyEvent (from JS keydown) +// and a QInputMethodEvent (from the hidden element's input method +// context) for each keystroke. Both events insert the same character into +// the QLineEdit, causing every character to appear twice. +// Fix: allow the first insertion event and suppress the duplicate. +class WasmInputDeduplicateFilter final : public QObject +{ +public: + using QObject::QObject; + ~WasmInputDeduplicateFilter() override; + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private: + QString m_lastText; + bool m_suppressDuplicate = false; +}; diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index 10b99481c..c324dc2a8 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -14,54 +14,7 @@ #include #ifdef Q_OS_WASM -// Workaround for Qt WASM double character input bug. -// Qt's WASM platform plugin generates both a QKeyEvent (from JS keydown) -// and a QInputMethodEvent (from the hidden element's input method -// context) for each keystroke. Both events insert the same character into -// the QLineEdit, causing every character to appear twice. -// Fix: allow the first insertion event and suppress the duplicate. -class WasmInputDeduplicateFilter final : public QObject -{ -public: - using QObject::QObject; - -protected: - bool eventFilter(QObject *obj, QEvent *event) override - { - if (event->type() == QEvent::KeyPress) { - auto *ke = static_cast(event); - const QString text = ke->text(); - if (!text.isEmpty() && text.at(0).isPrint()) { - if (m_suppressDuplicate && m_lastText == text) { - m_suppressDuplicate = false; - return true; // suppress duplicate insertion - } - m_lastText = text; - m_suppressDuplicate = true; - } else { - // Non-printable key (Backspace, arrows, etc.) — reset state - m_suppressDuplicate = false; - m_lastText.clear(); - } - } else if (event->type() == QEvent::InputMethod) { - auto *ime = static_cast(event); - const QString commit = ime->commitString(); - if (!commit.isEmpty()) { - if (m_suppressDuplicate && m_lastText == commit) { - m_suppressDuplicate = false; - return true; // suppress duplicate insertion - } - m_lastText = commit; - m_suppressDuplicate = true; - } - } - return QObject::eventFilter(obj, event); - } - -private: - QString m_lastText; - bool m_suppressDuplicate = false; -}; +#include "WasmInputDeduplicateFilter.h" #endif // Order of entries in charsetComboBox drop down diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d8d5f5c7c..6d0483cca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -312,6 +312,29 @@ set_target_properties( ) add_test(NAME TestRoomManager COMMAND TestRoomManager) +# WasmInputFilter +set(wasm_input_filter_SRCS + ../src/preferences/WasmInputDeduplicateFilter.cpp + ../src/preferences/WasmInputDeduplicateFilter.h +) +set(TestWasmInputFilter_SRCS TestWasmInputFilter.cpp TestWasmInputFilter.h) +add_executable(TestWasmInputFilter ${TestWasmInputFilter_SRCS} ${wasm_input_filter_SRCS}) +add_dependencies(TestWasmInputFilter mm_global) +target_link_libraries(TestWasmInputFilter + mm_global + Qt6::Gui + Qt6::Test + Qt6::Widgets + coverage_config) +set_target_properties( + TestWasmInputFilter PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + COMPILE_FLAGS "${WARNING_FLAGS}" +) +add_test(NAME TestWasmInputFilter COMMAND TestWasmInputFilter) + # HotkeyManager set(hotkey_manager_SRCS ../src/client/Hotkey.cpp diff --git a/tests/TestWasmInputFilter.cpp b/tests/TestWasmInputFilter.cpp new file mode 100644 index 000000000..94b976cb2 --- /dev/null +++ b/tests/TestWasmInputFilter.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include "TestWasmInputFilter.h" + +#include "../src/preferences/WasmInputDeduplicateFilter.h" + +#include +#include +#include +#include +#include + +TestWasmInputFilter::TestWasmInputFilter() = default; +TestWasmInputFilter::~TestWasmInputFilter() = default; + +// Helper: send a printable KeyPress and return whether the filter suppressed it. +static bool sendKeyPress(QLineEdit &target, const QChar ch) +{ + QKeyEvent ev(QEvent::KeyPress, 0, Qt::NoModifier, QString(ch)); + return QCoreApplication::sendEvent(&target, &ev); +} + +// Helper: send an InputMethodEvent with a commit string and return whether +// the filter suppressed it. +static bool sendInputMethod(QLineEdit &target, const QString &commit) +{ + QInputMethodEvent ev(QString(), {}); + ev.setCommitString(commit); + return QCoreApplication::sendEvent(&target, &ev); +} + +void TestWasmInputFilter::testSingleKeypressPassesThrough() +{ + QLineEdit edit; + auto *filter = new WasmInputDeduplicateFilter(&edit); + edit.installEventFilter(filter); + + // A single printable key should not be suppressed — the widget processes it. + const bool accepted = sendKeyPress(edit, QChar('a')); + // sendEvent returns true when the event is accepted by the target (QLineEdit + // accepts key events). The filter must NOT have blocked it (returned true from + // eventFilter), so QLineEdit still processes it. We verify the character + // actually reached the widget. + Q_UNUSED(accepted); + QCOMPARE(edit.text(), QString("a")); +} + +void TestWasmInputFilter::testDuplicateKeypressAndInputMethodSuppressed() +{ + QLineEdit edit; + auto *filter = new WasmInputDeduplicateFilter(&edit); + edit.installEventFilter(filter); + + // Simulate Chrome-style duplicate: KeyPress "a" then InputMethod "a". + sendKeyPress(edit, QChar('a')); + QCOMPARE(edit.text(), QString("a")); + + sendInputMethod(edit, QStringLiteral("a")); + // The duplicate InputMethod event should have been suppressed — text stays "a". + QCOMPARE(edit.text(), QString("a")); +} + +void TestWasmInputFilter::testDuplicateInputMethodAndKeypressSuppressed() +{ + QLineEdit edit; + auto *filter = new WasmInputDeduplicateFilter(&edit); + edit.installEventFilter(filter); + + // Reversed order: InputMethod "a" then KeyPress "a". + sendInputMethod(edit, QStringLiteral("a")); + QCOMPARE(edit.text(), QString("a")); + + sendKeyPress(edit, QChar('a')); + // The duplicate KeyPress should have been suppressed — text stays "a". + QCOMPARE(edit.text(), QString("a")); +} + +void TestWasmInputFilter::testDifferentCharsNotSuppressed() +{ + QLineEdit edit; + auto *filter = new WasmInputDeduplicateFilter(&edit); + edit.installEventFilter(filter); + + // Two different characters should both pass through. + sendKeyPress(edit, QChar('a')); + QCOMPARE(edit.text(), QString("a")); + + sendInputMethod(edit, QStringLiteral("b")); + QCOMPARE(edit.text(), QString("ab")); +} + +void TestWasmInputFilter::testNonPrintableResetsState() +{ + QLineEdit edit; + auto *filter = new WasmInputDeduplicateFilter(&edit); + edit.installEventFilter(filter); + + // Type "a", then Backspace (non-printable resets state), then InputMethod "a". + sendKeyPress(edit, QChar('a')); + QCOMPARE(edit.text(), QString("a")); + + // Send Backspace — non-printable, resets the dedup state. + QKeyEvent backspace(QEvent::KeyPress, Qt::Key_Backspace, Qt::NoModifier); + QCoreApplication::sendEvent(&edit, &backspace); + QCOMPARE(edit.text(), QString()); + + // Now InputMethod "a" should NOT be suppressed because state was reset. + sendInputMethod(edit, QStringLiteral("a")); + QCOMPARE(edit.text(), QString("a")); +} + +QTEST_MAIN(TestWasmInputFilter) diff --git a/tests/TestWasmInputFilter.h b/tests/TestWasmInputFilter.h new file mode 100644 index 000000000..60961a78d --- /dev/null +++ b/tests/TestWasmInputFilter.h @@ -0,0 +1,23 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2019 The MMapper Authors + +#include "../src/global/macros.h" + +#include + +class NODISCARD_QOBJECT TestWasmInputFilter final : public QObject +{ + Q_OBJECT + +public: + TestWasmInputFilter(); + ~TestWasmInputFilter() final; + +private Q_SLOTS: + void testSingleKeypressPassesThrough(); + void testDuplicateKeypressAndInputMethodSuppressed(); + void testDuplicateInputMethodAndKeypressSuppressed(); + void testDifferentCharsNotSuppressed(); + void testNonPrintableResetsState(); +}; From 3a4975b0641b5c7a7cc152fd26a408acfbda9475 Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sat, 28 Feb 2026 23:11:39 +0200 Subject: [PATCH 05/15] Fix --- src/CMakeLists.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9f5b1e349..d960442c1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -504,6 +504,8 @@ set(mmapper_SRCS preferences/configdialog.h preferences/generalpage.cpp preferences/generalpage.h + preferences/WasmInputDeduplicateFilter.cpp + preferences/WasmInputDeduplicateFilter.h preferences/graphicspage.cpp preferences/graphicspage.h preferences/grouppage.cpp From 5ef9a56c5ac3c5243fee2c4684e5875388328f58 Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 09:29:42 +0200 Subject: [PATCH 06/15] Fix for build (macos 15) --- external/immer/CMakeLists.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/external/immer/CMakeLists.txt b/external/immer/CMakeLists.txt index 4283c342b..5ace45ae9 100644 --- a/external/immer/CMakeLists.txt +++ b/external/immer/CMakeLists.txt @@ -9,8 +9,9 @@ else() message(STATUS "Could not find a local immer; downloading to local source tree") include(ExternalProject) ExternalProject_Add(immer - URL "https://github.com/arximboldi/immer/archive/v0.8.1.tar.gz" - URL_HASH SHA1=3ab24d01bc6952f5fc0258020271db74f9d30585 + GIT_REPOSITORY "https://github.com/arximboldi/immer.git" + GIT_TAG v0.8.1 + GIT_SHALLOW ON SOURCE_DIR "${CMAKE_CURRENT_BINARY_DIR}/immer" UPDATE_COMMAND "" From 8e8424634df7b05bc6d48f518da493c75de3c8fa Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 10:45:13 +0200 Subject: [PATCH 07/15] Firefox fix --- src/preferences/generalpage.cpp | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index c324dc2a8..a4baa4147 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -213,18 +213,35 @@ GeneralPage::GeneralPage(QWidget *parent) ui->accountPassword->setEchoMode(QLineEdit::Normal); }); - connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { - m_passwordFieldHasDummy = false; - setConfig().account.accountPassword = !password.isEmpty(); - // Avoid storing an encrypted empty string as a stale record in IndexedDB/keychain - // when the user clears the field (e.g. select-all + delete). + // Debounce password saves to prevent overlapping ASYNCIFY operations in WASM. + // Each wasm_store_password() call suspends the C++ stack via ASYNCIFY; rapid + // keystrokes would cause concurrent suspend/rewind cycles, corrupting emval + // handles in Firefox. The timer ensures only one save runs after typing stops. + auto *passwordSaveTimer = new QTimer(this); + passwordSaveTimer->setSingleShot(true); + passwordSaveTimer->setInterval(500); + + connect(passwordSaveTimer, &QTimer::timeout, this, [this]() { + const QString password = ui->accountPassword->text(); if (!password.isEmpty()) { passCfg.setPassword(password); - } else { - passCfg.deletePassword(); } }); + connect(ui->accountPassword, + &QLineEdit::textEdited, + this, + [this, passwordSaveTimer](const QString &password) { + m_passwordFieldHasDummy = false; + setConfig().account.accountPassword = !password.isEmpty(); + if (!password.isEmpty()) { + passwordSaveTimer->start(); // (re)start — fires 500ms after last keystroke + } else { + passwordSaveTimer->stop(); + passCfg.deletePassword(); + } + }); + connect(ui->showPassword, &QAbstractButton::clicked, this, [this]() { if (ui->showPassword->text() == "Hide Password") { // Hide: restore dummy dots in password mode From 04ae1daaad5f92c85936efda447b296a15294325 Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 10:58:05 +0200 Subject: [PATCH 08/15] Firefox fix#2 --- src/configuration/PasswordConfig.cpp | 21 +++++++++ src/preferences/generalpage.cpp | 68 ++++++++++++++++------------ 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index 35e2931d6..c2d35c2c0 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -18,6 +18,15 @@ // into IndexedDB via structured clone. // Returns 0 on success, -1 on error. EM_ASYNC_JS(int, wasm_store_password, (const char *key, const char *password), { + // Serialize concurrent calls — each invocation suspends the C++ stack via + // ASYNCIFY, and overlapping rewinds corrupt emval handles in Firefox. + // A promise queue ensures only one save is in flight at a time. + if (!Module._passwordSaveQueue) Module._passwordSaveQueue = Promise.resolve(); + const prev = Module._passwordSaveQueue; + let resolveQueue; + Module._passwordSaveQueue = new Promise(r => { resolveQueue = r; }); + await prev; + try { const keyStr = UTF8ToString(key); const passwordStr = UTF8ToString(password); @@ -64,6 +73,8 @@ EM_ASYNC_JS(int, wasm_store_password, (const char *key, const char *password), { } catch (e) { console.error("wasm_store_password error:", e); return -1; + } finally { + resolveQueue(); } }); @@ -129,6 +140,14 @@ EM_ASYNC_JS(char *, wasm_read_password, (const char *key), { // Delete a password entry from IndexedDB. // Returns 0 on success, -1 on error. EM_ASYNC_JS(int, wasm_delete_password, (const char *key), { + // Share the same serialization queue as wasm_store_password to prevent + // a delete from racing with an in-flight store (or vice versa). + if (!Module._passwordSaveQueue) Module._passwordSaveQueue = Promise.resolve(); + const prev = Module._passwordSaveQueue; + let resolveQueue; + Module._passwordSaveQueue = new Promise(r => { resolveQueue = r; }); + await prev; + try { const keyStr = UTF8ToString(key); @@ -157,6 +176,8 @@ EM_ASYNC_JS(int, wasm_delete_password, (const char *key), { } catch (e) { console.error("wasm_delete_password error:", e); return -1; + } finally { + resolveQueue(); } }); diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index a4baa4147..1f6cfaa22 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -213,34 +213,46 @@ GeneralPage::GeneralPage(QWidget *parent) ui->accountPassword->setEchoMode(QLineEdit::Normal); }); - // Debounce password saves to prevent overlapping ASYNCIFY operations in WASM. - // Each wasm_store_password() call suspends the C++ stack via ASYNCIFY; rapid - // keystrokes would cause concurrent suspend/rewind cycles, corrupting emval - // handles in Firefox. The timer ensures only one save runs after typing stops. - auto *passwordSaveTimer = new QTimer(this); - passwordSaveTimer->setSingleShot(true); - passwordSaveTimer->setInterval(500); - - connect(passwordSaveTimer, &QTimer::timeout, this, [this]() { - const QString password = ui->accountPassword->text(); - if (!password.isEmpty()) { - passCfg.setPassword(password); - } - }); - - connect(ui->accountPassword, - &QLineEdit::textEdited, - this, - [this, passwordSaveTimer](const QString &password) { - m_passwordFieldHasDummy = false; - setConfig().account.accountPassword = !password.isEmpty(); - if (!password.isEmpty()) { - passwordSaveTimer->start(); // (re)start — fires 500ms after last keystroke - } else { - passwordSaveTimer->stop(); - passCfg.deletePassword(); - } - }); + if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { + // In WASM, debounce password saves to avoid redundant IndexedDB writes on + // every keystroke. The JS-level serialization queue in wasm_store_password + // prevents overlapping ASYNCIFY rewinds (the Firefox crash fix), but without + // debounce each keystroke would still queue a full encrypt+store cycle. + auto *passwordSaveTimer = new QTimer(this); + passwordSaveTimer->setSingleShot(true); + passwordSaveTimer->setInterval(500); + + connect(passwordSaveTimer, &QTimer::timeout, this, [this]() { + const QString password = ui->accountPassword->text(); + if (!password.isEmpty()) { + passCfg.setPassword(password); + } + }); + + connect(ui->accountPassword, + &QLineEdit::textEdited, + this, + [this, passwordSaveTimer](const QString &password) { + m_passwordFieldHasDummy = false; + setConfig().account.accountPassword = !password.isEmpty(); + if (!password.isEmpty()) { + passwordSaveTimer->start(); // (re)start — fires 500ms after last keystroke + } else { + passwordSaveTimer->stop(); + passCfg.deletePassword(); + } + }); + } else { + connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { + m_passwordFieldHasDummy = false; + setConfig().account.accountPassword = !password.isEmpty(); + if (!password.isEmpty()) { + passCfg.setPassword(password); + } else { + passCfg.deletePassword(); + } + }); + } connect(ui->showPassword, &QAbstractButton::clicked, this, [this]() { if (ui->showPassword->text() == "Hide Password") { From 89f1d1716e7a658302b67199dea4fcdcf8c3a934 Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 13:35:31 +0200 Subject: [PATCH 09/15] Fix for input lag issue when typing username and password --- src/configuration/PasswordConfig.cpp | 197 ++++++++---------- .../WasmInputDeduplicateFilter.cpp | 27 ++- src/preferences/WasmInputDeduplicateFilter.h | 8 +- src/preferences/generalpage.cpp | 49 +---- tests/TestWasmInputFilter.cpp | 23 ++ tests/TestWasmInputFilter.h | 1 + 6 files changed, 145 insertions(+), 160 deletions(-) diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index c2d35c2c0..20fd6e6f8 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -16,66 +16,60 @@ // storage. We store raw bytes (not the CryptoKey object) because Firefox // throws DataCloneError when serializing non-extractable CryptoKey objects // into IndexedDB via structured clone. -// Returns 0 on success, -1 on error. -EM_ASYNC_JS(int, wasm_store_password, (const char *key, const char *password), { - // Serialize concurrent calls — each invocation suspends the C++ stack via - // ASYNCIFY, and overlapping rewinds corrupt emval handles in Firefox. - // A promise queue ensures only one save is in flight at a time. - if (!Module._passwordSaveQueue) Module._passwordSaveQueue = Promise.resolve(); - const prev = Module._passwordSaveQueue; - let resolveQueue; - Module._passwordSaveQueue = new Promise(r => { resolveQueue = r; }); - await prev; - - try { - const keyStr = UTF8ToString(key); - const passwordStr = UTF8ToString(password); - - const cryptoKey = await crypto.subtle.generateKey( - { name: "AES-GCM", length: 256 }, - true, // extractable — needed to export raw bytes for IndexedDB storage - ["encrypt", "decrypt"] - ); - - const iv = crypto.getRandomValues(new Uint8Array(12)); - const encoded = new TextEncoder().encode(passwordStr); - const ciphertext = await crypto.subtle.encrypt( - { name: "AES-GCM", iv: iv }, - cryptoKey, - encoded - ); - - // Export key as raw bytes instead of storing the CryptoKey object. - const rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", cryptoKey)); +// +// Fire-and-forget: uses EM_JS (not EM_ASYNC_JS) so the C++ call returns +// immediately without suspending the ASYNCIFY stack. The actual encrypt + +// IndexedDB write runs asynchronously in a JS promise chain. This prevents +// the Qt event loop from being blocked during saves, which was causing +// dropped keystrokes when typing fast. +EM_JS(void, wasm_store_password, (const char *key, const char *password), { + var keyStr = UTF8ToString(key); + var passwordStr = UTF8ToString(password); - const db = await new Promise((resolve, reject) => { - const req = indexedDB.open("mmapper-credentials", 1); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains("passwords")) { - db.createObjectStore("passwords"); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - - await new Promise((resolve, reject) => { - const tx = db.transaction("passwords", "readwrite"); - const store = tx.objectStore("passwords"); - store.put({ rawKey: rawKey, iv: iv, ciphertext: new Uint8Array(ciphertext) }, keyStr); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); - - db.close(); - return 0; - } catch (e) { - console.error("wasm_store_password error:", e); - return -1; - } finally { - resolveQueue(); - } + if (!Module._passwordSaveQueue) Module._passwordSaveQueue = Promise.resolve(); + Module._passwordSaveQueue = Module._passwordSaveQueue.then(async function() { + try { + var cryptoKey = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + var iv = crypto.getRandomValues(new Uint8Array(12)); + var encoded = new TextEncoder().encode(passwordStr); + var ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv: iv }, + cryptoKey, + encoded + ); + + var rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", cryptoKey)); + + var db = await new Promise(function(resolve, reject) { + var req = indexedDB.open("mmapper-credentials", 1); + req.onupgradeneeded = function() { + var db2 = req.result; + if (!db2.objectStoreNames.contains("passwords")) { + db2.createObjectStore("passwords"); + } + }; + req.onsuccess = function() { resolve(req.result); }; + req.onerror = function() { reject(req.error); }; + }); + + await new Promise(function(resolve, reject) { + var tx = db.transaction("passwords", "readwrite"); + var store = tx.objectStore("passwords"); + store.put({ rawKey: rawKey, iv: iv, ciphertext: new Uint8Array(ciphertext) }, keyStr); + tx.oncomplete = function() { resolve(); }; + tx.onerror = function() { reject(tx.error); }; + }); + + db.close(); + } catch (e) { + console.error("wasm_store_password error:", e); + } + }); }); // Read and decrypt a password from IndexedDB. @@ -138,49 +132,40 @@ EM_ASYNC_JS(char *, wasm_read_password, (const char *key), { }); // Delete a password entry from IndexedDB. -// Returns 0 on success, -1 on error. -EM_ASYNC_JS(int, wasm_delete_password, (const char *key), { - // Share the same serialization queue as wasm_store_password to prevent - // a delete from racing with an in-flight store (or vice versa). - if (!Module._passwordSaveQueue) Module._passwordSaveQueue = Promise.resolve(); - const prev = Module._passwordSaveQueue; - let resolveQueue; - Module._passwordSaveQueue = new Promise(r => { resolveQueue = r; }); - await prev; - - try { - const keyStr = UTF8ToString(key); - - const db = await new Promise((resolve, reject) => { - const req = indexedDB.open("mmapper-credentials", 1); - req.onupgradeneeded = () => { - const db = req.result; - if (!db.objectStoreNames.contains("passwords")) { - db.createObjectStore("passwords"); - } - }; - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - - await new Promise((resolve, reject) => { - const tx = db.transaction("passwords", "readwrite"); - const store = tx.objectStore("passwords"); - store.delete(keyStr); - tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error); - }); +// Fire-and-forget: shares the same promise chain as wasm_store_password +// to prevent a delete from racing with an in-flight store. +EM_JS(void, wasm_delete_password, (const char *key), { + var keyStr = UTF8ToString(key); - db.close(); - return 0; - } catch (e) { - console.error("wasm_delete_password error:", e); - return -1; - } finally { - resolveQueue(); - } + if (!Module._passwordSaveQueue) Module._passwordSaveQueue = Promise.resolve(); + Module._passwordSaveQueue = Module._passwordSaveQueue.then(async function() { + try { + var db = await new Promise(function(resolve, reject) { + var req = indexedDB.open("mmapper-credentials", 1); + req.onupgradeneeded = function() { + var db2 = req.result; + if (!db2.objectStoreNames.contains("passwords")) { + db2.createObjectStore("passwords"); + } + }; + req.onsuccess = function() { resolve(req.result); }; + req.onerror = function() { reject(req.error); }; + }); + + await new Promise(function(resolve, reject) { + var tx = db.transaction("passwords", "readwrite"); + var store = tx.objectStore("passwords"); + store["delete"](keyStr); + tx.oncomplete = function() { resolve(); }; + tx.onerror = function() { reject(tx.error); }; + }); + + db.close(); + } catch (e) { + console.error("wasm_delete_password error:", e); + } + }); }); - // clang-format on static const char *const WASM_PASSWORD_KEY = "password"; @@ -221,11 +206,10 @@ PasswordConfig::PasswordConfig(QObject *const parent) void PasswordConfig::setPassword(const QString &password) { #ifdef Q_OS_WASM + // Fire-and-forget: save runs asynchronously in JS without blocking the event loop. + // Errors are logged to the browser console. const QByteArray utf8 = password.toUtf8(); - const int result = wasm_store_password(WASM_PASSWORD_KEY, utf8.constData()); - if (result != 0) { - emit sig_error("Failed to store password in browser storage."); - } + wasm_store_password(WASM_PASSWORD_KEY, utf8.constData()); #elif !defined(MMAPPER_NO_QTKEYCHAIN) m_writeJob.setKey(PASSWORD_KEY); m_writeJob.setTextData(password); @@ -257,10 +241,7 @@ void PasswordConfig::getPassword() void PasswordConfig::deletePassword() { #ifdef Q_OS_WASM - const int result = wasm_delete_password(WASM_PASSWORD_KEY); - if (result != 0) { - emit sig_error("Failed to delete password from browser storage."); - } + wasm_delete_password(WASM_PASSWORD_KEY); #elif !defined(MMAPPER_NO_QTKEYCHAIN) // QtKeychain delete job (fire and forget) auto *deleteJob = new QKeychain::DeletePasswordJob(APP_NAME); diff --git a/src/preferences/WasmInputDeduplicateFilter.cpp b/src/preferences/WasmInputDeduplicateFilter.cpp index 5aa2a3af4..451e05285 100644 --- a/src/preferences/WasmInputDeduplicateFilter.cpp +++ b/src/preferences/WasmInputDeduplicateFilter.cpp @@ -11,31 +11,38 @@ WasmInputDeduplicateFilter::~WasmInputDeduplicateFilter() = default; bool WasmInputDeduplicateFilter::eventFilter(QObject *obj, QEvent *event) { + // Qt WASM fires both KeyPress AND InputMethod events for the same keystroke, + // causing double character insertion. We suppress the second event of each + // pair by remembering which event *type* produced the character. A duplicate + // is only suppressed when the same text arrives from a *different* event type + // (the hallmark of the Qt WASM bug). Two consecutive events of the *same* + // type with the same text are legitimate repeat keypresses. if (event->type() == QEvent::KeyPress) { auto *ke = static_cast(event); const QString text = ke->text(); if (!text.isEmpty() && text.at(0).isPrint()) { - if (m_suppressDuplicate && m_lastText == text) { - m_suppressDuplicate = false; - return true; // suppress duplicate insertion + if (m_lastType == QEvent::InputMethod && m_lastText == text) { + m_lastType = QEvent::None; + m_lastText.clear(); + return true; // suppress duplicate from different event type } m_lastText = text; - m_suppressDuplicate = true; + m_lastType = QEvent::KeyPress; } else { - // Non-printable key (Backspace, arrows, etc.) — reset state - m_suppressDuplicate = false; + m_lastType = QEvent::None; m_lastText.clear(); } } else if (event->type() == QEvent::InputMethod) { auto *ime = static_cast(event); const QString commit = ime->commitString(); if (!commit.isEmpty()) { - if (m_suppressDuplicate && m_lastText == commit) { - m_suppressDuplicate = false; - return true; // suppress duplicate insertion + if (m_lastType == QEvent::KeyPress && m_lastText == commit) { + m_lastType = QEvent::None; + m_lastText.clear(); + return true; // suppress duplicate from different event type } m_lastText = commit; - m_suppressDuplicate = true; + m_lastType = QEvent::InputMethod; } } return QObject::eventFilter(obj, event); diff --git a/src/preferences/WasmInputDeduplicateFilter.h b/src/preferences/WasmInputDeduplicateFilter.h index 03a7bfe08..ce2bd05e5 100644 --- a/src/preferences/WasmInputDeduplicateFilter.h +++ b/src/preferences/WasmInputDeduplicateFilter.h @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later // Copyright (C) 2019 The MMapper Authors +#include #include #include @@ -10,7 +11,10 @@ // and a QInputMethodEvent (from the hidden element's input method // context) for each keystroke. Both events insert the same character into // the QLineEdit, causing every character to appear twice. -// Fix: allow the first insertion event and suppress the duplicate. +// Fix: suppress a character only when the same text arrives from a +// *different* event type (KeyPress vs InputMethod), which is the hallmark +// of the Qt WASM double-fire bug. Two same-type events with the same +// text (e.g. pressing "e" twice) are legitimate and pass through. class WasmInputDeduplicateFilter final : public QObject { public: @@ -22,5 +26,5 @@ class WasmInputDeduplicateFilter final : public QObject private: QString m_lastText; - bool m_suppressDuplicate = false; + QEvent::Type m_lastType = QEvent::None; }; diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index 1f6cfaa22..2b692435f 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -213,46 +213,15 @@ GeneralPage::GeneralPage(QWidget *parent) ui->accountPassword->setEchoMode(QLineEdit::Normal); }); - if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { - // In WASM, debounce password saves to avoid redundant IndexedDB writes on - // every keystroke. The JS-level serialization queue in wasm_store_password - // prevents overlapping ASYNCIFY rewinds (the Firefox crash fix), but without - // debounce each keystroke would still queue a full encrypt+store cycle. - auto *passwordSaveTimer = new QTimer(this); - passwordSaveTimer->setSingleShot(true); - passwordSaveTimer->setInterval(500); - - connect(passwordSaveTimer, &QTimer::timeout, this, [this]() { - const QString password = ui->accountPassword->text(); - if (!password.isEmpty()) { - passCfg.setPassword(password); - } - }); - - connect(ui->accountPassword, - &QLineEdit::textEdited, - this, - [this, passwordSaveTimer](const QString &password) { - m_passwordFieldHasDummy = false; - setConfig().account.accountPassword = !password.isEmpty(); - if (!password.isEmpty()) { - passwordSaveTimer->start(); // (re)start — fires 500ms after last keystroke - } else { - passwordSaveTimer->stop(); - passCfg.deletePassword(); - } - }); - } else { - connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { - m_passwordFieldHasDummy = false; - setConfig().account.accountPassword = !password.isEmpty(); - if (!password.isEmpty()) { - passCfg.setPassword(password); - } else { - passCfg.deletePassword(); - } - }); - } + connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { + m_passwordFieldHasDummy = false; + setConfig().account.accountPassword = !password.isEmpty(); + if (!password.isEmpty()) { + passCfg.setPassword(password); + } else { + passCfg.deletePassword(); + } + }); connect(ui->showPassword, &QAbstractButton::clicked, this, [this]() { if (ui->showPassword->text() == "Hide Password") { diff --git a/tests/TestWasmInputFilter.cpp b/tests/TestWasmInputFilter.cpp index 94b976cb2..85b72b576 100644 --- a/tests/TestWasmInputFilter.cpp +++ b/tests/TestWasmInputFilter.cpp @@ -90,6 +90,29 @@ void TestWasmInputFilter::testDifferentCharsNotSuppressed() QCOMPARE(edit.text(), QString("ab")); } +void TestWasmInputFilter::testRepeatedSameCharNotSuppressed() +{ + QLineEdit edit; + auto *filter = new WasmInputDeduplicateFilter(&edit); + edit.installEventFilter(filter); + + // Typing "ee" — each keystroke produces KeyPress + InputMethod (Qt WASM bug). + // The InputMethod duplicate of each pair should be suppressed, but the second + // legitimate "e" keystroke must NOT be suppressed. + + // First "e": KeyPress passes, InputMethod suppressed + sendKeyPress(edit, QChar('e')); + QCOMPARE(edit.text(), QString("e")); + sendInputMethod(edit, QStringLiteral("e")); + QCOMPARE(edit.text(), QString("e")); // duplicate suppressed + + // Second "e": KeyPress must pass through (legitimate repeat), InputMethod suppressed + sendKeyPress(edit, QChar('e')); + QCOMPARE(edit.text(), QString("ee")); + sendInputMethod(edit, QStringLiteral("e")); + QCOMPARE(edit.text(), QString("ee")); // duplicate suppressed +} + void TestWasmInputFilter::testNonPrintableResetsState() { QLineEdit edit; diff --git a/tests/TestWasmInputFilter.h b/tests/TestWasmInputFilter.h index 60961a78d..8a22e21a9 100644 --- a/tests/TestWasmInputFilter.h +++ b/tests/TestWasmInputFilter.h @@ -19,5 +19,6 @@ private Q_SLOTS: void testDuplicateKeypressAndInputMethodSuppressed(); void testDuplicateInputMethodAndKeypressSuppressed(); void testDifferentCharsNotSuppressed(); + void testRepeatedSameCharNotSuppressed(); void testNonPrintableResetsState(); }; From f186bf892e838799f129186f657a6c555aa7ddfb Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 15:31:35 +0200 Subject: [PATCH 10/15] PR fixes --- src/configuration/PasswordConfig.cpp | 23 ++++++++++++++++------ src/preferences/generalpage.cpp | 14 +++++++++++++- tests/TestWasmInputFilter.cpp | 29 ++++++++++++++++++++++++++++ tests/TestWasmInputFilter.h | 2 ++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index 20fd6e6f8..d7d2e9efe 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -17,6 +17,12 @@ // throws DataCloneError when serializing non-extractable CryptoKey objects // into IndexedDB via structured clone. // +// Note: storing the raw key alongside the ciphertext means any same-origin JS +// can decrypt. This is a deliberate trade-off — Firefox throws DataCloneError +// when storing non-extractable CryptoKey objects in IndexedDB, and browser- +// specific code paths proved unreliable. The encryption provides data-at-rest +// protection and defense against accidental plaintext exposure in logs/UI. +// // Fire-and-forget: uses EM_JS (not EM_ASYNC_JS) so the C++ call returns // immediately without suspending the ASYNCIFY stack. The actual encrypt + // IndexedDB write runs asynchronously in a JS promise chain. This prevents @@ -73,8 +79,8 @@ EM_JS(void, wasm_store_password, (const char *key, const char *password), { }); // Read and decrypt a password from IndexedDB. -// Returns a malloc'd UTF-8 string on success, or NULL on error/not found. -// Caller must free() the returned pointer. +// Returns a malloc'd UTF-8 string on success (may be empty if no password is stored), +// or NULL on error. Caller must free() the returned pointer. EM_ASYNC_JS(char *, wasm_read_password, (const char *key), { try { const keyStr = UTF8ToString(key); @@ -102,7 +108,9 @@ EM_ASYNC_JS(char *, wasm_read_password, (const char *key), { db.close(); if (!record) { - return 0; // not found + var emptyPtr = _malloc(1); + HEAPU8[emptyPtr] = 0; + return emptyPtr; // not found — empty string, not an error } // Import raw key bytes back into a CryptoKey for decryption. @@ -224,11 +232,14 @@ void PasswordConfig::getPassword() { #ifdef Q_OS_WASM char *password = wasm_read_password(WASM_PASSWORD_KEY); - if (password) { - emit sig_incomingPassword(QString::fromUtf8(password)); + if (!password) { + emit sig_error("Failed to retrieve password from browser storage."); + } else if (password[0] == '\0') { + // Not found — no password stored yet. Silent, not an error. free(password); } else { - emit sig_error("Failed to retrieve password from browser storage."); + emit sig_incomingPassword(QString::fromUtf8(password)); + free(password); } #elif !defined(MMAPPER_NO_QTKEYCHAIN) m_readJob.setKey(PASSWORD_KEY); diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index 2b692435f..1b37a9b3f 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -214,7 +214,19 @@ GeneralPage::GeneralPage(QWidget *parent) }); connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { - m_passwordFieldHasDummy = false; + if (m_passwordFieldHasDummy) { + m_passwordFieldHasDummy = false; + // User started typing over dummy dots — clear and keep only the new character. + // The last character is the one just typed; the rest are bullet placeholders. + const QSignalBlocker blocker(ui->accountPassword); + const QString newChar = password.right(1); + ui->accountPassword->setText(newChar); + setConfig().account.accountPassword = !newChar.isEmpty(); + if (!newChar.isEmpty()) { + passCfg.setPassword(newChar); + } + return; + } setConfig().account.accountPassword = !password.isEmpty(); if (!password.isEmpty()) { passCfg.setPassword(password); diff --git a/tests/TestWasmInputFilter.cpp b/tests/TestWasmInputFilter.cpp index 85b72b576..9cc015369 100644 --- a/tests/TestWasmInputFilter.cpp +++ b/tests/TestWasmInputFilter.cpp @@ -133,4 +133,33 @@ void TestWasmInputFilter::testNonPrintableResetsState() QCOMPARE(edit.text(), QString("a")); } +void TestWasmInputFilter::testMultiCharCommitNotSuppressed() +{ + QLineEdit edit; + auto *filter = new WasmInputDeduplicateFilter(&edit); + edit.installEventFilter(filter); + + // KeyPress "a" followed by InputMethod "ab" — different text, no suppression. + sendKeyPress(edit, QChar('a')); + QCOMPARE(edit.text(), QString("a")); + + sendInputMethod(edit, QStringLiteral("ab")); + // Multi-char commit doesn't match single-char KeyPress, so it passes through. + QCOMPARE(edit.text(), QString("aab")); +} + +void TestWasmInputFilter::testMultiCharInputMethodPasses() +{ + QLineEdit edit; + auto *filter = new WasmInputDeduplicateFilter(&edit); + edit.installEventFilter(filter); + + // Two InputMethod events with multi-char commits — both should pass through. + sendInputMethod(edit, QStringLiteral("ab")); + QCOMPARE(edit.text(), QString("ab")); + + sendInputMethod(edit, QStringLiteral("cd")); + QCOMPARE(edit.text(), QString("abcd")); +} + QTEST_MAIN(TestWasmInputFilter) diff --git a/tests/TestWasmInputFilter.h b/tests/TestWasmInputFilter.h index 8a22e21a9..9916dece2 100644 --- a/tests/TestWasmInputFilter.h +++ b/tests/TestWasmInputFilter.h @@ -21,4 +21,6 @@ private Q_SLOTS: void testDifferentCharsNotSuppressed(); void testRepeatedSameCharNotSuppressed(); void testNonPrintableResetsState(); + void testMultiCharCommitNotSuppressed(); + void testMultiCharInputMethodPasses(); }; From 5a3169a194173f14ef8c41fbd4a5dd81f45ed49d Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 20:07:30 +0200 Subject: [PATCH 11/15] Simplified --- src/CMakeLists.txt | 2 - src/configuration/PasswordConfig.cpp | 28 +-- .../WasmInputDeduplicateFilter.cpp | 49 ------ src/preferences/WasmInputDeduplicateFilter.h | 30 ---- src/preferences/generalpage.cpp | 11 +- tests/CMakeLists.txt | 23 --- tests/TestWasmInputFilter.cpp | 165 ------------------ tests/TestWasmInputFilter.h | 26 --- 8 files changed, 12 insertions(+), 322 deletions(-) delete mode 100644 src/preferences/WasmInputDeduplicateFilter.cpp delete mode 100644 src/preferences/WasmInputDeduplicateFilter.h delete mode 100644 tests/TestWasmInputFilter.cpp delete mode 100644 tests/TestWasmInputFilter.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d960442c1..9f5b1e349 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -504,8 +504,6 @@ set(mmapper_SRCS preferences/configdialog.h preferences/generalpage.cpp preferences/generalpage.h - preferences/WasmInputDeduplicateFilter.cpp - preferences/WasmInputDeduplicateFilter.h preferences/graphicspage.cpp preferences/graphicspage.h preferences/grouppage.cpp diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index d7d2e9efe..34a695a48 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -11,23 +11,12 @@ // clang-format off -// Store a password encrypted with AES-GCM-256 in IndexedDB. -// Generates a new CryptoKey on every save, exports the raw key bytes for -// storage. We store raw bytes (not the CryptoKey object) because Firefox -// throws DataCloneError when serializing non-extractable CryptoKey objects -// into IndexedDB via structured clone. +// Encrypt and store a password (AES-GCM-256) in IndexedDB. +// The raw key is stored alongside the ciphertext because Firefox cannot +// store non-extractable CryptoKey objects in IndexedDB (DataCloneError). // -// Note: storing the raw key alongside the ciphertext means any same-origin JS -// can decrypt. This is a deliberate trade-off — Firefox throws DataCloneError -// when storing non-extractable CryptoKey objects in IndexedDB, and browser- -// specific code paths proved unreliable. The encryption provides data-at-rest -// protection and defense against accidental plaintext exposure in logs/UI. -// -// Fire-and-forget: uses EM_JS (not EM_ASYNC_JS) so the C++ call returns -// immediately without suspending the ASYNCIFY stack. The actual encrypt + -// IndexedDB write runs asynchronously in a JS promise chain. This prevents -// the Qt event loop from being blocked during saves, which was causing -// dropped keystrokes when typing fast. +// Async (non-blocking): returns immediately; the JS promise chain runs +// in the background so the Qt event loop is not stalled. EM_JS(void, wasm_store_password, (const char *key, const char *password), { var keyStr = UTF8ToString(key); var passwordStr = UTF8ToString(password); @@ -140,7 +129,7 @@ EM_ASYNC_JS(char *, wasm_read_password, (const char *key), { }); // Delete a password entry from IndexedDB. -// Fire-and-forget: shares the same promise chain as wasm_store_password +// Async (non-blocking): shares the same promise chain as wasm_store_password // to prevent a delete from racing with an in-flight store. EM_JS(void, wasm_delete_password, (const char *key), { var keyStr = UTF8ToString(key); @@ -214,8 +203,7 @@ PasswordConfig::PasswordConfig(QObject *const parent) void PasswordConfig::setPassword(const QString &password) { #ifdef Q_OS_WASM - // Fire-and-forget: save runs asynchronously in JS without blocking the event loop. - // Errors are logged to the browser console. + // Async (non-blocking): errors are logged to the browser console. const QByteArray utf8 = password.toUtf8(); wasm_store_password(WASM_PASSWORD_KEY, utf8.constData()); #elif !defined(MMAPPER_NO_QTKEYCHAIN) @@ -254,7 +242,7 @@ void PasswordConfig::deletePassword() #ifdef Q_OS_WASM wasm_delete_password(WASM_PASSWORD_KEY); #elif !defined(MMAPPER_NO_QTKEYCHAIN) - // QtKeychain delete job (fire and forget) + // QtKeychain delete job (async, non-blocking) auto *deleteJob = new QKeychain::DeletePasswordJob(APP_NAME); deleteJob->setAutoDelete(true); deleteJob->setKey(PASSWORD_KEY); diff --git a/src/preferences/WasmInputDeduplicateFilter.cpp b/src/preferences/WasmInputDeduplicateFilter.cpp deleted file mode 100644 index 451e05285..000000000 --- a/src/preferences/WasmInputDeduplicateFilter.cpp +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -// Copyright (C) 2019 The MMapper Authors - -#include "WasmInputDeduplicateFilter.h" - -#include -#include -#include - -WasmInputDeduplicateFilter::~WasmInputDeduplicateFilter() = default; - -bool WasmInputDeduplicateFilter::eventFilter(QObject *obj, QEvent *event) -{ - // Qt WASM fires both KeyPress AND InputMethod events for the same keystroke, - // causing double character insertion. We suppress the second event of each - // pair by remembering which event *type* produced the character. A duplicate - // is only suppressed when the same text arrives from a *different* event type - // (the hallmark of the Qt WASM bug). Two consecutive events of the *same* - // type with the same text are legitimate repeat keypresses. - if (event->type() == QEvent::KeyPress) { - auto *ke = static_cast(event); - const QString text = ke->text(); - if (!text.isEmpty() && text.at(0).isPrint()) { - if (m_lastType == QEvent::InputMethod && m_lastText == text) { - m_lastType = QEvent::None; - m_lastText.clear(); - return true; // suppress duplicate from different event type - } - m_lastText = text; - m_lastType = QEvent::KeyPress; - } else { - m_lastType = QEvent::None; - m_lastText.clear(); - } - } else if (event->type() == QEvent::InputMethod) { - auto *ime = static_cast(event); - const QString commit = ime->commitString(); - if (!commit.isEmpty()) { - if (m_lastType == QEvent::KeyPress && m_lastText == commit) { - m_lastType = QEvent::None; - m_lastText.clear(); - return true; // suppress duplicate from different event type - } - m_lastText = commit; - m_lastType = QEvent::InputMethod; - } - } - return QObject::eventFilter(obj, event); -} diff --git a/src/preferences/WasmInputDeduplicateFilter.h b/src/preferences/WasmInputDeduplicateFilter.h deleted file mode 100644 index ce2bd05e5..000000000 --- a/src/preferences/WasmInputDeduplicateFilter.h +++ /dev/null @@ -1,30 +0,0 @@ -#pragma once -// SPDX-License-Identifier: GPL-2.0-or-later -// Copyright (C) 2019 The MMapper Authors - -#include -#include -#include - -// Workaround for Qt WASM double character input bug. -// Qt's WASM platform plugin generates both a QKeyEvent (from JS keydown) -// and a QInputMethodEvent (from the hidden element's input method -// context) for each keystroke. Both events insert the same character into -// the QLineEdit, causing every character to appear twice. -// Fix: suppress a character only when the same text arrives from a -// *different* event type (KeyPress vs InputMethod), which is the hallmark -// of the Qt WASM double-fire bug. Two same-type events with the same -// text (e.g. pressing "e" twice) are legitimate and pass through. -class WasmInputDeduplicateFilter final : public QObject -{ -public: - using QObject::QObject; - ~WasmInputDeduplicateFilter() override; - -protected: - bool eventFilter(QObject *obj, QEvent *event) override; - -private: - QString m_lastText; - QEvent::Type m_lastType = QEvent::None; -}; diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index 1b37a9b3f..30c5c4c90 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -13,10 +13,6 @@ #include #include -#ifdef Q_OS_WASM -#include "WasmInputDeduplicateFilter.h" -#endif - // Order of entries in charsetComboBox drop down static_assert(static_cast(CharacterEncodingEnum::LATIN1) == 0); static_assert(static_cast(CharacterEncodingEnum::UTF8) == 1); @@ -35,9 +31,10 @@ GeneralPage::GeneralPage(QWidget *parent) ui->setupUi(this); #ifdef Q_OS_WASM - // Install key deduplication filter on text inputs affected by the Qt WASM double-key bug - ui->accountPassword->installEventFilter(new WasmInputDeduplicateFilter(ui->accountPassword)); - ui->accountName->installEventFilter(new WasmInputDeduplicateFilter(ui->accountName)); + // Qt WASM fires both KeyPress and InputMethod events per keystroke, causing double + // characters. Disabling input method on these ASCII-only fields prevents the duplicate. + ui->accountPassword->setAttribute(Qt::WA_InputMethodEnabled, false); + ui->accountName->setAttribute(Qt::WA_InputMethodEnabled, false); #endif connect(ui->remoteName, &QLineEdit::textChanged, this, &GeneralPage::slot_remoteNameTextChanged); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6d0483cca..d8d5f5c7c 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -312,29 +312,6 @@ set_target_properties( ) add_test(NAME TestRoomManager COMMAND TestRoomManager) -# WasmInputFilter -set(wasm_input_filter_SRCS - ../src/preferences/WasmInputDeduplicateFilter.cpp - ../src/preferences/WasmInputDeduplicateFilter.h -) -set(TestWasmInputFilter_SRCS TestWasmInputFilter.cpp TestWasmInputFilter.h) -add_executable(TestWasmInputFilter ${TestWasmInputFilter_SRCS} ${wasm_input_filter_SRCS}) -add_dependencies(TestWasmInputFilter mm_global) -target_link_libraries(TestWasmInputFilter - mm_global - Qt6::Gui - Qt6::Test - Qt6::Widgets - coverage_config) -set_target_properties( - TestWasmInputFilter PROPERTIES - CXX_STANDARD 17 - CXX_STANDARD_REQUIRED ON - CXX_EXTENSIONS OFF - COMPILE_FLAGS "${WARNING_FLAGS}" -) -add_test(NAME TestWasmInputFilter COMMAND TestWasmInputFilter) - # HotkeyManager set(hotkey_manager_SRCS ../src/client/Hotkey.cpp diff --git a/tests/TestWasmInputFilter.cpp b/tests/TestWasmInputFilter.cpp deleted file mode 100644 index 9cc015369..000000000 --- a/tests/TestWasmInputFilter.cpp +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -// Copyright (C) 2019 The MMapper Authors - -#include "TestWasmInputFilter.h" - -#include "../src/preferences/WasmInputDeduplicateFilter.h" - -#include -#include -#include -#include -#include - -TestWasmInputFilter::TestWasmInputFilter() = default; -TestWasmInputFilter::~TestWasmInputFilter() = default; - -// Helper: send a printable KeyPress and return whether the filter suppressed it. -static bool sendKeyPress(QLineEdit &target, const QChar ch) -{ - QKeyEvent ev(QEvent::KeyPress, 0, Qt::NoModifier, QString(ch)); - return QCoreApplication::sendEvent(&target, &ev); -} - -// Helper: send an InputMethodEvent with a commit string and return whether -// the filter suppressed it. -static bool sendInputMethod(QLineEdit &target, const QString &commit) -{ - QInputMethodEvent ev(QString(), {}); - ev.setCommitString(commit); - return QCoreApplication::sendEvent(&target, &ev); -} - -void TestWasmInputFilter::testSingleKeypressPassesThrough() -{ - QLineEdit edit; - auto *filter = new WasmInputDeduplicateFilter(&edit); - edit.installEventFilter(filter); - - // A single printable key should not be suppressed — the widget processes it. - const bool accepted = sendKeyPress(edit, QChar('a')); - // sendEvent returns true when the event is accepted by the target (QLineEdit - // accepts key events). The filter must NOT have blocked it (returned true from - // eventFilter), so QLineEdit still processes it. We verify the character - // actually reached the widget. - Q_UNUSED(accepted); - QCOMPARE(edit.text(), QString("a")); -} - -void TestWasmInputFilter::testDuplicateKeypressAndInputMethodSuppressed() -{ - QLineEdit edit; - auto *filter = new WasmInputDeduplicateFilter(&edit); - edit.installEventFilter(filter); - - // Simulate Chrome-style duplicate: KeyPress "a" then InputMethod "a". - sendKeyPress(edit, QChar('a')); - QCOMPARE(edit.text(), QString("a")); - - sendInputMethod(edit, QStringLiteral("a")); - // The duplicate InputMethod event should have been suppressed — text stays "a". - QCOMPARE(edit.text(), QString("a")); -} - -void TestWasmInputFilter::testDuplicateInputMethodAndKeypressSuppressed() -{ - QLineEdit edit; - auto *filter = new WasmInputDeduplicateFilter(&edit); - edit.installEventFilter(filter); - - // Reversed order: InputMethod "a" then KeyPress "a". - sendInputMethod(edit, QStringLiteral("a")); - QCOMPARE(edit.text(), QString("a")); - - sendKeyPress(edit, QChar('a')); - // The duplicate KeyPress should have been suppressed — text stays "a". - QCOMPARE(edit.text(), QString("a")); -} - -void TestWasmInputFilter::testDifferentCharsNotSuppressed() -{ - QLineEdit edit; - auto *filter = new WasmInputDeduplicateFilter(&edit); - edit.installEventFilter(filter); - - // Two different characters should both pass through. - sendKeyPress(edit, QChar('a')); - QCOMPARE(edit.text(), QString("a")); - - sendInputMethod(edit, QStringLiteral("b")); - QCOMPARE(edit.text(), QString("ab")); -} - -void TestWasmInputFilter::testRepeatedSameCharNotSuppressed() -{ - QLineEdit edit; - auto *filter = new WasmInputDeduplicateFilter(&edit); - edit.installEventFilter(filter); - - // Typing "ee" — each keystroke produces KeyPress + InputMethod (Qt WASM bug). - // The InputMethod duplicate of each pair should be suppressed, but the second - // legitimate "e" keystroke must NOT be suppressed. - - // First "e": KeyPress passes, InputMethod suppressed - sendKeyPress(edit, QChar('e')); - QCOMPARE(edit.text(), QString("e")); - sendInputMethod(edit, QStringLiteral("e")); - QCOMPARE(edit.text(), QString("e")); // duplicate suppressed - - // Second "e": KeyPress must pass through (legitimate repeat), InputMethod suppressed - sendKeyPress(edit, QChar('e')); - QCOMPARE(edit.text(), QString("ee")); - sendInputMethod(edit, QStringLiteral("e")); - QCOMPARE(edit.text(), QString("ee")); // duplicate suppressed -} - -void TestWasmInputFilter::testNonPrintableResetsState() -{ - QLineEdit edit; - auto *filter = new WasmInputDeduplicateFilter(&edit); - edit.installEventFilter(filter); - - // Type "a", then Backspace (non-printable resets state), then InputMethod "a". - sendKeyPress(edit, QChar('a')); - QCOMPARE(edit.text(), QString("a")); - - // Send Backspace — non-printable, resets the dedup state. - QKeyEvent backspace(QEvent::KeyPress, Qt::Key_Backspace, Qt::NoModifier); - QCoreApplication::sendEvent(&edit, &backspace); - QCOMPARE(edit.text(), QString()); - - // Now InputMethod "a" should NOT be suppressed because state was reset. - sendInputMethod(edit, QStringLiteral("a")); - QCOMPARE(edit.text(), QString("a")); -} - -void TestWasmInputFilter::testMultiCharCommitNotSuppressed() -{ - QLineEdit edit; - auto *filter = new WasmInputDeduplicateFilter(&edit); - edit.installEventFilter(filter); - - // KeyPress "a" followed by InputMethod "ab" — different text, no suppression. - sendKeyPress(edit, QChar('a')); - QCOMPARE(edit.text(), QString("a")); - - sendInputMethod(edit, QStringLiteral("ab")); - // Multi-char commit doesn't match single-char KeyPress, so it passes through. - QCOMPARE(edit.text(), QString("aab")); -} - -void TestWasmInputFilter::testMultiCharInputMethodPasses() -{ - QLineEdit edit; - auto *filter = new WasmInputDeduplicateFilter(&edit); - edit.installEventFilter(filter); - - // Two InputMethod events with multi-char commits — both should pass through. - sendInputMethod(edit, QStringLiteral("ab")); - QCOMPARE(edit.text(), QString("ab")); - - sendInputMethod(edit, QStringLiteral("cd")); - QCOMPARE(edit.text(), QString("abcd")); -} - -QTEST_MAIN(TestWasmInputFilter) diff --git a/tests/TestWasmInputFilter.h b/tests/TestWasmInputFilter.h deleted file mode 100644 index 9916dece2..000000000 --- a/tests/TestWasmInputFilter.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once -// SPDX-License-Identifier: GPL-2.0-or-later -// Copyright (C) 2019 The MMapper Authors - -#include "../src/global/macros.h" - -#include - -class NODISCARD_QOBJECT TestWasmInputFilter final : public QObject -{ - Q_OBJECT - -public: - TestWasmInputFilter(); - ~TestWasmInputFilter() final; - -private Q_SLOTS: - void testSingleKeypressPassesThrough(); - void testDuplicateKeypressAndInputMethodSuppressed(); - void testDuplicateInputMethodAndKeypressSuppressed(); - void testDifferentCharsNotSuppressed(); - void testRepeatedSameCharNotSuppressed(); - void testNonPrintableResetsState(); - void testMultiCharCommitNotSuppressed(); - void testMultiCharInputMethodPasses(); -}; From 854a2417e9c8a8689feb1639fab7243ee60cf847 Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 20:57:24 +0200 Subject: [PATCH 12/15] now strips leading bullet chars (U+2022) instead of assuming single keystroke, correctly handling paste and multi-char input --- src/preferences/generalpage.cpp | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index 30c5c4c90..96e278e6d 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -213,14 +213,16 @@ GeneralPage::GeneralPage(QWidget *parent) connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { if (m_passwordFieldHasDummy) { m_passwordFieldHasDummy = false; - // User started typing over dummy dots — clear and keep only the new character. - // The last character is the one just typed; the rest are bullet placeholders. + // Strip dummy bullet characters (U+2022) that precede the real input. + // Handles both single keystrokes and multi-character paste. + QString cleaned = password; + while (!cleaned.isEmpty() && cleaned.at(0) == QChar(0x2022)) + cleaned.remove(0, 1); const QSignalBlocker blocker(ui->accountPassword); - const QString newChar = password.right(1); - ui->accountPassword->setText(newChar); - setConfig().account.accountPassword = !newChar.isEmpty(); - if (!newChar.isEmpty()) { - passCfg.setPassword(newChar); + ui->accountPassword->setText(cleaned); + setConfig().account.accountPassword = !cleaned.isEmpty(); + if (!cleaned.isEmpty()) { + passCfg.setPassword(cleaned); } return; } From c9f0000b0317b3267fa7411bc9baae9ceb164bee Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 21:04:05 +0200 Subject: [PATCH 13/15] Added tests --- tests/CMakeLists.txt | 19 +++ tests/TestPasswordField.cpp | 246 ++++++++++++++++++++++++++++++++++++ tests/TestPasswordField.h | 34 +++++ 3 files changed, 299 insertions(+) create mode 100644 tests/TestPasswordField.cpp create mode 100644 tests/TestPasswordField.h diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d8d5f5c7c..2e62c5349 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -331,3 +331,22 @@ set_target_properties( COMPILE_FLAGS "${WARNING_FLAGS}" ) add_test(NAME TestHotkeyManager COMMAND TestHotkeyManager) + +# PasswordField +set(TestPasswordField_SRCS TestPasswordField.cpp TestPasswordField.h) +add_executable(TestPasswordField ${TestPasswordField_SRCS}) +add_dependencies(TestPasswordField mm_global) +target_link_libraries(TestPasswordField + mm_global + Qt6::Gui + Qt6::Test + Qt6::Widgets + coverage_config) +set_target_properties( + TestPasswordField PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON + CXX_EXTENSIONS OFF + COMPILE_FLAGS "${WARNING_FLAGS}" +) +add_test(NAME TestPasswordField COMMAND TestPasswordField) diff --git a/tests/TestPasswordField.cpp b/tests/TestPasswordField.cpp new file mode 100644 index 000000000..174546904 --- /dev/null +++ b/tests/TestPasswordField.cpp @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 The MMapper Authors + +#include "TestPasswordField.h" + +#include +#include +#include +#include + +TestPasswordField::TestPasswordField() = default; +TestPasswordField::~TestPasswordField() = default; + +// Replicate the bullet-stripping logic from generalpage.cpp textEdited handler. +// This is the exact same algorithm used in production. +static QString stripDummyBullets(const QString &password) +{ + QString cleaned = password; + while (!cleaned.isEmpty() && cleaned.at(0) == QChar(0x2022)) + cleaned.remove(0, 1); + return cleaned; +} + +static const QString DUMMY_DOTS = QString(8, QChar(0x2022)); + +// ─── Bullet stripping tests ─── + +void TestPasswordField::testStripBulletsSingleChar() +{ + // User types 'a' at end of 8 dummy bullets → only 'a' remains + QCOMPARE(stripDummyBullets(DUMMY_DOTS + "a"), QString("a")); +} + +void TestPasswordField::testStripBulletsMultiCharPaste() +{ + // User pastes "mypass" at end of 8 dummy bullets → full paste preserved + QCOMPARE(stripDummyBullets(DUMMY_DOTS + "mypass"), QString("mypass")); +} + +void TestPasswordField::testStripBulletsNoBullets() +{ + // User selected all and typed/pasted — no bullets to strip + QCOMPARE(stripDummyBullets("mypass"), QString("mypass")); +} + +void TestPasswordField::testStripBulletsOnlyBullets() +{ + // Edge case: only bullets, no new input + QCOMPARE(stripDummyBullets(DUMMY_DOTS), QString("")); +} + +void TestPasswordField::testStripBulletsPartialBullets() +{ + // User deleted some bullets before typing + QCOMPARE(stripDummyBullets(QString(3, QChar(0x2022)) + "abc"), QString("abc")); +} + +void TestPasswordField::testStripBulletsMidStringBullets() +{ + // Bullets in the middle of real text should NOT be stripped + QCOMPARE(stripDummyBullets("a" + QString(2, QChar(0x2022)) + "b"), + QString("a" + QString(2, QChar(0x2022)) + "b")); +} + +// ─── Password field state machine tests ─── +// +// These simulate the GeneralPage password field behavior using QLineEdit +// with the same logic as the textEdited handler and show/hide flow. + +void TestPasswordField::testDummyDotsShownOnLoad() +{ + // When a password is stored, loadConfig puts 8 bullet chars in the field + QLineEdit edit; + bool hasDummy = false; + + // Simulate slot_loadConfig with accountPassword = true + { + const QSignalBlocker blocker(&edit); + edit.setText(DUMMY_DOTS); + hasDummy = true; + } + + QCOMPARE(edit.text(), DUMMY_DOTS); + QVERIFY(hasDummy); +} + +void TestPasswordField::testTypeSingleCharClearsDummy() +{ + QLineEdit edit; + bool hasDummy = true; + QString savedPassword; + + // Set up dummy dots + { + const QSignalBlocker blocker(&edit); + edit.setText(DUMMY_DOTS); + } + + // Simulate textEdited with a single char appended to dummy + const QString editedText = DUMMY_DOTS + "x"; + // This is what the handler does: + if (hasDummy) { + hasDummy = false; + const QString cleaned = stripDummyBullets(editedText); + const QSignalBlocker blocker(&edit); + edit.setText(cleaned); + if (!cleaned.isEmpty()) { + savedPassword = cleaned; + } + } + + QCOMPARE(edit.text(), QString("x")); + QCOMPARE(savedPassword, QString("x")); + QVERIFY(!hasDummy); +} + +void TestPasswordField::testPasteOverDummyPreservesAll() +{ + QLineEdit edit; + bool hasDummy = true; + QString savedPassword; + + // Set up dummy dots + { + const QSignalBlocker blocker(&edit); + edit.setText(DUMMY_DOTS); + } + + // Simulate paste: "hunter2" appended after dummy dots + const QString editedText = DUMMY_DOTS + "hunter2"; + if (hasDummy) { + hasDummy = false; + const QString cleaned = stripDummyBullets(editedText); + const QSignalBlocker blocker(&edit); + edit.setText(cleaned); + if (!cleaned.isEmpty()) { + savedPassword = cleaned; + } + } + + // The full pasted text must survive — not truncated to last char + QCOMPARE(edit.text(), QString("hunter2")); + QCOMPARE(savedPassword, QString("hunter2")); +} + +void TestPasswordField::testNormalTypingWithoutDummy() +{ + QLineEdit edit; + bool hasDummy = false; + QString savedPassword; + bool deleted = false; + + // Simulate typing "abc" one char at a time, no dummy state + for (const auto &text : {QString("a"), QString("ab"), QString("abc")}) { + if (hasDummy) { + // Should not enter this branch + QFAIL("hasDummy should be false"); + } + if (!text.isEmpty()) { + savedPassword = text; + deleted = false; + } else { + deleted = true; + } + } + + QCOMPARE(savedPassword, QString("abc")); + QVERIFY(!deleted); +} + +void TestPasswordField::testClearPasswordDeletesStored() +{ + QLineEdit edit; + bool hasDummy = false; + bool passwordStored = true; + bool deleted = false; + + // Simulate clearing the field (textEdited with empty string) + const QString password; + if (!hasDummy) { + passwordStored = !password.isEmpty(); + if (password.isEmpty()) { + deleted = true; + } + } + + QVERIFY(!passwordStored); + QVERIFY(deleted); +} + +void TestPasswordField::testHideRestoresDummyDots() +{ + QLineEdit edit; + bool hasDummy = false; + bool accountPasswordStored = true; + + // Show state: password is revealed + edit.setText("secret"); + edit.setEchoMode(QLineEdit::Normal); + + // Simulate clicking "Hide Password" + edit.setEchoMode(QLineEdit::Password); + if (accountPasswordStored) { + const QSignalBlocker blocker(&edit); + edit.setText(DUMMY_DOTS); + hasDummy = true; + } else { + edit.clear(); + } + + QCOMPARE(edit.text(), DUMMY_DOTS); + QCOMPARE(edit.echoMode(), QLineEdit::Password); + QVERIFY(hasDummy); +} + +void TestPasswordField::testUncheckedClearsEverything() +{ + QLineEdit nameEdit; + QLineEdit passEdit; + bool hasDummy = true; + bool accountPasswordStored = true; + QString accountName = "player"; + + // Set up initial state + nameEdit.setText(accountName); + { + const QSignalBlocker blocker(&passEdit); + passEdit.setText(DUMMY_DOTS); + } + + // Simulate unchecking autoLogin + accountPasswordStored = false; + accountName.clear(); + nameEdit.clear(); + passEdit.clear(); + passEdit.setEchoMode(QLineEdit::Password); + hasDummy = false; + + QCOMPARE(nameEdit.text(), QString("")); + QCOMPARE(passEdit.text(), QString("")); + QVERIFY(!hasDummy); + QVERIFY(!accountPasswordStored); + QVERIFY(accountName.isEmpty()); +} + +QTEST_MAIN(TestPasswordField) diff --git a/tests/TestPasswordField.h b/tests/TestPasswordField.h new file mode 100644 index 000000000..4c7cd6f10 --- /dev/null +++ b/tests/TestPasswordField.h @@ -0,0 +1,34 @@ +#pragma once +// SPDX-License-Identifier: GPL-2.0-or-later +// Copyright (C) 2026 The MMapper Authors + +#include "../src/global/macros.h" + +#include + +class NODISCARD_QOBJECT TestPasswordField final : public QObject +{ + Q_OBJECT + +public: + TestPasswordField(); + ~TestPasswordField() final; + +private Q_SLOTS: + // Bullet stripping — the logic that replaces password.right(1) + void testStripBulletsSingleChar(); + void testStripBulletsMultiCharPaste(); + void testStripBulletsNoBullets(); + void testStripBulletsOnlyBullets(); + void testStripBulletsPartialBullets(); + void testStripBulletsMidStringBullets(); + + // Password field state machine + void testDummyDotsShownOnLoad(); + void testTypeSingleCharClearsDummy(); + void testPasteOverDummyPreservesAll(); + void testNormalTypingWithoutDummy(); + void testClearPasswordDeletesStored(); + void testHideRestoresDummyDots(); + void testUncheckedClearsEverything(); +}; From 0c6cc73b3a9458f54c6b93d1774a4cce200d85fa Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 21:12:06 +0200 Subject: [PATCH 14/15] Improved comments --- src/configuration/PasswordConfig.cpp | 1 + src/preferences/generalpage.cpp | 6 ++++-- src/proxy/proxy.cpp | 13 +++++-------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/configuration/PasswordConfig.cpp b/src/configuration/PasswordConfig.cpp index 34a695a48..a13c94440 100644 --- a/src/configuration/PasswordConfig.cpp +++ b/src/configuration/PasswordConfig.cpp @@ -10,6 +10,7 @@ #include // clang-format off +// (JS inside EM_JS macros is not valid C++) // Encrypt and store a password (AES-GCM-256) in IndexedDB. // The raw key is stored alongside the ciphertext because Firefox cannot diff --git a/src/preferences/generalpage.cpp b/src/preferences/generalpage.cpp index 96e278e6d..0a161c4ac 100644 --- a/src/preferences/generalpage.cpp +++ b/src/preferences/generalpage.cpp @@ -213,8 +213,10 @@ GeneralPage::GeneralPage(QWidget *parent) connect(ui->accountPassword, &QLineEdit::textEdited, this, [this](const QString &password) { if (m_passwordFieldHasDummy) { m_passwordFieldHasDummy = false; - // Strip dummy bullet characters (U+2022) that precede the real input. - // Handles both single keystrokes and multi-character paste. + // The password field shows 8 bullet dots as a placeholder when a + // stored password exists. When the user types or pastes, Qt fires + // textEdited with those bullets still prepended. Strip them so only + // the real input is kept. Handles single keystrokes and paste. QString cleaned = password; while (!cleaned.isEmpty() && cleaned.at(0) == QChar(0x2022)) cleaned.remove(0, 1); diff --git a/src/proxy/proxy.cpp b/src/proxy/proxy.cpp index 6442690dc..5143d2d7a 100644 --- a/src/proxy/proxy.cpp +++ b/src/proxy/proxy.cpp @@ -505,15 +505,12 @@ void Proxy::allocMudTelnet() { const auto &account = getConfig().account; if (account.rememberLogin && !account.accountName.isEmpty() && account.accountPassword) { - // On WASM, getPassword() uses EM_ASYNC_JS which requires ASYNCIFY stack - // unwinding. Calling it from deep within the GMCP signal chain would crash, - // so we defer to the next event loop iteration for a clean call stack. + // WASM: defer to next event loop iteration because + // getPassword() uses EM_ASYNC_JS which would crash + // if called from within the GMCP signal chain. + // The Proxy context guard cancels the call if the + // Proxy (and this object) are destroyed first. if constexpr (CURRENT_PLATFORM == PlatformEnum::Wasm) { - // Use the Proxy as both receiver and context: if the Proxy is destroyed - // before the timer fires, Qt cancels the invocation automatically. - // We capture `this` (LocalMudTelnetOutputs) which is safe because - // LocalMudTelnetOutputs is owned by the Proxy's pipeline, and the - // Proxy receiver guard prevents the lambda from firing after destruction. QTimer::singleShot(0, &getProxy(), [this]() { getProxy().getPasswordConfig().getPassword(); }); From d03d10d7dc1b46eb8145275b8b7d386bb2045309 Mon Sep 17 00:00:00 2001 From: KasparMetsa Date: Sun, 1 Mar 2026 22:04:03 +0200 Subject: [PATCH 15/15] Improved test that sometimes fails in CI --- tests/testclock.cpp | 113 ++++++++++++++++++++++++++------------------ 1 file changed, 66 insertions(+), 47 deletions(-) diff --git a/tests/testclock.cpp b/tests/testclock.cpp index 06ec3a2ba..5740f149d 100644 --- a/tests/testclock.cpp +++ b/tests/testclock.cpp @@ -7,7 +7,6 @@ #include "../src/configuration/configuration.h" #include "../src/global/HideQDebug.h" #include "../src/observer/gameobserver.h" -#include "../src/proxy/GmcpMessage.h" #include #include @@ -69,6 +68,12 @@ void TestClock::parseMumeTimeTest() GameObserver observer; MumeClock clock(observer); + // Use a fixed timestamp for all calls to avoid wall-clock race conditions. + // 1 real second = 1 MUME minute, so if parseMumeTime() and getMumeMoment() + // read the clock in different seconds, the MUME time can shift by a minute + // (or roll the hour if minute was 59). + const int64_t now = QDateTime::currentSecsSinceEpoch(); + // Defaults to epoch time of zero QString expectedZeroEpoch = "Sunday, the 1st of Afteryule, year 2850 of the Third Age."; QCOMPARE(testMumeStartEpochTime(clock, 0), expectedZeroEpoch); @@ -76,44 +81,44 @@ void TestClock::parseMumeTimeTest() // Real time is Wed Dec 20 07:03:27 2017 UTC. QString snapShot1 = "3pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; QString expected1 = snapShot1; - clock.parseMumeTime(snapShot1); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected1); + clock.parseMumeTime(snapShot1, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected1); // Real time is Wed Dec 20 07:18:02 2017 UTC. QString snapShot2 = "5am on Sterday, the 19th of Halimath, year 3030 of the Third Age."; QString expected2 = snapShot2; - clock.parseMumeTime(snapShot2); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected2); + clock.parseMumeTime(snapShot2, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected2); // Real time is Wed Dec 20 07:38:44 2017 UTC. QString snapShot3 = "2am on Sunday, the 20th of Halimath, year 3030 of the Third Age."; QString expected3 = snapShot3; - clock.parseMumeTime(snapShot3); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected3); + clock.parseMumeTime(snapShot3, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected3); // Real time is Thu Dec 21 05:27:35 2017 UTC. QString snapShot4 = "3pm on Highday, the 14th of Blotmath, year 3030 of the Third Age."; QString expected4 = snapShot4; - clock.parseMumeTime(snapShot4); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected4); + clock.parseMumeTime(snapShot4, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected4); // Sindarin Calendar QString sindarin = "3pm on Oraearon, the 14th of Hithui, year 3030 of the Third Age."; QString expectedSindarin = expected4; - clock.parseMumeTime(sindarin); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedSindarin); + clock.parseMumeTime(sindarin, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedSindarin); // Real time is Sat Mar 2 20:43:30 2019 UTC. QString snapShot5 = "6pm on Mersday, the 22nd of Winterfilth, year 2915 of the Third Age."; QString expected5 = snapShot5; - clock.parseMumeTime(snapShot5); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected5); + clock.parseMumeTime(snapShot5, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected5); // Real time is Thu Mar 7 06:28:11 2019 UTC. QString snapShot6 = "2am on Sunday, the 17th of Afterlithe, year 2916 of the Third Age."; QString expected6 = snapShot6; - clock.parseMumeTime(snapShot6); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expected6); + clock.parseMumeTime(snapShot6, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expected6); } void TestClock::getMumeMonthTest() @@ -222,32 +227,38 @@ void TestClock::parseWeatherTest() GameObserver observer; MumeClock clock(observer); + // Use a fixed timestamp to avoid wall-clock race conditions. + // onUserGmcp() reads the wall clock internally, so we call parseWeather() + // directly with an explicit timestamp instead. + const int64_t now = QDateTime::currentSecsSinceEpoch(); + QString snapShot1 = "3pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; QString expectedTime = snapShot1; - clock.parseMumeTime(snapShot1); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseMumeTime(snapShot1, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); expectedTime = "5:00am on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Sun {"what":"rise"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseWeather(MumeTimeEnum::DAWN, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); expectedTime = "6:00am on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Sun {"what":"light"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseWeather(MumeTimeEnum::DAY, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); expectedTime = "9:00pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Sun {"what":"set"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseWeather(MumeTimeEnum::DUSK, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); expectedTime = "10:00pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Sun {"what":"dark"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + clock.parseWeather(MumeTimeEnum::NIGHT, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Darkness {"what":"start"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + // Event.Darkness maps to UNKNOWN — time should not change + clock.parseWeather(MumeTimeEnum::UNKNOWN, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); - clock.onUserGmcp(GmcpMessage::fromRawBytes(R"(Event.Moon {"what":"rise"})")); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), expectedTime); + // Event.Moon is ignored by onUserGmcp — verify time is unchanged + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), expectedTime); QCOMPARE(static_cast(observer.getTimeOfDay()), static_cast(MumeTimeEnum::NIGHT)); @@ -265,20 +276,23 @@ void TestClock::parseClockTimeTest() GameObserver observer; MumeClock clock(observer); + // Use a fixed timestamp to avoid wall-clock race conditions. + const int64_t now = QDateTime::currentSecsSinceEpoch(); + // Clock set to coarse // Real time is Wed Dec 20 07:03:27 2017 UTC. const QString snapShot1 = "3pm on Highday, the 18th of Halimath, year 3030 of the Third Age."; - clock.parseMumeTime(snapShot1); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), snapShot1); + clock.parseMumeTime(snapShot1, now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), snapShot1); // Afternoon - clock.parseClockTime("The current time is 12:34pm."); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), + clock.parseClockTime("The current time is 12:34pm.", now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), "12:34pm on Highday, the 18th of Halimath, year 3030 of the Third Age."); // Midnight - clock.parseClockTime("The current time is 12:51am."); - QCOMPARE(clock.toMumeTime(clock.getMumeMoment()), + clock.parseClockTime("The current time is 12:51am.", now); + QCOMPARE(clock.toMumeTime(clock.getMumeMoment(now)), "12:51am on Highday, the 18th of Halimath, year 3030 of the Third Age."); } @@ -439,8 +453,12 @@ void TestClock::moonClockTest() 20376 - MUME_MINUTES_PER_MOON_PHASE); QCOMPARE(moment.toMoonVisibilityCountDown(), "10:24"); - clock.parseMumeTime("2:00 am on Sunday, the 19th of Forelithe, year 2997 of the Third Age."); - moment = clock.getMumeMoment(); + // Use a fixed timestamp for the remaining tests to avoid wall-clock race conditions. + const int64_t now = QDateTime::currentSecsSinceEpoch(); + + clock.parseMumeTime("2:00 am on Sunday, the 19th of Forelithe, year 2997 of the Third Age.", + now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "The waxing half moon is below the horizon."); QCOMPARE(moment.moonLevel(), 6); QCOMPARE(static_cast(moment.moonPosition()), @@ -450,8 +468,8 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::INVISIBLE)); - clock.parseMumeTime("10:00 pm on Sunday, the 30th of Astron, year 2995 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("10:00 pm on Sunday, the 30th of Astron, year 2995 of the Third Age.", now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waxing quarter moon to the west."); QCOMPARE(moment.moonLevel(), 5); QCOMPARE(static_cast(moment.moonPosition()), static_cast(MumeMoonPositionEnum::WEST)); @@ -460,8 +478,8 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::BRIGHT)); - clock.parseMumeTime("1:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("1:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age.", now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waning half moon to the southeast."); QCOMPARE(moment.moonLevel(), 8); QCOMPARE(static_cast(moment.moonPosition()), @@ -471,8 +489,8 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::BRIGHT)); - clock.parseMumeTime("4:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("4:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age.", now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waning half moon to the south."); QCOMPARE(moment.moonLevel(), 7); QCOMPARE(static_cast(moment.moonPosition()), static_cast(MumeMoonPositionEnum::SOUTH)); @@ -481,8 +499,8 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::BRIGHT)); - clock.parseMumeTime("7:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("7:00 am on Sterday, the 15th of Astron, year 2995 of the Third Age.", now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waning half moon to the southwest."); QCOMPARE(moment.moonLevel(), 7); QCOMPARE(static_cast(moment.moonPosition()), @@ -492,8 +510,9 @@ void TestClock::moonClockTest() QCOMPARE(static_cast(moment.moonVisibility()), static_cast(MumeMoonVisibilityEnum::BRIGHT)); - clock.parseMumeTime("10:00 pm on Monday, the 20th of Forelithe, year 2997 of the Third Age."); - moment = clock.getMumeMoment(); + clock.parseMumeTime("10:00 pm on Monday, the 20th of Forelithe, year 2997 of the Third Age.", + now); + moment = clock.getMumeMoment(now); QCOMPARE(moment.toMumeMoonTime(), "You can see a waxing half moon to the southwest."); QCOMPARE(moment.moonLevel(), 7); QCOMPARE(static_cast(moment.moonPosition()),