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:
+7
-4
@@ -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
|
||||
|
||||
@@ -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_{};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user