diff --git a/src/app.cpp b/src/app.cpp index 5f5adcf..646994d 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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 diff --git a/src/app.h b/src/app.h index 4307950..b089714 100644 --- a/src/app.h +++ b/src/app.h @@ -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_{}; diff --git a/src/app_security.cpp b/src/app_security.cpp index 0ea28a7..896be28 100644 --- a/src/app_security.cpp +++ b/src/app_security.cpp @@ -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(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 diff --git a/src/main.cpp b/src/main.cpp index 8b28465..ff66bc2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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; }; diff --git a/src/util/idle_tracker.h b/src/util/idle_tracker.h new file mode 100644 index 0000000..c417873 --- /dev/null +++ b/src/util/idle_tracker.h @@ -0,0 +1,22 @@ +// Idle tracking for the auto-lock: pure, header-only, main-thread only. +#pragma once +#include + +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(now - last_).count(); + } +private: + Clock::time_point last_; +}; + +} // namespace util +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 3c78242..615111d 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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();