Skip to content

Autologin feature#468

Open
KasparMetsa wants to merge 15 commits intoMUME:masterfrom
KasparMetsa:wasm-autologin
Open

Autologin feature#468
KasparMetsa wants to merge 15 commits intoMUME:masterfrom
KasparMetsa:wasm-autologin

Conversation

@KasparMetsa
Copy link
Contributor

@KasparMetsa KasparMetsa commented Feb 28, 2026

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.

Summary by Sourcery

Implement browser-compatible credential storage and auto-login behavior for WASM, and refine login preference handling and related build configuration.

New Features:

  • Add WASM-specific encrypted password storage in IndexedDB with async save, read, and delete support.
  • Enable automatic credential retrieval for WASM auto-login once GMCP negotiation completes, matching desktop behavior.

Bug Fixes:

  • Defer WASM password retrieval from the GMCP signal chain via a zero-timeout timer to avoid ASYNCIFY stack overflows.
  • Ensure password clearing from the preferences UI also removes stale stored credentials in both WASM storage and QtKeychain.
  • Fix auto-login availability logic so it is only disabled on non-WASM platforms without QtKeychain.

Enhancements:

  • Add password deletion support to the credential configuration layer for both WASM and QtKeychain backends.
  • Improve the preferences login UI by disabling fields when auto-login is off, clearing stored credentials when it is unchecked, and indicating stored passwords via dummy characters without revealing them.
  • Adjust WASM preferences inputs to avoid duplicate character entry from input method events.
  • Refine configuration loading to allow remembered logins on WASM even when QtKeychain is unavailable.
  • Update QMetaEnum usage to be compatible with both Qt 6.5 and Qt 6.10+ valueToKey signatures.
  • Include the GLM epsilon header and enable experimental GLM extensions required by newer system GLM versions.

Build:

  • Switch the immer dependency to a shallow Git checkout instead of downloading a fixed tarball.
  • Change the Clang ctad-maybe-unsupported diagnostic from an error to disabled to accommodate Qt 6.10 MOC-generated code.
  • Quote the macOS deployment libpath argument to macdeployqt to handle paths robustly.

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.
@sourcery-ai
Copy link

sourcery-ai bot commented Feb 28, 2026

Reviewer's Guide

Implements WASM-specific encrypted password storage and auto-login behavior aligned with desktop, refines the Preferences auto-login UI and behavior (including deletion of stored credentials), fixes WASM auto-login and double-input issues, and adjusts build/test configuration for newer Qt/GLM, QtKeychain deployment, and immer fetching.

Sequence diagram for WASM auto-login password retrieval via deferred GMCP handling

sequenceDiagram
    participant GMCPHandler
    participant Proxy
    participant PasswordConfig
    participant QTimer
    participant WasmPasswordBackend
    participant BrowserIndexedDB

    GMCPHandler->>Proxy: GMCP negotiation completed
    Proxy->>Proxy: allocMudTelnet()
    Proxy->>Proxy: check rememberLogin && accountName && accountPassword
    alt Platform_Wasm
        Proxy->>QTimer: singleShot(0, Proxy, lambda)
        QTimer-->>Proxy: invoke lambda on next event loop
        Proxy->>PasswordConfig: getPassword()
        PasswordConfig->>WasmPasswordBackend: wasm_read_password(WASM_PASSWORD_KEY)
        WasmPasswordBackend->>BrowserIndexedDB: open mmapper-credentials/passwords
        BrowserIndexedDB-->>WasmPasswordBackend: stored record or none
        alt record_found
            WasmPasswordBackend-->>PasswordConfig: malloc'd UTF8 password
            PasswordConfig-->>Proxy: sig_incomingPassword(QString)
        else no_record
            WasmPasswordBackend-->>PasswordConfig: empty string
            PasswordConfig-->>Proxy: no signal (silent)
        end
    else Other_platforms_with_QtKeychain
        Proxy->>PasswordConfig: getPassword()
        PasswordConfig->>QtKeychain_ReadJob: start()
        QtKeychain_ReadJob-->>PasswordConfig: finished with password or error
        PasswordConfig-->>Proxy: sig_incomingPassword or sig_error
    end
Loading

Class diagram for updated PasswordConfig and GeneralPage auto-login behavior

classDiagram
    class PasswordConfig {
        +PasswordConfig(QObject *parent)
        +void setPassword(QString password)
        +void getPassword()
        +void deletePassword()
        +void sig_error(QString msg)
        +void sig_incomingPassword(QString password)
    }

    class GeneralPage {
        +GeneralPage(QWidget *parent)
        -Ui_GeneralPage *ui
        -PasswordConfig passCfg
        -bool m_passwordFieldHasDummy
        +void slot_loadConfig()
    }

    class Configuration_AccountSettings {
        +QString accountName
        +bool accountPassword
        +bool rememberLogin
        +void read(QSettings conf)
    }

    class Proxy {
        +void allocMudTelnet()
        +PasswordConfig~ getPasswordConfig()
    }

    class WasmPasswordBackend {
        +void wasm_store_password(char *key, char *password)
        +char * wasm_read_password(char *key)
        +void wasm_delete_password(char *key)
        <<WASM JS bridge>>
    }

    GeneralPage --> PasswordConfig : owns
    GeneralPage --> Configuration_AccountSettings : uses_config
    Proxy --> PasswordConfig : uses
    PasswordConfig --> WasmPasswordBackend : calls_on_WASM
Loading

File-Level Changes

Change Details Files
Add WebAssembly-specific credential storage and retrieval in PasswordConfig and expose password deletion across backends.
  • Introduce EM_JS/EM_ASYNC_JS helpers to store, read, and delete AES-GCM-256–encrypted passwords in IndexedDB on WASM, keyed by a constant string.
  • Wire PasswordConfig::setPassword/getPassword to the WASM JS bridge while retaining QtKeychain behavior on other platforms and emitting errors when unavailable.
  • Add PasswordConfig::deletePassword that removes credentials via wasm_delete_password on WASM or QKeychain::DeletePasswordJob elsewhere, with error signaling.
src/configuration/PasswordConfig.cpp
src/configuration/PasswordConfig.h
Align auto-login preferences UI with credential state and support clearing/deleting stored passwords from the UI, including WASM-specific input handling.
  • On WASM, disable input methods on account name/password fields to avoid duplicate characters from Qt WASM input handling.
  • Tie the auto-login checkbox to enabling/disabling account fields, clearing config and UI state, and deleting stored passwords when auto-login is turned off.
  • Change password editing logic to handle dummy placeholder dots, only persist non-empty values, delete passwords when cleared, and track dummy state via a new member flag.
  • Update the show/hide password button to toggle between dummy bullets and real password retrieval via PasswordConfig::getPassword, and to reflect stored-password presence on load.
  • Adjust NO_QTKEYCHAIN logic so WASM can still use remember-login, and ensure UI initialization mirrors the new behavior on load.
src/preferences/generalpage.cpp
src/preferences/generalpage.h
src/preferences/generalpage.ui
src/configuration/configuration.cpp
Prevent WASM ASYNCIFY stack overflows by deferring auto-login password retrieval out of the GMCP signal chain.
  • In the Telnet allocation path, wrap auto-login password retrieval for WASM in a QTimer::singleShot(0) targeted at the Proxy to invoke PasswordConfig::getPassword() on the next event loop iteration.
  • Keep non-WASM behavior synchronous by calling getPassword() directly when auto-login preconditions are met.
src/proxy/proxy.cpp
Maintain compatibility with Qt 6.10+ QMetaEnum API changes.
  • Update the MUME clock helper to dispatch QMetaEnum::valueToKey() calls differently based on Qt version, casting to quint64 for Qt 6.10+ and using int for earlier versions.
src/clock/mumeclock.cpp
Adjust build configuration for newer GLM, Clang/Qt 6.10 toolchain quirks, QtKeychain macOS deployment, and immer dependency fetching.
  • Enable GLM experimental extensions globally and include epsilon utilities to satisfy newer GLM usage in OpenGL line rendering.
  • Relax Clang CTAD warnings from -Werror to -Wno-ctad-maybe-unsupported to avoid build failures with Qt 6.10 moc-generated code.
  • Quote the QtKeychain libpath argument in the macOS macdeployqt command to prevent deployment failures.
  • Change the immer external project to use a shallow Git clone of the tagged version instead of a fixed tarball archive.
CMakeLists.txt
external/immer/CMakeLists.txt
src/CMakeLists.txt
src/opengl/LineRendering.cpp

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In the WASM getPassword() implementation, wasm_read_password returning null is treated as an error and emits sig_error, which means a missing entry (e.g. no password ever saved or already deleted) is reported the same as an actual storage failure; consider distinguishing "not found" from real errors so normal states don’t show as failures.
  • The m_passwordFieldHasDummy flag is set/reset in several places but is never used to alter behavior (e.g. clearing dummy dots on first real edit), so either wire it into the password field logic or remove it to avoid dead state.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the WASM `getPassword()` implementation, `wasm_read_password` returning null is treated as an error and emits `sig_error`, which means a missing entry (e.g. no password ever saved or already deleted) is reported the same as an actual storage failure; consider distinguishing "not found" from real errors so normal states don’t show as failures.
- The `m_passwordFieldHasDummy` flag is set/reset in several places but is never used to alter behavior (e.g. clearing dummy dots on first real edit), so either wire it into the password field logic or remove it to avoid dead state.

## Individual Comments

### Comment 1
<location path="src/configuration/PasswordConfig.cpp" line_range="67-57" />
<code_context>
+EM_ASYNC_JS(char *, wasm_read_password, (const char *key), {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** `wasm_read_password` conflates not-found and error cases, which makes caller logic treat both as errors.

The function currently returns `0` both when no record exists and when an exception occurs. On the C++ side, `getPassword()` treats any resulting `nullptr` as `sig_error("Failed to retrieve password from browser storage.")`, so a normal “no password stored yet” case is surfaced as an error. To allow correct handling (e.g., quiet UX and different flows for “not found” vs. real failure), return a distinct status or sentinel value so the C++ side can differentiate the two cases.

Suggested implementation:

```cpp
 // Read and decrypt a password from IndexedDB.
 // Returns:
 //   - malloc'd UTF-8 string with the password on success
 //   - malloc'd empty string ("") when no record exists (not found)
 //   - NULL on unexpected error
 // Caller must free() the returned pointer when non-NULL.
 EM_ASYNC_JS(char *, wasm_read_password, (const char *key), {

```

To fully implement the distinction between "not found" and "error", you should also:

1. **Inside `wasm_read_password` (JS body in the same file):**
   - Locate the code that currently handles the "no record" case, which likely looks similar to:
     ```js
     if (!result) {
         return 0;
     }
     ```
     or:
     ```js
     if (getReq.result === undefined) {
         return 0;
     }
     ```
   - Replace that "not found" branch with something that returns an allocated empty string:
     ```js
     if (getReq.result === undefined) {
         // Not found: return a malloc'd empty string so C++ can distinguish it.
         const emptyPtr = _malloc(1);
         HEAPU8[emptyPtr] = 0; // null terminator
         return emptyPtr;
     }
     ```
   - Keep returning `0` (`NULL`) only for genuine error paths (e.g., inside `catch (e)`).

2. **On the C++ side where `wasm_read_password` is consumed (likely in `PasswordConfig::getPassword()`):**
   - After calling `wasm_read_password`, distinguish the three cases:
     ```cpp
     char* raw = wasm_read_password(key.c_str());
     if (!raw) {
         // NULL => real failure
         sig_error("Failed to retrieve password from browser storage.");
     }

     if (raw[0] == '\0') {
         // Empty string => "no password stored yet" (not found).
         // Handle this as the benign "not configured" case, e.g.:
         //   - quiet UX
         //   - alternative flow
         //   - ensure you still free(raw)
         std::string password; // empty => not found
         free(raw);
         return password;
     }

     // Normal success path with a non-empty password
     std::string password(raw);
     free(raw);
     return password;
     ```
   - Ensure all call sites that currently treat `nullptr` as “no password stored yet” are updated to interpret **empty string** as “not found” and **`nullptr`** as “real error”.

3. **Update any related documentation or comments** that still state "NULL on error/not found" to reflect the new tri-state behavior (success / not-found / error) to keep future changes correct.
</issue_to_address>

### Comment 2
<location path="src/preferences/generalpage.cpp" line_range="23-32" />
<code_context>
+class WasmInputDeduplicateFilter final : public QObject
</code_context>
<issue_to_address>
**suggestion:** The input deduplication filter may interfere with more complex input methods or multi-character commits.

This assumes a 1:1 mapping between `QKeyEvent::text()` and `QInputMethodEvent::commitString()`. For IME/composed input (especially on WASM), `commitString()` can contain multiple characters or differ from the key event text, so the `m_lastText == text/commit` heuristic could suppress valid commits. Consider tightening the suppression criteria (e.g., only for single printable characters with matching key/modifier state) or limiting this filter to contexts where simple Latin input is expected, to avoid breaking IME-heavy languages.

Suggested implementation:

```cpp
        if (event->type() == QEvent::KeyPress) {
            auto *ke = static_cast<QKeyEvent *>(event);
            const QString text = ke->text();

            // Only consider simple single-character, printable key events without
            // Ctrl/Alt/Meta modifiers for deduplication. This avoids interfering
            // with IMEs and composed/multi-character input where commitString()
            // may not have a 1:1 mapping with QKeyEvent::text().
            const bool isSimpleChar =
                text.size() == 1 &&
                !text.at(0).isControl() &&
                !(ke->modifiers() & (Qt::ControlModifier |
                                     Qt::AltModifier |
                                     Qt::MetaModifier));

            if (!isSimpleChar) {
                // Do not track this key event for deduplication – it likely
                // belongs to a more complex input method. Clear last text so that
                // subsequent QInputMethodEvent commits are not accidentally
                // suppressed.
                m_lastText.clear();
                return QObject::eventFilter(obj, event);
            }

```

Because only part of the class is visible, you should also adjust the `QInputMethodEvent` handling inside `WasmInputDeduplicateFilter`:

1. When you decide whether to suppress the `QInputMethodEvent`, require that:
   - `event->type() == QEvent::InputMethod`,
   - `const auto *ime = static_cast<QInputMethodEvent *>(event);`
   - `const QString commit = ime->commitString();`
   - `commit.size() == 1` and `!commit.at(0).isControl()`,
   - and `commit == m_lastText`.
   Only in that case should you return `true` (to suppress the duplicate commit). For any other `commitString()` (empty, multi-character, or not matching `m_lastText`), allow the event to pass through (return `false`).

2. Ensure `m_lastText` is set only in the “simple character” `QKeyEvent` path (after the block shown above) and cleared when appropriate (e.g. after a successful deduplication or when a non-deduplicated input method event is seen).

3. If there is any logic that currently suppresses on `m_lastText == text/commit` without these extra checks, tighten it to use the criteria described above, so IME-heavy languages and multi-character commits are not affected.
</issue_to_address>

### Comment 3
<location path="src/clock/mumeclock.cpp" line_range="81-83" />
<code_context>
 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<quint64>(value));
+    }
     NODISCARD static int keyCount() { return g_qme.keyCount(); }
</code_context>
<issue_to_address>
**issue (bug_risk):** Casting `int` to `quint64` in `valueToKey` could mis-handle negative values or out-of-range inputs.

The previous version passed the `int` directly; now it’s converted to `quint64` first. If `value` is ever negative or otherwise out of range, this will be reinterpreted as a large unsigned integer and cause incorrect lookups. If negatives are impossible, consider enforcing that via an assertion or a `quint64` parameter; otherwise, add validation or clamping before casting.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@codecov
Copy link

codecov bot commented Feb 28, 2026

Codecov Report

❌ Patch coverage is 74.35897% with 60 lines in your changes missing coverage. Please review.
✅ Project coverage is 25.76%. Comparing base (26d1a9d) to head (d03d10d).
⚠️ Report is 208 commits behind head on master.

Files with missing lines Patch % Lines
src/preferences/generalpage.cpp 0.00% 47 Missing ⚠️
src/configuration/PasswordConfig.cpp 0.00% 9 Missing ⚠️
tests/TestPasswordField.cpp 97.56% 3 Missing ⚠️
src/proxy/proxy.cpp 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           master     #468       +/-   ##
===========================================
- Coverage   66.48%   25.76%   -40.72%     
===========================================
  Files          85      501      +416     
  Lines        4186    41466    +37280     
  Branches      255     4515     +4260     
===========================================
+ Hits         2783    10684     +7901     
- Misses       1403    30782    +29379     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…erialize a non-extractable CryptoKey object via the structured clone algorithm. Chrome handles this fine, Firefox doesn't.

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.
Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The m_passwordFieldHasDummy flag in GeneralPage is written in several places but never read, so it can either be removed or wired into logic that actually depends on the dummy-password state to avoid dead state and future confusion.
  • In the WASM password storage EM_JS helpers, you are storing the AES-GCM raw key alongside the ciphertext and IV in IndexedDB, which largely defeats the purpose of encryption; consider either simplifying to plaintext storage or moving the key material to a different, better-protected location (or deriving it) so the added complexity has a security benefit.
  • The IndexedDB open/upgrade/transaction logic is duplicated across wasm_store_password, wasm_read_password, and wasm_delete_password; factoring this into a shared JS helper (e.g., a small internal module on Module) would make future schema changes or error handling tweaks less error-prone.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `m_passwordFieldHasDummy` flag in `GeneralPage` is written in several places but never read, so it can either be removed or wired into logic that actually depends on the dummy-password state to avoid dead state and future confusion.
- In the WASM password storage EM_JS helpers, you are storing the AES-GCM raw key alongside the ciphertext and IV in IndexedDB, which largely defeats the purpose of encryption; consider either simplifying to plaintext storage or moving the key material to a different, better-protected location (or deriving it) so the added complexity has a security benefit.
- The IndexedDB open/upgrade/transaction logic is duplicated across `wasm_store_password`, `wasm_read_password`, and `wasm_delete_password`; factoring this into a shared JS helper (e.g., a small internal module on `Module`) would make future schema changes or error handling tweaks less error-prone.

## Individual Comments

### Comment 1
<location path="src/preferences/generalpage.cpp" line_range="226-234" />
<code_context>
+        }
     });

     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();
</code_context>
<issue_to_address>
**issue (bug_risk):** The m_passwordFieldHasDummy flag is written in several places but never read, which suggests either dead state or a missing branch that should consult it.

This flag is set in multiple places (auto-login off, password textEdited, showPassword toggle, slot_loadConfig) but never consulted to affect behavior, so it’s effectively dead state. If it’s meant to distinguish dummy bullets from a real password, that logic is unfinished. Either use the flag in the show/hide/retrieval flow where appropriate, or remove it to avoid misleading state.
</issue_to_address>

### Comment 2
<location path="src/configuration/PasswordConfig.cpp" line_range="25-34" />
<code_context>
+EM_JS(void, wasm_store_password, (const char *key, const char *password), {
</code_context>
<issue_to_address>
**🚨 question (security):** Storing the AES key alongside the ciphertext in IndexedDB weakens the value of encrypting the password at rest.

The current JS flow generates an AES-GCM key, encrypts the password, then stores the raw key, IV, and ciphertext together in IndexedDB. Anyone who can read that IndexedDB entry can decrypt the password, so this only guards against accidental leaks, not a determined attacker. If you want protection from other contexts/extensions in the same profile, consider using a non-extractable Web Crypto CryptoKey (and solving Firefox’s DataCloneError differently), or document this trade-off and threat model explicitly in a comment.
</issue_to_address>

### Comment 3
<location path="tests/TestWasmInputFilter.cpp" line_range="79" />
<code_context>
+    QCOMPARE(edit.text(), QString("a"));
+}
+
+void TestWasmInputFilter::testDifferentCharsNotSuppressed()
+{
+    QLineEdit edit;
</code_context>
<issue_to_address>
**suggestion (testing):** Add coverage for multi-character commit strings (IME / paste) to ensure the filter does not over-suppress input.

Current tests cover only single-character input. Since `QInputMethodEvent::commitString()` can include multiple characters (IME, paste, autocomplete), please add tests for multi-character commits, for example:

* A `KeyPress` for a single character followed by an `InputMethod` with a longer commit string (e.g. "ab"), asserting that only true duplicates are suppressed.
* Two `InputMethod` events with multi-character commits (e.g. "あ" then "い") to confirm they are not incorrectly filtered.

This will verify the filter behaves correctly for realistic IME/paste scenarios on WASM.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@KasparMetsa
Copy link
Contributor Author

@sourcery-ai review

Copy link

@SourceryAI SourceryAI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • The password visibility toggle logic in GeneralPage relies on hard-coded button text values ("Show Password"/"Hide Password"), which is fragile and bypasses any future localization; consider tracking the state with an internal boolean or the line edit’s echoMode instead of string comparisons.
  • The WASM password storage helpers assume crypto.subtle and IndexedDB are available; you might want to guard these with capability checks and surface a clearer error path (e.g., a specific error signal) when running in environments that lack these APIs.
  • The AccountSettings::read logic for rememberLogin using (NO_QTKEYCHAIN && CURRENT_PLATFORM != PlatformEnum::Wasm) ? false : ... is a bit hard to parse; consider refactoring into named constexprs or an explicit if to make the platform/QtKeychain conditions more readable.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The password visibility toggle logic in `GeneralPage` relies on hard-coded button text values ("Show Password"/"Hide Password"), which is fragile and bypasses any future localization; consider tracking the state with an internal boolean or the line edit’s `echoMode` instead of string comparisons.
- The WASM password storage helpers assume `crypto.subtle` and IndexedDB are available; you might want to guard these with capability checks and surface a clearer error path (e.g., a specific error signal) when running in environments that lack these APIs.
- The `AccountSettings::read` logic for `rememberLogin` using `(NO_QTKEYCHAIN && CURRENT_PLATFORM != PlatformEnum::Wasm) ? false : ...` is a bit hard to parse; consider refactoring into named constexprs or an explicit `if` to make the platform/QtKeychain conditions more readable.

## Individual Comments

### Comment 1
<location path="src/configuration/PasswordConfig.cpp" line_range="41-50" />
<code_context>
+            var rawKey = new Uint8Array(await crypto.subtle.exportKey("raw", cryptoKey));
</code_context>
<issue_to_address>
**🚨 suggestion (security):** Storing the raw AES key next to the ciphertext largely nullifies the benefit of encryption at rest in IndexedDB.

Because `rawKey`, `iv`, and `ciphertext` are stored together in the same record, any script with access to this origin’s IndexedDB can decrypt the password, so this is closer to obfuscation than true at‑rest encryption. If you need protection against XSS or similar, consider deriving the key from a user secret (e.g., passphrase) or a non-persisted per‑session key so it can’t be recovered from storage alone. Otherwise, please document this tradeoff clearly and centralize the crypto parameters (algorithm, key length, IV size) so they can be revised more easily later.

Suggested implementation:

```cpp
            );

            // NOTE:
            // This encryption is intended to prevent casual inspection of stored passwords
            // in IndexedDB. Because any script with access to this origin can also access
            // IndexedDB, this does NOT protect against XSS or other in-origin attackers.
            // The AES parameters are centralized here so they can be revised more easily later.
            const AES_GCM_PARAMS = { name: "AES-GCM" };
            const AES_GCM_IV_LENGTH_BYTES = 12;

            var iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_LENGTH_BYTES));
            var encoded = new TextEncoder().encode(passwordStr);
            var ciphertext = await crypto.subtle.encrypt(
                { ...AES_GCM_PARAMS, iv: iv },
                cryptoKey,
                encoded
            );

```

1. Ensure that wherever the encrypted password record is written to IndexedDB, only `iv` and `ciphertext` (and any needed metadata) are stored; remove any reference to `rawKey` or the exported key material from the stored record.
2. Update the corresponding decryption logic to obtain or derive `cryptoKey` without reading it from IndexedDB (e.g., reusing an in-memory per-session key or deriving it from a user secret, depending on your threat model).
3. If stronger protection against XSS or origin-compromising attackers is required, introduce a key derivation step (e.g., using PBKDF2 or WebCrypto’s HKDF) from a user-supplied passphrase, and document that behavior near this code and in your higher-level documentation.
</issue_to_address>

### Comment 2
<location path="src/proxy/proxy.cpp" line_range="517-508" />
<code_context>
+                    // 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 {
</code_context>
<issue_to_address>
**issue (bug_risk):** Capturing `this` in the deferred lambda risks a use-after-free if `LocalMudTelnetOutputs` is destroyed before the timer fires.

`QTimer::singleShot` being tied to `Proxy` only guarantees the `Proxy` stays alive, not `LocalMudTelnetOutputs`. If `LocalMudTelnetOutputs` is destroyed while `Proxy` remains, the lambda can still run and dereference a dangling `this`. Instead, either avoid capturing `this` by only using data guaranteed to outlive the timer, capture a `QPointer<LocalMudTelnetOutputs>` and check it before use, or emit a signal and handle this logic in a slot so Qt manages lifetimes safely.
</issue_to_address>

### Comment 3
<location path="src/preferences/generalpage.cpp" line_range="214-223" />
<code_context>
     });

     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.
+            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();
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Overwriting the stored password with the first typed character when editing dummy dots may surprise users and lose the original password prematurely.

Because `m_passwordFieldHasDummy` is set, the first edit clears the field and immediately calls `passCfg.setPassword(newChar)`, replacing the stored password with a single character while the user is still typing. If the app crashes or the user navigates away before finishing and saving, the original password is lost. Consider deferring `setPassword` until the edit is complete (e.g., on focus-out/Enter), or at least buffer the full new password locally and only update once there is a confirmed non-empty value.

Suggested implementation:

```cpp
    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.
            const QSignalBlocker blocker(ui->accountPassword);
            const QString newChar = password.right(1);
            ui->accountPassword->setText(newChar);
            // Update the "has password" flag based on the current field content,
            // but do not overwrite the stored password yet.
            setConfig().account.accountPassword = !newChar.isEmpty();
            return;
        }
        setConfig().account.accountPassword = !password.isEmpty();
        if (!password.isEmpty()) {
            passCfg.setPassword(password);
        } else {
            passCfg.deletePassword();
        }
    });

```

If you want to go further and only persist the new password once editing is "complete", you can:
1. Connect to `ui->accountPassword`'s `editingFinished` signal and, in that handler, call `passCfg.setPassword(ui->accountPassword->text())` when non-empty, or `passCfg.deletePassword()` when empty.
2. In that case, you should remove or adjust the `passCfg.setPassword(...)` and `passCfg.deletePassword()` calls from the `textEdited` handler to avoid redundant writes and keep all persistence in the "edit complete" path.
</issue_to_address>

Hi @KasparMetsa! 👋

Thanks for trying out Sourcery by commenting with @sourcery-ai review! 🚀

Install the sourcery-ai bot to get automatic code reviews on every pull request ✨

Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In the WASM password EM_JS helpers, the IndexedDB open/upgrade logic is duplicated across store/read/delete; consider factoring this into a shared JS helper to keep the DB name/store/version handling consistent and easier to maintain.
  • wasm_read_password() does not participate in the Module._passwordSaveQueue sequencing used by wasm_store_password()/wasm_delete_password(), so reads can race with in-flight writes/deletes; if ordering matters, consider routing reads through the same promise chain or an explicit queue.
  • In GeneralPage::GeneralPage(), the accountPassword textEdited handler treats the first edit over dummy dots as a single new character (password.right(1)), which will drop all but the last character if the user pastes or types multiple characters at once; you may want to preserve the full new input instead of truncating it.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In the WASM password EM_JS helpers, the IndexedDB open/upgrade logic is duplicated across store/read/delete; consider factoring this into a shared JS helper to keep the DB name/store/version handling consistent and easier to maintain.
- wasm_read_password() does not participate in the Module._passwordSaveQueue sequencing used by wasm_store_password()/wasm_delete_password(), so reads can race with in-flight writes/deletes; if ordering matters, consider routing reads through the same promise chain or an explicit queue.
- In GeneralPage::GeneralPage(), the accountPassword textEdited handler treats the first edit over dummy dots as a single new character (password.right(1)), which will drop all but the last character if the user pastes or types multiple characters at once; you may want to preserve the full new input instead of truncating it.

## Individual Comments

### Comment 1
<location path="src/preferences/generalpage.cpp" line_range="213-219" />
<code_context>
     });

     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.
+            const QSignalBlocker blocker(ui->accountPassword);
+            const QString newChar = password.right(1);
+            ui->accountPassword->setText(newChar);
+            setConfig().account.accountPassword = !newChar.isEmpty();
</code_context>
<issue_to_address>
**issue (bug_risk):** Using `password.right(1)` when overwriting the dummy bullets breaks multi-character edits (e.g., paste) for the first edit.

Because `m_passwordFieldHasDummy` assumes the first `textEdited` is a single keystroke and keeps only `password.right(1)`, any multi-character edit (paste, IME, etc.) will be truncated to the last character, and the stored password will briefly be that single character.

In this branch, consider treating `password` as the full new value (removing the dummy bullets but preserving all characters), e.g.:

```cpp
const QSignalBlocker blocker(ui->accountPassword);
ui->accountPassword->setText(password);
passCfg.setPassword(password);
```

`QSignalBlocker` should still prevent re-entry loops.
</issue_to_address>

### Comment 2
<location path="src/preferences/generalpage.cpp" line_range="235-244" />
<code_context>
+    connect(ui->showPassword, &QAbstractButton::clicked, this, [this]() {
</code_context>
<issue_to_address>
**suggestion:** The show/hide logic doesn’t allow revealing a password that exists only in the current text field (not persisted).

`showPassword` currently handles only two cases: toggling back to hidden when the button text is "Hide Password", and calling `passCfg.getPassword()` when `getConfig().account.accountPassword` is true. When `account.accountPassword` is false but the user has typed a password into `accountPassword`, clicking the button does nothing. If users should be able to reveal what they’ve just typed even before it’s persisted, consider handling the case where the field is non-empty but no stored password exists by toggling the echo mode directly instead of calling `getPassword()`.

Suggested implementation:

```cpp
    connect(ui->showPassword, &QAbstractButton::clicked, this, [this]() {
        const bool hasStoredPassword = getConfig().account.accountPassword;
        const bool hasFieldText = !ui->accountPassword->text().isEmpty();

        if (ui->showPassword->text() == "Hide Password") {
            // Hide: restore password mode
            ui->showPassword->setText("Show Password");
            ui->accountPassword->setEchoMode(QLineEdit::Password);

            // Only restore dummy dots if we know a stored password exists.
            // For an unsaved password, QLineEdit::Password will handle masking.
            if (hasStoredPassword) {
                const QSignalBlocker blocker(ui->accountPassword);
                ui->accountPassword->setText(QString(8, QChar(0x2022)));
            }

            return;
        }

        // Show password
        if (hasStoredPassword) {
            const QString password = passCfg.getPassword();
            if (!password.isEmpty()) {
                const QSignalBlocker blocker(ui->accountPassword);
                ui->accountPassword->setEchoMode(QLineEdit::Normal);
                ui->accountPassword->setText(password);
                ui->showPassword->setText("Hide Password");
            }
        } else if (hasFieldText) {
            // No stored password, but user has typed something:
            // just toggle echo mode to reveal what is currently in the field.
            ui->accountPassword->setEchoMode(QLineEdit::Normal);
            ui->showPassword->setText("Hide Password");
        }

```

1. The snippet you provided ends in the middle of the lambda. After applying this replacement, remove any remaining original code that continues the old `showPassword` handler (e.g., the previous `else` branch, redundant `});`, or extra braces) so that there is only one complete lambda body ending with `});`.
2. Ensure that `passCfg.getPassword()` indeed returns a `QString`. If it returns another type (e.g., `std::optional<QString>`), adjust the retrieval and empty-check logic accordingly.
3. If there is centralized handling of button text (e.g., via `tr("Show Password")` / `tr("Hide Password")`), replace the string literals here with the existing translated strings to keep localization consistent.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants