v1.2.0: UX audit — security fixes, accessibility, and polish

Security (P0):
- Fix sidebar remaining interactive behind lock screen
- Extend auto-lock idle detection to include active widget interactions
- Distinguish missing PIN vault from wrong PIN; auto-switch to passphrase

Blocking UX (P1):
- Add 15s timeout for encryption state check to prevent indefinite loading
- Show restart reason in loading overlay after wallet encryption
- Add Force Quit button on shutdown screen after 10s
- Warn user if embedded daemon fails to start during wizard completion

Polish (P2):
- Use configured explorer URL in Receive tab instead of hardcoded URL
- Increase request memo buffer from 256 to 512 bytes to match Send tab
- Extend notification duration to 5s for critical operations (tx sent,
  wallet encrypted, key import, backup, export)
- Add Reduce Motion accessibility setting (disables page fade + balance lerp)
- Show estimated remaining time during mining thread benchmark
- Add staleness indicator to market price data (warning after 5 min)

New i18n keys: incorrect_pin, incorrect_passphrase, pin_not_set,
restarting_after_encryption, force_quit, reduce_motion, tt_reduce_motion,
ago, wizard_daemon_start_failed
This commit is contained in:
2026-04-04 19:10:58 -05:00
parent bbf53a130c
commit 3ff62ca248
22 changed files with 173 additions and 47 deletions

View File

@@ -93,6 +93,9 @@ static bool sp_gradient_background = false;
// Low-spec mode
static bool sp_low_spec_mode = false;
// Reduce motion (accessibility)
static bool sp_reduce_motion = false;
// Font scale (user accessibility, 1.03.0)
static float sp_font_scale = 1.0f;
@@ -179,6 +182,7 @@ static void loadSettingsPageState(config::Settings* settings) {
sp_theme_effects_enabled = settings->getThemeEffectsEnabled();
sp_low_spec_mode = settings->getLowSpecMode();
effects::setLowSpecMode(sp_low_spec_mode);
sp_reduce_motion = settings->getReduceMotion();
sp_font_scale = settings->getFontScale();
Layout::setUserFontScale(sp_font_scale); // sync with Layout on load
sp_keep_daemon_running = settings->getKeepDaemonRunning();
@@ -232,6 +236,7 @@ static void saveSettingsPageState(config::Settings* settings) {
settings->setScanlineEnabled(sp_scanline_enabled);
settings->setThemeEffectsEnabled(sp_theme_effects_enabled);
settings->setLowSpecMode(sp_low_spec_mode);
settings->setReduceMotion(sp_reduce_motion);
settings->setFontScale(sp_font_scale);
settings->setKeepDaemonRunning(sp_keep_daemon_running);
settings->setStopExternalDaemon(sp_stop_external_daemon);
@@ -672,6 +677,12 @@ void RenderSettingsPage(App* app) {
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_simple_bg"));
ImGui::SameLine(0, Layout::spacingLg());
if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &sp_reduce_motion)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_reduce_motion"));
ImGui::BeginDisabled(sp_low_spec_mode);
ImGui::SameLine(0, Layout::spacingLg());
@@ -962,6 +973,11 @@ void RenderSettingsPage(App* app) {
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_simple_bg_alt"));
if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &sp_reduce_motion)) {
saveSettingsPageState(app->settings());
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("tt_reduce_motion"));
ImGui::BeginDisabled(sp_low_spec_mode);
if (ImGui::Checkbox(TrId("console_scanline", "scanline").c_str(), &sp_scanline_enabled)) {

View File

@@ -380,7 +380,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
// collapsed: when true, sidebar is in icon-only mode (narrow width).
// The caller can toggle collapsed via a reference if a toggle button is desired.
inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHeight,
SidebarStatus& status, bool& collapsed)
SidebarStatus& status, bool& collapsed, bool locked = false)
{
using namespace material;
bool changed = false;
@@ -610,8 +610,12 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
// Click detection
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
// Click detection — block pages that require unlock when locked
bool pageNeedsUnlock = locked &&
item.page != NavPage::Console &&
item.page != NavPage::Peers &&
item.page != NavPage::Settings;
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !pageNeedsUnlock) {
current = item.page;
changed = true;
}
@@ -620,7 +624,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
float iconS = iconHalfSize; // icon half-size in pixels
float iconCY = cursor.y + btnH * 0.5f;
float textY = cursor.y + (btnH - ImGui::GetTextLineHeight()) * 0.5f;
ImU32 textCol = selected ? Primary() : OnSurfaceMedium();
ImU32 textCol = selected ? Primary() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium());
if (showLabels) {
// Measure total width of icon + gap + label, then center.

View File

@@ -144,7 +144,7 @@ void BackupWalletDialog::render(App* app)
s_status = statusMsg;
s_backing_up = false;
if (success) {
Notifications::instance().success(TR("backup_created"));
Notifications::instance().success(TR("backup_created"), 5.0f);
} else {
Notifications::instance().warning(statusMsg);
}

View File

@@ -326,7 +326,7 @@ static void RenderBalanceClassic(App* app)
// Lerp displayed balances toward actual values
{
float dt = ImGui::GetIO().DeltaTime;
float speed = kBalanceLerpSpeed;
float speed = (app->settings() && app->settings()->getReduceMotion()) ? 999.0f : kBalanceLerpSpeed;
auto lerp = [](double& disp, double target, float dt, float spd) {
double diff = target - disp;
if (std::abs(diff) < 1e-9) { disp = target; return; }
@@ -1278,7 +1278,7 @@ static void UpdateBalanceLerp(App* app) {
const float kBalanceLerpSpeed = S.drawElement("tabs.balance", "balance-lerp-speed").sizeOr(8.0f);
const auto& state = app->state();
float dt = ImGui::GetIO().DeltaTime;
float speed = kBalanceLerpSpeed;
float speed = (app->settings() && app->settings()->getReduceMotion()) ? 999.0f : kBalanceLerpSpeed;
auto lerp = [](double& disp, double target, float dt, float spd) {
double diff = target - disp;
if (std::abs(diff) < 1e-9) { disp = target; return; }

View File

@@ -213,7 +213,7 @@ void ExportAllKeysDialog::render(App* app)
s_exporting = false;
if (writeOk) {
s_status = "Exported to: " + filepath;
Notifications::instance().success(TR("export_keys_success"));
Notifications::instance().success(TR("export_keys_success"), 5.0f);
} else {
s_status = "Failed to write file";
Notifications::instance().error("Failed to save key file");

View File

@@ -142,7 +142,7 @@ void ExportTransactionsDialog::render(App* app)
s_status = "Exported " + std::to_string(state.transactions.size()) +
" transactions to: " + filepath;
Notifications::instance().success(TR("export_tx_success"));
Notifications::instance().success(TR("export_tx_success"), 5.0f);
}
}
}

View File

@@ -363,7 +363,7 @@ void ImportKeyDialog::render(App* app)
imported, failed);
s_status = buf;
if (imported > 0) {
Notifications::instance().success(TR("import_key_success"));
Notifications::instance().success(TR("import_key_success"), 5.0f);
}
};
});

View File

@@ -237,6 +237,26 @@ void RenderMarketTab(App* app)
ImVec2(centerX - valSz.x * 0.5f, valY), stats[i].valueCol, stats[i].value.c_str());
}
// ---- STALENESS INDICATOR ----
{
auto fetchTime = market.last_fetch_time;
if (fetchTime.time_since_epoch().count() > 0) {
auto elapsed = std::chrono::steady_clock::now() - fetchTime;
int ageSecs = (int)std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
bool stale = ageSecs > 300; // 5 minutes
if (ageSecs < 60)
snprintf(buf, sizeof(buf), "%s %ds %s", stale ? ICON_MD_WARNING : "", ageSecs, TR("ago"));
else
snprintf(buf, sizeof(buf), "%s %dm %s", stale ? ICON_MD_WARNING : "", ageSecs / 60, TR("ago"));
ImFont* staleFont = capFont;
ImU32 staleCol = stale ? Warning() : WithAlpha(OnSurface(), 100);
float staleY = statsY + ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + Layout::spacingSm();
ImVec2 staleSz = staleFont->CalcTextSizeA(staleFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(staleFont, staleFont->LegacySize,
ImVec2(cardMin.x + Layout::spacingLg(), staleY), staleCol, buf);
}
}
// ---- TRADE BUTTON (top-right of card) ----
if (!currentExchange.pairs.empty()) {
const char* pairName = currentExchange.pairs[s_pair_idx].displayName.c_str();

View File

@@ -1297,10 +1297,28 @@ static void RenderMiningTabContent(App* app)
// Status text above bar
int ct = s_benchmark.current_index < (int)s_benchmark.candidates.size()
? s_benchmark.candidates[s_benchmark.current_index] : 0;
snprintf(buf, sizeof(buf), "%s %d/%d (%dt)",
TR("mining_benchmark_testing"),
s_benchmark.current_index + 1,
(int)s_benchmark.candidates.size(), ct);
// Estimated remaining time
int remaining_tests = (int)s_benchmark.candidates.size() - s_benchmark.current_index;
float elapsed_in_phase = s_benchmark.phase_timer;
float phase_total = (s_benchmark.phase == ThreadBenchmark::Phase::WarmingUp)
? ThreadBenchmark::WARMUP_SECS
: ThreadBenchmark::MEASURE_SECS;
float remaining_in_current = std::max(0.0f, phase_total - elapsed_in_phase);
// Remaining tests after current each need warmup + measure
float est_secs = remaining_in_current
+ (remaining_tests - 1) * (ThreadBenchmark::WARMUP_SECS + ThreadBenchmark::MEASURE_SECS);
int est_min = (int)(est_secs / 60.0f);
int est_sec = (int)est_secs % 60;
if (est_min > 0)
snprintf(buf, sizeof(buf), "%s %d/%d (%dt) ~%dm%ds",
TR("mining_benchmark_testing"),
s_benchmark.current_index + 1,
(int)s_benchmark.candidates.size(), ct, est_min, est_sec);
else
snprintf(buf, sizeof(buf), "%s %d/%d (%dt) ~%ds",
TR("mining_benchmark_testing"),
s_benchmark.current_index + 1,
(int)s_benchmark.candidates.size(), ct, est_sec);
ImVec2 txtSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(barX + (barW - txtSz.x) * 0.5f, barY - txtSz.y - 2.0f * dp),

View File

@@ -11,7 +11,9 @@
#include "receive_tab.h"
#include "send_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../config/version.h"
#include "../../data/wallet_state.h"
#include "../../ui/widgets/qr_code.h"
@@ -45,7 +47,7 @@ static std::string TrId(const char* key, const char* id) {
// ============================================================================
static int s_selected_address_idx = -1;
static double s_request_amount = 0.0;
static char s_request_memo[256] = "";
static char s_request_memo[512] = "";
static double s_request_usd_amount = 0.0;
static bool s_request_usd_mode = false;
static std::string s_cached_qr_data;
@@ -76,16 +78,9 @@ static std::string TruncateAddress(const std::string& addr, size_t maxLen = 40)
return addr.substr(0, halfLen) + "..." + addr.substr(addr.length() - halfLen);
}
static void OpenExplorerURL(const std::string& address) {
std::string url = "https://explorer.dragonx.com/address/" + address;
#ifdef _WIN32
std::string cmd = "start \"\" \"" + url + "\"";
#elif __APPLE__
std::string cmd = "open \"" + url + "\"";
#else
std::string cmd = "xdg-open \"" + url + "\"";
#endif
system(cmd.c_str());
static void OpenExplorerURL(App* app, const std::string& address) {
std::string url = app->settings()->getAddressExplorerUrl() + address;
dragonx::util::Platform::openUrl(url);
}
// ============================================================================
@@ -978,7 +973,7 @@ void RenderReceiveTab(App* app)
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.receive", "explorer-btn-border-size").size);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(PrimaryLight()));
if (TactileButton(TrId("explorer", "recv").c_str(), ImVec2(otherBtnW, btnH), S.resolveFont("button"))) {
OpenExplorerURL(selected.address);
OpenExplorerURL(app, selected.address);
}
ImGui::PopStyleVar(); // FrameBorderSize
ImGui::PopStyleColor(4);

View File

@@ -804,7 +804,7 @@ void RenderSendConfirmPopup(App* app) {
s_tx_status = TR("send_tx_sent");
s_result_txid = result;
s_status_success = true;
Notifications::instance().success(TR("send_tx_success"));
Notifications::instance().success(TR("send_tx_success"), 5.0f);
s_to_address[0] = '\0';
s_amount = 0.0;
s_memo[0] = '\0';