fix(core): drive auto-lock idle from real input, not widget focus

The auto-lock idle clock was reset whenever any ImGui item was active,
so a text field left focused (a half-filled send form, say) counted as
continuous activity and kept the wallet unlocked indefinitely, well past
the configured idle timeout.

- The idle clock is now fed by real input events (key, text, mouse,
  touch, pen, gamepad button) as they are ingested on the main thread,
  plus a still-held mouse button so a drag or scrollbar grab that emits
  no new event still counts. A focused but untouched widget no longer
  does.
- A new header-only IdleTracker owns the clock and never moves backwards,
  so an event stamped before the previous one cannot shorten the idle
  time.

Behaviour change: a form left focused and idle past the timeout now
auto-locks. The form's buffers are app state and survive the lock and
unlock, so nothing typed is lost.

Tests: a new IdleTracker case covers idle time growing with no events
(enough to trip a 900s lock), activity resetting it, the clock never
moving backwards, and a query before the last activity reading zero
rather than a negative value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 21:42:23 -05:00
co-authored by Claude Opus 4.8
parent 1ea584311e
commit 0aaf33ecf0
6 changed files with 66 additions and 12 deletions
+7 -4
View File
@@ -868,14 +868,17 @@ void App::update()
// If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe).
processWalletSwitchRevert();
// Track user interaction for auto-lock. Not in the background: a minimized window runs update() without
// new frames, so the IO state is the last frame's, and a stale mouse delta kept auto-lock from ever firing.
// Real input arrives through App::noteUserInput() (main.cpp event ingest). What stays here covers input
// that produces no new SDL event this frame: a held mouse button (a drag or scrollbar grab without
// motion). NOT in the background: a minimized window runs update() with the last frame's IO, and a stale
// mouse delta once kept auto-lock from ever firing. Deliberately NOT IsAnyItemActive(): a focused text
// field stays active indefinitely (C21).
if (!window_background_ &&
(io.MouseDelta.x != 0 || io.MouseDelta.y != 0 ||
io.MouseClicked[0] || io.MouseClicked[1] ||
io.InputQueueCharacters.Size > 0 ||
ImGui::IsAnyItemActive())) {
last_interaction_ = std::chrono::steady_clock::now();
ImGui::IsAnyMouseDown())) {
idle_.noteActivity();
}
// Advance the refresh timers by the real time since the last update BEFORE the RPC results are
+7 -2
View File
@@ -43,6 +43,7 @@
#include "util/background_sampler.h"
#include "util/coalescing_writer.h"
#include "util/fetch_backoff.h"
#include "util/idle_tracker.h"
#include "util/pool_stats_service.h"
#include "wallet/wallet_capabilities.h"
#include "chat/chat_service.h"
@@ -559,6 +560,9 @@ public:
// output parser skips while the wallet is locked), so a background witness rebuild that started and
// finished meanwhile isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
void skipDaemonOutputBacklog();
// A real user input event (key, text, mouse, touch, pen, gamepad button) was ingested. Main thread only.
// Called from main.cpp's noteEvent; do NOT call from an SDL event watch/filter (resizeWatcher).
void noteUserInput() { idle_.noteActivity(); }
void refreshMiningInfo();
void refreshPeerInfo();
void refreshMarketData();
@@ -1935,8 +1939,9 @@ private:
char wizard_pin_confirm_buf_[16] = {};
std::string wizard_pin_status_;
// Auto-lock on idle
std::chrono::steady_clock::time_point last_interaction_ = std::chrono::steady_clock::now();
// Auto-lock on idle. Fed by real input events (noteUserInput, from main.cpp's event ingest), NOT by
// widget state: a focused-but-untouched text field must not keep the wallet unlocked (C21).
util::IdleTracker idle_;
// Wall-clock delta for logic timers (see logicDeltaTime()).
std::chrono::steady_clock::time_point last_update_time_{};
+4 -5
View File
@@ -479,7 +479,7 @@ void App::applyUnlockSuccess(const std::string& passphrase, int timeout) {
lock_error_msg_.clear();
lock_attempts_ = 0;
memset(lock_passphrase_buf_, 0, sizeof(lock_passphrase_buf_));
last_interaction_ = std::chrono::steady_clock::now();
idle_.noteActivity();
state_.encrypted = true;
state_.locked = false;
state_.unlocked_until = std::time(nullptr) + timeout;
@@ -726,17 +726,16 @@ void App::checkAutoLock() {
|| (xmrig_manager_ && xmrig_manager_->isRunning());
if (miningActive) {
// Keep resetting the idle timer so we don't lock the instant mining stops
last_interaction_ = std::chrono::steady_clock::now();
idle_.noteActivity();
return;
}
int timeout = settings_ ? settings_->getAutoLockTimeout() : 300;
if (timeout <= 0) return; // disabled
auto now = std::chrono::steady_clock::now();
float elapsed = std::chrono::duration<float>(now - last_interaction_).count();
const double elapsed = idle_.idleSeconds();
if (elapsed >= (float)timeout) {
if (elapsed >= (double)timeout) {
// Lite has no daemon `walletlock` — App::lockWallet() early-returns without rpc_, so route
// lite through lockLiteWallet() (locks the backend + tears down the chat session so no
// decrypted store / unlocked DB key survives the idle lock). In full-node builds
+4 -1
View File
@@ -1605,7 +1605,10 @@ int main(int argc, char* argv[])
Uint64 lastFrameStartNs = 0; // software frame cap bookkeeping (SDL_GetTicksNS)
int settleFrames = kSettleFrames;
auto noteEvent = [&](const SDL_Event& e) {
if (isInputEvent(e.type)) lastInputMs = SDL_GetTicks();
if (isInputEvent(e.type)) {
lastInputMs = SDL_GetTicks();
app.noteUserInput(); // auto-lock idle clock (C21): real input only, never widget state
}
else if (isWindowStateEvent(e.type)) settleFrames = kSettleFrames;
};
+22
View File
@@ -0,0 +1,22 @@
// Idle tracking for the auto-lock: pure, header-only, main-thread only.
#pragma once
#include <chrono>
namespace dragonx {
namespace util {
class IdleTracker {
public:
using Clock = std::chrono::steady_clock;
explicit IdleTracker(Clock::time_point start = Clock::now()) : last_(start) {}
// Never moves backwards (an event stamped before the last one must not shorten the idle time).
void noteActivity(Clock::time_point t = Clock::now()) { if (t > last_) last_ = t; }
double idleSeconds(Clock::time_point now = Clock::now()) const {
return now <= last_ ? 0.0 : std::chrono::duration<double>(now - last_).count();
}
private:
Clock::time_point last_;
};
} // namespace util
} // namespace dragonx
+22
View File
@@ -75,6 +75,7 @@
#include "util/seed_phrase.h"
#include "util/local_time.h"
#include "util/text_fit.h"
#include "util/idle_tracker.h"
#include "util/image_decode_core.h"
#include "daemon/daemon_startup_diagnosis.h"
#include "util/amount_format.h"
@@ -1132,6 +1133,26 @@ void testSpendableFiltering()
}
// S8: deferFor() is an absolute hold, independent of the page interval (the retry-storm fix).
void testIdleTracker()
{
using dragonx::util::IdleTracker;
using Clock = IdleTracker::Clock;
const auto t0 = Clock::now();
IdleTracker idle(t0);
EXPECT_NEAR(idle.idleSeconds(t0), 0.0, 1e-9);
// No events: idle time keeps growing, however long a (conceptual) field stays focused.
EXPECT_NEAR(idle.idleSeconds(t0 + std::chrono::seconds(900)), 900.0, 1e-6);
EXPECT_TRUE(idle.idleSeconds(t0 + std::chrono::seconds(901)) >= 900.0); // would trip a 900 s auto-lock
// Activity resets it.
idle.noteActivity(t0 + std::chrono::seconds(600));
EXPECT_NEAR(idle.idleSeconds(t0 + std::chrono::seconds(900)), 300.0, 1e-6);
// Never moves backwards.
idle.noteActivity(t0 + std::chrono::seconds(100));
EXPECT_NEAR(idle.idleSeconds(t0 + std::chrono::seconds(900)), 300.0, 1e-6);
// A query before the last activity is 0, not negative.
EXPECT_NEAR(idle.idleSeconds(t0 + std::chrono::seconds(10)), 0.0, 1e-9);
}
void testRefreshSchedulerDeferFor()
{
using dragonx::services::RefreshScheduler;
@@ -17475,6 +17496,7 @@ int main()
testAmountFormatting();
testSpendableFiltering();
testRefreshScheduler();
testIdleTracker();
testRefreshSchedulerDeferFor();
testRpcBusyGate();
testIsLongWalletMethod();