27 Commits

Author SHA1 Message Date
49b5ef6b20 fix(wallets): key in-place links on the canonical path (one wallet, one link)
A wallet reached via two different path strings — e.g. a symlinked mount
(/mnt/usb) and its real target (/media/usb) — used to hash to two different
in-place link names and list as two rows, so the same file looked like two
wallets with two datadir links.

Give WalletRow a canonPath (fs::canonical, symlinks resolved, computed once at
scan time so the every-frame render/active-marking path stays cheap) and key
both the link name and the row de-dup on it. Now the same physical wallet maps
to one identity regardless of the path used to reach it: one link name (honors
the "distinct per-target name" invariant), and scan() lists it once — datadir
first, so a datadir wallet wins over the same file seen via an external path.
The link also points straight at the resolved real file instead of through the
symlinked path. Genuinely distinct wallets keep distinct identities (verified
with a standalone symlinked-mount harness).

Closes the last deferred finding from the open-in-place adversarial review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:47:12 -05:00
6aaab9e01f feat(wallets): prefer symlink on Linux/macOS for open-in-place
On Unix a symlink is the better datadir link than a hard link: it needs no
privilege, spans volumes (a wallet on a USB / other partition just works,
where a hard link can't), and is visibly a pointer rather than an
indistinguishable second copy. Windows keeps hard-link-first (a symlink there
needs admin / Developer Mode), falling back to a symlink.

Symlink-first made two latent issues in the pre-clear reachable, fixed here
after an adversarial review:

- Dangling prior symlink: fs::exists follows the link (false when the source
  moved), so it was skipped and create_symlink then tripped on the occupied
  path. Catch it with fs::is_symlink (lstat) and replace it.

- Stale reserved-name occupant: the old "hard_link_count == 1 → reuse" path
  could load the WRONG wallet — a count-1 file at our name is not a valid link
  to the source (a live hard link has count >= 2), it's a stale orphan (source
  deleted, or a copy left by a datadir migrated across filesystems). Gate reuse
  on fs::equivalent(link, src) (same inode) instead, and reclaim the name only
  when the occupant is provably redundant (another hard link still holds the
  data); otherwise refuse rather than risk destroying something unique. So
  hard_link_count is no longer trusted to prove identity — only data-safety.

Also guard against a stale row (source moved/deleted between the scan and the
click): !exists(src) refreshes the list instead of linking a ghost the daemon
would silently swap for wallet.dat. Verified with a standalone harness across
fresh / re-open / valid-hardlink-reuse / stale-orphan / redundant-occupant /
vanished-source cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:39:57 -05:00
b7da6335d1 feat(wallets): open external wallets in place via datadir link (no copy)
Replace the "Import" action — which copied an out-of-datadir wallet into the
datadir and cluttered the list with hard-to-tell-apart duplicates — with an
in-place "Open" that links the real file into the datadir under a stable
per-target name (wallet-ip-<FNV8>.dat) and switches to it. The daemon only
loads a bare filename from its own datadir, so a link is the minimal bridge:
hard link first (no privileges, same volume — covers non-admin Windows),
symlink fallback (cross-volume), then an error. Never a copy (that would fork
the wallet) and never a delete of a real file.

Also:
- show each external wallet's originating sub-directory (…/Backups/2021)
- add a per-row "open folder location" button
- widen the modal (780 → 860) for the extra button
- guard the daemon against a dangling link (external file moved / USB
  unplugged) by falling back to wallet.dat rather than creating an empty one

Per-target link names (not one shared name) make switching between two
external wallets a real -wallet switch, so the rescan/cache index tracks each
correctly. Drops the now-dead wallets_import* i18n keys and back-fills the new
open/folder strings across all 8 languages + rebuilds the CJK subset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:21:30 -05:00
b765d62e00 feat(wallets): show each external wallet's sub-directory
Prepend the containing folder (last 1-2 path components, e.g. "…/Backups/2021")
to an external wallet's metadata line, so several same-named wallet.dat files
surfaced by the recursive scan are distinguishable at a glance (the info-icon
tooltip still shows the full path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:17:45 -05:00
3dc4d2d860 fix(wallets): make the sort-direction toggle a circular icon button
Swap the rounded-rect StyledButton for material::IconButton (square size →
bgRounding = radius, so a circle) with the arrow glyph mathematically centered;
carries its own hover fill + asc/desc tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:26:59 -05:00
fe40d38f0d feat(wallets): segmented sort control (like the Receive all/z/t toggle)
Replace the sort dropdown with a material::SegmentedControl — Created / Addresses
/ Txs / Size — matching the Receive tab's address-type toggle, with the asc/desc
arrow beside it. Shorten the four sort labels to fit the segments (updated across
all eight languages + the back-fill script). The direction arrow is positioned
explicitly on the segment's baseline (the control is draw-list based and doesn't
advance the layout cursor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 17:01:51 -05:00
1d3a919ac6 feat(wallets): pin the active wallet to the top of the list
Make "is the active wallet" the primary sort key so the current wallet always
stays at the top regardless of the chosen sort/direction; the remaining wallets
sort by the selected key underneath it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:49:05 -05:00
d549127e41 i18n(wallets): translate created-date + sort keys, rebuild CJK subset
Back-fill the 8 new keys (created label + the four sort options and asc/desc
tooltips) into all eight languages via add_missing_translations.py (+64 keys,
additive), and rebuild res/fonts/NotoSansCJK-Subset.ttf for the new glyphs (升 昇 렬).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:37:36 -05:00
54a14485a5 feat(wallets): show created date + sort the wallet list
Parse the earliest keymeta nCreateTime out of the wallet.dat btree (the daemon's
"wallet birthday") and surface it in each row's metadata line ("created Aug 2025").

Add a sort control above the list — Date created / Address count / Transaction
count / Wallet size, with an ascending/descending toggle (descending default:
newest / most / largest first). Sorting reorders a display-index array
(s_order) rather than s_rows, so the async, index-aligned probe batch is
untouched; sort keys read a single frame-consistent snapshot of the probe
results. Address count prefers the authoritative cached index value, else the
btree key count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:37:36 -05:00
8eefab99ec i18n(wallets): clarify never-opened wallet label ("Never" -> "Never opened")
Standalone "Never" in the wallet list's last-opened slot was ambiguous; make it
"Never opened" (and update all eight translations). Glyphs already covered by the
CJK subset, so no font rebuild needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:22:13 -05:00
4fdf281230 i18n(wallets): translate the new badge/count keys + rebuild CJK subset
Back-fill the 10 new Wallets-dialog keys (metadata "keys"/"txs" columns and the
Encrypted/Seed/Legacy/Unknown badge labels + tooltips) into all eight languages
via scripts/add_missing_translations.py (additive; +80 keys, no reformatting), and
rebuild res/fonts/NotoSansCJK-Subset.ttf so the new zh/ja/ko glyphs (判 種 類 …)
render instead of tofu.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:11:29 -05:00
c4f69b5a85 fix(wallets): probe synchronously during the offline screenshot sweep
The async probe fills badges/counts a frame after the dialog opens, which the
single-frame screenshot sweep captured before it landed (badges/counts missing).
Run the probe synchronously when App::isScreenshotSweeping(), so the captured
modal-wallets frame is populated; interactive use keeps the detached-thread path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:03:32 -05:00
171b705429 feat(wallets): smooth wheel scrolling for the wallet list
Adopt the shared ApplySmoothScroll helper (with NoScrollWithMouse) for the
##walletList child, matching the Settings page and the other scroll areas, so
the wallet list eases on wheel input instead of jumping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:51:55 -05:00
175950a2ec feat(wallets): exact address/tx counts via a minimal BDB btree parser
parseWalletBtree() walks the wallet.dat Berkeley DB btree directly (no libdb, no
daemon): validate the metapage magic, follow the "main" sub-database (its pgno is
stored big-endian in the master map), traverse internal/leaf pages, and tally
records by their length-prefixed type name — transparent + shielded spendable
keys, address-book, and tx count, plus exact encryption/seed flags. Every offset
is bounds-checked, pages are deduped at push time (stack stays O(npages)), and a
visited-set + page/key caps make it safe on a corrupt/adversarial imported file;
files using DB_CHKSUM/DB_ENCRYPT (which shift the page layout) are rejected so the
byte-scan fallback runs instead.

The async probe now uses this as the primary path (exact badges + counts), falling
back to the byte-scan only when the btree can't be fully parsed. The wallets list
shows "N keys · M txs" for probed rows (labeled "keys", not "addresses", since the
count includes change keys the daemon's address list omits). Validated read-only
against real wallets (counts cross-checked; enc/seed match the byte-scan) and a
hand-built minimal btree fixture in the unit suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:20:35 -05:00
36efe99a0f feat(wallets): vertical label+icon badge stack + async probing
Redesign the wallet.dat status badges as a right-aligned vertical stack of
"label icon" rows ("Seed phrase 🌱" over "Encrypted 🔒", "Legacy", or "Unknown"),
each with a hover tooltip; name/metadata reserve the stack width and truncate
before it.

Move the wallet.dat probing off the UI thread: scan() now builds the row list
synchronously (filename/size only) and hands the file reads to a detached
background thread whose results land in a shared, index-aligned batch that
render() reads under a mutex. A re-scan supersedes the old batch (cancel + swap);
the thread touches only its own heap batch (never s_rows/statics), so it's safe
across re-scans and shutdown. The dialog opens instantly even with a large
active wallet; badges fill in over the next few frames.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:47:27 -05:00
fadbd6a879 docs: Overview marble design mockup
Self-contained HTML/CSS mockup of the Overview screen (Marble theme) used as a
pixel reference for tuning the in-app marble background, frosted cards, DragonX
glyph, and card drop shadow. Embeds the real res/img marble texture as a data URI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:33:06 -05:00
112c581247 feat(wallets): recursive subdir scan + wallet.dat encryption/seed badges
Search user-added wallet folders RECURSIVELY (bounded: depth/hit/visit caps,
skip_permission_denied, no symlink-follow, node-junk + node-subtree pruning);
the datadir stays top-level. r.dir is each file's real parent so Open/import
resolve subdir wallets. Auto-suffix the import destination on a name collision.

Add util/wallet_file_probe.h: read encryption/seed/shielded flags straight off
a wallet.dat WITHOUT loading it — validate the Berkeley DB btree magic, then a
bounded streaming byte-scan for the length-prefixed record markers the daemon
writes (mkey -> encrypted; hdseed/hdchain -> seed/HD; zkey/sapzkey -> shielded).
Reads no key material. The Wallets dialog shows Encrypted / Seed / Legacy badges,
plus an Unknown badge when a large file couldn't be fully scanned (so absence of
a lock never falsely reads as unencrypted). Unit-tested + validated read-only
against real wallets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:32:59 -05:00
57147fd06b fix(console): readable theme-colored log text on light themes
The console log text washed out on Marble/Light (~1.2:1 contrast): refreshColors
chose text colors from the stored IsDarkTheme() flag (default true, and only
re-checked on a dark/light toggle) while the terminal surface used IsLightTheme(),
so dark-theme pale text landed on the light surface. Tie both to the same live
background-luminance predicate and refresh on any theme (schema generation) change.
Light themes now derive each channel from that theme's own palette (Primary,
OnSurface, Error, Secondary, ...) nudged to a WCAG contrast floor — on-theme AND
high-contrast (measured 4.6-17.8:1 across the five light skins).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:32:49 -05:00
aa60188665 fix(ui): tighter, darker, non-clipping card drop shadow
Rework DrawCardDropShadow (Light/Marble): a Gaussian-sampled ring stack
matching a `0 0 4px rgb(50 53 58 / 34a)` box-shadow — offset-free, darker,
and tighter than the old faint 14px black halo. Clip to the shadow's own
bounding box (card ± reach) instead of the child window bounds so top/bottom
shadows are no longer chopped where a card sits flush with its container,
while staying on the card's own draw list (correct z-order in modals/popups).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 12:32:42 -05:00
cff958c21e fix(themes): card shadow no longer cut off at child-window edges
Cards are drawn inside clipping child windows, so the shadow (which extends past
the card edge) was cut at the child boundary — most visibly a hard line on the
sidebar's inner edge, which also made the shadow look non-uniform. Widen the
shadow's clip horizontally (card + spread, clamped to the viewport) so the side
shadows reach into the gaps between panels, while keeping the vertical clip at the
child bounds so a card scrolled to a child's top/bottom edge can't bleed its
shadow into the header/footer. Verified: sidebar + settings cards now show an
even shadow on all four sides.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:42:08 -05:00
c50125523c change(themes): make the card shadow uniform (no directional offset)
Drop the downward offset so the soft shadow is equal on all four sides — an even
halo around each card rather than a directional cast, matching the requested
look. Slightly more rings for a smoother falloff. Still stroke-based (no body
contamination), Light/Marble only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:24:56 -05:00
063f1fa4be feat(themes): subtle card drop shadow for Light + Marble
Cards on the pale Light / Marble backgrounds now lift off the surface with a
subtle drop shadow. Drawn in DrawGlassPanel (behind the card fill) as fading
rounded-rect STROKES offset down — strokes never paint across the card body, so
only the outer soft blur remains once the fill covers the interior (the
"render then mask out the UI area" result, no FBO needed). Gated to the Light and
Marble themes (cached per theme-generation); dark themes are unchanged. Verified
across marble / light (shadow present, smooth, body clean) and dark (none).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 20:07:24 -05:00
ad7a150ad6 change(acrylic): lower the blur slider's range to a gentle max
Now that panels frost at full alpha, the slider's old top end (multiplier 4.0)
was far too blurry. Cap the range at multiplier 1.25 (kAcrylicMaxBlur) — the
slider still reads 0–100%, but 100% now maps to a moderate frost instead of a
heavy wash. Snap-to-off threshold scales with the new range, and a value saved
under the old 0–4 range is clamped on load so it lands within 0–100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:54:48 -05:00
433c7ae46f fix(acrylic): frost glass panels at full strength (slider was a near no-op)
The per-panel acrylic composite drew the blurred capture at
`fallbackColor.w * uiOpacity` alpha. At a lowered UI opacity that's ~24%, so the
SHARP background bled straight through the panels — the acrylic slider changed
almost nothing (measured: blur 0% vs 100% differed in 0.33% of pixels, and the
marbled background was equally crisp at both).

Draw the blurred capture at (near) full strength so it actually FROSTS the panel
(the blurred background replaces the sharp one); UI opacity now scales only the
tint overlay in DrawGlassPanel, which is the correct acrylic model — opacity =
tint, not sharp-vs-blur. Fixed symmetrically in both the GL and DX11 drawRect
paths. Verified on GL: blur 0% now shows a sharp background, 100% a smooth
frosted wash (0.33% -> 8.3% pixels changed), and the default (high-opacity) card
look is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 19:46:18 -05:00
a5efbaf83d change(settings): debug-options gate is once per session
Passing the gate once (confirmation + any re-auth) now unlocks the DEBUG OPTIONS
dropdown for the rest of the session — subsequent expands skip straight to the
options. The flag lives in the session-lifetime page state, so it resets on app
restart. Collapsing still needs no gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:49:11 -05:00
46fb0029b8 feat(settings): gate debug options behind a confirmation (+ re-auth if secured)
Revealing the DEBUG OPTIONS dropdown now opens a confirmation modal with a
warning instead of expanding directly. If the wallet is secured, it also requires
re-authentication before the options appear:
- a quick-unlock PIN → verified against the vault (Argon2id derive, off-thread,
  no wallet side effects);
- otherwise an encrypted wallet → the passphrase, verified via walletpassphrase.
Unsecured wallets just confirm. Collapsing needs no gate; each expand re-gates.

App gains debugGateRequiresAuth() + verifyDebugCredential() (cb on the main
thread; secrets wiped). Adds a modal-debug-gate sweep surface. Verified 100% +
150%, dark + light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:46:50 -05:00
aa587d1d84 feat(seed): glow the Migrate-to-seed button for a legacy wallet
A pre-seed-phrase (legacy, non-mnemonic) wallet is exactly the one that benefits
from migrating — so nudge the user by glowing the Settings "Migrate to seed"
button with a soft pulsing accent halo.

Adds a cached wallet mnemonic status (probeWalletSeedStatus), classified once per
connect via z_exportmnemonic (the same signal the migration Intro pre-flight
uses): NoMnemonic = legacy → glow; HasMnemonic / Incapable (old daemon) / while
locked or on lite = no glow. Reset on disconnect so it re-probes after a wallet
switch or a post-migration adopt, with a small attempt cap to avoid re-probing on
a persistent transient error. The sweep forces the status so the glow is
captured (restored after). Verified dark + light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 18:24:41 -05:00
25 changed files with 1863 additions and 168 deletions

270
overview_mockup.html Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@@ -1323,31 +1323,47 @@
"wallets_add": "Ordner hinzufügen",
"wallets_add_folder": "Auch einen weiteren Ordner nach Wallet-Dateien durchsuchen:",
"wallets_add_folder_toggle": "+ Weiteren Ordner nach Wallets durchsuchen…",
"wallets_badge_encrypted": "Verschlüsselt (passphrasengeschützt)",
"wallets_badge_encrypted_short": "Verschlüsselt",
"wallets_badge_legacy": "Legacy-Wallet (keine Seed-Phrase)",
"wallets_badge_legacy_short": "Legacy",
"wallets_badge_seed": "Seed-Phrase-Wallet (HD)",
"wallets_badge_seed_short": "Seed-Phrase",
"wallets_badge_unknown": "Wallet-Typ nicht vollständig ermittelt (große Datei — zum Bestätigen öffnen)",
"wallets_badge_unknown_short": "Unbekannt",
"wallets_button": "Wallets…",
"wallets_col_addresses": "Adressen",
"wallets_col_balance": "Guthaben",
"wallets_col_keys": "Schlüssel",
"wallets_col_name": "Wallet",
"wallets_col_opened": "Zuletzt geöffnet",
"wallets_col_size": "Größe",
"wallets_col_txs": "Tx",
"wallets_create": "Wallet erstellen",
"wallets_created": "erstellt",
"wallets_creating": "Wallet wird erstellt — der Node wird neu gestartet…",
"wallets_current": "aktuell",
"wallets_exists": "Eine Wallet mit diesem Namen existiert bereits.",
"wallets_external_tt": "Außerhalb deines Datenverzeichnisses — mit Import hineinkopieren.",
"wallets_external_tt": "Außerhalb deines Datenverzeichnisses — Öffnen verlinkt sie an ihrem Ort (keine Kopie).",
"wallets_folder_hint": "/pfad/zum/ordner mit .dat-Wallet-Dateien",
"wallets_folder_invalid": "Dieser Ordner existiert nicht.",
"wallets_import": "Importieren",
"wallets_import_failed": "Diese Wallet-Datei konnte nicht importiert werden.",
"wallets_import_hint": "Zum Öffnen importieren",
"wallets_import_tt": "Diese Wallet befindet sich außerhalb des Datenverzeichnisses. Beim Öffnen wird sie zuerst hineinkopiert (demnächst verfügbar).",
"wallets_imported": "Wallet importiert — wird gewechselt…",
"wallets_intro": "Wallet-Dateien in Ihrem Datenverzeichnis (und in beliebigen Ordnern, die Sie hinzufügen). Öffnen Sie eine, um zu wechseln — der Node wird neu gestartet, um sie zu laden, und jede Wallet behält ihre eigenen Daten.",
"wallets_name_invalid": "Bitte geben Sie einen gültigen Wallet-Namen ein.",
"wallets_never": "Nie",
"wallets_never": "Nie geöffnet",
"wallets_new_hint": "Name (z. B. Ersparnisse)",
"wallets_new_label": "Eine neue Wallet erstellen:",
"wallets_open": "Öffnen",
"wallets_open_failed": "Diese Wallet konnte nicht an ihrem Ort geöffnet werden. Sie liegt vermutlich auf einem anderen Laufwerk als dein Datenverzeichnis — verschiebe sie auf dasselbe Laufwerk (unter Windows erlaubt auch der aktivierte Entwicklermodus laufwerkübergreifende Verknüpfungen).",
"wallets_open_folder": "Ordnerpfad öffnen",
"wallets_open_inplace_tt": "Öffnet diese Wallet an ihrem Ort — im Datenverzeichnis verlinkt (keine Kopie)",
"wallets_reveal": "Ordner anzeigen",
"wallets_sort_addresses": "Adressen",
"wallets_sort_asc": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
"wallets_sort_by": "Sortieren:",
"wallets_sort_created": "Erstellt",
"wallets_sort_desc": "Absteigend (neuestes / meiste / größtes zuerst)",
"wallets_sort_size": "Größe",
"wallets_sort_txs": "Txs",
"wallets_title": "Wallets",
"warning": "Warnung",
"warning_upper": "WARNUNG!",

View File

@@ -1323,31 +1323,47 @@
"wallets_add": "Agregar carpeta",
"wallets_add_folder": "Escanear también otra carpeta en busca de archivos de cartera:",
"wallets_add_folder_toggle": "+ Buscar wallets en otra carpeta…",
"wallets_badge_encrypted": "Cifrada (protegida con contraseña)",
"wallets_badge_encrypted_short": "Cifrada",
"wallets_badge_legacy": "Billetera heredada (sin frase semilla)",
"wallets_badge_legacy_short": "Heredada",
"wallets_badge_seed": "Billetera con frase semilla (HD)",
"wallets_badge_seed_short": "Frase semilla",
"wallets_badge_unknown": "Tipo de billetera no determinado por completo (archivo grande — abra para confirmar)",
"wallets_badge_unknown_short": "Desconocido",
"wallets_button": "Carteras…",
"wallets_col_addresses": "Direcciones",
"wallets_col_balance": "Saldo",
"wallets_col_keys": "claves",
"wallets_col_name": "Cartera",
"wallets_col_opened": "Última apertura",
"wallets_col_size": "Tamaño",
"wallets_col_txs": "tx",
"wallets_create": "Crear cartera",
"wallets_created": "creada",
"wallets_creating": "Creando cartera — el nodo se reiniciará…",
"wallets_current": "actual",
"wallets_exists": "Ya existe una cartera con ese nombre.",
"wallets_external_tt": "Fuera de tu directorio de datos — usa Importar para copiarlo.",
"wallets_external_tt": "Fuera de tu directorio de datos — Abrir lo enlaza en su lugar (sin copiar).",
"wallets_folder_hint": "/ruta/a/carpeta con archivos .dat de cartera",
"wallets_folder_invalid": "Esa carpeta no existe.",
"wallets_import": "Importar",
"wallets_import_failed": "No se pudo importar ese archivo de cartera.",
"wallets_import_hint": "Importar para abrir",
"wallets_import_tt": "Esta cartera está fuera del directorio de datos. Al abrirla se copiará primero dentro (próximamente).",
"wallets_imported": "Cartera importada — cambiando…",
"wallets_intro": "Archivos de cartera en tu directorio de datos (y en las carpetas que agregues). Abre uno para cambiar: el nodo se reinicia para cargarlo y cada cartera conserva sus propios datos.",
"wallets_name_invalid": "Introduce un nombre de cartera válido.",
"wallets_never": "Nunca",
"wallets_never": "Nunca abierta",
"wallets_new_hint": "Nombre (p. ej. ahorros)",
"wallets_new_label": "Crear una nueva cartera:",
"wallets_open": "Abrir",
"wallets_open_failed": "No se pudo abrir esta cartera en su ubicación. Probablemente está en una unidad distinta a tu directorio de datos: muévela a la misma unidad (en Windows, activar el Modo de desarrollador también permite enlazar entre unidades).",
"wallets_open_folder": "Abrir ubicación de la carpeta",
"wallets_open_inplace_tt": "Abre esta cartera donde está, enlazándola al directorio de datos (sin copiar)",
"wallets_reveal": "Mostrar carpeta",
"wallets_sort_addresses": "Direcciones",
"wallets_sort_asc": "Ascendente (más antiguo / menos / más pequeño primero)",
"wallets_sort_by": "Ordenar:",
"wallets_sort_created": "Creado",
"wallets_sort_desc": "Descendente (más reciente / más / más grande primero)",
"wallets_sort_size": "Tamaño",
"wallets_sort_txs": "Txs",
"wallets_title": "Carteras",
"warning": "Advertencia",
"warning_upper": "¡ADVERTENCIA!",

View File

@@ -1323,31 +1323,47 @@
"wallets_add": "Ajouter le dossier",
"wallets_add_folder": "Analyser aussi un autre dossier à la recherche de fichiers de portefeuille :",
"wallets_add_folder_toggle": "+ Analyser un autre dossier pour les portefeuilles…",
"wallets_badge_encrypted": "Chiffré (protégé par phrase secrète)",
"wallets_badge_encrypted_short": "Chiffré",
"wallets_badge_legacy": "Portefeuille hérité (sans phrase de récupération)",
"wallets_badge_legacy_short": "Hérité",
"wallets_badge_seed": "Portefeuille à phrase de récupération (HD)",
"wallets_badge_seed_short": "Phrase secrète",
"wallets_badge_unknown": "Type de portefeuille non entièrement déterminé (fichier volumineux — ouvrir pour confirmer)",
"wallets_badge_unknown_short": "Inconnu",
"wallets_button": "Portefeuilles…",
"wallets_col_addresses": "Adresses",
"wallets_col_balance": "Solde",
"wallets_col_keys": "clés",
"wallets_col_name": "Portefeuille",
"wallets_col_opened": "Dernière ouverture",
"wallets_col_size": "Taille",
"wallets_col_txs": "tx",
"wallets_create": "Créer le portefeuille",
"wallets_created": "créé",
"wallets_creating": "Création du portefeuille — le nœud va redémarrer…",
"wallets_current": "actuel",
"wallets_exists": "Un portefeuille portant ce nom existe déjà.",
"wallets_external_tt": "Hors de votre répertoire de données — Importer pour le copier.",
"wallets_external_tt": "Hors de votre répertoire de données — Ouvrir le lie sur place (sans copie).",
"wallets_folder_hint": "/chemin/vers/le/dossier contenant les fichiers .dat",
"wallets_folder_invalid": "Ce dossier n'existe pas.",
"wallets_import": "Importer",
"wallets_import_failed": "Impossible d'importer ce fichier de portefeuille.",
"wallets_import_hint": "Importer pour ouvrir",
"wallets_import_tt": "Ce portefeuille se trouve hors du répertoire de données. L'ouvrir le copiera d'abord dedans (bientôt disponible).",
"wallets_imported": "Portefeuille importé — changement en cours…",
"wallets_intro": "Fichiers de portefeuille dans votre répertoire de données (et tout dossier que vous ajoutez). Ouvrez-en un pour changer — le nœud redémarre pour le charger, et chaque portefeuille conserve ses propres données.",
"wallets_name_invalid": "Veuillez saisir un nom de portefeuille valide.",
"wallets_never": "Jamais",
"wallets_never": "Jamais ouvert",
"wallets_new_hint": "Nom (par ex. épargne)",
"wallets_new_label": "Créer un nouveau portefeuille :",
"wallets_open": "Ouvrir",
"wallets_open_failed": "Impossible d'ouvrir ce portefeuille à son emplacement. Il se trouve probablement sur un lecteur différent de votre répertoire de données — déplacez-le sur le même lecteur (sous Windows, activer le mode développeur permet aussi de créer des liens entre lecteurs).",
"wallets_open_folder": "Ouvrir l'emplacement du dossier",
"wallets_open_inplace_tt": "Ouvre ce portefeuille à son emplacement — lié au répertoire de données (sans copie)",
"wallets_reveal": "Afficher le dossier",
"wallets_sort_addresses": "Adresses",
"wallets_sort_asc": "Croissant (plus ancien / moins / plus petit d'abord)",
"wallets_sort_by": "Trier :",
"wallets_sort_created": "Créé",
"wallets_sort_desc": "Décroissant (plus récent / plus / plus grand d'abord)",
"wallets_sort_size": "Taille",
"wallets_sort_txs": "Txs",
"wallets_title": "Portefeuilles",
"warning": "Attention",
"warning_upper": "ATTENTION !",

View File

@@ -1323,31 +1323,47 @@
"wallets_add": "フォルダを追加",
"wallets_add_folder": "ウォレットファイルを検索する別のフォルダを追加:",
"wallets_add_folder_toggle": "+ 別のフォルダーをウォレット検索…",
"wallets_badge_encrypted": "暗号化済み(パスフレーズ保護)",
"wallets_badge_encrypted_short": "暗号化",
"wallets_badge_legacy": "レガシーウォレット(シードフレーズなし)",
"wallets_badge_legacy_short": "レガシー",
"wallets_badge_seed": "シードフレーズウォレット (HD)",
"wallets_badge_seed_short": "シードフレーズ",
"wallets_badge_unknown": "ウォレットの種類を完全に判定できません(大きなファイル — 開いて確認)",
"wallets_badge_unknown_short": "不明",
"wallets_button": "ウォレット…",
"wallets_col_addresses": "アドレス",
"wallets_col_balance": "残高",
"wallets_col_keys": "個の鍵",
"wallets_col_name": "ウォレット",
"wallets_col_opened": "最終使用",
"wallets_col_size": "サイズ",
"wallets_col_txs": "件",
"wallets_create": "ウォレットを作成",
"wallets_created": "作成",
"wallets_creating": "ウォレットを作成中 — ノードが再起動します…",
"wallets_current": "現在",
"wallets_exists": "その名前のウォレットは既に存在します。",
"wallets_external_tt": "データディレクトリの外にあります — インポートでコピーします。",
"wallets_external_tt": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。",
"wallets_folder_hint": "wallet .dat ファイルのあるフォルダのパス",
"wallets_folder_invalid": "そのフォルダは存在しません。",
"wallets_import": "インポート",
"wallets_import_failed": "そのウォレットファイルをインポートできませんでした。",
"wallets_import_hint": "インポートして開く",
"wallets_import_tt": "このウォレットはデータディレクトリの外にあります。開くには先にコピーが必要です(近日対応予定)。",
"wallets_imported": "ウォレットをインポートしました — 切り替え中…",
"wallets_intro": "データディレクトリ(および追加したフォルダ)内のウォレットファイルです。開いて切り替えると、それを読み込むためにノードが再起動します。各ウォレットは個別のデータを保持します。",
"wallets_name_invalid": "有効なウォレット名を入力してください。",
"wallets_never": "なし",
"wallets_never": "未使用",
"wallets_new_hint": "名前savings",
"wallets_new_label": "新しいウォレットを作成:",
"wallets_open": "開く",
"wallets_open_failed": "このウォレットをその場で開けませんでした。データディレクトリとは別のドライブにある可能性があります — 同じドライブに移動してくださいWindows では開発者モードを有効にするとドライブ間のリンクも可能になります)。",
"wallets_open_folder": "フォルダーの場所を開く",
"wallets_open_inplace_tt": "このウォレットをその場で開きます — データディレクトリにリンク(コピーなし)",
"wallets_reveal": "フォルダを開く",
"wallets_sort_addresses": "アドレス",
"wallets_sort_asc": "昇順(古い/少ない/小さい順)",
"wallets_sort_by": "並べ替え:",
"wallets_sort_created": "作成",
"wallets_sort_desc": "降順(新しい/多い/大きい順)",
"wallets_sort_size": "サイズ",
"wallets_sort_txs": "取引",
"wallets_title": "ウォレット",
"warning": "警告",
"warning_upper": "警告!",

View File

@@ -1323,31 +1323,47 @@
"wallets_add": "폴더 추가",
"wallets_add_folder": "지갑 파일을 검색할 폴더를 추가로 지정:",
"wallets_add_folder_toggle": "+ 다른 폴더에서 지갑 검색…",
"wallets_badge_encrypted": "암호화됨 (암호로 보호됨)",
"wallets_badge_encrypted_short": "암호화됨",
"wallets_badge_legacy": "레거시 지갑 (시드 문구 없음)",
"wallets_badge_legacy_short": "레거시",
"wallets_badge_seed": "시드 문구 지갑 (HD)",
"wallets_badge_seed_short": "시드 문구",
"wallets_badge_unknown": "지갑 유형을 완전히 확인하지 못함 (큰 파일 — 열어서 확인)",
"wallets_badge_unknown_short": "알 수 없음",
"wallets_button": "지갑…",
"wallets_col_addresses": "주소",
"wallets_col_balance": "잔액",
"wallets_col_keys": "개 키",
"wallets_col_name": "지갑",
"wallets_col_opened": "마지막 열람",
"wallets_col_size": "크기",
"wallets_col_txs": "건",
"wallets_create": "지갑 만들기",
"wallets_created": "생성",
"wallets_creating": "지갑을 만드는 중 — 노드가 재시작됩니다…",
"wallets_current": "현재",
"wallets_exists": "같은 이름의 지갑이 이미 존재합니다.",
"wallets_external_tt": "데이터 디렉터리 외부에 있음 — 가져오기로 복사합니다.",
"wallets_external_tt": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음).",
"wallets_folder_hint": "wallet-*.dat 파일이 있는 폴더 경로",
"wallets_folder_invalid": "해당 폴더가 존재하지 않습니다.",
"wallets_import": "가져오기",
"wallets_import_failed": "해당 지갑 파일을 가져올 수 없습니다.",
"wallets_import_hint": "가져와서 열기",
"wallets_import_tt": "이 지갑은 데이터 디렉터리 밖에 있습니다. 열면 먼저 안으로 복사됩니다(출시 예정).",
"wallets_imported": "지갑을 가져왔습니다 — 전환 중…",
"wallets_intro": "데이터 디렉터리(및 추가한 폴더)에 있는 지갑 파일입니다. 하나를 열면 전환됩니다 — 노드가 재시작되어 해당 지갑을 불러오며, 각 지갑은 고유한 데이터를 유지합니다.",
"wallets_name_invalid": "유효한 지갑 이름을 입력하세요.",
"wallets_never": "없음",
"wallets_never": "미사용",
"wallets_new_hint": "이름(예: savings)",
"wallets_new_label": "새 지갑 만들기:",
"wallets_open": "열기",
"wallets_open_failed": "이 지갑을 제자리에서 열 수 없습니다. 데이터 디렉터리와 다른 드라이브에 있을 가능성이 높습니다 — 같은 드라이브로 옮기세요 (Windows에서는 개발자 모드를 켜면 드라이브 간 링크도 가능합니다).",
"wallets_open_folder": "폴더 위치 열기",
"wallets_open_inplace_tt": "이 지갑을 있는 자리에서 엽니다 — 데이터 디렉터리에 링크 (복사 없음)",
"wallets_reveal": "폴더 열기",
"wallets_sort_addresses": "주소",
"wallets_sort_asc": "오름차순 (오래된/적은/작은 순)",
"wallets_sort_by": "정렬:",
"wallets_sort_created": "생성",
"wallets_sort_desc": "내림차순 (최신/많은/큰 순)",
"wallets_sort_size": "크기",
"wallets_sort_txs": "거래",
"wallets_title": "지갑",
"warning": "경고",
"warning_upper": "경고!",

View File

@@ -1323,31 +1323,47 @@
"wallets_add": "Adicionar pasta",
"wallets_add_folder": "Verificar também outra pasta em busca de arquivos de carteira:",
"wallets_add_folder_toggle": "+ Procurar carteiras noutra pasta…",
"wallets_badge_encrypted": "Encriptada (protegida por senha)",
"wallets_badge_encrypted_short": "Encriptada",
"wallets_badge_legacy": "Carteira legada (sem frase semente)",
"wallets_badge_legacy_short": "Legada",
"wallets_badge_seed": "Carteira com frase semente (HD)",
"wallets_badge_seed_short": "Frase semente",
"wallets_badge_unknown": "Tipo de carteira não totalmente determinado (arquivo grande — abra para confirmar)",
"wallets_badge_unknown_short": "Desconhecido",
"wallets_button": "Carteiras…",
"wallets_col_addresses": "Endereços",
"wallets_col_balance": "Saldo",
"wallets_col_keys": "chaves",
"wallets_col_name": "Carteira",
"wallets_col_opened": "Aberta pela última vez",
"wallets_col_size": "Tamanho",
"wallets_col_txs": "tx",
"wallets_create": "Criar carteira",
"wallets_created": "criada",
"wallets_creating": "Criando carteira — o nó será reiniciado…",
"wallets_current": "atual",
"wallets_exists": "Já existe uma carteira com esse nome.",
"wallets_external_tt": "Fora do seu diretório de dados — use Importar para copiá-lo.",
"wallets_external_tt": "Fora do seu diretório de dados — Abrir o vincula no lugar (sem cópia).",
"wallets_folder_hint": "/caminho/para/pasta com arquivos .dat de carteira",
"wallets_folder_invalid": "Essa pasta não existe.",
"wallets_import": "Importar",
"wallets_import_failed": "Não foi possível importar esse arquivo de carteira.",
"wallets_import_hint": "Importar para abrir",
"wallets_import_tt": "Esta carteira está fora do diretório de dados. Ao abri-la, ela será copiada para lá primeiro (em breve).",
"wallets_imported": "Carteira importada — alternando…",
"wallets_intro": "Arquivos de carteira no seu diretório de dados (e em quaisquer pastas que você adicionar). Abra um para alternar — o nó reinicia para carregá-lo, e cada carteira mantém seus próprios dados.",
"wallets_name_invalid": "Insira um nome de carteira válido.",
"wallets_never": "Nunca",
"wallets_never": "Nunca aberta",
"wallets_new_hint": "Nome (ex.: poupança)",
"wallets_new_label": "Criar uma nova carteira:",
"wallets_open": "Abrir",
"wallets_open_failed": "Não foi possível abrir esta carteira no lugar. Provavelmente está em uma unidade diferente do seu diretório de dados — mova-a para a mesma unidade (no Windows, ativar o Modo de Desenvolvedor também permite vincular entre unidades).",
"wallets_open_folder": "Abrir local da pasta",
"wallets_open_inplace_tt": "Abre esta carteira onde está — vinculada ao diretório de dados (sem cópia)",
"wallets_reveal": "Mostrar pasta",
"wallets_sort_addresses": "Endereços",
"wallets_sort_asc": "Crescente (mais antigo / menos / menor primeiro)",
"wallets_sort_by": "Ordenar:",
"wallets_sort_created": "Criado",
"wallets_sort_desc": "Decrescente (mais recente / mais / maior primeiro)",
"wallets_sort_size": "Tamanho",
"wallets_sort_txs": "Txs",
"wallets_title": "Carteiras",
"warning": "Aviso",
"warning_upper": "AVISO!",

View File

@@ -1323,31 +1323,47 @@
"wallets_add": "Добавить папку",
"wallets_add_folder": "Также сканировать другую папку на наличие файлов кошельков:",
"wallets_add_folder_toggle": "+ Искать кошельки в другой папке…",
"wallets_badge_encrypted": "Зашифрован (защищён паролем)",
"wallets_badge_encrypted_short": "Зашифрован",
"wallets_badge_legacy": "Устаревший кошелёк (без seed-фразы)",
"wallets_badge_legacy_short": "Устаревший",
"wallets_badge_seed": "Кошелёк с seed-фразой (HD)",
"wallets_badge_seed_short": "Seed-фраза",
"wallets_badge_unknown": "Тип кошелька определён не полностью (большой файл — откройте для подтверждения)",
"wallets_badge_unknown_short": "Неизвестно",
"wallets_button": "Кошельки…",
"wallets_col_addresses": "Адреса",
"wallets_col_balance": "Баланс",
"wallets_col_keys": "ключей",
"wallets_col_name": "Кошелёк",
"wallets_col_opened": "Последнее открытие",
"wallets_col_size": "Размер",
"wallets_col_txs": "трз",
"wallets_create": "Создать кошелёк",
"wallets_created": "создан",
"wallets_creating": "Создание кошелька — узел будет перезапущен…",
"wallets_current": "текущий",
"wallets_exists": "Кошелёк с таким именем уже существует.",
"wallets_external_tt": "Вне вашего каталога данных — «Импорт» скопирует его.",
"wallets_external_tt": "Вне каталога данных — «Открыть» создаёт ссылку на месте (без копирования).",
"wallets_folder_hint": "/путь/к/папке с файлами кошельков .dat",
"wallets_folder_invalid": "Такой папки не существует.",
"wallets_import": "Импорт",
"wallets_import_failed": "Не удалось импортировать этот файл кошелька.",
"wallets_import_hint": "Импортировать для открытия",
"wallets_import_tt": "Этот кошелёк находится вне папки данных. При открытии он сначала будет скопирован в неё (скоро).",
"wallets_imported": "Кошелёк импортирован — переключение…",
"wallets_intro": "Файлы кошельков в вашей папке данных (и в любых добавленных вами папках). Откройте один, чтобы переключиться — узел перезапустится, чтобы загрузить его, и у каждого кошелька свои данные.",
"wallets_name_invalid": "Введите допустимое имя кошелька.",
"wallets_never": "Никогда",
"wallets_never": "Не открывался",
"wallets_new_hint": "Имя (например, savings)",
"wallets_new_label": "Создать новый кошелёк:",
"wallets_open": "Открыть",
"wallets_open_failed": "Не удалось открыть этот кошелёк на месте. Вероятно, он на другом диске, чем каталог данных — переместите его на тот же диск (в Windows включённый режим разработчика также позволяет создавать ссылки между дисками).",
"wallets_open_folder": "Открыть расположение папки",
"wallets_open_inplace_tt": "Открывает этот кошелёк на месте — по ссылке в каталоге данных (без копирования)",
"wallets_reveal": "Открыть папку",
"wallets_sort_addresses": "Адреса",
"wallets_sort_asc": "По возрастанию (сначала старые / меньше / меньший)",
"wallets_sort_by": "Сортировка:",
"wallets_sort_created": "Создан",
"wallets_sort_desc": "По убыванию (сначала новые / больше / больший)",
"wallets_sort_size": "Размер",
"wallets_sort_txs": "Транз.",
"wallets_title": "Кошельки",
"warning": "Предупреждение",
"warning_upper": "ПРЕДУПРЕЖДЕНИЕ!",

View File

@@ -1323,31 +1323,47 @@
"wallets_add": "添加文件夹",
"wallets_add_folder": "同时扫描另一个文件夹中的钱包文件:",
"wallets_add_folder_toggle": "+ 扫描其他文件夹中的钱包…",
"wallets_badge_encrypted": "已加密(密码保护)",
"wallets_badge_encrypted_short": "已加密",
"wallets_badge_legacy": "旧版钱包(无助记词)",
"wallets_badge_legacy_short": "旧版",
"wallets_badge_seed": "助记词钱包 (HD)",
"wallets_badge_seed_short": "助记词",
"wallets_badge_unknown": "钱包类型未完全确定(文件较大——打开以确认)",
"wallets_badge_unknown_short": "未知",
"wallets_button": "钱包…",
"wallets_col_addresses": "地址",
"wallets_col_balance": "余额",
"wallets_col_keys": "个密钥",
"wallets_col_name": "钱包",
"wallets_col_opened": "上次打开",
"wallets_col_size": "大小",
"wallets_col_txs": "笔交易",
"wallets_create": "创建钱包",
"wallets_created": "创建于",
"wallets_creating": "正在创建钱包——节点将重启…",
"wallets_current": "当前",
"wallets_exists": "已存在同名钱包。",
"wallets_external_tt": "位于数据目录之外 — 导入以将其复制进来。",
"wallets_external_tt": "数据目录之外 —「打开」会就地链接(不复制)。",
"wallets_folder_hint": "/含 .dat 钱包文件的文件夹路径",
"wallets_folder_invalid": "该文件夹不存在。",
"wallets_import": "导入",
"wallets_import_failed": "无法导入该钱包文件。",
"wallets_import_hint": "导入以打开",
"wallets_import_tt": "此钱包位于数据目录之外。打开它会先将其复制进来(即将推出)。",
"wallets_imported": "钱包已导入——正在切换…",
"wallets_intro": "位于您数据目录(以及您添加的任何文件夹)中的钱包文件。打开一个即可切换——节点将重启以加载它,且每个钱包各自保留其数据。",
"wallets_name_invalid": "请输入有效的钱包名称。",
"wallets_never": "从未",
"wallets_never": "从未打开",
"wallets_new_hint": "名称(例如 savings",
"wallets_new_label": "创建新钱包:",
"wallets_open": "打开",
"wallets_open_failed": "无法在原位置打开此钱包。它可能与数据目录位于不同的驱动器上 — 请将其移动到同一驱动器(在 Windows 上,启用开发者模式也可跨驱动器链接)。",
"wallets_open_folder": "打开文件夹位置",
"wallets_open_inplace_tt": "在原位置打开此钱包 — 链接到数据目录(不复制)",
"wallets_reveal": "打开文件夹",
"wallets_sort_addresses": "地址",
"wallets_sort_asc": "升序(最早/最少/最小优先)",
"wallets_sort_by": "排序:",
"wallets_sort_created": "创建",
"wallets_sort_desc": "降序(最新/最多/最大优先)",
"wallets_sort_size": "大小",
"wallets_sort_txs": "交易",
"wallets_title": "钱包",
"warning": "警告",
"warning_upper": "警告!",

View File

@@ -2100,6 +2100,138 @@ TRANSLATIONS = {
"pt": "EXPLORADOR", "ru": "ОБОЗРЕВАТЕЛЬ", "zh": "浏览器",
"ja": "エクスプローラー", "ko": "탐색기"
},
# --- Wallets dialog: metadata counts + status badges ---
"wallets_col_txs": {
"es": "tx", "de": "Tx", "fr": "tx", "pt": "tx",
"ru": "трз", "zh": "笔交易", "ja": "", "ko": ""
},
"wallets_col_keys": {
"es": "claves", "de": "Schlüssel", "fr": "clés", "pt": "chaves",
"ru": "ключей", "zh": "个密钥", "ja": "個の鍵", "ko": "개 키"
},
"wallets_badge_encrypted": {
"es": "Cifrada (protegida con contraseña)", "de": "Verschlüsselt (passphrasengeschützt)",
"fr": "Chiffré (protégé par phrase secrète)", "pt": "Encriptada (protegida por senha)",
"ru": "Зашифрован (защищён паролем)", "zh": "已加密(密码保护)",
"ja": "暗号化済み(パスフレーズ保護)", "ko": "암호화됨 (암호로 보호됨)"
},
"wallets_badge_seed": {
"es": "Billetera con frase semilla (HD)", "de": "Seed-Phrase-Wallet (HD)",
"fr": "Portefeuille à phrase de récupération (HD)", "pt": "Carteira com frase semente (HD)",
"ru": "Кошелёк с seed-фразой (HD)", "zh": "助记词钱包 (HD)",
"ja": "シードフレーズウォレット (HD)", "ko": "시드 문구 지갑 (HD)"
},
"wallets_badge_legacy": {
"es": "Billetera heredada (sin frase semilla)", "de": "Legacy-Wallet (keine Seed-Phrase)",
"fr": "Portefeuille hérité (sans phrase de récupération)", "pt": "Carteira legada (sem frase semente)",
"ru": "Устаревший кошелёк (без seed-фразы)", "zh": "旧版钱包(无助记词)",
"ja": "レガシーウォレット(シードフレーズなし)", "ko": "레거시 지갑 (시드 문구 없음)"
},
"wallets_badge_unknown": {
"es": "Tipo de billetera no determinado por completo (archivo grande — abra para confirmar)",
"de": "Wallet-Typ nicht vollständig ermittelt (große Datei — zum Bestätigen öffnen)",
"fr": "Type de portefeuille non entièrement déterminé (fichier volumineux — ouvrir pour confirmer)",
"pt": "Tipo de carteira não totalmente determinado (arquivo grande — abra para confirmar)",
"ru": "Тип кошелька определён не полностью (большой файл — откройте для подтверждения)",
"zh": "钱包类型未完全确定(文件较大——打开以确认)",
"ja": "ウォレットの種類を完全に判定できません(大きなファイル — 開いて確認)",
"ko": "지갑 유형을 완전히 확인하지 못함 (큰 파일 — 열어서 확인)"
},
"wallets_badge_seed_short": {
"es": "Frase semilla", "de": "Seed-Phrase", "fr": "Phrase secrète", "pt": "Frase semente",
"ru": "Seed-фраза", "zh": "助记词", "ja": "シードフレーズ", "ko": "시드 문구"
},
"wallets_badge_encrypted_short": {
"es": "Cifrada", "de": "Verschlüsselt", "fr": "Chiffré", "pt": "Encriptada",
"ru": "Зашифрован", "zh": "已加密", "ja": "暗号化", "ko": "암호화됨"
},
"wallets_badge_legacy_short": {
"es": "Heredada", "de": "Legacy", "fr": "Hérité", "pt": "Legada",
"ru": "Устаревший", "zh": "旧版", "ja": "レガシー", "ko": "레거시"
},
"wallets_badge_unknown_short": {
"es": "Desconocido", "de": "Unbekannt", "fr": "Inconnu", "pt": "Desconhecido",
"ru": "Неизвестно", "zh": "未知", "ja": "不明", "ko": "알 수 없음"
},
# --- Wallets dialog: created date + sort control ---
"wallets_created": {
"es": "creada", "de": "erstellt", "fr": "créé", "pt": "criada",
"ru": "создан", "zh": "创建于", "ja": "作成", "ko": "생성"
},
"wallets_sort_by": {
"es": "Ordenar:", "de": "Sortieren:", "fr": "Trier :", "pt": "Ordenar:",
"ru": "Сортировка:", "zh": "排序:", "ja": "並べ替え:", "ko": "정렬:"
},
"wallets_sort_created": {
"es": "Creado", "de": "Erstellt", "fr": "Créé", "pt": "Criado",
"ru": "Создан", "zh": "创建", "ja": "作成", "ko": "생성"
},
"wallets_sort_addresses": {
"es": "Direcciones", "de": "Adressen", "fr": "Adresses", "pt": "Endereços",
"ru": "Адреса", "zh": "地址", "ja": "アドレス", "ko": "주소"
},
"wallets_sort_txs": {
"es": "Txs", "de": "Txs", "fr": "Txs", "pt": "Txs",
"ru": "Транз.", "zh": "交易", "ja": "取引", "ko": "거래"
},
"wallets_sort_size": {
"es": "Tamaño", "de": "Größe", "fr": "Taille", "pt": "Tamanho",
"ru": "Размер", "zh": "大小", "ja": "サイズ", "ko": "크기"
},
"wallets_sort_asc": {
"es": "Ascendente (más antiguo / menos / más pequeño primero)",
"de": "Aufsteigend (ältestes / wenigste / kleinstes zuerst)",
"fr": "Croissant (plus ancien / moins / plus petit d'abord)",
"pt": "Crescente (mais antigo / menos / menor primeiro)",
"ru": "По возрастанию (сначала старые / меньше / меньший)",
"zh": "升序(最早/最少/最小优先)", "ja": "昇順(古い/少ない/小さい順)", "ko": "오름차순 (오래된/적은/작은 순)"
},
"wallets_sort_desc": {
"es": "Descendente (más reciente / más / más grande primero)",
"de": "Absteigend (neuestes / meiste / größtes zuerst)",
"fr": "Décroissant (plus récent / plus / plus grand d'abord)",
"pt": "Decrescente (mais recente / mais / maior primeiro)",
"ru": "По убыванию (сначала новые / больше / больший)",
"zh": "降序(最新/最多/最大优先)", "ja": "降順(新しい/多い/大きい順)", "ko": "내림차순 (최신/많은/큰 순)"
},
"wallets_open_folder": {
"es": "Abrir ubicación de la carpeta", "de": "Ordnerpfad öffnen",
"fr": "Ouvrir l'emplacement du dossier", "pt": "Abrir local da pasta",
"ru": "Открыть расположение папки", "zh": "打开文件夹位置",
"ja": "フォルダーの場所を開く", "ko": "폴더 위치 열기"
},
"wallets_open_inplace_tt": {
"es": "Abre esta cartera donde está, enlazándola al directorio de datos (sin copiar)",
"de": "Öffnet diese Wallet an ihrem Ort — im Datenverzeichnis verlinkt (keine Kopie)",
"fr": "Ouvre ce portefeuille à son emplacement — lié au répertoire de données (sans copie)",
"pt": "Abre esta carteira onde está — vinculada ao diretório de dados (sem cópia)",
"ru": "Открывает этот кошелёк на месте — по ссылке в каталоге данных (без копирования)",
"zh": "在原位置打开此钱包 — 链接到数据目录(不复制)",
"ja": "このウォレットをその場で開きます — データディレクトリにリンク(コピーなし)",
"ko": "이 지갑을 있는 자리에서 엽니다 — 데이터 디렉터리에 링크 (복사 없음)"
},
"wallets_open_failed": {
"es": "No se pudo abrir esta cartera en su ubicación. Probablemente está en una unidad distinta a tu directorio de datos: muévela a la misma unidad (en Windows, activar el Modo de desarrollador también permite enlazar entre unidades).",
"de": "Diese Wallet konnte nicht an ihrem Ort geöffnet werden. Sie liegt vermutlich auf einem anderen Laufwerk als dein Datenverzeichnis — verschiebe sie auf dasselbe Laufwerk (unter Windows erlaubt auch der aktivierte Entwicklermodus laufwerkübergreifende Verknüpfungen).",
"fr": "Impossible d'ouvrir ce portefeuille à son emplacement. Il se trouve probablement sur un lecteur différent de votre répertoire de données — déplacez-le sur le même lecteur (sous Windows, activer le mode développeur permet aussi de créer des liens entre lecteurs).",
"pt": "Não foi possível abrir esta carteira no lugar. Provavelmente está em uma unidade diferente do seu diretório de dados — mova-a para a mesma unidade (no Windows, ativar o Modo de Desenvolvedor também permite vincular entre unidades).",
"ru": "Не удалось открыть этот кошелёк на месте. Вероятно, он на другом диске, чем каталог данных — переместите его на тот же диск (в Windows включённый режим разработчика также позволяет создавать ссылки между дисками).",
"zh": "无法在原位置打开此钱包。它可能与数据目录位于不同的驱动器上 — 请将其移动到同一驱动器(在 Windows 上,启用开发者模式也可跨驱动器链接)。",
"ja": "このウォレットをその場で開けませんでした。データディレクトリとは別のドライブにある可能性があります — 同じドライブに移動してくださいWindows では開発者モードを有効にするとドライブ間のリンクも可能になります)。",
"ko": "이 지갑을 제자리에서 열 수 없습니다. 데이터 디렉터리와 다른 드라이브에 있을 가능성이 높습니다 — 같은 드라이브로 옮기세요 (Windows에서는 개발자 모드를 켜면 드라이브 간 링크도 가능합니다)."
},
"wallets_external_tt": {
"es": "Fuera de tu directorio de datos — Abrir lo enlaza en su lugar (sin copiar).",
"de": "Außerhalb deines Datenverzeichnisses — Öffnen verlinkt sie an ihrem Ort (keine Kopie).",
"fr": "Hors de votre répertoire de données — Ouvrir le lie sur place (sans copie).",
"pt": "Fora do seu diretório de dados — Abrir o vincula no lugar (sem cópia).",
"ru": "Вне каталога данных — «Открыть» создаёт ссылку на месте (без копирования).",
"zh": "在数据目录之外 —「打开」会就地链接(不复制)。",
"ja": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。",
"ko": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음)."
},
}
def main():

View File

@@ -742,6 +742,10 @@ void App::update()
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup();
// Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can
// glow for a legacy, pre-seed-phrase wallet.
probeWalletSeedStatus();
// Pick up progress/result from a running seed-wallet migration (create/sweep/adopt).
pumpSeedMigration();
// While confirming the sweep, poll the tx confirmations + legacy balance every ~5s.
@@ -4727,6 +4731,47 @@ bool App::hasPinVault() const {
return vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
}
bool App::debugGateRequiresAuth() const {
return hasPinVault() || state_.encrypted;
}
void App::verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb) {
if (!cb) return;
// A quick-unlock PIN vault → verify the PIN by deriving it (pure crypto, no wallet side effects).
if (hasPinVault()) {
auto* w = worker_.get();
if (!w) { cb(false); return; }
w->post([this, s = secret, cb]() mutable -> rpc::RPCWorker::MainCb {
std::string pass;
bool ok = vault_ && vault_->retrieve(s, pass);
if (!pass.empty()) util::SecureVault::secureZero(pass.data(), pass.size());
if (!s.empty()) util::SecureVault::secureZero(&s[0], s.size());
return [cb, ok]() { cb(ok); };
});
return;
}
// Encrypted wallet, no PIN → verify the passphrase via walletpassphrase (re-unlocks with the
// usual timeout; a wrong passphrase throws).
if (state_.encrypted) {
auto* w = worker_.get();
auto* r = rpc_.get();
if (!w || !r) { cb(false); return; }
int autoLock = settings_ ? settings_->getAutoLockTimeout() : 300;
int timeout = (autoLock > 0) ? std::max(600, autoLock * 2) : 86400;
w->post([r, s = secret, timeout, cb]() mutable -> rpc::RPCWorker::MainCb {
bool ok = false;
try {
if (r && r->isConnected()) { r->call("walletpassphrase", {s, timeout}); ok = true; }
} catch (...) { ok = false; }
if (!s.empty()) util::SecureVault::secureZero(&s[0], s.size());
return [cb, ok]() { cb(ok); };
});
return;
}
// No credential set — nothing to verify.
cb(true);
}
bool App::hasPendingRPCResults() const {
return (worker_ && worker_->hasPendingResults())
|| (fast_worker_ && fast_worker_->hasPendingResults());

View File

@@ -368,6 +368,9 @@ public:
void showBackupDialog() { show_backup_ = true; }
void showSeedBackupDialog() { show_seed_backup_ = true; }
void showSeedMigrationDialog(); // opens the migration modal (resumes a pending one at Sweep)
// True when the current full-node wallet is a legacy, pre-seed-phrase wallet (no BIP39 mnemonic)
// that a capable daemon could migrate — the Migrate-to-seed button glows to nudge the user.
bool isPreSeedWallet() const { return wallet_seed_status_ == WalletSeedStatus::NoMnemonic; }
void showAboutDialog() { show_about_ = true; }
// Legacy tab compat — maps int to NavPage
@@ -492,6 +495,12 @@ public:
void showPinRemoveDialog() { show_pin_remove_ = true; pin_status_.clear(); }
bool hasPinVault() const;
// Debug-options gate: does revealing the debug dropdown need re-authentication (a PIN vault or
// an encrypted wallet)? And verify the entered PIN/passphrase — cb(ok) is invoked on the main
// thread (PIN via the vault, else the wallet passphrase via RPC).
bool debugGateRequiresAuth() const;
void verifyDebugCredential(const std::string& secret, std::function<void(bool)> cb);
/// @brief Check if RPC worker has queued results waiting to be processed
bool hasPendingRPCResults() const;
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
@@ -711,6 +720,16 @@ private:
bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
// capable daemon can migrate; Incapable = the daemon lacks z_exportmnemonic (can't tell / can't
// migrate). Reset to Unknown on wallet switch so it re-probes the new wallet.
enum class WalletSeedStatus { Unknown, HasMnemonic, NoMnemonic, Incapable };
WalletSeedStatus wallet_seed_status_ = WalletSeedStatus::Unknown;
bool wallet_seed_status_in_flight_ = false;
int wallet_seed_status_attempts_ = 0; // give up (Incapable) after a few transient probe failures
void probeWalletSeedStatus(); // one-shot per connect; classifies the wallet's mnemonic status
// --- Seed-wallet migration (Phase 1: create; Phase 2: sweep + adopt) ---
enum class SeedMigrationStep { Intro, Working, ShowSeed, Sweep, Sweeping, Confirming, Adopting, Done, Error };
bool show_seed_migration_ = false;

View File

@@ -579,6 +579,11 @@ void App::onDisconnected(const std::string& reason)
state_.clear();
connection_status_ = reason;
// Re-classify the wallet's mnemonic status on the next connect (the active wallet may have
// changed — a switch or a post-migration adopt both disconnect here).
wallet_seed_status_ = WalletSeedStatus::Unknown;
wallet_seed_status_attempts_ = 0;
// Clear RPC result caches
viewtx_cache_.clear();
confirmed_tx_cache_.clear();
@@ -2936,6 +2941,35 @@ void App::maybeRemindSeedBackup()
});
}
// One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings
// Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same
// classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past
// the exportSeedPhrase callback (which wipes the phrase). Retries next tick on a transient failure.
void App::probeWalletSeedStatus()
{
if (capture_mode_) return; // no live ops during a UI sweep
if (lite_wallet_) return; // lite has its own seed UX
if (wallet_seed_status_ != WalletSeedStatus::Unknown) return; // already classified
if (!state_.connected || !state_.encryption_state_known) return;
if (state_.isLocked()) return; // needs an unlocked wallet to read it
if (wallet_seed_status_in_flight_) return;
wallet_seed_status_in_flight_ = true;
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& /*phrase*/,
const std::string& error) {
wallet_seed_status_in_flight_ = false;
if (ok)
wallet_seed_status_ = WalletSeedStatus::HasMnemonic;
else if (noMnemonic)
wallet_seed_status_ = WalletSeedStatus::NoMnemonic; // legacy → migratable → glow
else if (error.find("Method not found") != std::string::npos ||
error.find("-32601") != std::string::npos)
wallet_seed_status_ = WalletSeedStatus::Incapable; // old daemon: can't migrate
else if (++wallet_seed_status_attempts_ >= 3)
wallet_seed_status_ = WalletSeedStatus::Incapable; // give up after a few transient errors
// else: transient error → stay Unknown, retry next tick
});
}
void App::beginCreateSeedWallet()
{
if (seed_migration_in_flight_) return;

View File

@@ -21,6 +21,7 @@
#include "ui/windows/wallets_dialog.h"
#include "ui/windows/daemon_download_dialog.h"
#include "ui/windows/xmrig_download_dialog.h"
#include "ui/pages/settings_page.h"
#include "util/platform.h"
#include "wallet/wallet_capabilities.h"
@@ -113,6 +114,8 @@ void App::installDemoWalletData()
applyHealthyDemoState();
state_.sync.blocks = state_.sync.headers = 3124322;
state_.sync.verification_progress = 1.0; state_.sync.syncing = false;
// Legacy (pre-seed) wallet so the Settings "Migrate to seed" button glows in the sweep.
wallet_seed_status_ = WalletSeedStatus::NoMnemonic;
state_.privateBalance = 12.50000000; state_.transparentBalance = 3.25000000;
state_.totalBalance = 15.75000000; state_.unconfirmedBalance = 0.50000000;
@@ -210,6 +213,7 @@ void App::clearDemoWalletData()
{
auto& s = sweep_state_snapshot_;
if (!s.valid) return;
wallet_seed_status_ = WalletSeedStatus::Unknown; // re-probe on the next real connect
state_.connected = s.connected; state_.warming_up = s.warming_up;
state_.daemon_initializing = s.daemon_initializing;
state_.encrypted = s.encrypted; state_.locked = s.locked;
@@ -439,6 +443,11 @@ void App::buildSweepCatalog()
[](App& a) { a.console_tab_.sweepSetCommandsPopup(true); },
[](App& a) { a.console_tab_.sweepSetCommandsPopup(false); });
// Debug-options gate: confirmation + warning, with the passphrase re-auth field (encrypted).
add("modal-debug-gate", ui::NavPage::Settings,
[](App& a) { a.state_.encrypted = true; ui::SweepOpenDebugGate(true); },
[](App& a) { ui::SweepOpenDebugGate(false); a.applyHealthyDemoState(); });
// Daemon updater — the two-pane version picker (versions left, selected-version detail right).
// Seeds fake releases so the offline sweep renders it without a network fetch / live updater.
add("modal-daemon-update", ui::NavPage::Settings,

View File

@@ -1,7 +1,10 @@
#include "daemon_controller.h"
#include "../config/settings.h"
#include "../util/platform.h"
#include <algorithm>
#include <filesystem>
#include <system_error>
namespace dragonx {
namespace daemon {
@@ -23,7 +26,18 @@ void DaemonController::syncSettings(const config::Settings* settings)
if (!settings) return;
daemon_->setDebugCategories(settings->getDebugCategories());
daemon_->setMaxConnections(settings->getMaxConnections());
daemon_->setWalletFile(settings->getActiveWalletFile());
std::string walletFile = settings->getActiveWalletFile();
// The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a
// "wallet-ip-<hash>.dat" name. If that link went dangling (the external file was moved / a USB was
// unplugged between sessions), don't let the daemon create a fresh EMPTY wallet at that name — fall
// back to the default this launch. fs::exists follows the link, so it's false for a dangling one.
if (walletFile.rfind("wallet-ip-", 0) == 0) {
std::error_code ec;
if (!std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + walletFile, ec))
walletFile = "wallet.dat";
}
daemon_->setWalletFile(walletFile);
}
bool DaemonController::start(const config::Settings* settings)

View File

@@ -586,12 +586,14 @@ void AcrylicMaterial::drawRect(ImDrawList* drawList, const ImVec2& pMin, const I
float u1 = localX1 / viewportWidth_;
float v1 = 1.0f - localY1 / viewportHeight_; // V at bottom-right (low)
// Draw the blurred background. Scale by both the glass preset
// opacity (fallbackColor.w) AND the user's UI opacity slider so
// that lowering card opacity lets the sharp background through.
// Draw the blurred background at (near) full strength so the panel is actually FROSTED — the
// blurred capture replaces the sharp background in the card. UI opacity is applied only to the
// tint overlay (in DrawGlassPanel), NOT here: scaling the blur by uiOpacity used to let the
// SHARP background bleed straight through at low opacity, which made the acrylic slider look
// like a no-op. A small floor keeps it frosted even for low-alpha presets.
ImTextureID blurTex = getBlurredTexture();
uint8_t glassAlpha = static_cast<uint8_t>(
std::min(255.0f, std::max(0.0f, params.fallbackColor.w * settings_.uiOpacity * 255.0f)));
std::min(255.0f, std::max(0.0f, std::max(params.fallbackColor.w, 0.90f) * 255.0f)));
if (blurTex) {
drawList->AddImageRounded(
@@ -1562,11 +1564,12 @@ void AcrylicMaterial::drawRect(ImDrawList* drawList, const ImVec2& pMin, const I
(void*)blurTex, u0, v0, u1, v1);
}
// Draw the blurred background. Scale by both the glass preset
// opacity (fallbackColor.w) AND the user's UI opacity slider so
// that lowering card opacity lets the sharp background through.
// Draw the blurred background at (near) full strength so the panel is actually FROSTED — UI
// opacity is applied only to the tint overlay, NOT here (scaling the blur by uiOpacity let the
// SHARP background bleed through at low opacity, making the acrylic slider a no-op). Mirrors the
// GL drawRect fix above.
uint8_t glassAlpha = (uint8_t)std::min(255.f,
std::max(0.f, params.fallbackColor.w * settings_.uiOpacity * 255.f));
std::max(0.f, std::max(params.fallbackColor.w, 0.90f) * 255.f));
if (blurTex) {
drawList->AddImageRounded(

View File

@@ -545,10 +545,75 @@ inline bool& FullWindowBlurOverlayActiveRef() { static bool v = false; return v;
inline void SetFullWindowBlurOverlayActive(bool v) { FullWindowBlurOverlayActiveRef() = v; }
inline bool IsFullWindowBlurOverlayActive() { return FullWindowBlurOverlayActiveRef(); }
// Light-surface themes (Light / Marble) want cards to lift off the pale background with a subtle
// drop shadow. Cached per theme-generation so the string compare runs once per theme load, not per
// panel. (Data-driven later if more themes want it.)
inline bool CardDropShadowWanted()
{
static uint32_t s_gen = ~0u;
static bool s_want = false;
uint32_t g = schema::UI().generation();
if (g != s_gen) {
s_gen = g;
const std::string& tn = schema::UI().themeName();
s_want = (tn == "Light" || tn == "Marble");
}
return s_want;
}
// Soft, UNIFORM shadow around a rounded rect — no directional offset, equal on all sides. Drawn as
// fading rounded-rect STROKES (not fills), so it never paints across the card body — only the outer
// blur remains once the card fill covers the interior. The "render then mask out the UI area" result
// without an FBO.
inline void DrawCardDropShadow(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax, float rounding)
{
const float dp = Layout::dpiScale();
// Matches the design mockup's `box-shadow: 0 0 4px rgb(50 53 58 / 26%)`: a tight, darker, colored
// uniform shadow. Sampled as a stack of rounded-rect strokes stepping OUTWARD from the card edge
// with a Gaussian alpha falloff (darkest at the edge, ~0 by the tail). ~1px-spaced, near-1px-thick
// rings keep overlap (and thus alpha accumulation) low, so the peak reads close to the target.
const float blur = 4.0f * dp; // CSS-like blur radius
const float reach = blur * 1.5f; // how far the shadow extends beyond the card edge (all sides)
const float sigma = blur * 0.6f;
const float peak = 34.0f; // edge alpha for rgb(50,53,58) (~13% after the card fill masks the inner half)
const int steps = std::max(6, (int)(reach / dp + 0.5f));
// The shadow rides on the card's OWN draw list (so it keeps the right z-order in EVERY context —
// main content, modals, foreground popups, notifications; a global background draw list would drop
// a modal/popup card's shadow behind its scrim). But cards often live inside clipping child windows
// whose clip rect chops the shadow wherever a card sits flush with the child's top/bottom/side edge.
// Clip instead to the shadow's own bounding box (card ± reach), so all four sides show in full —
// bounded on each side to at most `reach` beyond the child clip (and the viewport) so a card scrolled
// partly out of a scroll area still can't smear its shadow more than the halo width into neighbours.
ImGuiViewport* vp = ImGui::GetMainViewport();
const ImVec2 cur0 = dl->GetClipRectMin();
const ImVec2 cur1 = dl->GetClipRectMax();
const float x0 = std::max(vp->Pos.x, std::max(pMin.x - reach, cur0.x - reach));
const float y0 = std::max(vp->Pos.y, std::max(pMin.y - reach, cur0.y - reach));
const float x1 = std::min(vp->Pos.x + vp->Size.x, std::min(pMax.x + reach, cur1.x + reach));
const float y1 = std::min(vp->Pos.y + vp->Size.y, std::min(pMax.y + reach, cur1.y + reach));
dl->PushClipRect(ImVec2(x0, y0), ImVec2(x1, y1), false);
for (int i = 0; i < steps; ++i) {
float d = reach * (float)i / (float)(steps - 1); // 0 (card edge) .. reach (outer tail)
float g = std::exp(-(d * d) / (2.0f * sigma * sigma));
int a = (int)(peak * g + 0.5f);
if (a < 1) continue;
ImVec2 smn(pMin.x - d, pMin.y - d);
ImVec2 smx(pMax.x + d, pMax.y + d);
dl->AddRect(smn, smx, IM_COL32(50, 53, 58, a), rounding + d, 0, 1.3f * dp);
}
dl->PopClipRect();
}
inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin,
const ImVec2& pMax,
const GlassPanelSpec& spec = GlassPanelSpec())
{
// Subtle drop shadow behind the card (light themes only). Drawn first so the card fill below
// covers the interior, leaving only the outer soft blur — the card body is never contaminated.
if (CardDropShadowWanted())
DrawCardDropShadow(dl, pMin, pMax, spec.rounding);
if (IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode() && !IsFullWindowBlurOverlayActive()) {
// --- Cached color lookups (invalidated on theme change) ---
// These 3 resolveColor() calls do string parsing + map lookup

View File

@@ -57,6 +57,11 @@ namespace ui {
using namespace material;
// Top of the Acrylic slider's range (the blur-radius multiplier at 100%). The panels frost at full
// alpha now, so this only controls how BLURRY the max is — kept gentle (the old max of 4.0 was way
// too strong). The slider still reads 0100%; this is just what 100% maps to.
static constexpr float kAcrylicMaxBlur = 1.25f;
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
static std::string TrId(const char* tr_key, const char* id) {
std::string s = TR(tr_key);
@@ -144,6 +149,15 @@ struct SettingsPageState {
std::set<std::string> debug_categories;
bool debug_cats_dirty = false;
bool debug_expanded = false;
// Debug-options gate: a confirmation + warning (and, when a PIN/passphrase is set, re-auth) is
// required before the debug dropdown reveals its options — once per session (passing it once
// unlocks the dropdown until the app restarts).
bool debug_gate_open = false;
bool debug_gate_passed = false;
bool debug_gate_verifying = false;
char debug_gate_buf[128] = {0};
std::string debug_gate_err;
float debug_gate_err_timer = 0.0f;
bool effects_expanded = false;
bool tools_expanded = false;
bool rpc_expanded = false; // Node & Security: reveal the RPC connection fields
@@ -382,8 +396,9 @@ static void loadSettingsPageState(config::Settings* settings) {
idx++;
}
// Load blur amount directly from saved multiplier
s_settingsState.blur_amount = settings->getBlurMultiplier();
// Load blur amount directly from saved multiplier, clamped to the (now gentler) slider max so a
// value saved under the old 04 range maps into 0100% instead of pinning far past the top.
s_settingsState.blur_amount = std::min(settings->getBlurMultiplier(), kAcrylicMaxBlur);
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
s_settingsState.ui_opacity = settings->getUIOpacity();
s_settingsState.window_opacity = settings->getWindowOpacity();
@@ -874,10 +889,10 @@ void RenderSettingsPage(App* app) {
if (s_settingsState.blur_amount < 0.01f)
snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off"));
else
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount * 25.0f);
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, 4.0f, blur_fmt,
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount / kAcrylicMaxBlur * 100.0f);
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, kAcrylicMaxBlur, blur_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < 0.15f) s_settingsState.blur_amount = 0.0f;
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < kAcrylicMaxBlur * 0.04f) s_settingsState.blur_amount = 0.0f;
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
}
@@ -1120,10 +1135,10 @@ void RenderSettingsPage(App* app) {
if (s_settingsState.blur_amount < 0.01f)
snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off"));
else
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount * 25.0f);
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, 4.0f, blur_fmt,
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount / kAcrylicMaxBlur * 100.0f);
if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, kAcrylicMaxBlur, blur_fmt,
ImGuiSliderFlags_AlwaysClamp)) {
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < 0.15f) s_settingsState.blur_amount = 0.0f;
if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < kAcrylicMaxBlur * 0.04f) s_settingsState.blur_amount = 0.0f;
s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f);
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
}
@@ -1336,10 +1351,24 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("seed_backup_button"), ImVec2(0, 0), btnFont))
app->showSeedBackupDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_backup"));
// Migrate to a new seed-phrase wallet (create in isolation, then sweep funds).
// Migrate to a new seed-phrase wallet (create in isolation, then sweep funds). A
// legacy (pre-seed-phrase) wallet glows the button to nudge the user toward migrating.
ImGui::SameLine(0, scaledSp);
const bool migrateGlow = app->isPreSeedWallet();
if (TactileButton(TR("seed_migrate_button"), ImVec2(0, 0), btnFont))
app->showSeedMigrationDialog();
if (migrateGlow) {
const ImVec2 gmn = ImGui::GetItemRectMin(), gmx = ImGui::GetItemRectMax();
const float gdp = Layout::dpiScale();
const float pulse = 0.5f + 0.5f * std::sin((float)ImGui::GetTime() * 3.2f); // 0..1
ImDrawList* gdl = ImGui::GetWindowDrawList();
for (int g = 3; g >= 1; --g) { // soft, pulsing outward halo in the accent color
const float e = (float)g * 2.2f * gdp;
const int a = (int)((70.0f + pulse * 95.0f) / (float)g);
gdl->AddRect(ImVec2(gmn.x - e, gmn.y - e), ImVec2(gmx.x + e, gmx.y + e),
material::WithAlpha(material::Primary(), a), 6.0f * gdp + e, 0, 1.6f * gdp);
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_migrate"));
// Multi-wallet: list wallet files + switch the active one.
ImGui::SameLine(0, scaledSp);
@@ -2507,7 +2536,17 @@ void RenderSettingsPage(App* app) {
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.05f));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.08f));
if (ImGui::Button("##DebugToggle", ImVec2(availWidth, ImGui::GetFrameHeight()))) {
s_settingsState.debug_expanded = !s_settingsState.debug_expanded;
if (s_settingsState.debug_expanded) {
s_settingsState.debug_expanded = false; // collapsing needs no gate
} else if (s_settingsState.debug_gate_passed) {
s_settingsState.debug_expanded = true; // already unlocked this session
} else {
// First expand this session is gated: confirmation + (if secured) re-auth.
s_settingsState.debug_gate_open = true;
s_settingsState.debug_gate_buf[0] = '\0';
s_settingsState.debug_gate_err.clear();
s_settingsState.debug_gate_verifying = false;
}
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", s_settingsState.debug_expanded ? TR("tt_debug_collapse") : TR("tt_debug_expand"));
ImGui::PopStyleColor(3);
@@ -2633,6 +2672,77 @@ void RenderSettingsPage(App* app) {
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_restart_daemon"));
}
}
// ---- Debug-options gate: confirmation + warning (+ re-auth if a PIN/passphrase is set) ----
if (s_settingsState.debug_gate_open) {
auto& st = s_settingsState;
if (st.debug_gate_err_timer > 0.0f) {
st.debug_gate_err_timer -= ImGui::GetIO().DeltaTime;
if (st.debug_gate_err_timer <= 0.0f) st.debug_gate_err.clear();
}
const bool needAuth = app->debugGateRequiresAuth();
const bool hasPin = app->hasPinVault();
const float gdp = Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = TR("debug_gate_title");
ov.p_open = &st.debug_gate_open;
ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 480.0f;
ov.idSuffix = "debuggate";
if (material::BeginOverlayDialog(ov)) {
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) st.debug_gate_open = false;
ImGui::TextWrapped("%s", TR("debug_gate_warning"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
bool submit = false;
if (needAuth) {
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(),
hasPin ? TR("debug_gate_pin_prompt") : TR("debug_gate_pass_prompt"));
ImGui::SetNextItemWidth(-FLT_MIN);
ImGuiInputTextFlags f = ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue;
if (hasPin) f |= ImGuiInputTextFlags_CharsDecimal; // PIN is numeric
if (ImGui::InputText("##debugGateSecret", st.debug_gate_buf, sizeof(st.debug_gate_buf), f))
submit = true;
if (!st.debug_gate_err.empty()) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
Type().textColored(TypeStyle::Caption, Error(), st.debug_gate_err.c_str());
}
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::BeginDisabled(st.debug_gate_verifying);
if (TactileButton(TR("debug_gate_confirm"), ImVec2(170.0f * gdp, 0)) || submit) {
if (!needAuth) {
st.debug_expanded = true;
st.debug_gate_passed = true;
st.debug_gate_open = false;
} else if (strlen(st.debug_gate_buf) > 0) {
st.debug_gate_verifying = true;
std::string secret = st.debug_gate_buf;
memset(st.debug_gate_buf, 0, sizeof(st.debug_gate_buf));
app->verifyDebugCredential(secret, [](bool ok) {
auto& s = s_settingsState;
s.debug_gate_verifying = false;
if (ok) { s.debug_expanded = true; s.debug_gate_passed = true; s.debug_gate_open = false; }
else { s.debug_gate_err = TR("debug_gate_incorrect"); s.debug_gate_err_timer = 4.0f; }
});
if (!secret.empty()) memset(&secret[0], 0, secret.size());
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (TactileButton(TR("cancel"), ImVec2(120.0f * gdp, 0))) {
memset(st.debug_gate_buf, 0, sizeof(st.debug_gate_buf));
st.debug_gate_open = false;
}
if (st.debug_gate_verifying) {
ImGui::SameLine();
ImGui::AlignTextToFramePadding();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("debug_gate_verifying"));
}
material::EndOverlayDialog();
}
}
}
// --- Shader-based scroll fade: unbind (restore ImGui's default shader) ---
@@ -2909,5 +3019,14 @@ void RenderSettingsPage(App* app) {
}
void SweepOpenDebugGate(bool open) {
s_settingsState.debug_gate_open = open;
if (open) {
s_settingsState.debug_gate_buf[0] = '\0';
s_settingsState.debug_gate_err.clear();
s_settingsState.debug_gate_verifying = false;
}
}
} // namespace ui
} // namespace dragonx

View File

@@ -15,5 +15,9 @@ namespace ui {
*/
void RenderSettingsPage(App* app);
// Sweep-only: force the debug-options confirmation gate open/closed so the offline UI sweep can
// capture it (it is otherwise opened only by clicking the DEBUG OPTIONS header).
void SweepOpenDebugGate(bool open);
} // namespace ui
} // namespace dragonx

View File

@@ -90,12 +90,78 @@ bool isResultBodyChannel(ConsoleChannel ch)
} // namespace
namespace {
// ── WCAG contrast helpers — keep console text high-contrast while following each theme's palette. ──
inline float srgbToLinear(float c8) {
float c = c8 / 255.0f;
return c <= 0.04045f ? c / 12.92f : std::pow((c + 0.055f) / 1.055f, 2.4f);
}
inline float relLuminance(float r, float g, float b) {
return 0.2126f * srgbToLinear(r) + 0.7152f * srgbToLinear(g) + 0.0722f * srgbToLinear(b);
}
inline float contrastRatio(float lumA, float lumB) {
float hi = std::max(lumA, lumB), lo = std::min(lumA, lumB);
return (hi + 0.05f) / (lo + 0.05f);
}
// Nudge `fg` toward black (light surface) or white (dark surface), preserving hue + alpha, until its
// WCAG contrast against `bg` reaches `minRatio`. Colors already passing come back essentially unchanged.
inline ImU32 EnsureContrast(ImU32 fg, ImU32 bg, float minRatio) {
const float bgL = relLuminance((float)((bg >> IM_COL32_R_SHIFT) & 0xFF),
(float)((bg >> IM_COL32_G_SHIFT) & 0xFF),
(float)((bg >> IM_COL32_B_SHIFT) & 0xFF));
float r = (float)((fg >> IM_COL32_R_SHIFT) & 0xFF);
float g = (float)((fg >> IM_COL32_G_SHIFT) & 0xFF);
float b = (float)((fg >> IM_COL32_B_SHIFT) & 0xFF);
const int a = (int)((fg >> IM_COL32_A_SHIFT) & 0xFF);
const bool darken = bgL > 0.5f; // light surface -> darken the text; dark surface -> lighten it
for (int i = 0; i < 40 && contrastRatio(relLuminance(r, g, b), bgL) < minRatio; ++i) {
if (darken) { r *= 0.90f; g *= 0.90f; b *= 0.90f; }
else { r += (255.0f - r) * 0.12f; g += (255.0f - g) * 0.12f; b += (255.0f - b) * 0.12f; }
}
return IM_COL32((int)(r + 0.5f), (int)(g + 0.5f), (int)(b + 0.5f), a);
}
// Floor a dynamically-derived channel color to a readable contrast on light terminals; no-op on dark.
inline ImU32 FloorLight(ImU32 c) {
return material::IsLightTheme() ? EnsureContrast(c, IM_COL32(255, 255, 255, 255), 4.5f) : c;
}
} // namespace
void ConsoleTab::refreshColors()
{
using namespace material;
auto& S = schema::UI();
bool dark = material::IsDarkTheme();
// Derive light/dark from the SAME live background-luminance predicate the terminal surface uses
// (the overlay at line ~274 is chosen by IsLightTheme()). Using the stored IsDarkTheme() flag here
// let the two disagree — baking dark-theme (pale) text onto the light near-white console surface.
bool dark = !IsLightTheme();
// Try schema overrides first, then use sensible per-theme defaults
// Per-theme channel defaults.
ImU32 defCmd, defRes, defErr, defDmn, defInf, defRpc;
if (dark) {
// Dark terminal: keep the authored light-on-dark palette (schema overrides honored below).
defCmd = IM_COL32(191, 209, 229, 255);
defRes = IM_COL32(200, 200, 200, 255);
defErr = IM_COL32(246, 71, 64, 255);
defDmn = IM_COL32(160, 160, 160, 180);
defInf = IM_COL32(191, 209, 229, 255);
defRpc = IM_COL32(120, 180, 255, 210);
} else {
// Light terminal: follow THIS theme's own palette (Marble slate/taupe, Dune sand, Light blue, …)
// so the console reads on-theme — each color nudged toward black until it clears a WCAG contrast
// floor on the near-white console surface. High-contrast AND theme-colored.
const ImU32 W = IM_COL32(255, 255, 255, 255); // overlay is white(205); target pure white = safe floor
defCmd = EnsureContrast(Primary(), W, 4.5f); // command echo — theme primary
defRes = EnsureContrast(OnSurface(), W, 7.0f); // result body — theme text (already dark)
defErr = EnsureContrast(Error(), W, 4.5f); // errors — theme error
defDmn = WithAlpha(EnsureContrast(OnSurfaceMedium(), W, 4.5f), 235); // node log — dimmer, still legible
defInf = EnsureContrast(Primary(), W, 4.5f); // info / app log — theme primary
defRpc = EnsureContrast(Secondary(), W, 4.5f); // rpc trace — theme secondary
}
// Schema console colors are authored for the dark terminal (light-on-dark); honor them only in dark
// themes. Light themes always use the palette-derived, contrast-floored defaults above.
if (S.isLoaded()) {
auto cmd = S.drawElement("console", "color-command");
auto res = S.drawElement("console", "color-result");
@@ -103,17 +169,6 @@ void ConsoleTab::refreshColors()
auto dmn = S.drawElement("console", "color-daemon");
auto inf = S.drawElement("console", "color-info");
auto rpc = S.drawElement("console", "color-rpc");
ImU32 defCmd = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
ImU32 defRes = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
ImU32 defErr = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
ImU32 defDmn = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
ImU32 defInf = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
ImU32 defRpc = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
// The schema console colors are authored for the dark terminal (light-on-dark); honor them only
// in dark themes. Light themes always use the dark-text defaults so log text stays legible on the
// now near-white console surface (otherwise the schema's light-blue text sat pale-on-white ~1.2:1).
COLOR_COMMAND = (dark && !cmd.color.empty()) ? S.resolveColor(cmd.color, defCmd) : defCmd;
COLOR_RESULT = (dark && !res.color.empty()) ? S.resolveColor(res.color, defRes) : defRes;
COLOR_ERROR = (dark && !err.color.empty()) ? S.resolveColor(err.color, defErr) : defErr;
@@ -121,13 +176,12 @@ void ConsoleTab::refreshColors()
COLOR_INFO = (dark && !inf.color.empty()) ? S.resolveColor(inf.color, defInf) : defInf;
COLOR_RPC = (dark && !rpc.color.empty()) ? S.resolveColor(rpc.color, defRpc) : defRpc;
} else {
// No schema — use hardcoded defaults per theme
COLOR_COMMAND = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
COLOR_RESULT = dark ? IM_COL32(200, 200, 200, 255) : IM_COL32(50, 50, 50, 255);
COLOR_ERROR = dark ? IM_COL32(246, 71, 64, 255) : IM_COL32(198, 40, 40, 255);
COLOR_DAEMON = dark ? IM_COL32(160, 160, 160, 180) : IM_COL32(90, 90, 90, 200);
COLOR_INFO = dark ? IM_COL32(191, 209, 229, 255) : IM_COL32(21, 101, 192, 255);
COLOR_RPC = dark ? IM_COL32(120, 180, 255, 210) : IM_COL32(25, 118, 210, 220);
COLOR_COMMAND = defCmd;
COLOR_RESULT = defRes;
COLOR_ERROR = defErr;
COLOR_DAEMON = defDmn;
COLOR_INFO = defInf;
COLOR_RPC = defRpc;
}
}
@@ -135,19 +189,22 @@ ImU32 ConsoleTab::channelTextColor(ConsoleChannel channel) const
{
using namespace material;
switch (channel) {
// COLOR_* channels are already palette-derived + contrast-floored in refreshColors(). The
// roles below are computed live from the theme palette, so floor them to a readable contrast on
// light terminals here (FloorLight is a no-op on dark themes, preserving the authored look).
case ConsoleChannel::Command: return COLOR_COMMAND;
case ConsoleChannel::Info: return COLOR_INFO;
case ConsoleChannel::Success: return WithAlpha(Success(), 255);
case ConsoleChannel::Warning: return Warning();
case ConsoleChannel::Success: return FloorLight(WithAlpha(Success(), 255));
case ConsoleChannel::Warning: return FloorLight(Warning());
case ConsoleChannel::Error: return COLOR_ERROR;
case ConsoleChannel::Rpc: return COLOR_RPC;
case ConsoleChannel::Daemon: return COLOR_DAEMON;
case ConsoleChannel::Xmrig: return COLOR_DAEMON;
case ConsoleChannel::App: return COLOR_INFO;
// JSON syntax roles — highlight against the plain-result body.
case ConsoleChannel::JsonKey: return WithAlpha(Secondary(), 255);
case ConsoleChannel::JsonString: return WithAlpha(Success(), 255);
case ConsoleChannel::JsonNumber: return WithAlpha(Warning(), 255);
case ConsoleChannel::JsonKey: return FloorLight(WithAlpha(Secondary(), 255));
case ConsoleChannel::JsonString: return FloorLight(WithAlpha(Success(), 255));
case ConsoleChannel::JsonNumber: return FloorLight(WithAlpha(Warning(), 255));
case ConsoleChannel::JsonBrace: return IsLightTheme() ? IM_COL32(90, 90, 90, 180) : IM_COL32(200, 200, 200, 150);
case ConsoleChannel::None:
default: return COLOR_RESULT;
@@ -208,14 +265,16 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
{
using namespace material;
// Refresh the console theme colors on a dark/light switch. Line colors are derived
// from each line's channel at draw time, so no per-line remap is needed.
// Refresh the console theme colors whenever the theme changes. Keyed on the schema generation
// (bumped on every theme/skin load) rather than a dark/light toggle: the constructor's refreshColors()
// can run before the theme is applied (s_isDarkTheme still defaults true), and a toggle-only trigger
// would never correct that on startup. Line colors are derived from each line's channel at draw time.
{
static bool s_lastDark = IsDarkTheme();
bool nowDark = IsDarkTheme();
if (nowDark != s_lastDark) {
static uint32_t s_lastGen = ~0u;
uint32_t gen = schema::UI().generation();
if (gen != s_lastGen) {
refreshColors();
s_lastDark = nowDark;
s_lastGen = gen;
}
}

View File

@@ -9,10 +9,18 @@
#pragma once
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <memory>
#include <mutex>
#include <string>
#include <thread>
#include <unordered_set>
#include <vector>
#include "imgui.h"
@@ -23,6 +31,7 @@
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/text_format.h"
#include "../../util/wallet_file_probe.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../notifications.h"
#include "../layout.h"
@@ -79,7 +88,8 @@ public:
+ ctrlRow // footer buttons
+ 6.0f * style.ItemSpacing.y; // uncounted inter-item gaps
const float headH = Type().h6()->LegacySize + Layout::spacingXs() // framework h6 title
+ Type().caption()->LegacySize + Layout::spacingSm(); // intro + gap
+ Type().caption()->LegacySize + Layout::spacingSm() // intro + gap
+ ctrlRow + Layout::spacingSm(); // sort control row + gap
const float padV = 48.0f; // content-child padding (top+bottom) + margin
// Size to content, but cap at a viewport fraction: with many wallets on a small / HiDPI
// screen the content-sized card could exceed the window (the framework would clip a too-tall
@@ -94,7 +104,7 @@ public:
ov.title = TR("wallets_title");
ov.p_open = &s_open;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 780.0f;
ov.cardWidth = 860.0f; // roomier — makes space for the per-row "open folder" button
ov.cardHeight = cardH;
ov.idSuffix = "wallets";
if (BeginOverlayDialog(ov)) {
@@ -102,6 +112,80 @@ public:
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat";
// When an external wallet is open in place, the active file is its per-target link name; that row
// is the external wallet whose path hashes to it. Precompute per row so the sort stays cheap.
const bool linkMode = isLinkName(active);
auto rowIsActive = [&](const WalletRow& row) {
return linkMode ? (!row.inDatadir && linkNameFor(row.canonPath) == active)
: (row.inDatadir && row.fileName == active);
};
std::vector<char> rowActive(s_rows.size(), 0);
for (std::size_t j = 0; j < s_rows.size(); ++j) rowActive[j] = rowIsActive(s_rows[j]) ? 1 : 0;
// ---- Frame-consistent snapshot of the async probe results (badges / counts / sort all read
// the same view; one lock instead of one-per-row). ----------------------------------
std::vector<ProbeResult> probeSnap;
if (s_probe) { std::lock_guard<std::mutex> lk(s_probe->mtx); probeSnap = s_probe->results; }
auto probeAt = [&](std::size_t idx) -> ProbeResult {
return idx < probeSnap.size() ? probeSnap[idx] : ProbeResult{};
};
// ---- Sort controls: a segmented key picker + an arrow that flips ascending/descending ----
{
ImGui::AlignTextToFramePadding();
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_sort_by"));
ImGui::SameLine();
ImFont* segFont = Type().caption();
const char* segLabels[] = { TR("wallets_sort_created"), TR("wallets_sort_addresses"),
TR("wallets_sort_txs"), TR("wallets_sort_size") };
float maxLW = 0.0f;
for (const char* l : segLabels)
maxLW = std::max(maxLW, segFont->CalcTextSizeA(segFont->LegacySize, FLT_MAX, 0, l).x);
const float segH = ImGui::GetFrameHeight();
const float segTW = (maxLW + 20.0f * dp) * 4.0f;
const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
const int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segTW, segH,
segLabels, 4, s_sortMode, segFont, "##walletSortSeg", dp);
if (clk >= 0) s_sortMode = clk;
// SegmentedControl is draw-list based and doesn't move the layout cursor, so place the
// direction toggle explicitly just to its right on the same baseline (a real item advances
// the cursor past this row for the list below). A circular IconButton (square size →
// bgRounding = radius) with the arrow glyph centered.
ImGui::SetCursorScreenPos(ImVec2(segOrigin.x + segTW + Layout::spacingSm(), segOrigin.y));
IconButtonStyle dirStyle;
dirStyle.restBg = WithAlpha(OnSurface(), 24);
dirStyle.hoverBg = WithAlpha(OnSurface(), 44);
dirStyle.color = OnSurfaceMedium();
dirStyle.tooltip = TR(s_sortDesc ? "wallets_sort_desc" : "wallets_sort_asc");
if (IconButton("##walletSortDir", s_sortDesc ? ICON_MD_ARROW_DOWNWARD : ICON_MD_ARROW_UPWARD,
Type().iconSmall(), ImVec2(segH, segH), dirStyle))
s_sortDesc = !s_sortDesc;
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Compute the display order for the active sort key (stable, so ties keep scan order).
s_order.resize(s_rows.size());
for (std::size_t k = 0; k < s_rows.size(); ++k) s_order[k] = k;
{
std::vector<long long> sortVal(s_rows.size(), 0);
for (std::size_t k = 0; k < s_rows.size(); ++k) {
const WalletRow& r = s_rows[k];
const ProbeResult pr = probeAt(k);
switch (s_sortMode) {
case 1: { const auto* m = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
sortVal[k] = (m && m->cachedAddressCount >= 0) ? (long long)m->cachedAddressCount
: pr.keyCount; break; }
case 2: sortVal[k] = pr.txCount; break;
case 3: sortVal[k] = r.sizeBytes; break;
default: sortVal[k] = pr.createdEpoch; break; // 0 = date created
}
}
std::stable_sort(s_order.begin(), s_order.end(), [&](std::size_t a, std::size_t b) {
const bool aa = rowActive[a] != 0, ba = rowActive[b] != 0;
if (aa != ba) return aa; // the active wallet is always pinned to the top
return s_sortDesc ? sortVal[a] > sortVal[b] : sortVal[a] < sortVal[b];
});
}
// ---- Wallet cards (Material-style rows; no table) -------------------------------------
const float listW = ImGui::GetContentRegionAvail().x;
@@ -111,8 +195,11 @@ public:
float listHFit = listH;
if (capped) listHFit = std::max(walRowH, ImGui::GetContentRegionAvail().y - belowH);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::BeginChild("##walletList", ImVec2(listW, listHFit), false);
// NoScrollWithMouse + ApplySmoothScroll gives the wheel the same eased scrolling as the
// Settings page (ApplySmoothScroll handles the wheel itself, so let it own that input).
ImGui::BeginChild("##walletList", ImVec2(listW, listHFit), false, ImGuiWindowFlags_NoScrollWithMouse);
ImGui::PopStyleVar();
ApplySmoothScroll();
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); // inter-card gaps are explicit
ImDrawList* dl = ImGui::GetWindowDrawList();
const float rowW = ImGui::GetContentRegionAvail().x;
@@ -131,11 +218,28 @@ public:
if (t) s += "\xE2\x80\xA6";
return s;
};
// The containing sub-directory as its last 1-2 components (e.g. "…/Backups/2021"), so several
// same-named wallet.dat files surfaced by the recursive scan are distinguishable at a glance.
auto shortSubdir = [](std::string d) {
while (d.size() > 1 && (d.back() == '/' || d.back() == '\\')) d.pop_back();
const auto p2 = d.find_last_of("/\\");
if (p2 == std::string::npos) return d; // no separator: whole thing
std::string leaf = d.substr(p2 + 1);
const auto p1 = (p2 > 0) ? d.find_last_of("/\\", p2 - 1) : std::string::npos;
std::string parent = (p1 == std::string::npos) ? d.substr(0, p2) : d.substr(p1 + 1, p2 - p1 - 1);
std::string out = parent.empty() ? leaf : (parent + "/" + leaf);
return (p1 != std::string::npos && p1 > 0) ? ("\xE2\x80\xA6/" + out) : out; // "…/" if deeper
};
for (std::size_t i = 0; i < s_rows.size(); ++i) {
for (std::size_t k = 0; k < s_order.size(); ++k) {
const std::size_t i = s_order[k]; // sorted display order → actual s_rows index
const WalletRow& r = s_rows[i];
const data::WalletIndexEntry* meta = app->walletIndex().find(r.fileName);
const bool isCurrent = r.inDatadir && r.fileName == active;
// The wallet index is keyed by bare filename and is only ever written for the active
// DATADIR wallet — so an external row (esp. a same-named one surfaced by the recursive
// subdir scan) must NOT borrow a datadir wallet's cached balance/addresses. Only look up
// metadata for datadir rows; external rows show size + "never opened" (accurate).
const data::WalletIndexEntry* meta = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
const bool isCurrent = rowActive[i] != 0;
ImVec2 rMin = ImGui::GetCursorScreenPos();
ImVec2 rMax(rMin.x + rowW, rMin.y + walRowH);
@@ -159,15 +263,41 @@ public:
isCurrent ? Success() : OnSurfaceMedium(), ICON_MD_ACCOUNT_BALANCE_WALLET);
x += iconSz + Layout::spacingMd();
// Action-button slot on the right — reserve room so text truncates before it.
// Action-button + folder-button slots on the right — reserve room so text truncates first.
const float btnW = 96.0f * dp, btnH = ImGui::GetFrameHeight();
const float btnX = rMax.x - padX - btnW;
const float textR = btnX - Layout::spacingMd();
const float btnX = rMax.x - padX - btnW; // Open button / Active chip zone
const float folderW = btnH; // circular "open folder" button
const float folderX = btnX - folderW - Layout::spacingSm();
const float textR = folderX - Layout::spacingMd();
// Name (+ info icon for external files)
// Name (+ info icon for external files). Status badges render as a right-aligned vertical
// stack of "label icon" rows (e.g. "Seed phrase 🌱" over "Encrypted 🔒") to the left of the
// action button — reserve their width first so name/metadata truncate before them.
const float topY = rMin.y + cardPadY;
float extra = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f;
std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, textR - x - extra));
const float badgeSz = metaFont->LegacySize;
// Positive detections are definitive; a MISSING marker is trustworthy only when the whole
// file was scanned (probeComplete): seed iff hdSeed; "legacy" only when complete & no seed;
// "encrypted" iff mkey found; and an "unknown" row when a truncated scan couldn't confirm
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
const bool bLock = pres.probed && pres.encrypted;
const bool bSeed = pres.probed && pres.hdSeed;
const bool bLegacy = pres.probed && pres.complete && !pres.hdSeed;
const bool bUnknown = pres.probed && !pres.complete && !pres.encrypted;
struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
Badge bl[4]; int nb = 0;
if (bSeed) bl[nb++] = { ICON_MD_ECO, WithAlpha(Success(), 235), TR("wallets_badge_seed_short"), TR("wallets_badge_seed") };
if (bLock) bl[nb++] = { ICON_MD_LOCK, WithAlpha(Warning(), 240), TR("wallets_badge_encrypted_short"), TR("wallets_badge_encrypted") };
if (bLegacy) bl[nb++] = { ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 220), TR("wallets_badge_legacy_short"), TR("wallets_badge_legacy") };
if (bUnknown) bl[nb++] = { ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 185), TR("wallets_badge_unknown_short"), TR("wallets_badge_unknown") };
const float bGap = Layout::spacingSm();
float maxLabelW = 0.0f;
for (int k = 0; k < nb; ++k) maxLabelW = std::max(maxLabelW, tw(metaFont, bl[k].label));
const float stackW = nb > 0 ? maxLabelW + bGap + badgeSz : 0.0f;
const float stackLeft = nb > 0 ? textR - stackW - Layout::spacingMd() : textR; // text stops here
const float infoReserve = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f;
std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, stackLeft - x - infoReserve));
dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str());
if (!r.inDatadir) {
ImVec2 ip(x + tw(nameFont, nm) + Layout::spacingSm(), topY + (nameFont->LegacySize - metaFont->LegacySize));
@@ -175,37 +305,70 @@ public:
if (ImGui::IsMouseHoveringRect(ip, ImVec2(ip.x + metaFont->LegacySize, ip.y + metaFont->LegacySize)))
Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str());
}
if (nb > 0) {
const float lineH = metaFont->LegacySize;
const float vGap = Layout::spacingXs();
const float totalH = nb * lineH + (nb - 1) * vGap;
const float sy = midY - totalH * 0.5f;
for (int k = 0; k < nb; ++k) {
const float ey = sy + k * (lineH + vGap);
const float lw = tw(metaFont, bl[k].label);
dl->AddText(metaFont, lineH, ImVec2(textR - badgeSz - bGap - lw, ey), bl[k].col, bl[k].label);
dl->AddText(icoFont, badgeSz, ImVec2(textR - badgeSz, ey), bl[k].col, bl[k].glyph);
if (ImGui::IsMouseHoveringRect(ImVec2(textR - badgeSz - bGap - lw, ey), ImVec2(textR, ey + lineH)))
Tooltip("%s", bl[k].tip);
}
}
// Metadata line (size · N addresses · balance DRGX · last opened)
std::string ms = util::Platform::formatFileSize((uint64_t)r.sizeBytes);
char b[64];
// Metadata line (size · N addresses/keys · M txs · balance DRGX · last opened). The cached
// wallet index gives the authoritative user-facing ADDRESS count; the btree walk gives an
// exact tx count and a spendable-KEY count (labeled "keys", not "addresses", since it counts
// change keys the daemon's address list omits — a different figure for the same wallet).
std::string ms;
if (!r.inDatadir) ms = shortSubdir(r.dir) + dot; // which sub-directory this wallet is in
ms += util::Platform::formatFileSize((uint64_t)r.sizeBytes);
char b[80];
if (meta && meta->cachedAddressCount >= 0) { snprintf(b, sizeof(b), "%s%lld %s", dot, (long long)meta->cachedAddressCount, TR("wallets_col_addresses")); ms += b; }
else if (pres.hasCounts && pres.keyCount > 0) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.keyCount, TR("wallets_col_keys")); ms += b; }
if (pres.hasCounts) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.txCount, TR("wallets_col_txs")); ms += b; }
if (pres.hasCounts && pres.createdEpoch > 0) { // wallet birthday, e.g. "created Aug 2025"
std::time_t ct = static_cast<std::time_t>(pres.createdEpoch);
char cd[24]; std::strftime(cd, sizeof(cd), "%b %Y", std::localtime(&ct));
snprintf(b, sizeof(b), "%s%s %s", dot, TR("wallets_created"), cd); ms += b;
}
if (meta && meta->cachedBalance >= 0.0) { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; }
ms += dot;
ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never"));
ms = fit(ms, metaFont, std::max(24.0f * dp, textR - x));
ms = fit(ms, metaFont, std::max(24.0f * dp, stackLeft - x));
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(x, topY + nameFont->LegacySize + Layout::spacingXs()),
OnSurfaceMedium(), ms.c_str());
// Action: Active chip (current) / Open / Import
// Action: circular "open folder" button, then the Active chip (current) or an Open button.
// External wallets Open IN PLACE (link, no copy); datadir wallets switch directly.
ImGui::PushID(static_cast<int>(i));
{
ImGui::SetCursorScreenPos(ImVec2(folderX, midY - folderW * 0.5f));
IconButtonStyle fs;
fs.restBg = WithAlpha(OnSurface(), 20);
fs.hoverBg = WithAlpha(OnSurface(), 44);
fs.color = OnSurfaceMedium();
fs.tooltip = TR("wallets_open_folder");
if (IconButton("##walletFolder", ICON_MD_FOLDER_OPEN, Type().iconSmall(), ImVec2(folderW, folderW), fs))
util::Platform::openFolder(r.dir);
}
if (isCurrent) {
const char* al = TR("wallets_active");
float aw = tw(metaFont, al), cw = aw + 16.0f * dp, ch = metaFont->LegacySize + 6.0f * dp;
ImVec2 cp(rMax.x - padX - cw, midY - ch * 0.5f);
dl->AddRectFilled(cp, ImVec2(cp.x + cw, cp.y + ch), WithAlpha(Success(), 34), 5.0f * dp);
dl->AddText(metaFont, metaFont->LegacySize, ImVec2(cp.x + 8.0f * dp, cp.y + 3.0f * dp), Success(), al);
} else if (r.inDatadir) {
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
app->switchToWallet(r.fileName);
s_open = false;
}
} else {
ImGui::SetCursorScreenPos(ImVec2(btnX, midY - btnH * 0.5f));
if (StyledButton(TR("wallets_import"), ImVec2(btnW, btnH)))
importAndOpen(app, r);
if (ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_import_tt"));
if (StyledButton(TR("wallets_open"), ImVec2(btnW, btnH))) {
if (r.inDatadir) { app->switchToWallet(r.fileName); s_open = false; }
else { openInPlace(app, r); }
}
if (!r.inDatadir && ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_open_inplace_tt"));
}
ImGui::PopID();
@@ -230,7 +393,7 @@ public:
if (StyledButton(TR("wallets_create"), ImVec2(createBtnW, 0))) {
std::string name = normalizeWalletName(s_newName);
std::error_code ec;
if (name.empty()) {
if (name.empty() || isLinkName(name)) { // reserve the in-place-link prefix
Notifications::instance().warning(TR("wallets_name_invalid"));
} else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
Notifications::instance().warning(TR("wallets_exists"));
@@ -286,10 +449,33 @@ private:
struct WalletRow {
std::string fileName;
std::string dir;
std::string canonPath; // symlink-resolved, absolute path — the STABLE identity of the physical
// file (two paths reaching the same wallet share it), used for the
// in-place link name + to de-dup rows. Computed once at scan time.
bool inDatadir = false;
long long sizeBytes = 0;
};
// Light facts read off each wallet.dat WITHOUT loading it (util/wallet_file_probe.h), computed on a
// background thread so scan() never blocks the UI on a big file read. Results live in a shared,
// index-aligned batch: the probe thread fills it, render() reads it under the mutex, and a re-scan
// supersedes the old batch (cancel + swap) so a detached in-flight probe can't touch stale rows.
struct ProbeResult {
bool probed = false; // validated as a BDB wallet and scanned
bool complete = false; // scan covered the whole file → a MISSING marker is trustworthy
bool encrypted = false; // has an mkey record
bool hdSeed = false; // HD/seed wallet (else legacy)
bool hasCounts = false; // an exact btree walk produced the counts below
int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
int txCount = 0; // wallet transaction records
long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown
};
struct ProbeBatch {
std::mutex mtx;
std::vector<ProbeResult> results; // index-aligned with s_rows at the moment of scan()
std::atomic<bool> cancel{false};
};
// Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the
// datadir scan and can't collide with the daemon's internal .dat files. Returns "" if the
// input has no usable characters.
@@ -308,51 +494,225 @@ private:
return clean + ".dat";
}
// Copy an out-of-datadir wallet file into the datadir (the daemon only loads plain filenames
// from there) under a wallet-*.dat name, then switch to it. Never overwrites an existing file.
static void importAndOpen(App* app, const WalletRow& r) {
// A reserved bare-filename PREFIX for the datadir links that let out-of-datadir wallets open in place.
// Each external wallet gets its own STABLE link name derived from its path — so switching between two
// of them is a real -wallet=<different name> switch (not a no-op on one shared name), and the wallet
// index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list.
static constexpr const char* kLinkPrefix = "wallet-ip-";
static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; }
// Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a
// deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the
// SAME physical wallet always maps to the same name regardless of which path reached it.
static std::string linkNameFor(const std::string& absPath) {
std::uint64_t h = 1469598103934665603ULL;
for (unsigned char c : absPath) { h ^= c; h *= 1099511628211ULL; }
char buf[40];
std::snprintf(buf, sizeof(buf), "%s%08x.dat", kLinkPrefix,
static_cast<unsigned>(h ^ (h >> 32)));
return buf;
}
// Symlink-resolved, absolute path — the stable identity of a physical wallet file. Two different path
// strings that reach the same file (e.g. a symlinked mount /mnt/usb vs the real /media/usb) collapse to
// one value, so they share a single in-place link name and de-dup to one row. fs::canonical resolves
// symlinks (the file exists at scan time); on failure fall back to a lexical-absolute path.
static std::string canonicalOf(const std::filesystem::path& p) {
namespace fs = std::filesystem;
std::string dest = normalizeWalletName(r.fileName);
if (dest.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; }
std::error_code ec;
const std::string datadir = util::Platform::getDragonXDataDir();
fs::path c = fs::canonical(p, ec);
if (ec) c = fs::weakly_canonical(p, ec);
return c.empty() ? p.string() : c.string();
}
// Open an out-of-datadir wallet IN PLACE. The daemon only loads a bare filename from its datadir, so
// link the real file in under its per-target name and switch to that. On Linux/macOS prefer a SYMLINK
// (no privileges, spans volumes so a wallet on a USB / other partition works, and it's visibly a
// pointer — not a duplicate); on Windows prefer a HARD LINK (a symlink there needs admin / Developer
// Mode), falling back to a symlink. Either way we NEVER copy (that would fork the wallet) and NEVER
// delete a real file — only our own link.
static void openInPlace(App* app, const WalletRow& r) {
namespace fs = std::filesystem;
std::error_code ec;
const std::string datadir = util::Platform::getDragonXDataDir();
fs::create_directories(datadir, ec);
const std::string destPath = datadir + "/" + dest;
if (fs::exists(destPath, ec)) { Notifications::instance().warning(TR("wallets_exists")); return; }
fs::copy_file(r.dir + "/" + r.fileName, destPath, ec);
if (ec) { Notifications::instance().error(TR("wallets_import_failed")); return; }
Notifications::instance().success(TR("wallets_imported"));
app->switchToWallet(dest);
const std::string src = r.dir + "/" + r.fileName;
// The row could be stale (file moved/deleted between the scan and this click). Don't link a ghost:
// on Linux/macOS create_symlink to a missing target would happily succeed (a dangling link), we'd
// switch to it, and the daemon guard would silently fall back to wallet.dat — "opened X, got the
// default". Refresh the list instead so the vanished row drops out.
if (!fs::exists(src, ec)) { scan(); return; }
// canonPath (symlinks resolved) is the wallet's stable identity: hash it so the same file reached
// via any path maps to ONE link name, and point the link straight at the resolved real file.
const std::string target = r.canonPath.empty() ? canonicalOf(src) : r.canonPath;
const std::string linkName = linkNameFor(target);
const std::string linkPath = datadir + "/" + linkName;
// Reconcile whatever already holds our reserved name. We only ever reclaim a link of OURS — never
// destroy an unknown file's unique data. is_symlink uses lstat, so it also catches a DANGLING prior
// symlink (source moved) that fs::exists — which follows the link — would miss and that
// create_symlink would then trip over ("file exists").
if (fs::is_symlink(linkPath, ec)) {
fs::remove(linkPath, ec); // our prior symlink (maybe dangling) — replace it
} else if (fs::exists(linkPath, ec)) {
if (fs::equivalent(linkPath, src, ec)) { // already a valid hard link to THIS wallet
app->switchToWallet(linkName); s_open = false; return; // → reuse as-is (same inode)
}
// A DIFFERENT file holds our reserved name. scan()/create() forbid real wallets at this prefix,
// so it's a stale orphan (a hard link whose source moved, or a copy left by a datadir that was
// migrated across filesystems) — NOT the current wallet; reusing it would load the wrong
// balance. Reclaim the name only if the file is provably redundant (another hard link still
// holds the data); otherwise refuse rather than risk destroying something unique.
if (fs::hard_link_count(linkPath, ec) > 1) {
fs::remove(linkPath, ec);
} else {
Notifications::instance().error(TR("wallets_open_failed"));
return;
}
}
std::error_code lec;
#ifdef _WIN32
fs::create_hard_link(target, linkPath, lec); // 1) hard link — no privileges, same volume
if (lec) { lec.clear(); fs::create_symlink(target, linkPath, lec); } // 2) symlink — cross-volume (needs admin / Developer Mode)
#else
fs::create_symlink(target, linkPath, lec); // 1) symlink — no privileges, spans volumes
if (lec) { lec.clear(); fs::create_hard_link(target, linkPath, lec); } // 2) hard link — same-volume fallback
#endif
if (lec) { Notifications::instance().error(TR("wallets_open_failed")); return; } // no copy fallback (would fork the wallet)
app->switchToWallet(linkName); // distinct per wallet → the index tracks rescan/cache correctly
s_open = false;
}
// Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat)
// and each user-added folder (any *.dat). Exception-safe: iterates with error_codes.
// and each user-added folder (any *.dat). User-added folders are searched RECURSIVELY into
// subdirectories; the datadir stays top-level only (never descend into blocks/ chainstate/ …).
// Exception-safe: iterates with error_codes. Recursion is bounded (depth / hit / visit caps) and
// never follows directory symlinks (std default), so it can't loop or stall the UI on a huge tree.
static void scan() {
// Supersede any in-flight probe batch: its detached thread notices cancel and stops. It only ever
// touches its OWN batch (never s_rows), so rebuilding the list below is safe while it winds down.
if (s_probe) s_probe->cancel.store(true);
s_rows.clear();
namespace fs = std::filesystem;
auto addFrom = [](const std::string& dir, bool inDatadir) {
// Guards for recursive external-folder scans (a user could point us at a large tree / home dir).
constexpr int kMaxDepth = 8; // don't descend deeper than this below the chosen folder
constexpr size_t kMaxHits = 200; // cap total wallet files listed
constexpr int kMaxVisited = 40000; // cap total entries walked, so scan() can't stall the UI
// Node/daemon .dat files that are NOT wallets. The datadir scan already restricts to a "wallet"
// prefix, but recursive external folders accept any *.dat — and a user can easily point the picker
// at a folder that IS or CONTAINS a datadir, so filter these out everywhere or the list fills with
// blk*/rev* block files, peers.dat, etc. presented as importable "wallets".
auto isNodeArtifact = [](const std::string& n) {
static const char* kExact[] = { "peers.dat", "banlist.dat", "fee_estimates.dat",
"mempool.dat", "asmap.dat", "zindex.dat" };
for (const char* e : kExact) if (n == e) return true;
return n.rfind("blk", 0) == 0 || n.rfind("rev", 0) == 0; // blk00000.dat / rev00000.dat
};
std::unordered_set<std::string> seenCanon; // one row per physical wallet. The datadir is scanned
// first, so a datadir wallet wins over the same file
// reached via an external (possibly symlinked) path.
auto consider = [&](const fs::path& p, bool inDatadir) {
// String-only checks FIRST (no syscalls) — the vast majority of entries in a large tree are
// neither .dat nor wallets, so reject them before paying for a stat().
const std::string name = p.filename().string();
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return;
if (inDatadir) { if (name.rfind("wallet", 0) != 0 || isLinkName(name)) return; } // datadir: wallet-prefixed, hide in-place links
else { if (isNodeArtifact(name)) return; } // external: reject node junk
std::error_code fec;
if (!fs::is_regular_file(p, fec)) return;
WalletRow r;
r.fileName = name;
r.dir = p.parent_path().string(); // the file's ACTUAL parent (may be a subdir) — Open reads r.dir + "/" + r.fileName
r.canonPath = canonicalOf(p); // symlink-resolved identity (see below)
if (!seenCanon.insert(r.canonPath).second) return; // same physical wallet already listed via another path
r.inDatadir = inDatadir;
r.sizeBytes = static_cast<long long>(fs::file_size(p, fec));
s_rows.push_back(std::move(r)); // encryption/seed flags are filled in asynchronously (see below)
};
// Directory names we never descend into during a recursive external scan — node data subtrees hold
// no user wallets and would otherwise burn the visit budget (and flood the list) with block files.
auto isNodeSubtree = [](const std::string& n) {
return n == "blocks" || n == "chainstate" || n == "database" ||
n == "indexes" || n == "index";
};
auto addFrom = [&](const std::string& dir, bool inDatadir, bool recursive) {
std::error_code ec;
if (dir.empty() || !fs::is_directory(dir, ec)) return;
fs::directory_iterator it(dir, ec), end;
if (!recursive) {
fs::directory_iterator it(dir, ec), end;
for (; !ec && it != end; it.increment(ec)) consider(it->path(), inDatadir);
return;
}
// skip_permission_denied keeps a locked subdir from aborting the whole walk; the iterator
// does NOT follow directory symlinks by default, so symlink cycles can't cause infinite loops.
fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end;
int visited = 0;
for (; !ec && it != end; it.increment(ec)) {
std::error_code fec;
if (!it->is_regular_file(fec)) continue;
std::string name = it->path().filename().string();
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") continue;
if (inDatadir && name.rfind("wallet", 0) != 0) continue;
WalletRow r;
r.fileName = name;
r.dir = dir;
r.inDatadir = inDatadir;
r.sizeBytes = static_cast<long long>(fs::file_size(it->path(), fec));
s_rows.push_back(std::move(r));
if (++visited > kMaxVisited) break;
std::error_code dec;
if (it->is_directory(dec)) {
// Prune: don't descend past the depth cap or into node data subtrees.
if (it.depth() >= kMaxDepth || isNodeSubtree(it->path().filename().string()))
it.disable_recursion_pending();
continue; // directories aren't wallet files
}
consider(it->path(), inDatadir);
if (s_rows.size() >= kMaxHits) break;
}
};
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true);
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true, /*recursive=*/false);
if (s_app)
for (const auto& f : s_app->walletIndex().extraFolders())
addFrom(f, /*inDatadir=*/false);
addFrom(f, /*inDatadir=*/false, /*recursive=*/true);
// Probe the encryption/seed flags on a background thread so a large wallet.dat read never stalls
// the UI. The thread is DETACHED and captures only its own heap batch + a copy of the target paths
// (no static/s_rows access), so it's safe to outlive a re-scan or app shutdown. Results land in the
// shared batch that render() reads under the mutex; badges fill in over the next few frames.
auto batch = std::make_shared<ProbeBatch>();
batch->results.resize(s_rows.size());
s_probe = batch;
std::vector<std::pair<std::string, std::size_t>> targets;
targets.reserve(s_rows.size());
for (std::size_t i = 0; i < s_rows.size(); ++i)
targets.emplace_back(s_rows[i].dir + "/" + s_rows[i].fileName, i);
auto runProbe = [](std::shared_ptr<ProbeBatch> batch,
std::vector<std::pair<std::string, std::size_t>> targets) {
// Per-file 256 MB (BDB sorts long-keyed mkey/hdchain LATE, so a small cap risks a
// false-negative); a shared budget bounds total I/O, charged by bytes actually read.
std::size_t budget = 768u * 1024u * 1024u;
constexpr std::size_t kPerFile = 256u * 1024u * 1024u;
for (const auto& t : targets) {
if (batch->cancel.load()) return;
ProbeResult res;
if (budget > 0) {
// Primary: an exact btree walk (encryption/seed flags + address/tx counts). If it
// can't fully parse (unknown BDB variant, or a >cap file), fall back to the cheap
// byte-scan for badges only (no counts).
const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
if (bt.parsed && bt.complete) {
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch };
budget -= std::min(budget, bt.bytesRead);
} else {
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead));
}
}
std::lock_guard<std::mutex> lk(batch->mtx);
if (t.second < batch->results.size()) batch->results[t.second] = res;
}
};
if (targets.empty()) {
// nothing to probe
} else if (s_app && s_app->isScreenshotSweeping()) {
runProbe(batch, targets); // offline sweep: probe synchronously so the captured frame is populated
} else {
std::thread(runProbe, batch, targets).detach();
}
}
static inline bool s_open = false;
@@ -360,6 +720,10 @@ private:
static inline bool s_needScan = false;
static inline char s_newName[128] = "";
static inline std::vector<WalletRow> s_rows;
static inline std::shared_ptr<ProbeBatch> s_probe; // current async probe batch (index-aligned w/ s_rows)
static inline std::vector<std::size_t> s_order; // display order (indices into s_rows) for the active sort
static inline int s_sortMode = 0; // 0 created · 1 addresses · 2 txs · 3 size
static inline bool s_sortDesc = true; // newest / most / largest first
};
} // namespace ui

View File

@@ -258,25 +258,41 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_col_name"] = "Wallet";
strings_["wallets_col_size"] = "Size";
strings_["wallets_col_addresses"] = "Addresses";
strings_["wallets_col_txs"] = "txs";
strings_["wallets_col_keys"] = "keys";
strings_["wallets_created"] = "created";
strings_["wallets_sort_by"] = "Sort:";
strings_["wallets_sort_created"] = "Created";
strings_["wallets_sort_addresses"] = "Addresses";
strings_["wallets_sort_txs"] = "Txs";
strings_["wallets_sort_size"] = "Size";
strings_["wallets_sort_asc"] = "Ascending (oldest / fewest / smallest first)";
strings_["wallets_sort_desc"] = "Descending (newest / most / largest first)";
strings_["wallets_col_balance"] = "Balance";
strings_["wallets_col_opened"] = "Last opened";
strings_["wallets_current"] = "current";
strings_["wallets_active"] = "Active";
strings_["wallets_badge_encrypted"] = "Encrypted (passphrase-protected)";
strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)";
strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)";
strings_["wallets_badge_unknown"] = "Wallet type not fully determined (large file — open to confirm)";
strings_["wallets_badge_seed_short"] = "Seed phrase";
strings_["wallets_badge_encrypted_short"] = "Encrypted";
strings_["wallets_badge_legacy_short"] = "Legacy";
strings_["wallets_badge_unknown_short"] = "Unknown";
strings_["wallets_open"] = "Open";
strings_["wallets_never"] = "Never";
strings_["wallets_import_hint"] = "Import to open";
strings_["wallets_import_tt"] = "This wallet is outside the data directory. Opening it will copy it in first (coming soon).";
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Import to copy it in.";
strings_["wallets_open_folder"] = "Open folder location";
strings_["wallets_open_inplace_tt"] = "Open this wallet where it is — linked into the data directory (no copy)";
strings_["wallets_open_failed"] = "Couldn't open this wallet in place. It's likely on a different drive than your data directory — move it onto the same drive (on Windows, enabling Developer Mode also lets it link across drives).";
strings_["wallets_never"] = "Never opened";
strings_["wallets_external_tt"] = "Outside your data directory \xE2\x80\x94 Open links it in place (no copy).";
strings_["wallets_scan_folder"] = "Scan another folder for wallets\xE2\x80\xA6";
strings_["wallets_folder_invalid"] = "That folder doesn't exist.";
strings_["wallets_import"] = "Import";
strings_["wallets_new_label"] = "Create a new wallet:";
strings_["wallets_new_hint"] = "Name (e.g. savings)";
strings_["wallets_create"] = "Create wallet";
strings_["wallets_name_invalid"] = "Please enter a valid wallet name.";
strings_["wallets_exists"] = "A wallet with that name already exists.";
strings_["wallets_imported"] = "Wallet imported — switching…";
strings_["wallets_import_failed"] = "Could not import that wallet file.";
strings_["wallets_creating"] = "Creating wallet — the node will restart…";
strings_["wallets_reveal"] = "Reveal folder";
// In-app folder picker (Scan another folder)
@@ -666,6 +682,14 @@ void I18n::loadBuiltinEnglish()
strings_["debug_logging"] = "DEBUG OPTIONS";
strings_["settings_debug_select"] = "Select categories to enable daemon debug logging (-debug= flags).";
strings_["settings_debug_restart_note"] = "Changes take effect after restarting the daemon.";
// Debug-options gate (confirmation + optional re-auth)
strings_["debug_gate_title"] = "Show debug options?";
strings_["debug_gate_warning"] = "These are advanced diagnostic and node-tuning options. They can expose sensitive information and change how your node runs. Only continue if you know what you're doing.";
strings_["debug_gate_pin_prompt"] = "Enter your unlock PIN to continue:";
strings_["debug_gate_pass_prompt"] = "Enter your wallet passphrase to continue:";
strings_["debug_gate_confirm"] = "Show debug options";
strings_["debug_gate_incorrect"] = "Incorrect PIN or passphrase.";
strings_["debug_gate_verifying"] = "Verifying\xE2\x80\xA6";
// Settings window (legacy dialog) descriptions
strings_["settings_language_note"] = "Note: Some text requires restart to update";

View File

@@ -0,0 +1,313 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// wallet_file_probe.h — read light metadata off a wallet.dat WITHOUT loading it into the daemon.
//
// DragonX wallet.dat is a Berkeley DB (btree) key/value store. Only the private-key material is
// encrypted (ckey/czkey/csapzkey/chdseed); the record *keys* and non-secret metadata live in
// plaintext, so a handful of boolean facts can be recovered cheaply by (a) validating the BDB btree
// magic and (b) scanning the raw bytes for the length-prefixed record names the daemon writes
// (a std::string is serialized as CompactSize(len)+bytes, so e.g. an "mkey" record's key begins with
// the 5 bytes 0x04 'm' 'k' 'e' 'y'). This is a heuristic PRESENCE test — reliable for booleans, not
// for exact counts — with no libdb dependency and no daemon. It never reads or exposes key material.
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
namespace util {
struct WalletFileProbe {
bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat)
bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted
bool hdSeed = false; ///< has hdseed/chdseed/hdchain → HD/seed wallet (else legacy)
bool hasShielded = false; ///< has zkey/czkey/sapzkey/csapzkey → holds shielded addresses
// A found marker is always definitive; a MISSING marker is only trustworthy when the scan actually
// covered the whole file. `scanComplete` is true when we reached EOF, or early-exited having found
// everything — false only when the byte cap cut the scan short. Callers should treat negative flags
// (e.g. "legacy" = !hdSeed, or "not encrypted" = !encrypted) as reliable only when scanComplete is true.
bool scanComplete = false;
std::size_t bytesRead = 0; ///< bytes actually consumed (early-exit reads far less than the file) — for budgeting
};
// Probe a wallet.dat by header-validating it as a BDB btree then byte-scanning (bounded, streaming,
// early-exit) for record markers. `maxBytes` caps how far we read so a pathologically large file can't
// stall the caller. Returns an all-false probe (isBerkeleyDB=false) for anything that isn't a readable
// BDB btree file. Safe to call on a wallet currently open by the daemon (read-only, pattern scan only).
inline WalletFileProbe probeWalletFile(const std::string& path,
std::size_t maxBytes = 96u * 1024u * 1024u) {
WalletFileProbe out;
std::ifstream f(path, std::ios::binary);
if (!f) return out;
// --- 1) Validate the Berkeley DB btree metapage magic (offset 12, either endianness). ---
unsigned char hdr[16];
f.read(reinterpret_cast<char*>(hdr), sizeof(hdr));
if (f.gcount() < static_cast<std::streamsize>(sizeof(hdr))) return out;
auto rd32 = [](const unsigned char* p, bool le) -> uint32_t {
return le ? (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24)
: (uint32_t)p[3] | ((uint32_t)p[2] << 8) | ((uint32_t)p[1] << 16) | ((uint32_t)p[0] << 24);
};
constexpr uint32_t kBtreeMagic = 0x00053162u; // DB_BTREEMAGIC
if (rd32(hdr + 12, true) != kBtreeMagic && rd32(hdr + 12, false) != kBtreeMagic) return out;
out.isBerkeleyDB = true;
// --- 2) Streaming byte-scan for length-prefixed record markers. ---
// group: 0 = encrypted, 1 = hd/seed, 2 = shielded.
const std::vector<std::pair<std::string, int>> patterns = {
{std::string("\x04", 1) + "mkey", 0}, // master key → wallet is encrypted
{std::string("\x06", 1) + "hdseed", 1}, // plaintext HD seed
{std::string("\x07", 1) + "chdseed", 1}, // encrypted HD seed
{std::string("\x07", 1) + "hdchain", 1}, // HD chain counter (present in both HD variants)
{std::string("\x04", 1) + "zkey", 2}, // sprout shielded key
{std::string("\x05", 1) + "czkey", 2}, // encrypted sprout key
{std::string("\x07", 1) + "sapzkey", 2}, // sapling shielded key
{std::string("\x08", 1) + "csapzkey", 2}, // encrypted sapling key
};
std::size_t maxPat = 0;
for (const auto& p : patterns) maxPat = std::max(maxPat, p.first.size());
const std::size_t overlap = maxPat > 0 ? maxPat - 1 : 0; // bytes carried between chunks for split matches
f.clear();
f.seekg(0, std::ios::beg);
constexpr std::size_t kChunk = 1u << 20; // 1 MiB
std::vector<char> buf(kChunk);
std::string carry;
std::size_t readTotal = 0;
bool enc = false, hd = false, sh = false;
bool eof = false, allFound = false;
while (readTotal < maxBytes) {
const std::size_t want = std::min(kChunk, maxBytes - readTotal);
f.read(buf.data(), static_cast<std::streamsize>(want));
const std::streamsize got = f.gcount();
if (got <= 0) { eof = true; break; }
readTotal += static_cast<std::size_t>(got);
std::string window;
window.reserve(carry.size() + static_cast<std::size_t>(got));
window.assign(carry);
window.append(buf.data(), static_cast<std::size_t>(got));
for (const auto& p : patterns) {
bool& flag = (p.second == 0) ? enc : (p.second == 1) ? hd : sh;
if (!flag && window.find(p.first) != std::string::npos) flag = true;
}
if (enc && hd && sh) { allFound = true; break; } // nothing more to learn
if (window.size() > overlap) carry.assign(window.data() + window.size() - overlap, overlap);
else carry.assign(window);
if (static_cast<std::size_t>(got) < want) { eof = true; break; } // reached EOF
}
out.encrypted = enc;
out.hdSeed = hd;
out.hasShielded = sh;
out.bytesRead = readTotal;
// false only if the byte cap cut a larger file short. If we stopped because readTotal hit maxBytes on
// an exact chunk boundary, ifstream never set eofbit — peek() tells us whether the file actually ended
// there (EOF → complete) or there's more beyond the cap (a real byte → truncated).
out.scanComplete = allFound || eof;
if (!out.scanComplete && f && f.peek() == std::ifstream::traits_type::eof()) out.scanComplete = true;
return out;
}
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// Tier 2: exact record counts by actually walking the Berkeley DB btree (still no daemon, no libdb).
//
// wallet.dat is a BDB btree: a metapage (page 0) points at a root page; internal pages point at child
// pages; leaf pages hold (key,data) item pairs. We traverse from the root, visiting only reachable
// leaves (so freed/stale pages aren't counted), and tally records by the length-prefixed type name each
// key begins with. Every offset is bounds-checked against the page/file; a visited-set + page/key caps
// make it safe on a corrupt or adversarial file. On any structural surprise it returns parsed=false and
// the caller falls back to the byte-scan probe above.
struct WalletBtreeStats {
bool parsed = false; ///< the btree walked cleanly
bool complete = false; ///< the whole file was read (not cap-truncated) → counts are exact
int transparentKeys = 0; ///< key + wkey + ckey (spendable keys, incl. change; keypool "pool" recs excluded)
int shieldedKeys = 0; ///< zkey + czkey + sapzkey + csapzkey (≈ shielded addresses)
int addressBook = 0; ///< name records (labeled/received addresses)
int txCount = 0; ///< tx records (wallet transactions)
long long createdEpoch = 0; ///< earliest keymeta nCreateTime (wallet birthday); 0 = unknown
bool encrypted = false; ///< saw an mkey record
bool hdSeed = false; ///< saw hdseed/chdseed/hdchain
std::size_t bytesRead = 0; ///< bytes actually read (for budgeting)
int addresses() const { return transparentKeys + shieldedKeys; }
};
inline WalletBtreeStats parseWalletBtree(const std::string& path,
std::size_t maxBytes = 256u * 1024u * 1024u) {
WalletBtreeStats st;
std::ifstream f(path, std::ios::binary);
if (!f) return st;
std::string buf;
{
f.seekg(0, std::ios::end);
std::streamoff sz = f.tellg();
if (sz < 512) return st;
const std::size_t want = std::min<std::size_t>(static_cast<std::size_t>(sz), maxBytes);
f.seekg(0, std::ios::beg);
buf.resize(want);
f.read(&buf[0], static_cast<std::streamsize>(want));
buf.resize(static_cast<std::size_t>(std::max<std::streamsize>(0, f.gcount())));
if (buf.size() < 512) return st;
st.bytesRead = buf.size();
st.complete = (buf.size() == static_cast<std::size_t>(sz)); // read the whole file, not cap-truncated
}
const unsigned char* B = reinterpret_cast<const unsigned char*>(buf.data());
const std::size_t N = buf.size();
// Byte order comes from the metapage magic (BDB stores fields in the creating machine's order).
auto rd32at = [&](std::size_t o, bool le) -> uint32_t {
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) | ((uint32_t)B[o+2]<<16) | ((uint32_t)B[o+3]<<24)
: (uint32_t)B[o+3] | ((uint32_t)B[o+2]<<8) | ((uint32_t)B[o+1]<<16) | ((uint32_t)B[o]<<24);
};
constexpr uint32_t kBtreeMagic = 0x00053162u;
bool le;
if (rd32at(12, true) == kBtreeMagic) le = true;
else if (rd32at(12, false) == kBtreeMagic) le = false;
else return st; // not a BDB btree
auto r32 = [&](std::size_t o) { return o + 4 <= N ? rd32at(o, le) : 0u; };
auto r16 = [&](std::size_t o) -> uint32_t {
if (o + 2 > N) return 0;
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) : (uint32_t)B[o+1] | ((uint32_t)B[o]<<8);
};
const uint32_t pagesize = r32(20);
if (pagesize < 512 || pagesize > 65536 || (pagesize & (pagesize - 1)) != 0) return st; // must be a power of two
const uint32_t npages = static_cast<uint32_t>(N / pagesize);
const uint32_t root = r32(88);
if (npages == 0 || root == 0 || root >= npages) return st;
// Page-level checksums (DB_CHKSUM) or encryption (DB_ENCRYPT) shift the per-page offset index array
// (26 → 32 / 64), which our fixed-offset-26 reads don't account for — that would silently under-count
// rather than fail. DragonX wallets use neither, so bail to the byte-scan fallback if either is set
// (metapage encrypt_alg@24, metaflags@26 & DBMETA_CHKSUM 0x01).
if (B[24] != 0 || (B[26] & 0x01)) return st;
// BDB page layout: hdr[26] = {..., entries@20:u16, level@24:u8, type@25:u8}, then a u16 offset array.
// Items on a leaf are BKEYDATA {len@0:u16, type@2:u8, data@3}; on an internal page BINTERNAL {pgno@4}.
constexpr uint8_t P_IBTREE = 3, P_LBTREE = 5, P_BTREEMETA = 9, B_KEYDATA = 1;
constexpr std::size_t kMaxPagesVisited = 600000; // bounds a corrupt/huge file
constexpr int kMaxKeys = 4000000;
std::vector<bool> visited(npages, false);
std::size_t pagesVisited = 0;
int keys = 0;
bool aborted = false;
auto rdpgno = [&](const unsigned char* p) -> uint32_t {
return le ? (uint32_t)p[0] | ((uint32_t)p[1]<<8) | ((uint32_t)p[2]<<16) | ((uint32_t)p[3]<<24)
: (uint32_t)p[3] | ((uint32_t)p[2]<<8) | ((uint32_t)p[1]<<16) | ((uint32_t)p[0]<<24);
};
// Walk the btree rooted at `rootPg`, invoking fn(keyPtr,keyLen, dataPtr,dataLen,dataType) per leaf pair.
auto traverse = [&](uint32_t rootPg, auto&& fn) {
std::fill(visited.begin(), visited.end(), false);
std::vector<uint32_t> stack;
// Mark pages visited at PUSH time (here + at each internal child below) so every page is enqueued
// at most once. The stack then stays O(npages) — a crafted file with many internal pages all
// referencing a shared child set can't balloon it to ~npages*entries pushes (a ~0.5 GB DoS).
if (rootPg < npages && !visited[rootPg]) { visited[rootPg] = true; stack.push_back(rootPg); }
while (!stack.empty()) {
const uint32_t pg = stack.back(); stack.pop_back();
if (pg >= npages) continue;
if (++pagesVisited > kMaxPagesVisited) { aborted = true; return; }
const std::size_t base = static_cast<std::size_t>(pg) * pagesize;
if (base + 26 > N) continue;
const uint8_t type = B[base + 25];
const uint32_t entries = r16(base + 20);
if (26 + static_cast<std::size_t>(entries) * 2 > pagesize) continue; // index array must fit
if (type == P_IBTREE) {
for (uint32_t i = 0; i < entries; ++i) {
const uint32_t off = r16(base + 26 + i * 2);
if (off + 8 > pagesize) continue;
const uint32_t child = r32(base + off + 4);
if (child > 0 && child < npages && !visited[child]) { visited[child] = true; stack.push_back(child); }
}
} else if (type == P_LBTREE) {
for (uint32_t i = 0; i + 1 < entries; i += 2) { // items alternate (key, data)
if (++keys > kMaxKeys) { aborted = true; return; }
const uint32_t ko = r16(base + 26 + i * 2);
const uint32_t dO = r16(base + 26 + (i + 1) * 2);
if (ko + 3 > pagesize || dO + 3 > pagesize) continue;
if (B[base + ko + 2] != B_KEYDATA) continue; // overflow/dup key — never a type key
const uint32_t kl = r16(base + ko);
if (kl < 1 || ko + 3 + kl > pagesize) continue;
const uint8_t dtype = B[base + dO + 2];
const uint32_t dl = r16(base + dO);
const unsigned char* dp = (dO + 3 + dl <= pagesize) ? B + base + dO + 3 : nullptr;
fn(B + base + ko + 3, kl, dp, dl, dtype);
}
}
// metapage / overflow / free pages: ignored.
}
};
// wallet.dat stores its records in a NAMED sub-database ("main"): the file's root btree is a master map
// of subdb-name -> subdb meta/root pgno. Follow each mapping to the real record btree. (A plain,
// single-database BDB file has no such mapping, so we fall back to walking the file root directly.)
std::vector<uint32_t> subRoots;
traverse(root, [&](const unsigned char*, uint32_t, const unsigned char* dp, uint32_t dl, uint8_t dtype) {
if (dtype != B_KEYDATA || dl != 4 || !dp) return;
// The master-db value (the subdb's meta/root pgno) is stored BIG-ENDIAN regardless of the file's
// native order. Try big-endian first, then native, and accept whichever lands on a real subdb page.
const uint32_t cand[2] = {
(uint32_t)dp[3] | ((uint32_t)dp[2]<<8) | ((uint32_t)dp[1]<<16) | ((uint32_t)dp[0]<<24), // big-endian
rdpgno(dp), // native
};
for (const uint32_t pgno : cand) {
if (pgno == 0 || pgno >= npages) continue;
const uint8_t pt = B[static_cast<std::size_t>(pgno) * pagesize + 25];
if (pt == P_BTREEMETA) { // subdb metapage → its root is at +88
const uint32_t sr = r32(static_cast<std::size_t>(pgno) * pagesize + 88);
if (sr > 0 && sr < npages) { subRoots.push_back(sr); break; }
} else if (pt == P_LBTREE || pt == P_IBTREE) { // mapping points straight at the root
subRoots.push_back(pgno); break;
}
}
});
if (aborted) return st;
if (subRoots.empty()) subRoots.push_back(root); // single-database file
auto countRecord = [&](const unsigned char* kp, uint32_t kl,
const unsigned char* dp, uint32_t dl, uint8_t dtype) {
const uint32_t nlen = kp[0]; // CompactSize length of the type string
if (nlen < 2 || nlen > 20 || 1u + nlen > kl) return;
const char* nm = reinterpret_cast<const char*>(kp + 1);
auto is = [&](const char* s) { return std::strlen(s) == nlen && std::memcmp(nm, s, nlen) == 0; };
if (is("key") || is("wkey") || is("ckey")) st.transparentKeys++;
else if (is("zkey") || is("czkey") || is("sapzkey") || is("csapzkey")) st.shieldedKeys++;
else if (is("name")) st.addressBook++;
else if (is("tx")) st.txCount++;
else if (is("mkey")) st.encrypted = true;
else if (is("hdseed") || is("chdseed") || is("hdchain")) st.hdSeed = true;
else if (is("keymeta")) {
// CKeyMetadata data = nVersion(int32) + nCreateTime(int64) + …, all little-endian (Bitcoin
// serialization). The earliest non-zero nCreateTime is the wallet birthday (daemon's
// nTimeFirstKey). B_KEYDATA(1) only; overflow values aren't inline.
if (dtype == B_KEYDATA && dp && dl >= 12) {
long long t = 0;
for (int b = 0; b < 8; ++b) t |= static_cast<long long>(dp[4 + b]) << (8 * b);
if (t > 0 && (st.createdEpoch == 0 || t < st.createdEpoch)) st.createdEpoch = t;
}
}
};
for (const uint32_t sr : subRoots) {
traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char* dp, uint32_t dl, uint8_t dt) {
countRecord(kp, kl, dp, dl, dt);
});
if (aborted) return st;
}
st.parsed = true;
return st;
}
} // namespace util
} // namespace dragonx

View File

@@ -36,6 +36,7 @@
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
#include "wallet/lite_owned_string.h"
@@ -737,6 +738,97 @@ void testPaymentUri()
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
}
void testWalletFileProbe()
{
namespace fs = std::filesystem;
using dragonx::util::probeWalletFile;
const fs::path dir = fs::path("/tmp/dragonx") / "probe";
std::error_code ec; fs::create_directories(dir, ec);
auto writef = [](const fs::path& p, const std::string& d) {
std::ofstream o(p, std::ios::binary); o.write(d.data(), (std::streamsize)d.size());
};
// A valid Berkeley DB btree metapage has the magic 0x00053162 at byte offset 12 (little-endian here).
auto bdb = []() { std::string h(16, '\0'); h[12] = 0x62; h[13] = 0x31; h[14] = 0x05; h[15] = 0x00; return h; };
// Wallet records are keyed by a CompactSize-length-prefixed name, e.g. "mkey" -> {0x04,'m','k','e','y'}.
auto rec = [](const std::string& name) { return std::string(1, (char)name.size()) + name; };
// 1) Non-BDB file → not recognized, no flags.
writef(dir / "junk.dat", std::string(4096, 'x'));
{ auto p = probeWalletFile((dir / "junk.dat").string());
EXPECT_FALSE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }
// 2) BDB with no markers → legacy plaintext; a complete scan makes the negatives trustworthy.
writef(dir / "legacy.dat", bdb() + std::string(2000, '\0'));
{ auto p = probeWalletFile((dir / "legacy.dat").string());
EXPECT_TRUE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed);
EXPECT_FALSE(p.hasShielded); EXPECT_TRUE(p.scanComplete); }
// 3) Encrypted (mkey) + HD (hdchain) + shielded (csapzkey).
writef(dir / "enc.dat", bdb() + std::string(500, 'a') + rec("mkey") + std::string(300, 'b') +
rec("hdchain") + std::string(300, 'c') + rec("csapzkey") + std::string(500, 'd'));
{ auto p = probeWalletFile((dir / "enc.dat").string());
EXPECT_TRUE(p.encrypted); EXPECT_TRUE(p.hdSeed); EXPECT_TRUE(p.hasShielded); }
// 4) False-positive guard: the bare word "mkey" WITHOUT the 0x04 length prefix must not count.
writef(dir / "bare.dat", bdb() + std::string(100, '\0') + "mkey" + std::string(100, '\0'));
{ auto p = probeWalletFile((dir / "bare.dat").string()); EXPECT_FALSE(p.encrypted); }
// 5) Byte cap: a marker beyond maxBytes is missed AND the scan is flagged incomplete (so a caller
// won't wrongly assert "legacy"); the same file scanned fully finds it and reports complete.
{ std::string d = bdb(); d.resize(2u * 1024 * 1024, 'q'); d += rec("mkey"); writef(dir / "deep.dat", d);
auto capped = probeWalletFile((dir / "deep.dat").string(), 1024u * 1024u);
EXPECT_TRUE(capped.isBerkeleyDB); EXPECT_FALSE(capped.encrypted); EXPECT_FALSE(capped.scanComplete);
EXPECT_TRUE(capped.bytesRead <= 1024u * 1024u); // budget accounting reflects a truncated read
auto full = probeWalletFile((dir / "deep.dat").string());
EXPECT_TRUE(full.encrypted); EXPECT_TRUE(full.scanComplete); }
// 6) Exact-boundary case: a file whose length is an exact 1 MiB multiple (and == maxBytes) must still
// report scanComplete=true (the peek() guard), so a fully-scanned wallet isn't mislabeled truncated.
{ std::string d = bdb(); d.resize(1024u * 1024u, 'e'); // exactly one 1 MiB chunk, no markers
writef(dir / "exact.dat", d);
auto p = probeWalletFile((dir / "exact.dat").string(), 1024u * 1024u);
EXPECT_TRUE(p.isBerkeleyDB); EXPECT_TRUE(p.scanComplete); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }
// 7) Btree record counting (tier 2): hand-build a minimal single-database BDB btree — a 512-byte
// metapage (magic/pagesize/root) + one leaf page holding "tx", "key", and a "keymeta" record — and
// confirm parseWalletBtree walks it, tallies the counts, and reads the keymeta nCreateTime (wallet
// birthday). Also that a non-BDB file yields parsed=false.
{
std::string w(1024, '\0');
auto put16 = [&](std::size_t o, unsigned v) { w[o] = (char)(v & 0xff); w[o+1] = (char)((v >> 8) & 0xff); };
auto put32 = [&](std::size_t o, unsigned v) { for (int k=0;k<4;k++) w[o+k] = (char)((v >> (8*k)) & 0xff); };
put32(12, 0x00053162u); put32(20, 512); w[25] = (char)9; put32(88, 1); // page 0: metapage, root = page 1
const std::size_t P = 512; // page 1: a btree leaf
put16(P + 20, 6); w[P + 24] = (char)1; w[P + 25] = (char)5; // entries=6 (3 pairs), level 1, P_LBTREE
// index array → (key,data) item offsets, laid out high→low without overlap:
put16(P + 26, 500); put16(P + 28, 496); // tx
put16(P + 30, 488); put16(P + 32, 484); // key
put16(P + 34, 472); put16(P + 36, 456); // keymeta
auto keyItem = [&](std::size_t off, const std::string& type) { // BKEYDATA holding CompactSize(len)+type
std::string kd; kd += (char)type.size(); kd += type;
put16(P + off, (unsigned)kd.size()); w[P + off + 2] = (char)1;
for (std::size_t k = 0; k < kd.size(); ++k) w[P + off + 3 + k] = kd[k];
};
auto dataItem = [&](std::size_t off) { put16(P + off, 1); w[P + off + 2] = (char)1; w[P + off + 3] = 0; };
keyItem(500, "tx"); dataItem(496);
keyItem(488, "key"); dataItem(484);
keyItem(472, "keymeta");
// keymeta data = CKeyMetadata: nVersion(int32=10) + nCreateTime(int64) — little-endian.
const long long kCreated = 1700000000LL;
put16(P + 456, 12); w[P + 456 + 2] = (char)1; // BKEYDATA len=12, type=1, data@459
put32(456 + 3 + P, 10); // nVersion
for (int k = 0; k < 8; ++k) w[P + 456 + 3 + 4 + k] = (char)((kCreated >> (8 * k)) & 0xff);
writef(dir / "btree.dat", w);
auto s = dragonx::util::parseWalletBtree((dir / "btree.dat").string());
EXPECT_TRUE(s.parsed); EXPECT_TRUE(s.complete);
EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
EXPECT_EQ(s.createdEpoch, kCreated);
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
}
fs::remove_all(dir, ec);
}
void testAmountFormatting()
{
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
@@ -6263,6 +6355,7 @@ int main()
testHushChatOutgoing();
testHushChatTransport();
testHushChatShuffledReceive();
testWalletFileProbe();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();