293 Commits

Author SHA1 Message Date
0942691eb3 fix(win): route shell-outs through a windowless helper (no cmd.exe flash)
_popen/_popen-style shell-outs flash a cmd.exe console window on Windows. Add
Platform::runHiddenCapture() — CreateProcess + CREATE_NO_WINDOW capturing stdout on
Windows, popen on POSIX — and route the remaining shell-outs through it:
- GPU-aware idle detection (getGpuUtilization: "where nvidia-smi" / "nvidia-smi --query-gpu")
- xmrig discovery + version (findXmrigBinary "where xmrig.exe"; "<bin> --version", stderr merged)
- wallet-rebuild helper (app_network) — keeps its exit-code check via the new exitCode out-param

None of these are on the launch path (that was the daemon spawn, fixed in a2f84be); each
would flash a console only when it ran (idle-GPU mining, mining tab, wallet recovery).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:58:54 -05:00
a2f84be2d4 fix(win): stop console-window flash on launch (spawn daemon with CREATE_NO_WINDOW)
The embedded daemon was launched with CREATE_NEW_CONSOLE + SW_HIDE. CREATE_NEW_CONSOLE
allocates a console window that flashes on screen before SW_HIDE hides it — visible as a
console-window flash every time the wallet starts dragonxd (i.e. on launch). Switch to
CREATE_NO_WINDOW (the console child gets no window at all, matching the xmrig launcher);
dragonxd logs to debug.log, not a console, so nothing is lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:43:12 -05:00
7e8b99a82b feat(shutdown): confirm before stopping the daemon mid witness-cache rebuild
Stopping dragonxd while it's rebuilding the Sapling witness cache discards the
in-progress work — BuildWitnessCache aborts on shutdown without persisting — so
the next launch redoes a multi-minute rebuild (the "Activating best chain…" hang).
This bites especially with stop_external_daemon enabled, where wallet exit sends
the node a stop.

beginShutdown() now defers when it would StopDaemon while a rebuild is active and
shows a confirm modal: "Keep node running & quit" (DisconnectOnly — leaves it up
to finish), "Stop anyway & quit", or "Cancel". Rebuild detection reads the
debug.log tail markers (Cleared witness data / Setting Initial Sapling Witness /
Reading blocks for witness rebuild, vs. the "rebuilt … in …ms" / abort lines).
The gate lives entirely in beginShutdown()/render() — no SDL event-loop changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:32:03 -05:00
29274c2f48 fix(ui): trim verbose startup notice; show daemon output on shutdown for external daemons
- Loading "taking longer than expected" notice: shorten the body + hint so the
  startup screen reads less wordy (same info, ~half the text).
- Shutdown screen: when the wallet attached to an EXTERNAL daemon (no captured
  stdout — debug_log_path_ is only set when we spawn it), the "dragonxd output"
  panel was always empty, leaving just a spinner. Fall back to tailing the
  daemon's debug.log so the user can watch the node flush the block index and
  exit. Adds App::tailDaemonDebugLog() (best-effort, reads only the file tail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 23:09:17 -05:00
870793433b fix(sync): stop large-wallet balance polling from starving block connection
On a fully-shielded (ac_private=1) chain, z_gettotalbalance is O(mapWallet) and
holds the daemon's cs_main for its whole duration — ~20s on a ~5k-tx wallet. The
Overview refresh polled it every ~2s (twice: minconf 0 and 1), so cs_main was
held almost continuously, starving the single block-connection thread: the node
connected blocks only in the gaps between polls and could fall further behind
the tip than it caught up (observed live: gap growing 58→100 blocks while the
GUI was open, one core pegged on GetFilteredNotes, 22 idle, ~17 B/s download).

Two hardening changes on top of the existing "skip balance while syncing" guard:

- Hysteresis: keep the low-impact sync profile (and balance suppression) for a
  short settle window after catching up, so a large-wallet scan can't
  immediately re-starve connection and bounce the node back into syncing. Armed
  only on the syncing→caught-up edge, so a wallet synced from the start is never
  throttled at connect (effectivelySyncing()).

- Adaptive balance cadence: time each z_gettotalbalance scan and require the
  next poll to wait at least (cost / 10%), so balance scanning never occupies
  more than ~10% of wall-clock. Cheap wallets are unaffected (the tab's Core
  timer stays the cadence); a ~20s scan backs off to ~200s. Wallet mutations
  (send/shield) force the next poll through so the user's own action updates the
  balance immediately (balanceRefreshDue()).

getblockchaininfo keeps its normal cadence throughout, so sync progress stays
live. Build + test_phase4 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-30 22:45:51 -05:00
08cfeb0e08 feat(ui): rework the consolidate/merge modal
Make Merge to Address actually serve wallet-bloat consolidation and be far less
opaque. New ShieldDialog::showConsolidate() preset (used by the large-wallet
Settings banner + alert action) frames it as "Consolidate funds" and targets
shielded notes — the bloat the nudge warns about.

- Source selector: consolidate shielded notes (ANY_SAPLING), transparent
  (ANY_TADDR), or both (*) — previously hardcoded to ANY_TADDR, which never
  reduced the shielded-witness bloat. Batch limit now applies to the right side.
- Scope: on open, count spendable UTXOs + notes (listunspent / z_listunspent)
  and show "N transparent + M shielded · ~X DRGX"; warn "repeat to finish" when
  the set exceeds one batch.
- Destination auto-selects the best spendable z-address (button enabled by
  default); empty wallets get an inline "Create shielded address" (z_getnewaddress).
- Advanced disclosure hides Fee + "Max inputs per batch" (renamed from the "UTXO
  Limit" jargon) with sane defaults.
- Inline confirm step before the fund-moving call (amount + input count + dest).
- Live progress: self-polls z_getoperationstatus to show Consolidating… →
  Done/Failed, replacing the raw opid + manual "Check status" button.

All three merge entry points now use the typed showMerge()/showConsolidate()
(no stale-static leaks from direct show(MergeToAddress)). Shield-coinbase mode
keeps working. New i18n keys fall back to English.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 22:33:59 -05:00
5daf2d83b6 feat(ui): large-wallet nudge as a one-time toast + clickable alert
Extend the wallet-bloat warning beyond the Settings banner: when wallet.dat
first crosses 500 MB (full-node, synced), fire a one-time warning toast plus a
clickable "Consolidate notes…" entry in the bell/alerts panel that opens Merge
to Address. The persisted large_wallet_warned flag keeps it once-only and
re-arms if the file later shrinks back under the threshold.

- AlertRecord gains an optional onClick + actionHint; Notifications::action()
  pushes a toast and a clickable history entry. renderAlertHistoryPanel() now
  renders the accent action link (under the message) and measures true content
  height so wrapped messages + the link aren't clipped.
- App::maybeWarnLargeWallet() (mirrors maybeRemindSeedBackup) runs once per
  launch from update(); reuses the existing wallet_size_warn/consolidate strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 18:09:36 -05:00
6d26ccd0ed feat(ui): large-wallet nudge in Node & Security
The BDB wallet.dat bloats with shielded-note witness data and never shrinks
in place, so a mining/shielded wallet can grow past 500 MB. Below the Wallet
Size row, show a one-line amber hint once wallet.dat crosses 500 MB with a
"Consolidate notes…" shortcut that opens the Merge to Address (z_mergetoaddress)
dialog. Full-node only (lite has no wallet.dat here); threshold is a single
named constant. i18n keys fall back to English for non-English locales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:41:25 -05:00
a7514becbc feat(ui): credit The DragonX Developers in the About tab
Add "The DragonX Developers" to the About-tab credits (after The Hush
Developers), acknowledging the DragonX chain/daemon this wallet drives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:17:03 -05:00
558cfcbe56 fix(ui): restore ObsidianDragon logo in header and About tab
ensureLogoTexture() rasterized the embedded DragonX SVG into logo_tex_ and
returned early (added in 1752500 "themed DragonX logo"), so the app/product
branding — the top-left header (app.cpp AddImage) and the About tab
(getLogoTexture) — showed the DragonX coin mark instead of the ObsidianDragon
logo. Drop that step so logo_tex_ resolves via the intended path: active-skin
override → ui.toml header-icon → bundled ObsidianDragon dark/light PNG (disk,
then embedded RESOURCE_LOGO). The DragonX SVG stays for coin_logo_tex_ (balance
card) and drgx_emoji_tex_ (chat emoji), which are the currency mark and correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 17:06:04 -05:00
d0bd55b9c1 feat(ui): settings polish — button retune, daemon card, RPC 2-row, chat preview
Settings tabs brought closer to the approved mockup:
- ActionButton/renderCardButton retune (settings-scoped): 7px radius, 9px
  padX, Primary → accent-outline chip, Secondary/card buttons more defined.
- Daemon-binary card: compact status right-aligned on the DAEMON BINARY
  heading (Up to date / Version differs / Not installed), filled/rounded
  status box, neutral danger divider (was alarming red), roomier spacing.
- RPC Connection: two-row column-aligned layout (Host | Port, then
  Username | Password) so the password no longer clips off the card edge.
- Chat settings tab: live conversation preview below the Appearance /
  Messaging cards; "Focus input on open" checkbox reflowed onto the console
  color-toggle row.
- Debug Options: "Current theme only" toggle restricts either screenshot
  sweep to the active theme instead of cycling every skin.
- Tabs fill the full content width (content-max-width cap disabled) and the
  sidebar nav panel centers within the true visible area.
- i18n: new keys for the above (untranslated keys fall back to English).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-20 16:59:03 -05:00
b37d3d97b6 fix(send/receive): unify card width, justify receive footer, fix recipient-row button height/clip/glyph
Send and Receive are now consistent in layout, and the Send recipient row's buttons
render correctly.

Card envelope (Send ⇄ Receive consistency):
- Add Layout::mainComposeCardBox(availW) — a single shared source for the compose card's
  width + centering (fill the available column up to content-max-width, then center). Both
  tabs derive their card from it, so they can't drift again. Previously Send capped at
  760dp and Receive at 860dp, so the Send card rendered ~150px narrower on any window wider
  than ~860dp; now they fill available width identically.

Receive:
- Justify the footer buttons edge-to-edge (equal shares over the live count) instead of
  left-clustering with dead space, matching Send's full-width footer rhythm.
- Build the address-dropdown preview to the combo's real pixel width so the trailing
  balance ("— 12.00000000 DRGX") no longer hard-clips at 150% (was char-count truncation).

Send recipient row (input | Paste | contacts-icon):
- Pin the contacts icon button to the frame height so the larger iconMed font doesn't
  auto-size it taller than Paste/the input.
- Reserve the real ItemSpacing.x gaps (not the smaller spacingSm token) so the row no
  longer overshoots the card and clips the icon's right border.

draw_helpers (root cause, app-wide):
- TactileButton's icon path measured/drew the label INCLUDING the "##id" suffix (which
  CalcTextSizeA/AddText don't strip the way ImGui's text render does), shoving the glyph
  off-center-left. Strip at "##" before measuring/drawing. Corrects any icon button that
  passes an explicit size and a "##id" label; no-op for labels without "##".

Verified via headless sweeps at 1.0x and 1.5x, plus a real 3800px-wide render (both cards
byte-identical at L=1174/R=2773). ctest 1/1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 16:10:02 -05:00
8778398d31 feat(ui): layout polish — fill dead space, pair/box/collapse, warning weight, dialog glass
Implements the layout-improvement suggestions from the layout audit (visual arrangement
only; no functionality added or removed):

- Send/Receive: the recent-activity list now grows to fill the space below the fixed
  compose/receive card (more history visible), with a centered empty-state when there is
  none — instead of leaving dead canvas.
- Shield/Merge: pair the Fee and UTXO-Limit fields on one row to tighten vertical rhythm.
- Market: extend + frame the portfolio group-list as one contained panel (with a bottom
  edge) and center its empty-state, closing the previously un-anchored gap.
- Overlay dialogs: raise the card glass fill/border alpha (35/50 -> 60/90 of 255) so the
  dialog card reads as a distinct surface over busy backdrops (global, all overlays).
- Wallets: size the list height to the actual wallet count instead of always reserving 7
  rows, removing the large gap before the scan/create prompts (still scrolls when many).
- Contacts: width-aware address truncation shows more of the address on wide rows.
- Transfer Funds: give the "sends the full balance" warning a warning icon + color so the
  stakes stand out from the neutral result-preview lines.
- First-run wizard: collapse a completed Step 1 (Appearance) to the compact pill like
  Step 2, so a finished step is no longer taller than the active one.
- Explorer: distribute the Chain card's two stats to match the density of the sibling
  metrics grid.
- Validate Address: a "Results will appear here" caption fills the pre-interaction blank.
- Change Passphrase: add the warning banner its sibling security dialogs have.
- Migration ShowSeed: box the 24-word mnemonic grid (a GlassSectionScope behind the
  existing RenderSeedWordGrid) so the critical secret reads as a distinct artifact —
  purely visual, no seed/logic/state change.

Verified at 1280 across full-node + Lite + Windows (ctest green) and an adversarial diff
review (clean). New i18n key backfilled into all 8 languages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 13:56:13 -05:00
d5c30237d2 refactor(ui): consolidate dialog footers + shared empty-state, unify buttons/rounding
Addresses the cross-screen inconsistencies from the layout audit by routing screens onto
the design system's own (previously under-used) shared helpers:

- Dialog footers: migrate ~9 overlay dialogs off hand-rolled placement onto the shared
  helpers — DialogActionFooter (primary+Close), DialogConfirmFooter, or
  BeginOverlayDialogFooter for custom/multi-button rows — so footers share one centered
  treatment. All footer/action buttons now use TactileButton (glass press) instead of the
  bare StyledButton some dialogs used.
- Empty states: add a shared material::DrawEmptyState(icon, title, hint) (centered icon +
  title + wrapped hint) and adopt it in Peers, Transactions, and Market-portfolio, which
  previously showed a bare left-aligned caption.
- Security dialogs: add the missing Cancel to Change Passphrase and Set PIN so the whole
  security family shares a two-button footer (Cancel dismisses without applying).
- Transactions pager: shared TactileButton helpers (matching Explorer).
- Frosted-pane rounding: Contacts/Chat use Layout::glassRounding() instead of hardcoded
  12/10/8px literals, matching Peers.
- "Set Label..." title loses its stray trailing ellipsis.

Preserves every button's label and action; the transfer footer's order becomes
[Confirm][Cancel] to match the shared helper's primary-first convention. Verified at 1280
across full-node + Lite + Windows (ctest green) and an adversarial diff review (clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 11:23:49 -05:00
a598217975 fix(ui): cut-off/clipping — recent-tx collisions, updater note wrap, request-payment URI, console filter, icon-grid scroll
Fixes the cut-off/clipping bugs from the layout audit (all visible at the default
1280/1024 window sizes):

- Receive "Recent Received" rows: the amount collided with the relative-time
  ("+15.7500 DRGX14 days ago") and the type label touched the address at narrow
  widths. Use the shared short time format (formatTimeAgoShort, matching Overview),
  chain the amount's right edge off the measured time width, and start the address
  after the measured type-label width — so neither pair can collide.
- Daemon & xmrig updater verify-note: drawn unwrapped and clipped at the card's right
  edge; wrap it (PushTextWrapPos) within the already-reserved height.
- Request Payment: the three footer buttons shared one fixed width (clipping "Copy
  Full Address"); size each to its own label. The Payment URI overflowed a plain
  field; render it in a bordered read-only box (bounded, un-chunked).
- Console: the filter input shrank below its own placeholder (gone entirely at 1024);
  give it a min width >= the placeholder and drop the "N lines" count when the row
  can't fit both.
- Address-label "Choose Icon" grid: had NoScrollbar hiding most of the catalog with
  no cue; give it a real scrollbar.
- Overview "Recent Transactions": drop the 4th row at 1024 (it clipped off-screen) by
  capping to rows that fully fit the reserved height.
- Sidebar: reserve the unread-badge width in the nav-label centering so History/Chat
  labels no longer collide with their badge.

Verified at 1024 and 1280 across full-node + Lite + Windows (ctest green) and an
adversarial diff review (clean). Skipped the legacy settings_window overlay footer
(dead code / removal candidate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-19 10:11:55 -05:00
9205addf55 feat(ui): width-responsiveness — content max-width cap + per-surface form/input clamps
Wide/ultrawide (1440-3440px) responsiveness was unhealthy: no page/content-level
max-width cap existed, and every card/form/table derived width from raw
GetContentRegionAvail().x with floor-only clamps, so surfaces stretched edge-to-edge
(2000-3000px inputs, ballooning cards, giant grid cells, 2000px+ dead row-voids).

Root cause: adopt the (previously dead-code) clamp helpers.
- New Layout::kContentMaxWidth() (~1600dp, tunable via ui.toml [layout]
  content-max-width; <=0 disables). Cap ##ContentArea to it and center the column in
  wider windows — every tab derives from this child, so one change tames the app at
  wide widths. No-op below the cap (fills as before), so 1080p/1440p are unaffected.

Per-surface upper-clamps (std::min(cap*dp, expr), floors preserved) where a single
element is still too wide even within the capped column:
- Settings: Theme/Layout/Language combos, the font-scale slider (~3000px -> 360dp),
  the effect sliders, Explorer URL and RPC credential fields.
- Send / Receive: cap the compose / receive cards to a readable form width and center
  them (Indent(pad+offset) so the auto-layout fields align with the hand-drawn card);
  the recent-tx lists below keep the full column width.
- Chat message bubbles + composer, mining pool URL/payout inputs + stats left/right
  split, contacts search, and the lite-network add-server row / server cards / status
  panel (capped + centered).
- Wizard: vertically center the cards when they fit (was top-anchored, leaving a void
  on tall monitors), compensating the content-height measurement so it can't oscillate.

The 1600 cap also subsumes the fixed-4-column balance grids (~400px cards) and the
right-anchored row dead-gaps (voids shrink from ~2700px to ~800px), so those are left
to the cap rather than blind column/row redesigns.

Verified at 1024/1280 (and via a temporary 900dp cap to exercise the cap+center path,
since the test display clamps to 1280) across full-node + Lite + Windows (ctest green)
and an adversarial diff review (clean). The true wide/ultrawide look and the 1600dp cap
value still want eyes on a real wide monitor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 23:15:39 -05:00
ce8c7696d4 fix(ui): finish HiDPI pass — cosmetic ×dpiScale, narrow-width reflow, recent-list reserves
The tail of the DPI/font-scale/responsiveness audit — ~26 remaining findings.

Container / recent-list (Theme-1 leftovers):
- Send: drop NoScrollbar|NoScrollWithMouse on ##SendFormScroll so Recent Sends is
  reachable at font_scale 1.5 (parity with receive).
- Receive: cap the QR/form card via std::min(mainCardTargetH, availH - recentReserve)
  so RECENT RECEIVED stays on-screen (identity at 1.0x).
- Wallets dialog: size the capped-mode list to whole rows so it no longer clips a
  partial row / crowds "Create a new wallet".

Narrow-width (1024px) reflow:
- Console toolbar reserves space for ALL trailing controls (both icon toggles + zoom
  buttons) so the +/- zoom no longer runs off-window.
- History sort combo sized to its measured widest localized label ("Newest first").
- Settings Theme/Layout/Language row: scale the wide→stacked breakpoint by dpiScale so
  it drops to full-width stacked combos at 1.5x (Consolidated Card no longer clips).
- Recent-tx type label: derive the address column X from the measured label width so it
  can't collide at narrow widths.
- Mining Recent Pool Payouts: floor the panel height to fit the empty-state caption.

Cosmetic ×dpiScale() on absolute geometry (no-ops at 1.0x): mining SOLO|POOL toggle &
idle combos, market pair-chips, password/PIN strength bars, receive/send currency
toggles, explorer search bar/rows/rounding, About-card logo, chat empty-state wrap,
recent-list address/time offsets, address-toolbar & two-row action buttons, console
line-gap/status-dot/pane rounding.

Verified at font_scale 1.5 and at 1024px across full-node + Lite + Windows (ctest
green) and an adversarial diff review (one over-reserve regression fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 21:29:10 -05:00
99d1e73676 fix(ui): scale unscaled geometry at HiDPI — inputs, send progress cards, chat/pool overlaps, peer rows
At font_scale 1.5 ~13 sites read absolute geometry (schema .size/.width) straight
into ImGui without ×dpiScale(), so they stayed native-size while the font grew and
overlapped/clipped real text or money:

- Shield/Merge fee + UTXO inputs, Request Payment amount, Block Info height input:
  ×dpiScale() so the value no longer clips (e.g. 0.00010000 -> 0.00010).
- Send: the confirm-popup Amount Details divider (floored the row step at the scaled
  caption height so it no longer strikes the Fee row), the tx-progress error and
  sending/success cards, and the zero-balance CTA button — all ×dp.
- Mining pool row: ellipsis-truncate the hostname so it can't collide with the
  right-aligned hashrate.
- Chat conversation list: scale the pane-width clamp AND clip the peer name to the
  column left of the timestamp (measure-then-clip) so name and time never overlap.
- Explorer block-detail label column, Peers row offsets, and DialogConfirmFooter
  button height — ×dp.

transaction_details keeps its negative fill-sentinel widths unscaled (a content
margin, not raw px). Verified at font_scale 1.5 across full-node + Lite + Windows
(ctest green) and an adversarial diff review (three wrong-scale regressions fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 20:53:29 -05:00
755cf22ad0 fix(ui): make HiDPI-overflowing containers scrollable — wizard, overlay dialogs, balance recent-tx
At font_scale 1.5 (dpiScale 1.5) three fixed, non-scrolling containers clipped
content off the bottom with no scroll escape:

- First-run wizard: the hand-drawn cards grow ~1.5x past the fixed window,
  pushing Continue / Encrypt & Continue / Skip off-screen (a setup blocker).
  Inject a wheel-driven scroll offset into the layout seed + a scroll indicator;
  gate the wheel on !IsPopupOpen + NoPopupHierarchy so an open combo popup does
  not scroll the wizard behind it. No-op at 1.0x.
- Overlay dialogs (BeginOverlayDialog): auto-height cards taller than the
  viewport (About, Request Payment) ran their footer off the bottom. Add a
  sticky per-open overflow flag that clamps the card to the viewport and makes
  the content child scrollable; short dialogs still center unchanged. Give the
  nested settings clear-history confirm its own idSuffix so it can't inherit the
  parent dialog's overflow state or collide on the child window id.
- Balance Recent Transactions: the dp-scaled address card evicted the recent-tx
  list off the non-scrolling tab host. Cap the card inside RenderSharedAddressList
  against the space that actually remains (minus a caller-provided reserve) so
  the section below stays on-screen — covers all 10 balance layouts.

Verified at font_scale 1.5 across full-node + Lite + Windows (ctest green) and an
adversarial diff review (two low-severity regressions found + fixed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 20:12:01 -05:00
e24ca015d1 feat(ui): implement UI/UX audit — i18n, HiDPI, theme, destructive-action & overflow fixes
Implements all 26 confirmed UI/UX audit findings plus the 4 dashboard/timeline
tile labels. Verified across full-node + Lite + Windows builds (ctest green) and
an adversarial diff review (one market-tab delete regression caught + fixed).

i18n coverage:
- Balance hero/quick-actions/toasts + dashboard & timeline tiles (Total Balance,
  Shielded, Transparent, Quick Send, Quick Receive, Click to open, Market)
- Send: Review Send / Cancel / Paste / view-only tooltip / memo byte counter
- Settings: Lite lifecycle errors, plaintext-RPC security warning, 8 toasts
- Mining pool-payout tooltip
- Resolve daemon_update_title double-assignment collision (new daemon_update_prompt_title)
- 23 new keys translated into all 8 locales; CJK subset font rebuilt

HiDPI: DPI-scale the Send amount bar, mining thread-tile clamp bounds, receive
loading skeleton, and sidebar notification-badge insets.

Light theme: theme-aware material::SurfaceOverlay() for balance bar tracks and
row-hover highlights; contacts active-pill foreground uses OnPrimary().

Destructive actions: arm/confirm for portfolio-group delete, saved pool/worker
remove, and avatar-image delete.

Interaction/overflow: route balance star/eye buttons and the console fold-toggle
through popup-safe guards; clip peer addr/subver; truncate the balance custom
label; measure Settings tool-button widths; mining stepper disabled-state
feedback; +/- stepper buttons restyled to match the thread tiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 17:54:15 -05:00
0de44569b7 feat(mining): thread-count stepper, adaptive tiles, dropdown click-through fix, xmrig version state
Mining-tab UI improvements:
- Thread selector: a centered [-] N [+] stepper (top-aligned in the header) to pick an exact
  thread count — number centered, -/+ step by one (clamped to [1, cores]), still typeable
  (commits on Enter so it doesn't restart the miner mid-typing).
- Thread tiles now render at an adaptive step (1/2/4/8 by core count) plus 1 and the max, so a
  high-core CPU (e.g. a 192-thread EPYC) shows ~25 tiles instead of one-per-thread and no longer
  overflows the card. Unchanged for <=24-core machines.
- Fix: clicking the X (or a row) in an open saved-pools / payout-address dropdown no longer bleeds
  through to the thread tiles / Mine button — the custom drawlist hit-tests now gate on
  IsPopupOpen(AnyPopup).
- xmrig update button: shows "xmrig releases" when installed >= latest (numeric version compare),
  else "Update <latest>"; the "Current: <ver>" text and the button are subtle green when up to date
  and subtle orange when an update is available (neutral when either version is unknown).

New i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs.
Verified across full-node, lite, and Windows builds; the stepper layout confirmed via the UI sweep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 15:39:43 -05:00
06afbee4f8 fix(mining): remediate mining-tab audit (22 findings) — crash-safety, async control, validation, math
Fixes all 22 confirmed findings from the mining-tab audit (10 Medium, 12 Low; 0 Critical/High),
adversarially reviewed (6 follow-ups found + fixed, incl. the review-caught idle-auto-start bypass
and a wrong benchmark-restore condition).

Crash-safety & lifecycle:
- M-04: join a stale/finished monitor thread in XmrigManager::start() and ~XmrigManager so an xmrig
  crash-then-restart (or quit) no longer std::terminate()s the wallet.
- L-03/L-10: surface an unexpected miner exit once and clear the stale running flag.

UI never blocks (M-03/L-06/L-08/L-09/L-13): pool start/stop now run on a dedicated serialized FIFO
mining-control thread (joined before teardown), so the ~13 call sites don't block the render thread on
stop()'s SIGTERM->SIGKILL->join; the spawn result marshals back to the UI.

Miner-process / pool trust boundary:
- M-01: validate the payout address (util::isValidRecipientAddress) at EVERY start path — the UI gate
  AND App::startPoolMining() (idle auto-start / thread scaling) — so a stale/wrong-chain address can't
  silently lose rewards.
- M-09: SSRF guard skips the background pool-stats GET for loopback/private/link-local/single-label hosts.
- M-02/L-02: cap the pool-stats + xmrig-API HTTP response bodies.
- L-01: write the xmrig config 0600 at creation (POSIX open with mode) — no world/group-readable window.
- M-10: reject shell-metacharacter binary paths before the version popen (excluding '()' so Program Files
  (x86) still works).

Solo mining: M-06/M-08 clamp thread count to [1, cores] at the setgenerate/xmrig boundary; M-07 notify +
don't lie on stop failure.

Correctness: L-05 block-time constant 75->150s (chainparams); M-05 discloses pool-mode "Est. Daily" as a
rough solo-equivalent; L-04/L-11/L-12 benchmark lifecycle (cancel on nav-away / mode-switch with restore,
skip rebalance mid-benchmark); L-07 honor cancel mid-extract in both the xmrig and daemon updaters.

Two new i18n keys back-filled across all 8 languages; CJK subset font rebuilt for the new glyphs.
Verified across full-node, lite, and Windows builds; tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 14:53:46 -05:00
6ee81a5abe fix: security-audit remediation (15 findings), empty-wallet warning, and send/chat/console/shutdown UX
Security audit remediation (15 confirmed findings from the codebase audit):
- H-02: scrub+delete the decrypt-flow plaintext key export on ALL exit paths
  (RAII guard) and purge stale obsidiandecryptexport* files at startup.
- M-01/L-03/L-04/L-05/L-07: sodium_memzero the Set-PIN and encrypt-PIN worker
  passphrase/PIN copies, the RPC Basic-auth string (auth_), the exported/imported
  key buffers (App::wipeSecrets, called from ~App and before main's _Exit), and
  the first-run wizard "Skip" buffers.
- M-03/M-04/M-05/L-06: return locked COPIES from XmrigManager/EmbeddedDaemon
  getters (dedicated error_mutex_; DaemonController::lastError now by value),
  route xmrig last_error_ writes through a locked setter, and wrap
  shutdown_status_/wizard_stop_status_ in a locking GuardedStatus
  (wizard_stopping_external_ -> std::atomic).
- M-02: persist after a console send/shield/import in the lite backend.
- L-01: require the confirm click for z_shieldcoinbase/z_mergetoaddress.
- L-02: quote/escape each Windows daemon argv per the MSDN CommandLineToArgvW rules.
- L-08: pin json/tomlplusplus/libwebp FetchContent to immutable commit SHAs.
- I-01: extract updater archives from the already-verified in-memory buffer
  (no disk re-read TOCTOU).

Feature: warn once (full-node) when the active wallet loads empty while a sibling
wallet file in the datadir holds keys. A funded salvage wallet.<ts>.bak routes to
the recovery/Restore flow; a funded sibling .dat routes to the wallet manager.
Per-wallet-file dismissal; gated on synced + address-list-loaded to avoid false
positives on warm reconnect / spent-down wallets.

UX fixes:
- send: show the TOTAL balance (with a spendable "available" note) in the source
  dropdown and keep pending-change addresses visible.
- chat: insert emoji at the cursor position; restrict new-chat recipients to
  shielded (z) addresses.
- console: optional auto-focus of the command input on tab open (off by default).
- shutdown: when "stop external daemon" is on, keep the shutdown screen up until
  the external node actually exits, showing live status.

Adversarially reviewed; verified across full-node, lite, and Windows builds; tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-18 13:18:49 -05:00
ea26c0cbbb fix(balance): stop the displayed balance cratering during a pending shielded send
Sending a small amount from an address holding a large balance made the displayed
balance collapse to ~0 until the tx confirmed. A shielded spend consumes the whole
source note; the change returns as a 0-confirmation note, and every balance query
used the default minconf=1 — so the spent note dropped out and the change wasn't
counted yet.

Split every balance into two views:

- DISPLAY (balance / privateBalance / transparentBalance / totalBalance) — now
  queried at minconf=0, so it INCLUDES the user's own pending change and no longer
  craters. This is what the Overview, balance tab, market portfolio and receive
  tab show. unconfirmedBalance is now populated (= total - spendable).
- SPENDABLE (new spendableBalance / spendable*Balance) — confirmed (minconf>=1),
  what z_sendmany (run at minconf=1) can actually spend. The Send form's available/
  Max/validation, the from-address selection, the drag-to-transfer dialog cap, the
  chat pay-from and the auto-shield gate all size off these, so they never offer
  0-conf change the daemon would reject.

Implementation: a single z_listunspent(0)/listunspent(0), partitioned per-note by
"confirmations">=1; z_gettotalbalance called at minconf 0 (display) and 1
(spendable); the z_getbalance fallback queries both. applyPendingSendDelta (the
optimistic post-send debit) now touches ONLY the spendable fields — debiting the
display too would re-crater it on top of the honest minconf=0 RPC. Lite mirrors
spendableBalance = balance (its per-address balance is already confirmed) so lite
sends aren't zeroed. The confirmed-only gates (seed-migration/sweep z_gettotalbalance,
sweep z_getbalance(addr,1), z_sendmany's minconf arg) are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-13 16:40:14 -05:00
13b225d8f5 fix(send): prefix shielded-send memos with "utf8:" so the daemon accepts them
A shielded send with a memo failed: "Invalid parameter, expected memo data in
hexadecimal format or to use 'utf8:' prefix." The Send-tab path (and its fee-gap
retry) put the user's plain-text memo straight into the z_sendmany recipient,
which the daemon now rejects — it wants the memo hex-encoded or with a "utf8:"
prefix. The chat path already prefixes with "utf8:"; do the same for user memos.

Only the RPC recipient["memo"] is prefixed; the raw memo is still what's stored
for the transaction-history display, and the daemon returns the decoded memoStr
to receivers, so it round-trips as plain text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-13 15:49:09 -05:00
08aed34bbe fix(daemon): actually stop an external daemon on quit when the setting is on
"Stop external daemon" silently left an external dragonxd running. beginShutdown()
calls rpc_->requestAbort() (a sticky abort flag, cleared only by connect()) to
unblock in-flight requests; the shutdown thread's stopEmbeddedDaemon() then sent
the graceful "stop" over that same connection, so curl self-aborted it
(CURLE_ABORTED_BY_CALLBACK). doRPC swallowed the error but stop_sent was set true
anyway, skipping the temp-connection fallback that would have worked — so the
daemon never received "stop" and only died via the 20s by-name force-kill (which
collides with the 8s "Force Quit / may corrupt chain data" prompt, so it read as
"doesn't work").

Clear the abort before the shutdown stop and send it synchronously via
sendStopCommandSafely so real delivery success is surfaced (and the fallback can
still run on failure). The graceful stop now reaches the daemon, it exits in a
second or two, and the 20s stall / Force-Quit prompt no longer appears.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-12 00:08:08 -05:00
5edbe8a276 feat(recovery): redesign the wallet auto-recovery flow
Turns the "Daemon Error + raw log dump" moment into one calm, honest recovery
dialog plus a recovery-aware rescan screen. Presentation + orchestration only —
the file-safety logic in rebuildWalletDatabase()/restoreOriginalWallet() (source
selection, verify-before-swap, copy/rename-never-delete, .bak) is unchanged.

- One authoritative dialog with a phase machine Offer -> Working -> Done/Failed.
  The duplicate in-overlay recovery card, the untranslated red "Daemon Error"
  heading, and the raw daemon-log dump are gone for the recovery case (they stay
  for genuine, unrelated crashes).
- Offer is a choice-cards layout: "Repair automatically" (recommended, accent-
  tinted) vs "Restore original", side by side; the rare actions ("Show me the
  files", "Decide later") and a plain-language "What happens to my files?" sit in
  a quiet footer. When the rebuild helper is missing, it collapses to a single
  Restore card — never a dead end.
- Post-repair rescan shows a calm "Finishing your wallet repair" screen with
  elapsed time + the growing wallet size, instead of "RPC timeout / taking longer
  than expected / restart daemon"; the daemon-crash toast is suppressed and the
  detection toast is downgraded from red to info.
- Fixes a confirmed dead-end: if a repair succeeds but the restarted daemon then
  crashes for a *different* reason (block index, disk, OOM), the recovery flags
  now clear (in tryConnect + onConnected) so it surfaces as a normal daemon
  failure instead of freezing forever on a reassuring "don't restart" screen.
- Clickable "Wallet repair available" status-bar chip for re-entry.

The same app.cpp changes HiDPI-harden the surfaces the recovery flow lives on:
the status-bar and loading-overlay hand-drawn geometry are multiplied by dpiScale
(they rendered native-size and clipped at HiDPI / font_scale>1), the loading-
overlay status text wraps instead of running off both edges, and the node-status
banner floors its height to its DPI-baked font so the title can't clip off the top.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:31:55 -05:00
001e85ac1a fix(ui): prevent text/button cutoff and scale hand-drawn geometry at HiDPI
Findings from a UI-cutoff audit — each is a spot where an in-tree helper
(truncateMiddle / TruncateToWidth / measured button width / the *dpiScale/*hs
factors) was bypassed:

- notifications: the toast-pill height/padding/icon-gap were raw logical px while
  the icon/text drawn inside are DPI-baked, so they clipped the pill at HiDPI.
  Scale the geometry by dpiScale (not the already-scaled glyph metrics).
- settings: in the two-column NODE & SECURITY layout the data-directory path could
  overrun into the Daemon-binary column (shared draw list, no clip rect between
  them). Middle-ellipsize it to the column width; the full path stays in the
  tooltip + click-to-open + copy.
- send: the "Confirm & Send" button width came straight from the schema and was
  never measured against the label, clipping the Russian translation on the
  pre-broadcast dialog. Size to max(schema width, measured label + padding).
- balance: the recent-tx address-column offset missed the `* hs` DPI factor its
  sibling (amount-right-margin) uses, overlapping the type label at HiDPI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:11:07 -05:00
393f3d147e feat(recovery): bundle & embed the offline wallet-rebuild helper in every release
The dragonx-wallet-rebuild helper is the only thing that repairs a genuinely
BDB-inconsistent wallet.dat — plain "Restore" just re-triggers the daemon's
salvage cascade — yet it was silently dropped from every packaged build:

- Linux zip/AppImage copied a hand-picked file list that omitted it.
- Windows bundled it only behind a soft `[[ -f ]]` guard (silent skip).
- macOS never wired Berkeley DB, never built it, never bundled it.

build.sh now HARD-REQUIRES the helper for full-node releases (fails the build if
the vendored Berkeley DB depends are missing, rather than shipping recovery-less),
and ships it in the Linux zip + AppImage, the Windows zip, and the macOS .app.

It also compiles the helper standalone for Windows and INCBINs it, and
embedded_resources gains ensureWalletRebuildHelperExtracted() to extract it on
demand — so a self-contained ObsidianDragon.exe carries recovery exactly like the
embedded daemon, even on a machine where first-run param extraction already ran.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 22:10:57 -05:00
ac49f44f84 fix(node): show recovery actions in the daemon-error overlay (not a separate dialog)
Reported with a screenshot: on a salvage-then-abort, the status correctly read
"Wallet needs recovery — see the prompt" but no prompt appeared — the separate
BeginOverlayDialog is occluded by the full-frame loading/daemon-error overlay
that's drawn every frame while the node is down.

Render the recovery actions directly IN the daemon-error overlay when a salvage is
detected: a concise message + prominent one-click "Rebuild wallet database" /
"Restore original" / "Open data folder" buttons (same handlers as the dialog),
placed right after the title and skipping the verbose daemon-output dump so they
stay on-screen. The verbose diagnostics + crash-count hint still show for
non-recovery errors. Build clean, suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:28:48 -05:00
3216debc7d fix(node): detect a wallet salvage at startup, not only on connect
Reported: loading a BDB-inconsistent wallet silently renamed it and created a new
one — no recovery dialog. Two causes, both fixed:

1) Detection ran only in onConnected(). The salvage happens at STARTUP, and the
   node may never connect (block-index abort, long sync, crash) — or a long sync
   trims the salvage line out of the rolling output buffer before connect. Extract
   detectWalletAutoRecovery() and run it every tryConnect() tick (every ~5s during
   startup), so the salvage is caught the instant it appears, regardless of whether
   the node connects. Also hold the crash-restart loop while a salvage is pending,
   so the wallet can't be re-salvaged/shrunk while the Rebuild/Restore dialog is up.

2) walletAutoRecovered() only matched the SUCCESSFUL-salvage strings. A
   BDB-inconsistent file makes aggressive salvage FAIL ("found no records"), which
   prints different lines. Broaden the detector to the signals that fire in every
   case: "CDBEnv::Salvage", the "Renamed <wallet> to wallet.<ts>.bak" rename, and
   "found no records in wallet" — while still not matching normal startup or a
   block-DB abort.

Adds the exact failed-salvage sequence to the detector test. Build clean, suite
green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 12:04:07 -05:00
8ffcd9cc8c build(node): bundle dragonx-wallet-rebuild in Linux + Windows releases
Wires the wallet-rebuild recovery helper into the release pipeline so a shipped
build actually carries it (the app locates it next to dragonxd).

- CMakeLists: link the vendored STATIC Berkeley DB for the helper — add
  Threads::Threads + dl (Linux) / ws2_32 (Windows) that the static libdb-6.2 needs
  (the system shared lib pulled those in transitively; the static one doesn't).
- build.sh (Linux + Windows): pass BDB_INCLUDE_DIR/BDB_LIBRARY explicitly at
  configure, pointing at external/dragonx/depends/<triple>/{include,lib/libdb-6.2.a}
  — the same libdb the daemon links, so the helper's output is a v6.2 btree the
  bundled dragonxd reads. Explicit paths bypass find_library (and the mingw
  toolchain's sysroot-only find restriction). Guarded: no depends → helper simply
  not built/bundled. Strip + copy the helper next to dragonxd(.exe) in both bundles.

Verified: Linux links the vendored libdb-6.2.a statically (no dynamic libdb) and
rebuilds the real broken wallet correctly; the helper cross-compiles cleanly with
mingw against the vendored Windows libdb-6.2.a to a PE32+ x64 exe. Full app +
helper build, suite green (1/1), build.sh syntax OK.

Remaining: macOS Berkeley DB (no in-tree depends artifact) + resource-embedding as
an alternative to side-by-side bundling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 11:38:20 -05:00
bc183257b7 feat(node): in-app "Rebuild wallet database" recovery for a BDB-inconsistent wallet
Automates the manual recovery that fixed a wallet.dat with stale Berkeley DB
extent metadata (the "main" subdb metapage records a low last_pgno while its live
data spans thousands of pages beyond it). A tolerant page-walk reads every record,
but the daemon's BDB verify rejects the file and auto-salvages it — finding nothing
and shrinking the wallet to empty on each restart (the salvage cascade that looks
like fund loss). Plain "Restore original" can't fix it (hands the same broken file
back → re-salvage); a rebuild must produce a fresh, consistent DB.

Pieces (Approach A from the design workflow — out-of-process helper keeps AGPL
Berkeley DB out of the GPLv3 GUI):
- util/wallet_file_probe.h: extractWalletBtreeRecords() — sibling to parseWalletBtree
  that collects raw (key,value) bytes (same bounds-checked, subdb-aware walk).
  Records copied verbatim → encrypted key material passes through as opaque
  ciphertext (no passphrase). Overflow-page values (only large tx history) are
  skipped + counted; a rescan rebuilds history — funds unaffected.
- tools/wallet_rebuild/main.cpp: dragonx-wallet-rebuild CLI — reads via the tolerant
  reader, writes the records into a fresh BDB "main" btree via libdb (DB_EXCL, never
  overwrites), prints a JSON summary. New BDB-guarded CMake target.
- App::rebuildWalletDatabase(): picks the largest readable wallet/.bak as source,
  stops the daemon, runs the helper, VERIFIES the output (readable BDB with keys)
  before swapping, moves the current wallet aside (kept, timestamped), installs the
  rebuilt one, clears the stale BDB env, sets -rescan, restarts. Copy/rename only —
  never deletes. Result surfaced via the existing pumpWalletRestore channel.
- Wired as the preferred action on the existing wallet-auto-recovery dialog
  (shown only when the helper is present). Full-node only; lite-safe.

Verified end-to-end against the real broken wallet: helper reads 3,808 t-keys + 1
z-key + HD seed and the daemon LOADS the rebuilt output with no salvage. Adds
extractWalletBtreeRecords coverage. Build clean, suite green (1/1).

Remaining (follow-up): release packaging — build.sh bundling the helper built
against the vendored per-platform static libdb (DRAGONX_BDB_ROOT), and a macOS
Berkeley DB port (no in-tree artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 11:26:55 -05:00
b2e037bb67 chore(gitignore): never track wallet.dat (holds private keys)
A wallet.dat placed in the repo root for recovery was untracked but NOT ignored,
so a stray 'git add .' could commit private keys. *.bak already covered the
salvage backups; add wallet.dat / wallet-*.dat / wallet.dat.* explicitly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 10:20:32 -05:00
975650f11b fix(node): restore the LARGEST salvage backup, not the newest (salvage cascade)
The "Restore original wallet" action picked the newest wallet.<ts>.bak — but the
daemon auto-salvages on every failed BDB verify, and each round SHRINKS the wallet
(salvage keeps only readable records + drops the dead-page bloat). In a cascade the
newest .bak is the most-degraded (seen in the wild as "Salvage found no records")
while the original is the oldest and by far the largest.

Pick by file SIZE instead: add largestWalletSalvageBak((name,size) pairs) — the
largest wallet.<digits>.bak is the least-salvaged, i.e. the pristine original (an
emptied salvage is tiny; a real wallet is large); ties break to the newest ts.
Factor the shared parse into parseWalletSalvageBakTs(). restoreOriginalWallet()
now gathers file sizes and uses it (still verifies the pick is a valid BDB before
swapping). newestWalletSalvageBak kept for reference.

Adds a cascade regression test (a 40KB emptied newest .bak must NOT win over the
194MB original). Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:52:41 -05:00
384d64ea5d feat(node): one-click "Restore original wallet" after a daemon auto-recovery
Adds the restore action to the wallet-auto-recovery warning: undo the daemon's
salvage by swapping the untouched original (wallet.<ts>.bak) back over the
salvaged copy and clearing the stale BDB env that triggered the false recovery,
then restarting. Modeled on beginAdoptSeedWallet (stop daemon → file ops →
restart on a worker; result pumped to the main thread for notifications).

Safety (fund-adjacent file ops on a real wallet — copy/rename only, never delete
user data):
- picks the newest wallet.<unixtime>.bak via the pure, unit-tested
  newestWalletSalvageBak(); aborts if none.
- verifies the .bak is a real Berkeley DB (probeWalletFile) before touching
  anything — won't overwrite a working wallet with a bad backup.
- stops the daemon first (stopDaemonForWalletSwitch) so wallet.dat is released.
- moves the salvaged copy aside to wallet.dat.salvaged-<ts>.dat (kept), COPIES
  the .bak into place (the .bak stays), moves database/ aside to
  database.pre-restore-<ts>.bak (kept), and drops only the transient __db.*
  BDB region files. Rolls back the move if the copy fails.
- relaunches the node even on failure so it's never left down.

The warning dialog now offers Restore original wallet / Open data folder /
Keep salvaged copy. Full-node only; lite-safe. Build clean, suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:45:47 -05:00
3b7423f3a1 feat(node): warn when the daemon auto-recovers (salvages) wallet.dat
dragonxd auto-recovers a wallet.dat that fails BDB verification on startup — no
flag needed (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)):
it moves the original to wallet.<timestamp>.bak, salvages readable keys into a
fresh wallet.dat, and keeps running. The salvage can be incomplete (or the whole
thing a FALSE POSITIVE from stale/cross-platform BDB env state — __db.* / the
database/ dir carried between machines), so the node silently comes up on a
possibly-empty wallet. To the user that reads as fund loss, with no warning.

Detect it and warn loudly instead:
- daemon/daemon_startup_diagnosis.h: pure walletAutoRecovered() (the salvage /
  "Original wallet.dat saved as wallet.<ts>.bak" markers) + newestWalletSalvageBak()
  (picks the wallet.<unixtime>.bak the recovery just made).
- onConnected() scans the node's captured output once per session; on a match it
  shows a warning dialog + notification: the ORIGINAL is safe in wallet.<ts>.bak,
  the shown balance may be incomplete, and here are the exact steps to restore it
  (rename the .bak back + delete the stale database/ + __db.* env). One-click
  "Open data folder" jumps straight there. Full-node only; lite-safe.

Deliberately does NOT auto-swap the wallet files (untested per-platform file
manipulation on a real wallet is not worth the risk) — it informs + guides.

Adds walletAutoRecovered / newestWalletSalvageBak coverage to
testBlockDbOutputDiagnosis. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 00:30:11 -05:00
d136916e80 feat(node): detect an unreadable block DB on startup and offer a one-click reindex
When a daemon update changes the block-index on-disk format (or the index is
corrupt), dragonxd aborts at startup — "non-canonical optional discriminant" →
"Error loading block database. Aborted." — and the wallet silently shows a zero
balance. Previously the connect loop just crash-restarted into the same abort up
to 3x and then reported a bare "Daemon crashed N times", with no path forward.

Now:
- daemon/daemon_startup_diagnosis.h: pure blockDbOutputLooksBroken() classifies
  the crashed node's captured console output (the fatal block-DB markers).
- The connect loop detects it on the FIRST abort, STOPS crash-restarting into the
  same failure (each retry reloads the whole index — wasteful), and offers a fix.
- A one-shot -reindex flag (EmbeddedDaemon::setReindexOnNextStart → DaemonController
  forwarder → args) rebuilds the block index + chainstate from the intact raw
  blocks; App::reindexBlockDatabase() arms it and un-gates the loop to restart.
- An auto-shown dialog (renderBlockDbReindexDialog) + a notification explain the
  situation ("your coins are safe; the node just can't load the chain") and offer
  a one-click "Rebuild block database". Full-node only (gated), lite-safe.

This is the exact trap behind a real "big wallet shows no funds" report: a
post-format-change daemon over pre-change chaindata. Reindex also fixes a plain
corrupt index.

Adds testBlockDbOutputDiagnosis (the abort sequence + individual markers trip it;
normal startup / wallet-corruption / asmap errors do not). Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 23:49:46 -05:00
f88304fed2 fix(wallets): stop a legacy wallet showing as a seed-phrase wallet when linked
The wallets list badged the active/linked row from the runtime seed status
(activeWalletSeedBadge → wallet_seed_status_, reset only on disconnect) in
preference to the offline on-disk probe. Two issues let a genuinely legacy
wallet render as "seed phrase":

- The offline probe's budget-fallback branch dropped the fMnemonicSeed flag:
  res.mnemonic was set only in the (parsed && complete) branch. The probe shares
  a 768 MB budget across all wallet files, so a large wallet (e.g. a 194 MB one)
  probed after the budget is spent falls into the fallback, loses its seed/legacy
  classification (mnemonic → 0), and the row defers to the runtime badge.
- With mnemonic == 0, the code used the runtime badge, which can still carry a
  HasMnemonic from a previously-active mnemonic wallet — mislabelling the legacy
  wallet.

Fix:
- Carry the definitive positives (fMnemonicSeed/hdSeed/mkey) from a cap-truncated
  btree walk — a found marker is authoritative even when the scan didn't finish.
- Make the on-disk fMnemonicSeed read take precedence: it's the SAME flag the
  daemon's IsMnemonicSeed()/z_exportmnemonic consult, so a definitive read wins;
  the runtime badge is used only when the probe genuinely couldn't decide, and
  never overrides a definitive on-disk classification.

Verified the wallet in question is truly legacy (fMnemonicSeed=false on disk,
matching the daemon's CHDChain serialization + IsMnemonicSeed). The flag reader
(hdChainMnemonicFlag) is already unit-tested; suite green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 15:29:23 -05:00
5296dd7ae5 fix(parsers): harden RPC/price/updater parsing against valid-but-unhandled input
Audit of the full-node RPC response parsers and updater release-body parsers
(find -> adversarially-verify workflow) surfaced three worth fixing; three
others guard formats the project doesn't emit and are backstopped by signature
verification, so they're documented rather than churned.

- Price (Medium): parseCoinGeckoPriceResponse used .value(key, 0.0), which
  throws type_error on a PRESENT null. CoinGecko emits null for usd_24h_change/
  usd_24h_vol on illiquid tokens (DRGX is one) while still returning a valid
  spot price; the outer catch turned that into no price update at all. Read
  null-tolerantly so the valid usd/btc survives.

- Daemon updater (Medium): parseDaemonChecksums blanked '|'/backtick but not
  markdown emphasis, so a bolded **archive.zip** checksum row was dropped and a
  valid, correctly-signed release would be refused. Also blank '*'/'_' (cannot
  cause a wrong-asset match; the 64-hex + .zip-suffix tests are unchanged).

- Opid poll (Low, severe failure mode): parseOperationStatusPoll read id/status
  via .value() (throws on a present non-string) and the call site parsed OUTSIDE
  its try/catch, so a throw left opid_poll_in_progress_ stuck true and wedged all
  z-operation polling for the session. Type-check the reads and parse inside the
  guard. (dragonxd can't emit non-string id/status; this is defense-in-depth.)

Regression tests: CoinGecko null field; opid non-string id/status; **bold**
checksum row. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 13:38:50 -05:00
a1d3964e34 fix(lite): require exactly 24 words on first-run restore (crash on valid seed)
The lite first-run restore wizard enabled Restore for {12,15,18,21,24}-word
phrases, but the SDXL backend only accepts 24-word / 32-byte-entropy seeds:
LightWallet::new does copy_from_slice(&phrase.entropy()) into a [u8;32]
(lightwallet.rs:231), which panics on 16/20/24/28-byte entropy. Mnemonic::
from_phrase accepts the shorter valid phrase, and the restore FFI
litelib_initialize_new_from_phrase (lib.rs:127) has no catch_unwind (unlike
litelib_execute), so the panic unwinds across extern "C" -> process abort
(UB on the pinned rustc 1.63). A user restoring a legitimate 12-word seed
from another wallet crashed the app.

The Settings restore gate was already tightened to == 24 (6ff1fda) but the
first-run wizard gate (df14533) was never updated — same restore path, two
verdicts, crash only via the more-common first-run path.

Add shared util/seed_phrase.{h,cpp} as the single source of truth:
- normalizeSeedPhrase: fold NBSP/en/em/ideographic/narrow spaces to ASCII,
  strip zero-width marks, collapse+trim (word bytes untouched)
- seedPhraseWordCount
- isCompleteRecoveryPhrase(int) == 24  (the sole SDXL contract)

Both restore gates now count via the normalizer and gate via
isCompleteRecoveryPhrase, and both submit the normalized phrase. This closes
the crash, reconciles the two gates so they can't drift again, and — because
tiny-bip39 splits on literal ASCII space with no NFKD — makes an NBSP-pasted
24-word seed (common from PDFs/note apps) restore correctly instead of being
undercounted and rejected.

Adds testSeedPhraseHelpers. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 01:32:40 -05:00
5b6ba5094b docs(lite): clarify SDXL viewing-key HRP differs from the full node
Audit of the lite import path found no false-rejection defect (no client-side
gate; the two-command fallback in importKey makes the U/5/K/L prefix guess
non-binding; lite send reuses the now-P2SH-fixed send_tab helpers). But the
"zxview" viewing-key comment — which was WRONG in the full node (fixed earlier)
— is genuinely CORRECT here: SDXL's import takes an extended full viewing key
(zxviews…, hrp_sapling_viewing_key), whereas the full node's z_importviewingkey
takes an incoming viewing key (zivks…). The two are not interchangeable.

Add a note so nobody "harmonizes" the two gates and reintroduces the full-node
bug. Comment-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 00:59:29 -05:00
c3e81a5fa6 fix(send): accept P2SH/multisig recipients in the send + URI address gates
Same defect class as the import-key fix: a hardcoded prefix/length pre-filter
layered over the checksum validators rejected valid addresses before the daemon
saw them. The send-screen recipient gate required a[0]=='R', and the payment-URI
parser accepted only 'R'/'t' with rigid length bands — so every valid P2SH /
multisig address (DragonX SCRIPT_ADDRESS=85 → 'b…') was silently refused, leaving
the Send button disabled with no usable recipient.

Centralize recipient recognition in util/address_validation:
- isTransparentAddress: Base58Check with a 21-byte version+hash160 payload —
  covers P2PKH ('R…', v60) AND P2SH ('b…', v85) on every network, rejects WIF
  keys / typos by real checksum.
- isShieldedAddress: Bech32 + a Sapling payment-address HRP (zs / ztestsapling /
  zregtestsapling), distinguishing a payment address from a viewing key.
- isValidRecipientAddress: either of the above.

send_tab's two validity helpers (the single choke point for all 5 call sites) and
the payment-URI format check now route through these. The URI parser now
checksum-validates the recipient (fail-fast on transcription errors) rather than
being prefix/length-only.

Tests use real checksummed vectors (P2PKH/P2SH/shielded, WIF- and typo-rejection);
testPaymentUri updated off its old fake fixed-char addresses. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-09 00:12:17 -05:00
d603a54618 fix(import): recognize real DragonX key formats in the import gate
The client-side pre-check rejected legitimate keys before the daemon ever
saw them, surfacing "Unrecognized key format" / a cryptic daemon "Invalid"
error. Two concrete defects plus the brittle heuristic behind them:

- Viewing keys: isViewingKey looked for Zcash's "zxview" extended-FVK
  prefix, but DragonX's z_exportviewingkey emits a Sapling *incoming*
  viewing key (HRP "zivks"), which z_importviewingkey is the only form the
  daemon decodes. Every real DragonX viewing key was refused. (F1)
- Uncompressed transparent WIF: the length+first-char heuristic accepted
  {5,K,L,U} only, but a version-188 uncompressed key starts with '7'. (F2)

Replace the heuristic with structural validation using the existing
checksum validators (F3): add util::decodeBase58Check (checksum-stripped
payload) and util::bech32Hrp (HRP of a valid Bech32 string). Transparent
keys are now accepted by decoding Base58Check and checking the payload is a
33/34-byte secret key with a DragonX SECRET_KEY version byte (188 main/
regtest, 128 testnet) — covering compressed and uncompressed, rejecting
addresses/typos by real checksum. Viewing keys are matched by the real
incoming-VK HRPs (zivks / zivktestsapling / zivkregtestsapling).

The Sweep gate and the dialog's live type indicator run off the same
predicates, so they are fixed too (F4). Messaging now names the likely
cause and appends a wrong-coin/network hint to the daemon's raw "Invalid"
error (F5).

Adds testPrivateKeyImportRecognition plus decodeBase58Check/bech32Hrp
coverage; suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-08 22:22:29 -05:00
c61c211dfe fix(ui): add vertical padding above and below the recent-alerts popup content
The alert-history popup content sat flush against the popup's top and bottom edges. Add a
padY spacer above the header and below the content (on both the empty and populated paths).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 23:12:20 -05:00
16244d84a0 chore(release): bump version to 2.0.1
Full-node ObsidianDragon 2.0.0 -> 2.0.1 (single source of truth: the project() VERSION in
CMakeLists.txt). Verified the generated header renders "2.0.1 (ObsidianDragon)". The Lite
variant is versioned independently (DRAGONX_LITE_VERSION, unchanged at 1.0.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 22:53:23 -05:00
32be868dbc feat(migrate): persist the sweep opid so a mid-sweep interruption can resume (W3-3)
Migrate-to-seed submits z_mergetoaddress -> an async opid, then only persists the resolved
txid once the op completes. An app-close during Sweeping (opid submitted, txid not yet
resolved) dropped the opid and resumed at the re-sweep gate, silently losing the tx.

Now the opid is persisted and re-tracked on resume. If the daemon forgot it (restart —
its op queue is in-memory only), the existing poller flags it stale and the callback falls
back to the dismissable Sweep gate; it can never hang (a thrown RPC aborts the poll, so a
stale classification only comes from a *successful* poll that omits the opid).

- New seed_migration_sweep_opid setting; adopted atomically with clearing any prior txid in
  the SAME settings.save(), and only once the submit succeeds — so a failed "Sweep remaining"
  re-sweep keeps the already-mined first sweep's Confirming context, and txid/opid are never
  both authoritative (resume checks txid first; torn-write safe).
- Resume routing extracted to a pure, unit-tested helper
  (data/seed_migration_resume.h::decideSeedMigrationResume): txid -> Confirming; opid AND
  connected -> re-track (Sweeping); else -> the dismissable Sweep gate. The connectivity gate
  keeps a disconnected resume out of the buttonless Sweeping spinner.
- Shared makeSweepCompletionCallback(resumed): success -> Confirming; resumed-stale -> Sweep
  gate (re-fetch balance + "may have already completed" copy); fresh-fail -> Error.

Fund safety unchanged: adopt still gated on legacy balance ~0 AND sweep tx mined; legacy
wallet.dat only ever moved to a never-deleted timestamped .bak.

Reviewed in two adversarial rounds (design + implementation) per the migration-code mandate;
both safety facts (no fund loss, no hang) held, and the resume-UX traps they surfaced are
fixed. Build-clean; ctest 1/1 (adds testSeedMigrationResume). See docs/wallet-hardening.md.

*** Still requires a live mainnet interrupted-sweep run before release (human gate). ***

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 22:11:37 -05:00
8bb3198562 fix(diagnostics): address adversarial review of the QoL UI (popup, staleness, DPI, i18n)
Follow-up to the node-banner / staleness-badge / alert-history features — a 5-dimension
finder->verify review surfaced 4 real issues (the ImGui-stack-balance finder found none):

- Alert popup grew off the right edge: pivot (0,1) pinned the panel's LEFT edge at the
  bell, which sits near the window's right edge, so a 320px panel overflowed rightward
  (an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp). Anchor the
  bottom-RIGHT corner at the bell instead (pivot (1,1) at bellMax.x) so it grows left.

- Staleness badge could flash red on reconnect: WalletState::clear() reset everything
  except the four last_*_update stamps, so the pre-outage timestamp survived and the
  badge briefly showed "Updated Nm ago" the same frame the node banner cleared. Zero the
  stamps in clear() (all readers treat 0 as "never"; app_network.cpp:1473 guards != 0).

- Banner min-height floor wasn't DPI-scaled: std::max(minH, baseH*vScale()) now uses
  minH * dpiScale() so both operands are in scaled px.

- New i18n keys weren't in res/lang/: back-filled all 16 diagnostics/QoL keys into the 8
  language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2
  glyphs missing from the CJK subset and hard-asserted tofu-free against the subset font.

Build-clean both variants; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:14:38 -05:00
4b3f0fa92b feat(diagnostics): persistent alert history with a status-bar bell (Foundation QoL)
Toasts fade in 1-4s, so anything that scrolled past was gone. Notifications now retains
every pushed alert in a capped (100) ring buffer with a wall-clock epoch (AlertRecord) —
separate from the 5-item live-toast deque — plus a monotonic total_pushed_ counter.

A bell in the status-bar right cluster opens an upward popup listing recent alerts
newest-first: severity icon + colour (reusing the toast palette), the message, and a
relative age (formatTimeAgoShort), with a Clear-all action. An unread dot on the bell,
coloured by the most-severe unseen alert, marks alerts that arrived since the panel was
last opened — driven by totalPushed() deltas so it survives capping/clearing.

Thread note: every push is on the UI thread (RPC results run as main-thread MainCb
callbacks), matching this class's existing lock-free model; documented as a
no-raw-worker-thread invariant.

New i18n keys (alerts_*). Build-clean; ctest 1/1 (adds testNotificationHistory: retention,
order, cap, monotonic counter, clear). Closes the QoL bundle and the Foundation tier.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:57:02 -05:00
c7c3440a7b feat(diagnostics): refresh-staleness badge on the Total Balance card (W6-2)
When the wallet is connected but the balance has quietly stopped refreshing — a busy
daemon can fail z_gettotalbalance without dropping the whole connection (only *both*
core RPCs failing 3x triggers a disconnect) — the old number sits on screen while the
node-status banner stays hidden. The Total Balance card now shows a small pill on its
status line ("Updated 2m ago", amber, escalating to red past 3 min) so the stale value
isn't silently trusted; hovering explains it and points at the node connection.

No refresh-path changes: WalletState::last_balance_update is already stamped only on a
successful fetch (network_refresh_service.cpp), so the badge reads it and computes age
against the same std::time clock via util::formatTimeAgoShort. The decision is a pure,
unit-tested helper (ui/staleness_badge.h::evaluateStalenessBadge, 45s/180s thresholds)
gated on connected so it never contradicts the banner.

Closes P2 (5/5). Build-clean; ctest 1/1 (adds testStalenessBadge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:48:56 -05:00
e779ded2e8 feat(diagnostics): persistent node/RPC error banner at top of content (Foundation QoL)
A persistent horizontal strip now appears at the top of the content column whenever the
wallet can't reach its node — unlike the transient toasts it stays up for as long as the
fault persists, so an offline wallet is never silently mistaken for a working one.

The show/severity/action decision is a pure, unit-tested function
(ui/node_status_banner.h::evaluateNodeStatusBanner) fed a state snapshot by the new
App::renderNodeStatusBanner(). Three cases:
  - full-node offline        -> amber, "Reconnect"    (App::tryConnect)
  - embedded daemon crashed
    & auto-restart gave up    -> red,   "Restart node" (App::restartDaemon)
  - lite wallet open failed   -> red,   message-only

Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown,
and while an expected startup phase (warmup / init / connect-in-progress) already owns the
screen. Banner height lives in res/themes/ui.toml (banners.node-status); colours come from the
material semantic palette; the detail text is ellipsis-clipped so it can't push the action
button off-screen. Drawn before the content edge-fade vertex capture so it stays fully opaque.

New i18n keys (node_banner_*). Build-clean both variants; ctest 1/1 (adds testNodeStatusBanner).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:21:05 -05:00
940dd21464 feat(diagnostics): add "Copy diagnostics" + "Open log folder" actions (Foundation QoL)
Settings (logging section) gains two support-friendly actions, now that the logging
foundation actually produces logs (W7-2):

- Open log folder: opens the config dir (Platform::openFolder) so users can find
  dragonx-debug.log / dragonx-crash.log.

- Copy diagnostics: copies a plaintext support snapshot to the clipboard via the new
  App::buildDiagnosticsReport() — version, build variant, platform, connection status,
  active wallet path + existence + size, encryption/lock state, sync heights, and (full-
  node) daemon status/running/crash-count/lastError, plus the log paths. No secrets.

Build-clean; ctest 1/1. Remaining QoL: persistent alert history, a daemon/RPC error
banner, and the W6-2 refresh-staleness badge. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:37:52 -05:00
207f9074db feat(diagnostics): make the logging + crash infrastructure actually work (W7-2, W7-3, W7-4)
The Foundation tier — answers the original "make it easier to diagnose problems" ask.

- W7-2 (keystone): the app-level Logger file sink was never initialized, so LOG/LOGF/
  VERBOSE_LOGF went nowhere and dragonx-debug.log didn't exist on Linux/macOS at all.
  main() now calls Logger::init(<config>/dragonx-debug.log) on every platform. Fixed a
  latent deadlock this exposed: init() wrote its banner via write(), which re-locks the
  non-recursive mutex_ it already holds — now written directly. On Windows the raw
  stdout/stderr freopen moved to a separate dragonx-stdout.log so the two writers don't
  contend on one file. Added testLoggerFileSink (also a deadlock guard — it would hang if
  the fix regressed).

- W7-3: no crash handler existed on Linux/macOS. Added an async-signal-safe sigaction
  handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes the signal id + a backtrace_symbols_fd
  backtrace to dragonx-crash.log, then re-raises the default disposition for a core dump —
  the POSIX counterpart of the Windows SEH filter.

- W7-4: Logger::init now rotates the log to a single .1 backup past 10 MB, so a long or
  verbose session can't grow it unbounded.

Build-clean; ctest 1/1. Remaining Foundation: the QoL bundle (mostly UI). See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:31:23 -05:00
05b00b158b fix(wallet): surface silent save failures + stale-state cleanups (W5-1, W5-2, W6-1, W6-3)
P2 robustness batch (localized):

- W5-1 (Med, lite): persistAfterBroadcast returned false on a persistent post-send/shield
  save failure, but both callers discarded it and it never logged — completely silent. It
  now liteLogs the failure (the spent note re-derives on the next sync, so it's a
  robustness gap, not fund loss).

- W5-2 (Med, lite): the post-sync and post-rescan save results (in the detached scan
  threads) were ignored; both now liteLog on failure. LiteDiagnostics::log is mutex-guarded,
  so it's safe from those threads.

- W6-1 (Med): WalletState::clear() didn't reset mining/pool_mining, so a wallet switch could
  briefly show the previous wallet's hashrate/blocks. Now reset in clear() (the daemon
  restarts on switch, so mining genuinely stops).

- W6-3 (Low): AddressBook::load() cleared entries_ then threw on the first non-object array
  element — discarding EVERY contact. It now guards is_object() + per-entry try/catch,
  skipping and counting malformed entries.

Build-clean; ctest 1/1. Remaining P2: W6-2 (refresh-staleness badge — needs UI, overlaps
the diagnostics Foundation bundle). See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:22:13 -05:00
f9ddab059e fix(wallet): stamp syncedHere only after identity verified + guard the startup wallet file (W1-3)
- W1-3 (Med): updateWalletIndexForActiveWallet stamped syncedHere in the markOpened block
  at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed
  rescan. syncedHere is now stamped only once the wallet's identity is verified (idHash
  non-empty), so it takes effect at the post-address-refresh index update; lastOpenedEpoch
  still records at open.

- Startup guard (the W1-1 launch counterpart): App::init now exists()-checks the recorded
  active wallet before the daemon is configured. A non-default active wallet moved/deleted
  between sessions falls back to the default wallet.dat with a warning, instead of the
  daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN
  vault init so the vault is scoped to the wallet actually opened.

Completes P1-B. Remaining P1: W3-3 (sweep opid persistence) deferred for careful
adversarially-reviewed work — re-tracking a stale opid could hang the migration if the op
poller doesn't time out; the existing balance/mined gates already prevent fund loss. See
docs/wallet-hardening.md.

Build-clean; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:18:09 -05:00
de1ae736de fix(wallet): guard against opening a missing/wrong wallet file (W1-1, W1-2, W1-4)
- W1-1 (High): switchToWallet never verified the target wallet file exists before
  switching. dragonxd auto-creates a fresh empty wallet for a missing -wallet=<name>, so a
  moved/deleted wallet file silently "opened" as a brand-new empty wallet with a zero
  balance — looking exactly like fund loss. It now std::filesystem::exists-checks
  datadir/<walletFile> before switching (ahead of the daemon-stop prompt) and blocks with a
  "not found (moved or deleted?)" warning. Because the check runs regardless of how
  switchToWallet is invoked, it also closes W1-4 (the stale switcher-row TOCTOU).

- W1-2 (Med): walletOutputLooksCorrupt matched the generic "Error loading wallet" string,
  which dragonxd also prints for DB_TOO_NEW (a newer-version wallet) — so a version mismatch
  was offered a -salvagewallet repair that cannot fix it. The generic match is now excluded
  when the output also contains "newer version".

Build-clean; ctest 1/1. Remaining P1-B: W1-3 (syncedHere timing) + the startup-path
existence check. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:56:59 -05:00
03c1b63e03 fix(migrate): correct fund-adjacent migrate-to-seed bugs (W3-1, W3-2, W3-4)
Migrate-to-seed (legacy -> mnemonic wallet) moves real funds; three correctness fixes:

- W3-1 (High): beginAdoptSeedWallet swapped a hardcoded datadir/wallet.dat instead of the
  ACTIVE wallet file. With a non-default active wallet (e.g. wallet-2.dat) it installed the
  swept seed wallet into an unloaded wallet.dat and left the daemon reloading the emptied
  legacy wallet — swept funds only recoverable via the seed phrase. Now swaps
  datadir + "/" + getActiveWalletFile(), captured on the main thread (switching is blocked
  during migration, so no race).

- W3-2 (High): SeedWalletCreator::create() ran remove_all(<config>/seed-migrate)
  unconditionally at the start, so a prior migration that swept funds into the temp wallet
  but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed.
  It now refuses (with a clear message) when DRAGONX/wallet.dat already exists — a completed
  migration removes the dir on adopt, so a leftover means an unfinished one.

- W3-4 (Med): switchToWallet blocked switching only while the migration dialog was open;
  closing it via "Later" mid-migration dropped the guard. Now also blocks while
  getSeedMigrationPending().

Build-clean; ctest 1/1. Remaining P1-A: W3-3 (persist the sweep opid). See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:53:58 -05:00
8c12b27c0a fix(security): don't silently leave a wallet unencrypted or unlocked (W2-2, W2-4)
P0-B encryption-integrity cluster.

W2-2: the first-run wizard's "encrypt" stored the passphrase only in memory and let the
user into the app immediately, so a quit/crash or a failed daemon connect before the
deferred encryption applied left the wallet unencrypted with NO record encryption was
ever requested — the user believing it was encrypted. A persisted encryption_pending
settings flag is now set the moment encryption is requested (never the passphrase, only
the fact). refreshWalletEncryptionState() reconciles it on every connect: wallet observed
encrypted -> clear the flag; wallet NOT encrypted while the flag is set and no deferred
encryption is pending/in-flight -> a once-per-session "your wallet is NOT encrypted — open
Settings to finish" warning (the flag stays set, so it recurs each launch until resolved).
The passphrase is deliberately never persisted to auto-complete — surfacing it is the
secure choice.

W2-4: lockWallet()'s continuation only handled success — a failed walletlock RPC silently
left the wallet UNLOCKED (an unfulfilled auto-lock). It now logs and warns once (reset on
the next successful lock) so a failing auto-lock is visible instead of leaving the wallet
exposed.

Touches settings.{h,cpp}, app_wizard.cpp, app_security.cpp, app.h. Not unit-testable at
this layer (RPC/connect-driven state). Build-clean; ctest 1/1. See docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:48:02 -05:00
c7d163f44a feat(lite): wire the create-time passphrase into the lite encrypt/unlock flow (W5-3)
The lite create/open/restore requests carried a passphrase field that the UI collected
(a labeled, masked "passphrase" input) but the backend initialize* calls never used —
so a user could believe their lite wallet was passphrase-protected at creation when it
did nothing. It now has a real meaning, wired in LiteWalletController:

- create / restore -> encryptWallet(passphrase): the backend encrypts + locks + saves
  the brand-new wallet.
- open -> unlockWallet(passphrase), but only when encryptionStatus() reports the existing
  wallet is actually encrypted + locked (no spurious unlock on an unencrypted wallet).

encryptWallet/unlockWallet take their own copy of the passphrase and wipe it; the
request copy is still wiped as before. A post-create encrypt failure is liteLog'd (the
wallet still exists, so the create is not failed).

Six existing lite-controller tests carried an incidental "hunter2" create passphrase from
when the field was dead; removed (they exercise non-encryption flows and want an
unencrypted wallet), and added testLiteWalletControllerCreateEncryptsWithPassphrase to
prove the new behavior. Completes the wallet-hardening P0-A cluster (7/7). ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:39:02 -05:00
7e4822c021 fix(security): warn that the seed-backup file is unencrypted plaintext (W4-5)
The seed-phrase "Save" already wrote the file 0600 and zeroed the in-memory buffer, but
the success message was a bare "Saved to <path>" — no hint that it's a permanent
UNENCRYPTED copy of the seed at a predictable location. The message now reads
"Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this
copy: <path>". English source updated; the res/lang back-fill of this changed key is
deferred to the batch i18n pass.

Also documents W5-3 (lite create-time passphrase) as a product decision rather than a
speculative change: the field is already wiped on every path (minimal security risk),
but the labeled masked "passphrase" input at lite create/open/restore is never consumed
by the backend — so either remove the dead UI or wire it into the lite encrypt flow.

Finishes the actionable part of the wallet-hardening P0-A cluster (docs/wallet-hardening.md).
Build-clean; ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:25:15 -05:00
f9b622cb25 fix(security): scrub in-memory key/passphrase copies in the wallet secret paths (W4-1, W4-3, W2-3)
The wallet-hardening memzero cluster. Uses the file's established sodium_memzero
pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and the JSON
scrub at :4025) rather than a new type, since importPrivateKey/sweepPrivateKey are
fund-moving code.

- W4-1 importPrivateKey / sweepPrivateKey: the spending/viewing key was copied ≥3×
  (calling frame -> worker-lambda capture -> JSON params) and never scrubbed. Now zeroed
  on all paths: the calling-frame copy after the worker post, the lambda's captured copy
  (lambda made mutable, zeroed once the request is sent), and the request params copy.

- W4-3 exportAllKeys / backupWallet: the concatenated all-keys buffer is now zeroed after
  the consumer uses it, and the backup is written via
  Platform::writeFileAtomically(..., restrictPermissions=true) — atomic and owner-only
  (0600) — instead of a umask-default std::ofstream that left it world-readable.

- W2-3 decrypt-wallet passphrase: std::move-captured into the worker lambda (no plaintext
  copy left in the calling frame) and sodium_memzero'd right after unlockWallet, its only
  use.

Not unit-testable (no observable RPC effect — the key value sent to the daemon is
unchanged; only post-use memory zeroing is added). Build-clean; ctest 1/1. See
docs/wallet-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:12:44 -05:00
9204fa148a fix(security): delete the plaintext key export after decrypt-wallet import (W2-1)
Removing wallet encryption z_exportwallet'd every private key in cleartext to
<datadir>/obsidiandecryptexport<ts>, re-imported it, and never deleted it — leaving a
full plaintext dump of every key on disk permanently. The decrypt flow now scrubs
(best-effort in-place zero-overwrite) and removes that file as soon as the
z_importwallet attempt resolves, on both the success and failure paths. Recovery, if
ever needed, remains the encrypted backup (wallet.dat.encrypted.bak), never this file.

Second fix in the wallet-hardening P0-A cluster (docs/wallet-hardening.md). Not
unit-testable (fs I/O in a deep worker lambda); build-clean, ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 14:02:45 -05:00
da0e9f5915 fix(console): redact secret-bearing commands from the console echo and history (W7-1)
The RPC console echoed and stored typed commands verbatim, so `walletpassphrase
<secret>`, `z_importkey <key>`, `encryptwallet <pass>` etc. left the secret in the
visible log AND the 100-entry recall history (copyable). Adds a pure, unit-testable
RedactConsoleCommand()/ConsoleCommandCarriesSecret() (allowlist of 13 secret-bearing
first-tokens) in console_tab_helpers; submitConsoleCommand() now echoes and stores
`> walletpassphrase ****` while still executing the real command unredacted. Bare
secret commands and non-secret commands pass through unchanged.

Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) — whose secret is
in the RESULT — are a separate redaction concern, tracked as a follow-up.

First fix in the wallet-hardening P0-A cluster (see docs/wallet-hardening.md). New
testConsoleSecretRedaction (11 assertions); ctest 1/1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 13:57:52 -05:00
d188a08db7 test(daemon): add F1/F2 process-lifecycle integration tests; fix clobbered start error
Links the real EmbeddedDaemon into the ObsidianDragonTests target (its deps were
already present) and adds two POSIX integration tests that exercise the actual
fork/exec/waitpid fixes headlessly:

- testExecFailureReported (F2): start() against a non-executable file must fail with a
  precise "not executable or wrong architecture" reason.
- testDaemonCrashDetected (F1): a short-lived child that exits abnormally is still
  detected (crash_count_ increments) while isRunning() is hammered from the test
  thread — a regression test for the reap race.

Writing the F2 test surfaced a real bug: start()'s failure branch called
setState(State::Error, "Failed to start dragonxd process"), and setState stores the
Error message into last_error_ — clobbering the precise message startProcess() had
just set, so getLastError()/the UI only ever saw the generic string. Fixed to pass the
preserved detail to setState, so the precise reason survives and now also reaches the
state callback (crash panel / status).

ctest 1/1, green including the two new integration tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 13:11:15 -05:00
ff5f5ddf23 docs: add CHANGELOG with daemon-startup hardening release notes
Introduces CHANGELOG.md (Keep a Changelog style, Unreleased section) covering this
batch, with F8's breaking change — remote plaintext RPC now refused by default —
called out front and center along with the rpctls=1 / rpcallowplaintext=1 recovery
steps. Also records the Security / Fixed / Added entries for F1-F7. Updates the
tracking doc's status to reflect the completed release-notes + i18n back-fill and the
remaining pre-release items (F1/F2 manual repros, CJK subset-font rebuild).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 12:06:02 -05:00
56f9802fb9 i18n: back-fill daemon-startup hardening strings
Additively adds translations for the 6 new keys from this batch
(sb_daemon_extract_failed, sb_daemon_files_failed, loading_stall_{title,body,hint},
sb_plaintext_remote_blocked) across res/lang/*.json. es/de/fr/pt/ru get all 6; for
zh/ja/ko a string is only added when every glyph is already in the current
NotoSansCJK-Subset.ttf, since the subset can't be rebuilt here — 6 zh/ja/ko entries
whose glyphs aren't yet subsetted are left on the English fallback rather than render
as tofu. Written sorted, indent=4, ensure_ascii=False (matching add_missing_translations.py);
purely additive (42 insertions, 0 removals). The remaining 6 need a
scripts/build_cjk_subset.py font rebuild.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 12:05:53 -05:00
efb271cb9a fix(rpc): refuse plaintext-remote RPC by default and tighten isLocalHost
F8 (security). Two related fixes to how the wallet decides whether an RPC target is
safe to send Basic-auth credentials to:

- isLocalHost() was matching any host that merely *starts* "127." via
  rfind("127.",0)==0, so "127.evil.com" (and "127.0.0.1.attacker", "127.300.0.1",
  "1270.0.0.1") were misclassified as loopback and treated as local. It now uses a
  strict isExactIPv4Loopback() parser: exactly four 0-255 dot-separated octets with
  the first == 127. localhost / ::1 / [::1] handling is unchanged.

- A remote rpchost over plain HTTP (no rpctls=1) previously only produced a
  dismissible warning and then sent rpcuser:rpcpassword in cleartext, where a
  local-network MITM could capture them. tryConnect() now REFUSES that connection
  (clear status line + one-time notification, no creds sent) unless the user opts in
  explicitly with rpcallowplaintext=1 in DRAGONX.conf (new
  ConnectionConfig::allow_plaintext_remote, parsed in parseConfFile; policy in the
  new allowsPlaintextRemote()). Local/embedded daemons and rpctls=1 remotes are
  unaffected.

BREAKING: a wallet configured for remote plaintext RPC will stop connecting until
rpcallowplaintext=1 (or rpctls=1) is added to DRAGONX.conf. Must be called out in the
release notes. The Settings-toggle UI is deferred (the conf-key opt-in is the recovery
path; see docs/daemon-startup-hardening.md).

Adds testIsLocalHost and testAllowsPlaintextRemote to test_phase4.cpp; one i18n key
(English) added to i18n.cpp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:40:35 -05:00
eb69e491b9 fix(startup): surface a "taking too long" notice when the daemon won't come up
F3: the daemon connect loop retried forever with only an animated spinner when the
daemon was reachable-but-never-ready (stuck in RPC warmup / -28, or an external daemon
that never finishes init) -- no error, no guidance, no escape. It now stamps
connect_stall_since_ the moment the daemon first goes "reachable but not ready" (the
warmup branch + applyDaemonInitStatus) and clears it on connect / disconnect /
warmup-complete. A pure, unit-testable util::connectHasStalled() helper (new
util/connect_stall.h, 45s default from ui.toml [screens.loading].stall-timeout-sec)
drives a "Taking longer than expected" notice in renderLoadingOverlay(): a title, a
reassuring body with elapsed seconds, and a full-node hint to Settings > Restart Daemon
or the Console. The background retry keeps running underneath, so the notice self-clears
the instant it connects. Guarded off while the daemon is in State::Error (that case is
owned by the existing crash-count hint).

The overlay is a pure draw-list layer with no interactive widgets, so this follows the
existing crash-hint idiom (guidance text, not injected buttons); the stalled state is
computed locally in the overlay, so the only new App member is connect_stall_since_.

Adds testConnectHasStalled to test_phase4.cpp and three i18n keys to i18n.cpp (English
source of truth; the res/lang/*.json back-fill is deferred to a single
add_missing_translations.py run at the end of the batch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:33:26 -05:00
2675b8ab93 fix(startup): surface filesystem failures and verify Sapling param integrity
Three verified daemon-startup edge-case fixes centered on the config/params
filesystem path:

- F7: new non-throwing Platform::ensureDirectory(dir, outError) with one
  consistent "Cannot create <dir>: <reason>. Check permissions / free space."
  message. Replaces the unchecked/throwing create_directories sites at main.cpp
  (pre-init: log + Windows MessageBox + return 1), connection.cpp's
  autoDetectConfig (was the *throwing* overload -- could raise an uncaught
  filesystem_error through its callers; now sets the new
  ConnectionConfig::dir_error), and both app.cpp daemon-dir sites (surface via
  daemon_status_ + return false). The primary connect path (app_network.cpp)
  checks dir_error and shows it instead of mislabelling it "waiting for config".
  embedded_resources.cpp already checked its error_code, so it is left as-is.

- F6: verifySaplingParams() now hash-verifies each param against its pinned
  canonical SHA-256 (source of truth: scripts/build-lite-backend-artifact.sh)
  instead of only checking existence, so a truncated / corrupt-but-present param
  is rejected up front rather than failing later on a shielded operation. A
  <params_dir>/.sapling_verified marker keyed on size:mtime avoids re-hashing
  ~48MB on every startup. Logic extracted to the injectable, unit-testable
  verifySaplingParamsIn(dir, digests); reuses util::sha256Hex (no new hash impl).

- F5: startEmbeddedDaemon() now checks extractEmbeddedResources()'s return and
  the previously-dropped copy_file error_code in the daemon-binary fallback loop,
  aborting with a clear status (sb_daemon_extract_failed / sb_daemon_files_failed)
  instead of failing opaquely at spawn. An absent source file stays non-fatal.

Adds testPlatformEnsureDirectory and testVerifySaplingParams to test_phase4.cpp.
i18n keys added to i18n.cpp (English source of truth); the res/lang/*.json
back-fill via add_missing_translations.py is deferred to a single run at the end
of the batch. Progress tracked in docs/daemon-startup-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 11:07:32 -05:00
b3444e0a89 fix(daemon): harden startup process lifecycle (crash race, exec failure, datadir lock)
Three verified daemon-startup edge-case fixes in the embedded-daemon process
lifecycle (all in embedded_daemon.{cpp,h}):

- F1: EmbeddedDaemon::isRunning() (POSIX) now reads the atomic state_ instead of
  calling waitpid(WNOHANG) from the UI thread, which raced monitorProcess()'s own
  reap. waitpid is one-shot: whichever thread won consumed the exit status; if
  isRunning() won, the monitor never saw the crash, so crash_count_/State::Error
  and the 3-strike restart cap were silently lost. monitorProcess() is now the sole
  reaper (predicate Running || Stopping keeps stop()'s wait loops correct). Mirrors
  the existing XmrigManager::isRunning() fix.

- F2: startProcess() (POSIX) adds a close-on-exec self-pipe exec handshake. On a
  non-executable / wrong-arch / corrupt binary, execv fails in the child and the
  parent now learns synchronously (reads errno vs EOF), reaps the zombie, sets a
  precise last_error_ ("not executable or wrong architecture"), and returns false
  -- instead of reporting State::Running for a daemon that never started. Uses
  pipe()+FD_CLOEXEC (not pipe2) so the branch stays shared with macOS. Parent-side
  setpgid is now best-effort + logged.

- F4: start() gates on a lingering datadir lock after the port check. A graceful
  shutdown releases the RPC port ~90s before the datadir .lock, so a rapid
  stop->start spawned a daemon that died on the lock and, three times in ~12s,
  tripped the 3-strike crash cap before the lock cleared. start() now polls
  isDaemonProcessRunning() with a bounded ~300ms wait and bails with a distinct
  non-crash Error (no crash_count_ bump) that the connect loop retries once the
  lock clears. Isolated migrate-to-seed starts (skip_port_check_ / -datadir
  override) are exempt.

Adds the testDatadirLockGate unit test (pure evaluateDatadirLockGate matrix) to
test_phase4.cpp. Plan and progress tracked in docs/daemon-startup-hardening.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 10:32:30 -05:00
45b652f514 feat(mining): live pool fee, saved/custom pool rows, and payout-address fix
Several related mining-tab pool improvements:

- Report the default pool fee correctly: pool.dragonx.is is 1%, not 0%.
  The registry constant was hardcoded to 0. It now also fetches the live
  poolFee from the pool's /api/stats alongside hashrate (no extra
  request), so the displayed fee self-corrects and falls back to the
  compile-time value only when the fetch hasn't landed.

- Show fractional fees: new FormatFeePercent trims trailing zeros so
  whole fees read "1%" and fractional ones keep their decimals ("1.5%").

- Surface saved + custom pools in the pool list card: the list is now
  the union of the official pools, the user's saved favorites, and the
  currently-mined pool (effectivePools), each a selectable, endpoint-
  deduped row. Previously the card only showed the hardcoded knownPools().

- Fix the xmrig "user" field: the "Payout Address" field now drives the
  pool login rewards are credited to (resolveMiningUserAddress), instead
  of being written only to "pass" while "user" was auto-derived from the
  wallet's own first z-address -- which silently ignored a configured
  payout address and could route rewards to the wrong address.

Unit tests cover parsePoolFee, FormatFeePercent, effectivePools, and
resolveMiningUserAddress; full app + ObsidianDragonTests build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 03:48:05 -05:00
fffee9f0b5 build(lite-backend): pin the SDXL backend to rustc 1.63 via rust-toolchain.toml
The pinned librustzcash / transitive crates (notably traitobject 0.1.0) rely on
pre-1.70 trait coherence and fail to compile on newer rustc (E0119), so the backend
must build with 1.63. Add a rust-toolchain.toml in the vendored backend so rustup
auto-selects 1.63 when cargo runs there — no more manual RUSTUP_TOOLCHAIN=1.63.0.
The pin is scoped to the backend tree (repo-root cargo keeps the default toolchain).
Also symlink the pin into the prepared build root so --silentdragonxlitelib-dir
builds honor it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:30:16 -05:00
00ffc959e5 fix(build): name macOS DMGs after the app + stop wiping other variants' artifacts
- The macOS DMG filename used a separate hardcoded DMG_BASENAME ("DragonX_Wallet"),
  so the export didn't match the .app/zip (ObsidianDragon). Derive it from
  APP_BASENAME: full-node -> ObsidianDragon-*.dmg, lite -> ObsidianDragonLite-*.dmg.
  The mounted volume + CFBundleName keep the "DragonX Wallet" display branding.
- build_release_mac did `rm -rf "$out"`, wiping all of release/mac on every build, so
  building one variant destroyed the other's artifacts. Scope the cleanup to the
  current variant's files (as the Linux/Windows release paths already do) so the
  full-node and lite releases coexist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 21:24:54 -05:00
b561406c23 fix(build): make macOS release builds work natively
The --mac-release path (full-node and --lite-backend) had never run on a real
Mac and broke on several Linux-only assumptions:

- build.sh version parser used GNU sed \+ (BSD sed reads it literally), so it
  aborted before compiling — switched to POSIX [[:space:]][[:space:]]*.
- build.sh libsodium universal check used GNU grep \| — switched to grep -E.
- libwebp's cpu.cmake applies -mno-sse2 to its reference DSP files; under a
  universal build (-arch arm64;x86_64) that lands on the x86_64 slice, where it
  disables _Float16 and breaks the SDK 26 <math.h>. Added an idempotent
  FetchContent PATCH_COMMAND (cmake/patch-libwebp-simd.cmake) to neutralize it.
- The SDXL lite backend static lib needs Security + CoreFoundation frameworks on
  macOS; added them to the imported dragonx_lite_backend target for APPLE.
- Added a DRAGONX_MAC_ARCHS override plus auto-detect: with --lite-backend the
  app is built for the arch(es) the backend .a actually provides (its pinned
  ring 0.16.11 is x86_64-only), instead of failing to link a universal app.
- build-lite-backend-artifact.sh uses bash 4+ (mapfile); added a re-exec guard
  for macOS's stock bash 3.2 and added bash to setup.sh's macOS core deps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 20:03:33 -05:00
5a0743a17b fix(setup): skip sudo apt when the Windows toolchain is already installed
setup.sh --win ran `sudo apt-get install` (and update-alternatives)
unconditionally, forcing the whole run under sudo even when mingw-w64 was
already present. Running setup as root makes the daemon cross-compile run as
root, leaving root-owned artifacts under external/dragonx that break `make
clean` on a later non-sudo build (stale objects relink -> mingw link failure
recurs). Gate the apt/update-alternatives block behind a presence check so
`./setup.sh --win` runs sudo-free (and the daemon build as the invoking user)
when the toolchain is already installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:59:05 -05:00
a7b0770ad0 feat(scripts): sign-daemon-release release command (package + sign prebuilt binaries)
Adds `sign-daemon-release.sh release <secret.key> <version>`: zips each staged
prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,
macos,win64}.zip, signs each (detached ed25519), and prints the SHA-256 checksum
table for the release body. Platforms with no staged daemon are skipped; warns
if the signing key doesn't match the pubkey pinned in daemon_updater.h. Keeps the
existing keygen/pubkey/sign subcommands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:59:05 -05:00
7d8323a622 Merge branch 'chore/rename-xmrig-hac-to-drg-xmrig' into dev
Point the miner build at DragonX/drg-xmrig and rename the prebuilt staging
dir xmrig-hac -> drg-xmrig across setup.sh, build.sh, the legacy Windows
script, .gitignore and README.
2026-07-23 14:56:28 -05:00
320944fd18 chore(setup): build miner from DragonX/drg-xmrig and rename staging dir
setup.sh now clones/builds the miner from git.dragonx.is/DragonX/drg-xmrig
(was dragonx/xmrig-hac), and the prebuilt staging dir is renamed
prebuilt-binaries/xmrig-hac -> prebuilt-binaries/drg-xmrig across build.sh, the
legacy Windows build script, .gitignore and README.

Also aligns setup.sh's section-8 directory list with section 7 (it previously
used a bare "xmrig" token, creating a stray empty prebuilt-binaries/xmrig/),
and updates the xmrig_manager.cpp header comment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 14:55:42 -05:00
6d5e0ac614 Merge contacts table-view footer fix into dev (follow-up to #1) 2026-07-22 23:46:55 -05:00
02554d523d fix(ui): align contacts table-view footer with cards/list views
In table view the table is inset into its glass panel by tVpad and its
outer_size is listH-2*tVpad, so the post-table cursor ended tVpad (~10px) above
where the cards/list views leave it, pulling the "N address saved" footer up.
Land the cursor at the glass-panel bottom (tpMin.y + listH) so the footer lines
up across all three view styles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 23:45:14 -05:00
21da9e75fc Merge pull request 'Security audit remediations + Overview/Mining UI polish' (#1) from fix/audit-remediations-dev into dev
Reviewed-on: #1
2026-07-22 17:19:29 -05:00
c53b7f771e fix(ui): overview/mining polish + default-pool cleanup
- balance: inset the address-row favorite (star) button by the card's inner
  padding so it mirrors the left margin instead of hugging the card edge.
- mining: remove pool.dragonx.cc from the built-in default pools (pool.dragonx.is
  is now the sole default); update the pool-registry test accordingly.
- mining: middle-truncate saved pool-URL and payout-address dropdown rows (new
  shared material::TruncateToWidth helper) so a full z-address no longer runs
  under the trailing delete (X) button.
- mining: fix the thread-grid cells overflowing the card at >100% display
  scaling — the reserved Mine-button width used a raw clamp that didn't scale;
  scale it by dp so cols is estimated correctly (no-op at 100%).

Full-node build + test suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:01:51 -05:00
bcee4bfe72 fix(security): apply audit remediations to dev (ported + dev-only)
Dev-branch audit (docs/_archive/security-audit-dev-2026-07-22.md) re-found the
master issues (absent on dev) plus new ones in dev-only code. Applied here;
full-node build + test suite green; the subtle fixes were adversarially re-verified.

Ported from the master remediation (adapted to dev's code):
- bootstrap: reject zip-slip / path-traversal archive members (isSafeArchivePath)
  before writing (S2-1). (dev already fail-closes on a missing checksum.)
- xmrig updater: fail closed when a signature is required but no key is pinned (F1-1).
- http_download.httpGetString: 16 MiB hard cap + MAXFILESIZE on the shared
  metadata/price fetch (F1-2 / caps the updater + exchange paths at one site).
- rpc_client: explicit SSL_VERIFYPEER/VERIFYHOST (F4-1) and a 256 MiB response
  cap in WriteCallback (F4-3).
- lite_connection_service: reject remote http:// lite servers, loopback only (L1-1).
  Loopback is matched by a strict dotted-decimal 127.0.0.0/8 check (not a
  startsWith("127.") prefix, which would wrongly accept 127.0.0.1.evil.com), with
  userinfo/fragment stripping.
- lite controller: propagate encrypt/decrypt save() failure instead of reporting
  success (F7-1).
- xmrig_manager: chmod(0600) the pool config before writing secrets (F5-2).
- app: clear the copied secret from the OS clipboard on shutdown (F3b-1).
- export_transactions: neutralize CSV/spreadsheet formula injection (F13-1).
- build pipeline: build-from-source lite backend + remove the self-attested
  CMake signature gate (F15-1); pinned+verified appimagetool (F15-3/4);
  verified Sapling params in setup.sh (F15-6); build.sh exits 0 on success.
  (F14-1 empty-quoted-arg and F8-2 NUL-termination were already fixed on dev.)

Dev-only findings:
- rpc_client.callRaw: scrub the raw buffer + parsed tree (templated scrubJsonSecrets
  for ordered_json) so console dumpprivkey/z_exportkey keys don't linger in freed
  heap (N1-1).
- seed_wallet_creator: wipe the exported mnemonic on the failure path so a discarded
  failed result never carries a live seed (W1-2).
- export_all_keys: write the plaintext key dump 0600 + atomically via
  writeFileAtomically (U1-2).
- chat_database: restrict chat_messages.sqlite and its WAL/SHM sidecars to owner-only (C3-1).

Not done (need a decision, documented in the report):
- Chat header metadata (cid/z/p) rides outside the AEAD (C1/C2) — binding it is a
  wire-protocol change requiring SilentDragonXLite interop review.
- Bootstrap lacks an offline-rooted signature (S2-2 residual) — needs signing infra.
- Console scrollback retains console-typed key-export output in plaintext (N1-1
  residual) — inherent to an echoing console; would need output redaction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 12:08:24 -05:00
e1870c3b23 i18n: translate the 37 new settings tooltips into all 8 languages
Add de/es/fr/pt/ru/zh/ja/ko translations for the tt_* tooltip keys added for
the Chat & Contacts, lite Node & Security, and Debug Options controls. Written
additively (indent=4, sort_keys, ensure_ascii=False) — +37 keys per file, no
other churn. Product/technical tokens (dragonxd, RPC, HD, CPU, 0-conf,
lite-seed-backup.txt, Shift+Enter, numeric units) kept verbatim. Rebuilt the
CJK subset font for the 30 new zh/ja/ko glyphs (all now covered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:02:19 -05:00
7cf0bb8bc7 feat(settings): add hover tooltips to the settings controls that lacked them
A per-card audit found 38 interactive controls with no tooltip. Add them, with
new tt_* i18n keys (English source; per-language JSONs fall back to English
until translated):
- Chat & Contacts (8): emoji/bubble style, accent, density, text size, poll
  rate, timestamps, enter-sends — the segmented/beginRow helpers added none.
- Node & Security (26): the whole lite wallet lifecycle / backup-keys /
  encryption panel, plus the full-node RPC-details toggle and daemon Refresh.
- Debug Options (4): the screenshot-sweep / full-UI-sweep / open-folder /
  seed-demo-chat buttons.

Theme, Wallet, Explorer, About were already fully covered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:31:15 -05:00
541a9e1fd5 feat(settings): split Chat & Contacts into Appearance | Messaging columns
Break up the tall, left-heavy Chat & Contacts card internally: Appearance on
the left, Messaging on the right when the card is wide (fills the width, ~halves
the height). Same Indent technique as the Node & Security card; the row helpers
retarget their leftX/rowW per column so controls right-align within each. The
narrow chat-settings modal stays single-column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:16:37 -05:00
07b8e0b2cb revert(settings): drop the page-level card masonry
Page-level two-column masonry made the cards narrow and fought their internal
layouts. Revert the whole-page band (full-width stacked cards again) and keep
the internal NODE & SECURITY two-column — the better lever is breaking up the
large cards internally, not columning the page. The Explorer row-start-X fix is
kept (correct at full width too).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:13:37 -05:00
de48414c65 feat(settings): hybrid masonry — full-width Theme + About, columns between
Per feedback, keep Theme & Language and About/Debug full width (they read
better wide), and put only Wallet / Node & Security (left) and Explorer /
Chat & Contacts (right) in the two-column band. The band captures its top
anchor below the Theme card and restores full width before About.

Also fix an Explorer bug the two-column band exposed: row 1 placed the Address
column with an absolute SameLine(pad + halfW + …) that ignores the right-column
Indent, so "Address URL" leaked into the left column. Anchor it to the row's
indent-inclusive start X instead (identical result at full width).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:01:04 -05:00
f0c681b0b5 fix(settings): lower page-masonry threshold so it engages under display scaling
The 1000-logical-px threshold never engaged at 125% OS scaling: the panel's
real-px width (~1190) was below 1000×dpiScale (~1250), so the page stayed
single-column even though the internal NODE & SECURITY two-column (760×dpiScale)
did engage. Drop to 780 logical px — still requires usable (~390 logical) columns
but engages at the widths real windows actually have under display scaling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 20:50:19 -05:00
fdf2502ca8 feat(settings): two-column page masonry for the section cards (A)
Render the section cards in two columns when the page is wide (>1000px):
left = Theme, Wallet, Node & Security; right = Explorer, Chat & Contacts,
About; Debug spans full width below. Driven by shadowing `availWidth` to the
column width for the whole card region and floating the right column with
Indent() (a one-shot cursor set can't hold a column across line-advances);
each card's own responsive gates collapse their internal columns at the
narrower width. Collapses to one column below the breakpoint.

Fix the half-width overflows a parallel audit surfaced (cards previously only
ever rendered full-width):
- WALLET Tools & Actions: merge-to-address was a 3rd button on a 2-button row
  (unconditional SameLine) — flip the separators so 2-per-row pairs cleanly.
- EXPLORER: wrap the Block Explorer button to its own row when the checkboxes
  + button won't fit (measured, locale-safe).
- NODE & SECURITY: wrap "Remove Encryption" below when the encrypted row's
  three buttons + status don't fit the column.
- ABOUT: 2x2 button grid instead of 1x4 so labels don't clip.
- THEME: reserve the real gaps + custom-skin "*" marker in the theme combo
  width so Refresh doesn't spill past the edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:15:21 -05:00
e393b0d847 fix(settings): wrap the SECURITY row in the narrow two-column card
The encrypt controls + auto-lock combo + PIN hint are one SameLine chain that
fits at full width but overflowed the half-width NODE & SECURITY column,
spilling the "Encrypt wallet first to enable PIN" hint over the Daemon
column's buttons. When the section is narrow, break the auto-lock/PIN group
onto its own row at the section's left edge instead of continuing the line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:45:56 -05:00
3b041f20c1 fix(settings): hold the right column with Indent, not a one-shot cursor set
The two-column NODE & SECURITY card collapsed both columns onto the left,
overlapping. A one-time SetCursorScreenPos() can't hold a column: ImGui
resets the cursor X to the window's left indent on every line-advance, so
the first widget in the right column (a Dummy spacer) bounced everything
after it back to the left margin.

Shift the whole right column with ImGui::Indent(colW+gap) instead, so each
line-start lands at the column X; set the first widget's X explicitly to
match, and Unindent before continuing the page. Left column already renders
at the shadowed half-width, so it stays in its lane.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:04:15 -05:00
77a0ad64b0 feat(settings): two-column NODE & SECURITY card (B) — fill the empty right side
The NODE & SECURITY card stacked Node / RPC / Security / Daemon-binary in one
full-width column, packing everything on the left. Split it into two columns when
the card is wide enough: Node + RPC + Security on the left, Daemon binary on the
right — which fills the previously-empty right half and roughly halves the card's
height. Falls back to a single column on narrow windows (and when there's no
daemon section). Implemented by shadowing contentW + sectionOrigin per column
(renderSecuritySection already takes an explicit width/origin), so every
sub-section lays out within its column; the card auto-sizes to the taller column.
Full-node only for now; the lite Node section stays single-column.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:15:32 -05:00
d0ca2fdf52 feat(settings): wrap crammed checkbox rows + cleanup (sweep)
Step 5. The Wallet privacy/daemon checkbox row and the Advanced-Effects checkbox
row shrank their text (SetWindowFontScale) to cram onto one line — they now wrap
to new rows at full size instead, so nothing clips on narrow windows (the lite
"Animate avatars" was being cut off). Also drop two now-unused locals left over
from the Node/Daemon rebuild. (The Explorer row, the RPC collapsible header, and
the secondary/compact effects layout are left for a later polish pass.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 16:03:36 -05:00
9c07b6d33d feat(settings): converge the lite Node section styling with full-node
Step 4. Bring the Lite settings section in line with the full-node one: its
subsection headers (Backup & keys, Security, Maintenance) switch from the Body2
type style to the accent Overline used by the full-node Node/Daemon/Security
subsections, and the standalone action buttons (Open data folder, Show seed,
Show private keys, Redownload blocks) become icon ActionButtons matching the
full-node button styling. The intricate secret-reveal / encryption flow and its
label-column input layout are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:56:23 -05:00
fedfe3d60c feat(settings): rebuild Node data-dir + Daemon binary on the new components
Step 3. Node "Data Dir" is now a clean key/value block: the directory on its own
row as a click-to-open accent link with a copy button, the wallet size below, and
the folder buttons on their own row (icon ActionButtons) — no more path
font-scaling or right-align overflow math. Daemon binary shows Installed / Bundled
as key/value rows plus a Success/Warning status chip (replacing the fragile
atom-by-atom wrapped status line); "Check for updates…" is now the accent Primary
action, and the maintenance actions are a separate row with Delete Blockchain
styled Destructive (Test/Rescan/Repair Secondary). All handlers, disabled-state
predicates and tooltips are preserved; the buttons wrap instead of overflowing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:53:28 -05:00
95ff9ce9ae feat(settings): shared design components + Wallet button hierarchy
Step 1: promote the polished chat-settings building blocks into reusable material
components (src/ui/material/settings_controls.h) — SettingsSubheader (accent
small-caps), SettingsRow (label left / control right), SegmentedControl (iOS
track+pill), and a tiered ActionButton (Primary=accent fill, Secondary=glass,
Tertiary=ghost, Destructive=error) with an optional leading Material icon, plus a
ButtonFlow that wraps rows of buttons.

Step 2: rebuild the Wallet BACKUP & DATA button wall on them. The 9–11 identical
buttons that were font-scaled onto one row are now tier-ordered, icon-labelled,
and wrap to new rows: the emphasized actions the user flagged — Import key, Seed
phrase, Wallets…, Download Bootstrap — cluster on top as accent Primary buttons;
common actions (viewing-key import, Backup, Migrate, Setup Wizard) are Secondary;
exports are low-emphasis Tertiary. All click handlers/tooltips and the migrate
"legacy wallet" glow are preserved. Removes the SetWindowFontScale legibility hack.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:48:21 -05:00
203967411a feat(chat): theme the :drgx: emoji to the accent (was fixed brand colors)
The custom DragonX chat emoji now recolors to the theme like the logo — body =
accent, detail = white on dark skins / on-surface (dark) on light skins — and
re-rasterizes on a theme/dark-light change (moved out of the one-time fixed-color
load into ensureLogoTexture's re-render block). The detail highlights keep it
legible even on accent-tinted outgoing bubbles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:12:44 -05:00
3d0305a9ca fix(ui): darken the logo SVG detail on light skins
The SVG's white highlight (detail) washed out against a light card/background on
light skins — the dragon's wings/detail nearly vanished, leaving only the accent
body. Make the detail colour theme-aware: white on dark skins (unchanged), the
theme's on-surface colour (dark) on light skins, so the full mark reads on both.
Applies to the header logo and the balance coin icon; re-rasterizes on the
dark↔light flip (already tracked by the logo guard). The fixed-brand ":drgx:"
emoji is unaffected (its crimson body carries white detail fine on any bubble).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:09:03 -05:00
e7f38c2a45 fix(ui): theme the balance-card coin icon per skin too (not just the header)
The prominent DragonX mark on the Overview (next to the balance) is the coin /
currency icon (coin_logo_tex_), a separate texture from the header logo — and it
was still loaded from the fixed logo_dragonx_128.png, so it never recolored when
switching themes. Route it through the same themed SVG: ensureLogoTexture() now
rasterizes BOTH the header logo and the coin icon from logo_dragonx.svg recolored
to the theme accent, re-rasterizing both together on a skin / dark-light change
(coin done first, since the header branch early-returns on success). The old
one-shot PNG coin-logo load in render() is removed (superseded), the PNG remains
a fallback, and the coin texture is now freed on theme switch (was leaked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:02:13 -05:00
3a2be661ea feat(chat): custom DragonX emoji via the :drgx: shortcode
Add a DragonX custom emoji to chat. The emoji picker gains a DragonX tile (first
cell) that inserts the text ":drgx:"; message bodies containing it render the mark
inline via layoutChatBodyRich, which flows text words + the emoji image with word
wrap (plain bodies keep the tighter layoutChatBody path, so normal messages are
unaffected). Other clients simply show the literal ":drgx:" text — a portable
encoding with graceful degradation.

The emoji is the DragonX SVG rasterized once at fixed brand colors (crimson body,
white detail) via LoadTextureFromSvg — theme-independent so it looks identical for
sender and receiver — exposed as App::getDrgxEmojiTexture(). Emoji insertion is
factored into one insertToken() helper (space-prepend + on-chain byte cap), shared
by the DragonX tile and the Unicode emojis. Drag-to-select text is skipped on
":drgx:" messages (their inline layout doesn't match the plain-text hit-test
geometry); right-click "copy" still copies the whole message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:42:30 -05:00
17525003c5 feat(ui): themed DragonX logo — runtime-rasterized SVG recolored per skin
Replace the shared app logo (Overview header, first-run wizard, lock screen) with
the DragonX mark rendered from res/img/logos/logo_dragonx.svg, recolored to each
theme: the dragon body takes the theme accent (Primary()), the inner detail stays
light. Vendor nanosvg (memononen, zlib/public-domain) in libs/nanosvg/ and add
util::LoadTextureFromSvg (parse a copy, recolor shapes by luminance, rasterize to
RGBA, upload via the existing CreateRawTexture — GL + DX11). The SVG is embedded as
a string (src/embedded/logo_dragonx_svg.h) so it's available in every build.

The load moves into App::ensureLogoTexture(), called at the top of render() BEFORE
the wizard/lock early-returns (so those screens get the logo too, which they never
did before), and re-rasterizes when the accent or the dark/light variant changes.
The per-skin PNG remains a fallback if rasterization ever fails; logo-texture
lifetime is now freed on replace + on theme switch (was leaked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:28:51 -05:00
267d839d5b feat(chat): add a dragon-themed emoji set to the picker
Adds 🐉 🐲 🐍 🦎 🐊 🐾 🥚 🪺 🏰 ⚔ 🛡 🗡 to the emoji picker, with DRGX-flavoured
keywords (the dragons are searchable by "dragon"/"drgx"). All are single-codepoint
emoji verified present in both the bundled monochrome NotoEmoji subset and the
color Twemoji font, so no font rebuild is needed and they render on other clients
(standard Unicode, UTF-8 in the memo, within the 236-byte cap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 12:45:49 -05:00
987d9b93c7 feat(history): badge chat transactions + add a Chat filter
Transactions that carried a chat message (sent or received, messages + contact
requests) now show a "Message" badge in the History tab, and a new "Chat" option
in the type-filter combo shows only those transactions.

Detection reuses what the chat store already tracks: ChatStore gains an inline
chatTxids() returning the set of on-chain txids that carried a chat message; the
tab builds it once per frame (cheap — O(chat messages)) and tests txid membership
for the badge and the filter. Empty when chat is disabled or the wallet has no
chat identity. Both variants populate the chat store before this tab renders, so
the same code serves full-node and lite with no separate handling.

The "Message" badge reuses the stacked top-pill slot (chat txs are send/receive,
not the autoshield "shield" type, so it and the "Shielded" badge are mutually
exclusive; chat takes precedence) in a distinct Secondary() colour. Guards the
summary-card accent idx_map so the new filter value (4) can't index it OOB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:43:55 -05:00
4c43f2e082 fix(chat): draw the jump-to-latest pill on top of the messages
The pill was drawn on the parent window's draw list after EndChild, but ImGui
renders a child window ON TOP of its parent — so the message text covered it.
Draw it instead on the thread child's own draw list, after the message loop
(later draw commands render on top), with a PushClipRect so the child's content
padding / scrollbar doesn't clip it. Clickability is unchanged (hand-drawn +
IsMouseHoveringRect / IsMouseClicked test the cursor directly), and it stays
pinned to the visible bottom-right corner via absolute screen coordinates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 01:23:23 -05:00
57fa6470b7 feat(chat): thread UX — auto-scroll, selectable text, clickable Latest, emoji polish
Five message-thread improvements:
- Auto-scroll like the Console tab: new messages pin to the bottom only while
  the reader is already there; a wheel-up detaches (re-armed on return to the
  bottom, after a cooldown). A direct mouse-in-rect test (not IsWindowHovered,
  which is false while the composer holds focus) matches ApplySmoothScroll so
  scrolling up to read history while typing still detaches.
- The "Latest" jump pill is now hand-drawn (rect + IsMouseHoveringRect /
  IsMouseClicked) instead of ImGui::Button: it overlaps the thread child which
  owns mouse-hover there, so a real widget on the parent never got the click.
  Clicking it re-arms auto-scroll; its geometry is shared with the deselect
  guard so a pill click no longer drops an active selection.
- Sending a message closes the emoji picker (it takes over the conversation
  list) so the list reappears, and clears its search filter.
- Inserting an emoji prepends a space when the draft doesn't already end in
  whitespace (still respecting the on-chain byte cap).
- Message text is selectable by click-drag (chatBodyLines / chatBodyHitTest
  mirror layoutChatBody's wrap so highlight + hit-test align with the drawn
  text); while a range is active a copy button shows at the bubble's top on the
  opposite side and copies the selected substring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:04:26 -05:00
c68889d276 i18n(chat): translate chat note-buffer status strings (8 languages)
The status-bar chat-buffer indicator was hardcoded English. Add semantic keys
(chat_buffer_sending[_one]/preparing/loading/ready) to the English source and
route chatBufferStatusText() through TR()+snprintf like the other counted
strings — a singular/plural pair for the send count, %d/%d for the buffer
fill. Translate all five into de/es/fr/ja/ko/pt/ru/zh (additive JSON edits,
format specifiers preserved so the runtime overlay accepts them), and rebuild
the CJK subset font for the two new glyphs (버퍼's 퍼 U+D37C, 缓冲's 冲 U+51B2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:22:59 -05:00
b0a4333cdf feat(chat): pre-split note buffer for rapid sends + status + console logging
Each chat message is a shielded tx that spends a verified note, so a burst of
messages hit "insufficient verified funds" once the single note is spent. Add a
per-frame note-buffer coordinator (both variants) that keeps ~10 verified
spendable notes: it serializes sends through the one broadcast channel, counts
verified notes by block depth (lite) or a rate-limited z_listunspent scan
(full node), self-splits in the background to refill toward the target, and
queues overflow to drain as change matures — with honest Sent/Failed status
instead of the prior optimistic "Sent". Guardrails: single split in flight with
a watchdog, cooldown, and session-generation guards so a wallet switch can't
drain another wallet's queue. Surface a "Chat buffer: N/10 ready" indicator in
the status bar while the Chat tab is active, and route chat diagnostics through
the console on both variants (the full-node console now drains the shared ring).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 22:00:00 -05:00
4471f54842 feat(chat): stamp sender compose-time in message header (clock-clamped)
Carry the sender's compose time as an optional "ts" (Unix seconds) in the
plaintext header JSON that rides outside the AEAD, and prefer it as the
displayed message time so both ends show the same send time regardless of when
the tx confirms. Parse "ts" leniently. On ingest, clamp: reject a "ts"
implausibly in the future vs the receive/block time (1h skew tolerated) so a
wrong/ahead peer clock can't pin messages to the bottom of a thread; a past
compose time is fine (the note buffer may broadcast a queued message later, and
a confirmed tx's block time is always >= compose time). Tests cover the
round-trip and the future-clock clamp.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:59:46 -05:00
e08121af2b feat(chat): redesign message composer — radial byte gauge, autogrow, wrap
Replace the plain "N/236" byte counter with a radial wheel drawn inside the
input, right-aligned, filling green→amber→red as the message approaches the
236-byte memo cap. Vertically center the text with left padding (FramePadding),
hard-cap typing at the cap (no silent overflow), add Shift+Enter for a newline
(via an always-callback, since ImGui 1.92's Enter shortcut needs exact mods)
while plain Enter still sends, animate the input growing taller as lines are
added, and word-wrap the display instead of overflowing left. Give a hard
newline a touch more space above than a soft wrap in the message bubble.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:59:37 -05:00
31ebfc782e style(theme): make Jade's gold vein follow rounded corners
Jade's edge-trace hero hand-walked the panel perimeter as straight line
segments, so rounded corners chamfered into flat polygons. Retire it in
favour of the gradient-border effect, which draws via AddRect and hugs
the real corner arcs.

- theme_effects: drawGradientBorderShift gains phaseOffset + alphaMul;
  drawPanelEffects now draws it on every glass panel (position-phased so
  panels drift like veins at different depths, 0.6x alpha vs the active
  nav button), gated behind a new opt-in gradient-border-panels flag so
  button-only themes (Obsidian) are unaffected.
- jade.toml: edge-trace off; gradient-border-panels on, speed 0.10,
  jade->gold; jade motes + viewport wash/vignette kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 00:24:36 -05:00
f3776bcbe5 feat(theme): add the Jade skin background asset
The 1024x1024 jade marble texture referenced by res/themes/jade.toml,
committed on request so the Jade skin ships with its background instead
of falling back to the programmatic gradient. Sits alongside the other
tracked theme backgrounds in res/img/backgrounds/texture/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:34:25 -05:00
38f2aa2f8f fix(ui): frost the chat + contacts panes with acrylic blur + padding
The chat and contacts panes drew flat ImGui fills (ChildBg / default
WindowBg / NoBackground), so they never sampled the acrylic blur and
showed the raw backdrop texture. Route them through DrawGlassPanel --
the pattern the peers_tab sibling and every other tab already use:

- chat: frost the conversation-list + thread panes and the composer
  input box, and add inner padding so content doesn't hug the glass edges
- contacts: frost the card/list container AND the table view, padding both

Padding needed ImGuiChildFlags_AlwaysUseWindowPadding -- a borderless
child silently ignores WindowPadding otherwise (documented gotcha in
daemon_download_dialog.h). BeginTable can't take that flag, so the table
view is inset within its glass panel instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:32:11 -05:00
40f8425d94 feat(theme): add the Jade skin (dark jade-green + gold veins)
A new bundled skin driven by the existing jade_bg.png background: deep
green surfaces, jade-green primary accents, and a soft gold secondary
echoing the stone's veins, plus a jade specular-glare + jade-to-gold
sidebar-border effect. SkinManager auto-discovers it (no C++/CMake
changes) and it appears in Settings -> Appearance as "Jade".

The background asset (res/img/backgrounds/texture/jade_bg.png) is a
binary handled separately; until it lands in the repo the skin falls
back to the programmatic gradient on other machines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 19:32:02 -05:00
2157a30192 fix(tests): guard updater asset-selection index reads against OOB
testXmrigAssetSelection / testDaemonAssetSelection index rel.assets[i]
right after EXPECT_TRUE(i >= 0), but select*Asset returns -1 on no match
and this harness's EXPECT doesn't abort -- so a fixture/parser regression
would read rel.assets[-1] and SIGSEGV the whole suite instead of
reporting the failed EXPECT. Gate each index read behind `if (i >= 0)`
(same pattern as the AddressBook config-dir fix). No behavior change on
the valid checked-in fixtures; ctest passes 100%.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 13:39:04 -05:00
bef3c0c47d fix(tests): use the per-variant config dir so the lite ctest doesn't segfault
testAddressBookScope hardcoded ".config/ObsidianDragon" for its
pre-scoping addressbook.json, but AddressBook::load() reads the
per-variant config dir (Lite -> ObsidianDragonLite/). In a --lite build
the write and read diverged, so load() found nothing, size()==0, and the
following entries()[0] read out of bounds -> SIGSEGV, taking the whole
suite down.

Write to dragonx::util::Platform::getConfigDir() (the same path load()
uses, resolved under the test's temp HOME) and guard the entries()[0]
access so a non-aborting EXPECT can never SIGSEGV the suite. Verified:
ctest now passes 100% in BOTH the full-node and --lite builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:28:04 -05:00
5c570613c8 feat(console): lite backend command-reference modal
Give the lite console the parity analog of the full-node RPC command
reference: the same searchable two-pane modal (browse/search, detail
pane, examples, Insert / Insert & run, destructive confirm), driven by
the lite backend's own command set instead of daemon RPC.

- ConsoleCommandExecutor::commandReference() returns the category table
  (full node = consoleCommandCategories(); lite = new
  liteConsoleCommandCategories() -- 25 backend verbs in 5 categories).
  The shared renderCommandsPopup reads the table from the executor.
- The Commands button now shows for both variants (gated on
  commandReference()!=nullptr); title/tooltip/arg-quoting branch on
  hasRpcReference() (clarified to mean "speaks JSON-RPC / full node").
- Lite args are bare tokens (the backend takes one unsplit arg string
  and does not strip quotes), so the param-builder's JSON string
  auto-quoting is disabled for lite -- Insert & run emits runnable bare
  commands. send uses the JSON-array form (its positional form is
  unreachable via the single-arg transport); new uses zs/R; import
  takes just the key (the backend hardcodes birthday=0).

Adds 7 i18n keys across all 8 languages (additive). Full-node behavior
is unchanged. Adversarially reviewed (data vs the backend registry,
plumbing/regression, i18n) with all findings fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 12:27:51 -05:00
567732206e fix(console): localize the lite console + drop full-node-only wording
The lite console tab lagged the full-node one on i18n: its toolbar status,
status lines, help, and error/warning strings were hard-coded English or
worded for a full node (a "daemon" the lite wallet doesn't have).

- toolbarStatus()/statusLines(): route through TR() (reusing the existing
  lite_net_* keys where they already map)
- printHelp(): enumerate the 25 pass-through backend verbs for discoverability,
  since the C++ tab intercepts `help` before the backend's own HelpCommand runs
- gate the destructive 'stop' confirmation to the full node (hasRpcReference);
  lite has no node, so it lets 'stop' fall through to the backend instead of
  showing a phantom "shut down the node" warning behind a dead gate
- word the not-connected error per variant (daemon vs "no wallet open")
- TR() the "(no output)" command-result fallback

Adds 7 i18n keys across all 8 languages (additive); rebuilds the CJK subset
for the one new glyph (U+C5D4 for the Korean "backend"). Build + ctest +
source-hygiene green; changes adversarially verified (no logic/i18n defects).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 20:10:17 -05:00
0dcc09fc5f fix(console): DPI-scale the toolbar status-dot offsets
The status indicator's left inset (+2) and dot-to-text reservation (+6) were raw pixels while the dot radius is DPI-scaled, so the dot shifted a couple native px at HiDPI. Multiply both by Layout::dpiScale() per the project's hand-drawn-geometry rule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:03:11 -05:00
82d3178119 fix: use reentrant localtime_r/localtime_s off the worker thread
std::localtime shares a process-wide static tm; both sites are reachable from background threads (the RPC price-parse on the worker thread, and Logger::write from worker/monitor threads), so a concurrent localtime call could clobber the struct between the call and the read. Copy into a local std::tm via localtime_r / localtime_s, matching the codebase's existing convention (console_tab rpcTraceTimestamp, app_network, etc.). From the console-tab audit; benign (garbled/stale timestamp only), no crash or state impact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:03:10 -05:00
7d47c6e14e fix(console): balance colour-toggle push/pop so it stops drawing a red error rect
The accent + text-colour toggle buttons pushed/popped ImGuiCol_Text guarded by a flag the TactileButton flips in between, so a click left the colour stack unbalanced for that frame — and ImGui's error recovery drew a red rectangle around the console window (imgui.cpp:11727). Capture the flag into a local before the button and guard both the push and the pop with it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:39:42 -05:00
973bc0d338 feat(market): trend sparkline in all portfolio styles + inline chart/style pickers
Render the portfolio trend sparkline in every row style, each with a layout that features it:
- Table: a dedicated header-labelled TREND column (48dp rows), aligned across rows.
- Cards: a left-aligned info stack (icon + label / value+delta chip / DRGX) with a full-height sparkline filling the rest of the width (content-driven, so short groups give the chart more room).
- Spotlight: a big-value-over-a-full-width-chart-band hero tile (92dp).
Default sparkline interval is now MONTH (a real curve from the daily series, not the young minute buffer). Removed the per-row container accent line (redundant with the icon/sparkline colour) and the per-group shielded/transparent bar (private-by-default makes it near-always 100%, and dropped its now-dead SumPortfolioSplit helper). DPI-scale the sparkline stroke. Adds the "Trend" column string across 9 languages.

Relocated the Market controls out of a removed settings modal + gear: the chart line/candle picker now sits top-right of the chart (left of the trade button, candle-capable ranges only), and the Table/Cards/Spotlight picker sits left of Manage. Dropped the market fetch-prices toggle (still in the general Settings page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 13:39:42 -05:00
e4c9b98ca2 feat(market): trend sparkline in all portfolio styles + fix candlestick availability
Two coupled Market-tab changes (same file):
- Sparkline in every style: render the per-entry trend sparkline in Table (a reserved trend column, aligned under the column header, reserved only when at least one group opts in so the dense grid isn't padded otherwise) and Cards (a filled strip on the right, above the full-width Z/T bar), not just Spotlight — so the per-entry 'show sparkline' setting is honored in all three views. DPI-scale the sparkline stroke (1.2f/1.4f * dp), from the sparkline audit's L1.
- Fix the candlestick toggle intermittently missing: the Market tab never triggered the per-exchange OHLC fetch on its own (only a pair-chip / refresh click did), so candlesticks were unavailable on a fresh tab open. Call refreshExchangeChart() every frame (self-throttled + in-flight-guarded — its own doc comment already says 'safe to call every frame'), which also fixes the new pair's candles never loading if you switch pairs mid-fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 00:56:06 -05:00
1c38f68781 fix(market): HiDPI-scale hand-drawn geometry + dedup row/column helpers
Market-tab audit follow-up (all 9 confirmed findings):
- Scale the unscaled corner radii on the Cards/Spotlight row rects, the address-preview glass panel, the chart Y-axis labels, and both chart hover tooltips by dpiScale() — they mismatched the coincident already-scaled rects on HiDPI (the CLAUDE.md hand-drawn-geometry rule).
- Share the portfolio row-height/gap formula (pfRowHeight/pfRowGapFor) and the TABLE column widths (kPfValColW/kPfDrgxColW) between the draw loop and the scroll-region height budget so they can't drift and clip.
- Derive the portfolio total from the shielded/transparent split (one address scan instead of two in Cards).
- Build the address-picker selected-set once (unordered_set) instead of a linear PortfolioEntryContains per sort-compare and per row.
No correctness/security/threading defects were found in the audit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 23:00:02 -05:00
818c29fca7 feat(market): settings gear, options modal & distinct portfolio styles
Add a settings-gear modal (portfolio layout / chart style / fetch prices) and persist the portfolio style. Redesign the three row styles into distinct purposes: Table (borderless grid + column header + % pill), Cards (glass card + muted label/shadow value + shielded/transparent split bar), Spotlight (heavy tile + enlarged direction-coloured hero value + delta chip). Dim empty groups. Add data::SumPortfolioSplit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:35:03 -05:00
0a43742897 feat(chat): side-by-side settings modal with live conversation preview
Widen the chat settings modal and split it into a live message preview (canned bubbles rendered with the real style/accent/density/font/emoji/timestamp code) beside the controls; size the Done button to its text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:35:03 -05:00
defe14d913 feat(contacts): avatar shapes, list scale & live settings preview
Add a settings-gear modal with a segmented avatar-shape picker (circle / rounded square / full-row left tab), a list-scale slider, and a live two-row preview. Smooth-scroll the address list and fix a latent wheel double-scroll on the outer scroll child. (settings.h also refreshes the portfolio-style doc comment.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:35:03 -05:00
8b55a90102 i18n: contacts/market settings strings + portfolio-style rename
Add contacts_shape_tab, the market settings-modal + column-header keys; rename the portfolio-style labels to Table / Cards / Spotlight (same keys, new values). Rebuild the CJK subset for the new glyphs. All 8 languages, additive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:35:02 -05:00
0fd3d214a4 fix(ui): clip & left-align overflowing SegmentedControl labels
material::SegmentedControl drew each label centred with no clip, so long non-English labels (Russian/German, etc.) overflowed their cell and overlapped neighbours. Left-align on overflow and clip per cell (mirroring the chat modal's segmented helper); the fits-case is visually unchanged. Benefits every caller (contacts, receive, wallets, market).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:35:02 -05:00
996e10ed02 fix(ui): stop forward-wheel double-scroll on peers & console tabs
The inner scroll child (##PeersList / ConsoleOutput) uses NoScrollWithMouse + ApplySmoothScroll; ImGui forwards its wheel up to the outer scroll container, so the wheel scrolled both. Add NoScrollWithMouse to the outer containers (safe: both fill the remaining height and never overflow), matching the contacts/explorer fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 22:35:02 -05:00
2c909d35ea feat(chat): message-list rework, customization surface, smooth scroll
Chat tab overhaul:
- Header/layout: single-row header (name + key-lock + compact click-to-copy address +
  right-aligned icon toolbar with a settings "notch"), composer moved below the message
  box (emoji toggle left of a bottom-flush input), tightened list-pane controls.
- Message list: per-day date separators (Today/Yesterday/date), time-only group headers,
  tight same-sender grouping with iMessage-style merged corners, per-run peer avatar,
  delivery status (clock -> check), hover-reveal per-message time. Message base ~18px.
- Customization: a settings "notch" gear opens a modal (also under Settings ->
  Chat & Contacts) with segmented controls for emoji style / bubble style / density /
  timestamps, sliders for poll rate + text size, a bubble-accent dropdown, Enter-to-send.
  Bubble style/accent/density/text-size/timestamps all applied live in the message loop.
- Settings: app-wide clock-format control lives in Settings -> General; chat keeps a
  per-tab override. Both chat panes now use the app's smooth wheel-scroll.
- i18n: new strings across all 8 languages; CJK subset rebuilt for the added glyphs.

Inline contact rename, hide/mute, 0-conf fast-scan and the export path are carried through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:45:19 -05:00
3b5db02e09 feat(app): font-atlas rebuild hook + clock/emoji sync
- requestFontRebuild() + preFrame handling so toggling color emoji swaps the atlas live;
  request a rebuild at startup when the saved setting wants color.
- preFrame syncs Typography's color-emoji flag and the util clock flag from settings.
- Chat 0-conf fast-scan cadence now reads the user's poll-rate setting (was a hardcoded 2.5s).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:44:55 -05:00
8d8cd337cf feat(util): app-wide 24h/12h clock format
Add util::formatClockDateTime/formatClockTime driven by a process-wide flag (setClock12h,
synced from the time_format setting each frame). Switch the primary user-facing timestamp
displays to it — the transaction list, wallet-state tx + banned-peer times, the explorer
block time, and the block-info dialog — so one preference drives every clock.

Log files, export filenames/content, console line prefixes, the market chart axis, and the
worker-thread "last updated" string intentionally stay fixed 24h.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:44:44 -05:00
acde8c0833 feat(config): chat-customization + app-wide clock settings
Persisted (additive JSON, clamped on load) preferences backing the new chat settings
surface and the global clock:

- chat: emoji style (color default), poll rate, bubble style + accent, message density,
  text scale, per-tab timestamp override, Enter-to-send.
- app-wide time_format (0=24h, 1=12h) that the Chat tab falls back to and every other
  user-facing timestamp now follows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:44:29 -05:00
a78b44246e build(freetype): color-emoji rendering via a cross-platform FreeType backend
The chat "Emoji style: Color" option renders the merged emoji in color (COLR/CPAL
Twemoji) instead of the monochrome NotoEmoji subset. This needs FreeType, which the
default stb_truetype rasterizer can't do for color glyphs.

- Vendor imgui_freetype (matches the bundled 1.92 ImFontLoader API) and embed a 1.4 MB
  COLRv0 Twemoji font (no libpng/harfbuzz needed).
- CMake gains an optional FreeType path: native Linux/macOS use the system FreeType via
  find_package; the mingw-w64 cross-compile has none, so build.sh --win-release now
  cross-builds a minimal static FreeType (scripts/build-freetype-mingw.sh) and passes it
  in. Absent FreeType => graceful monochrome fallback, so no build breaks.
- Typography selects the FreeType loader + the color font (LoadColor) when color emoji is
  on, else the stb loader + mono subset; toggling reloads the atlas. Both the DX11 and
  OpenGL backends already support the 1.92 RGBA dynamic atlas, so color glyphs render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 18:44:19 -05:00
63ed31d2ec feat(chat): polish thread header, emoji picker, and pane gutter
Tier A + B chat-tab visual pass:
- Widen the list<->thread gutter (1px -> 10px) for breathing room.
- Redesign the thread header: avatar + name on the left with a
  right-aligned frameless icon toolbar (add-contact/export/mute/hide,
  each with a tooltip); the name is clipped to the toolbar's left edge
  so a long contact label can't overrun the icons.
- Replace the "Copy Full Address" button with a click-to-copy shortened
  address (copies the full z-addr), preceded by a key-verify lock whose
  tooltip shows a comparable identity-key fingerprint.
- Show a "Waiting for reply" chip in the header when the peer's identity
  key isn't known yet.
- Emoji picker: frameless grid with tight cells (glyphs fill the cell)
  and leftover width spread into the column gaps so it stays edge-to-edge.
- Larger 30px composer emoji toggle that lights up while the picker is
  open; footer height + byte-counter centering adjusted to match.

New i18n keys (chat_copy_address_tip / chat_verify_key / chat_awaiting_key)
added additively to all 8 languages; CJK subset font rebuilt for the new
zh glyph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:14:44 -05:00
64b27db2ff feat(chat): emoji picker as an in-pane overlay with keyword search
Reworked the emoji picker from a floating popup (which overlapped the message
area) into an overlay that takes over the conversation-list pane while open:
a Cancel button + a keyword search box at the top, then a responsive grid below
that wraps to the pane width.

Each emoji now carries search keywords (grin/heart/fire/…) so the search box
filters the ~150-emoji set. The 🙂 composer button toggles the overlay;
selecting an emoji appends it to the composer (byte-cap respected) and the
overlay stays open for multiple picks.

+1 CJK glyph (絵) for the ja "Search emoji" string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 08:06:37 -05:00
03fe6e077c feat(chat): emoji picker + show-hidden toggle
- Emoji picker: a 🙂 button on the composer row opens a scrollable grid of ~150
  common single-codepoint emoji (all verified present in the bundled NotoEmoji
  subset); clicking appends to the composer, respecting the byte cap. ImGui does
  no shaping, so the set is single-codepoint only (ZWJ sequences / flags omitted).
- Show hidden: when there are hidden conversations, a "Show hidden (N)" toggle
  appears in the list; toggling it reveals them (dimmed) and the thread header's
  Hide button becomes Unhide. Off by default, and resets when nothing is hidden.

8-language strings; no new CJK glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 01:08:35 -05:00
125ffa8863 feat(chat): dedicated ~2.5s poll for the 0-conf fast-scan
The fast-scan was hung on the page's Transactions timer, which is 10–15s on most
pages (15s on Chat), so incoming messages still took up to ~15s to appear. Give it
its own ~2.5s accumulator (delta-time based, independent of the page cadence) so
messages land in a few seconds — network propagation then dominates, which is as
fast as 0-conf gets. Still gated behind the warmup/rescan block and self-gated in
fastScanChatMemos; the in-flight guard prevents overlapping RPCs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:58:42 -05:00
3465acc57b fix(chat): session-guard the fast-scan callback (stale-wallet ingest)
Review of 30bd0d9 found the 0-conf fast-scan MainCb was the one async chat
callback missing the chat_session_generation_ guard the broadcast + identity-fetch
callbacks use. worker_ survives a wallet switch/lock, so a fast-scan posted under
wallet A could drain after resetChatSession() and ingest A's metadata (or toast)
against wallet B's freshly-provisioned store.

- Capture scanGen at post time and drop the result if it changed by drain time.
- Clear chat_fast_scan_in_flight_ in resetChatSession() so a switch immediately
  re-enables the fast path; the stale callback returns WITHOUT clearing the flag so
  it can't clobber the new session's own in-flight scan (generation is bumped only
  in resetChatSession, which already reset the flag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:50:58 -05:00
30bd0d99ff feat(chat): 0-conf fast-scan so incoming messages show before a block
The receive harvest gates each z-address behind scannedAtTip — it only re-scans
when a new block advances the tip — so incoming chat waited ~1 confirmation even
though the daemon already exposes mempool notes (FindMySaplingNotes runs on
mempool txs; z_listreceivedbyaddress(addr,0) returns them).

Add App::fastScanChatMemos(): every transaction-refresh cycle, re-scan JUST the
chat reply address (where peers send) at minconf=0, extract chat metadata, and
ingest — so messages surface at mempool speed (a few seconds) instead of waiting
for a block. Full-node only (lite has its own harvest). An in-flight guard avoids
stacking RPCs; the store dedups on txid+position, so the confirmed harvest never
double-inserts.

Hidden conversations are deliberately skipped by the fast path — they don't get
the mempool speed-up and still come back through the normal confirmed harvest
(which un-hides on a new message). New non-muted messages toast off-tab as usual.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:38:41 -05:00
29fbe46cce fix(chat): un-hide on lite receives + scope the contact picker per wallet
Review of fc414ee found two issues:

- HIGH: the lite variant harvests chat via ingestLiteChatMemos, which called
  ingest() without the newIncomingCids out-param — so the un-hide never ran and a
  hidden conversation stayed hidden PERMANENTLY on new messages (chat is default-ON
  and fully supported in Lite), breaking the "a new message brings it back"
  invariant. Wire the same un-hide + off-tab toast into the lite path.

- LOW: the new-conversation contact picker listed every z-address contact,
  ignoring the per-wallet scope the Contacts tab enforces — leaking another
  wallet's scoped contact into this wallet's picker. Apply the same scope test
  (global + legacy fail open; "w:" scopes match the active wallet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:36:14 -05:00
fc414eeed4 feat(chat): hide conversations + contact address picker for new conversation
Hide conversations:
- A "Hide" action in the thread header drops a conversation from the list. The
  messages stay in the seed-encrypted store (on-chain history can't be deleted);
  a new INCOMING message un-hides it (you can't un-receive), so nothing is lost.
- Hidden cids persist in settings (mirrors the mute list) and are skipped by both
  the conversation list and the unread badge.

New-conversation address picker:
- A "Choose from contacts" dropdown lists the address book's shielded (z-address)
  contacts and fills the recipient field on selection; manual paste still works.

8-language strings + CJK subset (+1 glyph 届).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 23:26:04 -05:00
fce873936e fix(chat): demo identity must not clobber a real one or be a shared constant
Root cause of the reported "my own messages come back as replies": seedChatDemoData
(the Settings "Seed demo chat" debug button) set the chat identity to a FIXED
secret ("obsidian-dragon-demo-chat") and, because maybeProvisionChatIdentity
no-ops once any identity exists (app_network.cpp:2771), that demo identity stuck
and overrode the wallet's real seed-derived one. Clicking it on two different-seed
wallets gave BOTH the same constant identity — so they were cryptographically the
same person, and a wallet's own outgoing memo (harvested) decrypted back as an
incoming message.

Two rules now:
- Only fabricate a demo identity when there is NO real one (never clobber a
  provisioned wallet identity).
- Derive it from a RANDOM per-run secret, so it can never be a constant shared
  across installs/wallets.

Complements 7351d8a (which removed the self-harvest path); together they close the
loopback both at the harvest and at the identity source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 22:41:43 -05:00
7351d8a06d fix(chat): stop our own sent messages appearing as incoming duplicates
Two causes of the "every message I send shows up again as a reply from the peer":

1. Harvest bug — the full-refresh path fed a SENT tx's outgoing memos (from
   z_viewtransaction outgoing outputs) into the chat metadata extractor, and
   ChatService::ingest marks everything Incoming. So each send was re-ingested as
   a phantom "from peer" message. The recent-refresh path already omitted this, so
   it was accidental. Drop the outgoing chat-harvest (keep the tx-history harvest);
   genuine incoming still comes from z_listreceivedbyaddress, and our sends are
   recorded by the local echo.

2. Own-identity ingest filter — a memo whose sender public key equals our own
   identity is by definition something we sent (only we hold our key); it must
   never be ingested as incoming. Skip those in ingest. This also collapses
   same-seed self-chat (running the SAME wallet in the full node and Lite makes
   them one chat identity, so sends land on an address we also own and loop back).

For a real two-party chat use two DIFFERENT wallets/seeds — same-seed wallets are
one identity and can't be distinct peers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:21:41 -05:00
76093fe82d fix(chat): address adversarial review of the send-path change
Four confirmed findings from the review of ef247c9:

1. Persistence regression — the deferred-persist echo (in-memory Sending, written
   only when the async callback resolved) meant a message broadcast on-chain but
   whose callback hadn't fired yet was LOST from history if the app quit/crashed
   in that window. Persist the echo immediately as Sending and UPSERT the final
   status on resolve (new ChatDatabase::upsert with ON CONFLICT DO UPDATE, since
   append is INSERT-OR-IGNORE). A stray persisted Sending still loads as Sent.

2. Fee ceiling — dragonxd REJECTS a 0-value tx whose fee exceeds the default
   miners fee (0.0001), and max(getDefaultFee(), 0.0001) can only raise it, so a
   default_fee > 0.0001 broke every chat send. Pin chat to exactly kChatMinFeeDrgx,
   dropping getDefaultFee() from this path (chat always moves 0 value).

3. Lifetime — the resolve callback had no generation guard, so a wallet lock (which
   doesn't disconnect) between submit and callback could resolve against a cleared
   store. Capture chat_session_generation_ and bail on mismatch (both the full-node
   callback and the lite optimistic resolve), matching the identity-fetch pattern.

4. Retry misdirect — Retry on a failed CONTACT REQUEST called sendChatMessage,
   which (no peer key yet) just showed "waiting for reply". Route it to
   sendContactRequestForCid() (refactored out of startChatConversation) so it
   re-sends the request into the SAME conversation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:42:59 -05:00
ef247c95ff feat(chat): fee floor, real delivery status, pay-from-funded, funds pre-check
Chat sends move 0 value, so the network fee is structurally load-bearing (it's
the only thing that forces a real shielded input; 0-value + 0-fee builds a
degenerate, unrelayable tx). Three gaps addressed:

1. Fee floor — broadcastChatMemos now uses max(getDefaultFee(), kChatMinFeeDrgx),
   so a 0 / too-low global default-fee setting can't silently break chat.

2. Real delivery status — the echo was marked Sent on SUBMIT regardless of the
   on-chain outcome (the z_sendmany callback was empty), so failures were
   invisible and the Retry affordance never fired for async failures. Add a third
   ChatDelivery::Sending state (appended so persisted 0=Sent stays valid); record
   the echo in-memory as Sending, and resolve it to Sent/Failed from the
   z_sendmany completion callback — persisting only the final status (so a restart
   never shows a stuck spinner; a stray persisted Sending loads as Sent). A subtle
   "sending…" label shows while in flight.

3. Pay-from-funded + pre-check — z_sendmany spends from one z-address, and the
   identity reply address may be unfunded while funds sit elsewhere. chatPayFromZaddr
   picks a spendable z-address that can cover the fee (preferring the identity
   address); the memo still advertises the identity address as reply-to, so paying
   from a different note is transport-transparent. If nothing can cover the fee, a
   clear "need a small shielded balance" toast replaces the cryptic failure.

Full node only for the callback path; lite resolves optimistically on queue.
8-language strings + CJK subset (+1 glyph 賄).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:27:43 -05:00
c291e8a587 fix(app): use #if not #ifdef for the per-variant instance lock
DRAGONX_LITE_BUILD is ALWAYS defined (0 for the full node, 1 for Lite) via
$<BOOL:...>, so the previous #ifdef was true for BOTH variants — the full node
took the Lite branch and grabbed the "obsidiandragonlite" lock, so launching Lite
next still collided ("ObsidianDragonLite already running"). Switch to
#if DRAGONX_LITE_BUILD (value check), matching how the rest of the codebase
guards this macro.

Verified: full-node binary now contains only "obsidiandragon"; preprocessor check
confirms LITE=1 selects "obsidiandragonlite". (wallet_capabilities.h's #ifndef is
a separate, correct default-definition idiom — not affected.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 19:08:41 -05:00
06e55d6394 feat(app): per-variant single-instance lock so full node + Lite can run together
The single-instance lock hardcoded the name "obsidiandragon" for both variants,
so launching ObsidianDragonLite while ObsidianDragon was running (or vice-versa)
was refused with "Another instance is already running". Nothing else actually
required them to be exclusive — DRAGONX_APP_NAME already gives each variant its
own config dir (settings / wallets index / address book / chat db), the full
node's daemon lives under ~/.hush/DRAGONX with no Lite counterpart, and the lock
guards no cross-instance IPC (the payment URI is handled locally).

Key the lock per variant (obsidiandragon / obsidiandragonlite) — the Windows
named mutex already derives from the same name, so it's fixed on both platforms.
Each variant still enforces a single instance of itself. Also make the
already-running message report the actual variant (DRAGONX_APP_NAME) and use
MessageBoxA so it can. mingw-verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:42:10 -05:00
2072f70a60 fix(contacts): stable per-wallet scope so contacts don't vanish (address-hash drift)
Contacts were scoped by activeWalletIdentityHash() — a hash of the wallet's
ENTIRE address set. Creating a new receive address grows the set, changing the
hash, so every contact stamped with the old hash falls out of the scope filter
(contacts_tab.cpp:915) while still being counted — the "3 saved, 1 showing"
symptom, where only the one set to global (which bypasses the scope) survives.
It also hid scoped contacts on every startup before the daemon connected (hash
empty until addresses load).

Introduce a stable per-wallet scope id: WalletIndexEntry.scopeId ("w:"+random
hex), generated once and persisted in the wallet index (keyed by wallet file),
never recomputed from the mutable address set — so creating addresses, locking,
or disconnecting never changes it. App::activeWalletScopeId() establishes it on
first use. Contacts now scope + filter on this instead of the drifting hash. The
tx-history-cache identity (the hash's real purpose) is untouched.

Recovery for already-orphaned contacts:
- AddressBook::reattachLegacyScopes() re-attaches non-global, non-"w:" contacts
  to the active wallet's stable id; run once when there's a single known wallet
  (unambiguous attribution). Idempotent.
- The scope filter fails OPEN for legacy scopes (multi-wallet case where recovery
  can't attribute them) so no contact is ever hidden; stable "w:" scopes still
  match strictly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:27:23 -05:00
ee9ea15233 fix(rpc,ui): close the last secret residues found by the final-gate review
Two verified medium leaks in the B7 scrub chain:

- parseRpcResult parses the body into a local `response` tree and returns a COPY
  of response["result"] (operator[] yields an lvalue ref, so the by-value return
  copy-constructs). The local tree — holding its own heap copy of the secret — was
  then freed without zeroing, so callSecret/callSecretString still left one
  un-scrubbed copy. Add scrubJsonSecrets() (recursive string zero) and a
  scrubSource flag; the secret paths opt in, wiping the tree before it frees. The
  secret export chain is now fully covered: raw body → parse tree → result copy →
  caller-owned string.

- key_export_dialog cleared s_key with plain std::string::clear() on the Close
  button, the scrim/Esc dismiss path, and the QR cache (s_qr_cached) — leaving the
  displayed private/spending key in freed heap on the ordinary close paths. Route
  all three through wallet::secureWipeLiteSecret (zero-then-clear), matching
  show()/hide().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:41:24 -05:00
11de117331 feat(chat): render emoji in messages and user text (Q12)
Merge a monochrome Noto Emoji subset into the text fonts so chat messages, the
composer, contact names, and memos render emoji (😀 🎉🔥 👍 …) instead of
tofu.

- Enable IMGUI_USE_WCHAR32 (imconfig.h): emoji live above the BMP (U+1F300+), so
  16-bit ImWchar literally can't address them. This widens ImWchar build-wide;
  the only ImWchar uses in-tree are glyph-range arrays and one BMP private-use
  codepoint, so nothing else is affected. Tests + full build pass.
- Bundle res/fonts/NotoEmoji-Subset.ttf — the OFL monochrome Noto Emoji (color
  CBDT/COLR fonts can't be rasterized by ImGui's stb_truetype) pinned to wght=400
  and subset to the emoji planes (1411 glyphs, 747 KB). Reproducible via
  scripts/build_emoji_subset.py. Embedded via INCBIN like the CJK subset.
- Typography::loadFont merges it (MergeMode) only into the small text fonts
  (Body/Subtitle/Caption/Button) — not headers, which don't need 1400 emoji.
  The base font keeps precedence for U+2600–26FF, so text-style symbols stay.

Limits: ImGui does no shaping, so single-codepoint emoji render but ZWJ sequences
(family/profession) and regional-indicator flags won't compose; emoji are
monochrome (the OS emoji picker still inputs them fine, and the composer byte
counter already counts their 4-byte UTF-8 cost against the on-chain cap).

Verified headless: sizeof(ImWchar)==4 and every probed emoji is in-font and bakes
into the atlas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:28:50 -05:00
fac0245297 fix(chat,rpc): address adversarial review of the chat backlog
Four confirmed findings from the review pass:

SECURITY — B7 was incomplete:
- The single-key Export dialog (key_export_dialog) still used plain call() for
  z_exportkey/dumpprivkey/z_exportviewingkey — a live spending-key leak on the
  most common per-address export path, missed by the B7 commit.
- callSecret() zeros the raw body but the parsed json holds its OWN heap copy of
  the secret; several callers did .get<string>() on a temporary json and freed
  that copy un-wiped.
  Fix: add RPCClient::callSecretString() — returns the bare-string result with
  BOTH the raw body AND the json node zeroed, so callers can't forget. Route
  key_export_dialog (×2), exportPrivateKey, and export_all_keys (×2) through it;
  scrub the z_exportmnemonic json node in seed_wallet_creator (object result);
  also wipe the transient key copies, the displayed s_key on reset, and the
  aggregated export-all `keys` buffer.

CHAT:
- Jump-to-latest pill: SetCursorScreenPos moved the parent cursor and never
  restored it, so the composer footer rendered ~8px too high while scrolled up.
  Save + restore the cursor around the pill.
- New-message toast: gating on a chatUnreadCount() watermark delta could be
  swallowed when an outgoing echo (wall-clock) pushed the seen-watermark past a
  later reply's block time. ingest() now reports the cids it appended; the toast
  fires when any is a non-muted conversation — skew-proof, still mute-aware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:11:31 -05:00
9cb91415d9 i18n(chat): translate the new chat-backlog strings (8 languages)
Translate the 14 chat strings added by the backlog work (relative time, retry,
jump-to-latest, empty states, search, export, message-too-long, mute/unmute) into
de/es/fr/ja/ko/pt/ru/zh, added additively (no existing key overwritten).

Also fix a key collision the English pass introduced: the new empty-state
sub-hint reused "chat_empty_hint", which already meant the standalone list hint —
so English and the translations disagreed. Split it into a distinct
"chat_empty_start" for the "Start one with New conversation" sub-line, leaving
the original chat_empty_hint intact.

Rebuilt the CJK subset font: +3 glyphs (刚静音) for zh "刚刚"/"静音", 0 removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:53:48 -05:00
24c661f743 feat(chat): mute conversations (Q10)
Per-conversation mute toggle in the thread header. Muted conversations (tracked
by cid in settings, so it persists) are skipped by chatUnreadCount(), so they
neither raise the nav-item unread badge nor the new-message toast — the toast now
gates on a chatUnreadCount() delta across ingest, which already skips muted cids,
so mute is respected for free. "Block" (rejecting a peer's inbound memos) is a
larger ingest-filter change and is intentionally left out of this pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:48:57 -05:00
fecc9015fb feat(rpc): scrub the raw response body for secret-bearing calls (B7)
The secret exports (z_exportmnemonic / z_exportkey / dumpprivkey) already scrub
the parsed value at the call site, but the raw HTTP response string those RPCs
build — the curl write buffer, which holds the same secret in the clear — was
freed without zeroing. That's the "fuller fix belongs in the RPC layer" the
identity-fetch comment flagged.

Add RPCClient::callSecret(), a call() variant that sodium_memzeros the raw
response body after parsing (on success and on throw). NRVO makes the returned
string the very buffer curl wrote into, so one wipe covers it. Route every
secret-bearing export through it: chat identity (mnemonic + z_exportkey
fallback), Settings seed-phrase + single-key export, Export-all-keys, and the
migrate-to-seed isolated-node mnemonic export. Purely additive — the parsed
result is byte-identical, so no behavior change (safe for the fund-critical
migrate path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:45:41 -05:00
bce69362eb feat(chat): message bubbles, empty states, search, export, richer composer
Chat-tab backlog from the audit:

- V3/V7/Q7/Q9 — thread overhaul: direction-aligned rounded bubbles with
  sender/minute grouping and a grouped meta line; peer avatar in the header;
  hover shows the full timestamp; a right-aligned "not sent · Retry" affordance
  re-sends failed outgoing messages; a floating "Latest" pill appears when the
  thread is scrolled up.
- V4 — centered empty states (icon + title + hint) for the locked, no-conversations,
  and no-selection panes.
- V6 — faint sidebar tint on the conversation list + a tight single-line seam.
- Q5 — compact relative time ("now"/"5m"/"3h"/"2d"/"Mon DD") in the list preview.
- Q6 — multi-line composer (Enter sends, Ctrl+Enter newline) with a live byte
  counter against the on-chain body cap (= (512−len"utf8:")/2 − ABYTES = 236),
  Send disabled + counter reddened when over.
- Q8 — case-insensitive conversation search over name + last body (thread stays
  open even when filtered out); "no matches" hint.
- Q11 — export a decrypted conversation to a plaintext file in the config dir
  (restricted perms, plaintext-warning tooltip), toasting the path.

English strings added to i18n.cpp; per-language JSONs follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:41:00 -05:00
7f46d9e2d5 fix(chat): harden reply-target + scrub legacy secret (B2/B4)
B4: the legacy chat-identity fallback copied the z_exportkey spending key out of
the RPC response and let the temporary json destruct un-zeroed. Mirror the
mnemonic path — take our copy, then sodium_memzero the json's own buffer.

B2: the memo header's peer z-address / cid ride OUTSIDE the secretstream AEAD, so
trusting the newest message's header let a later message redirect our replies or
splice threads. Pin the reply target (and displayed peer) to the EARLIEST
(establishing) message instead of the latest, at both the send and display sites.
Because ChatStore returned filtered INSERTION order (a scan harvests txids in
set/hash order — not chronological), "earliest" wasn't reliable; ChatStore::
conversation now returns messages sorted by (timestamp, txid, payload_position),
which also fixes out-of-order thread rendering and the last-message preview.

A complete fix binds z+cid into the AEAD additional-data, but that's a coordinated
HushChat/SDXLite wire-format change; this pin hardens the reply target without it.
Adversarially verified; the store-ordering gap it surfaced is fixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:07:12 -05:00
100ed01469 feat(chat): unread count + sidebar badge (Q1)
Track a per-conversation "last seen" watermark (message timestamp) on App:
- chatUnreadCount() sums incoming messages newer than each conversation's
  watermark; surfaced as SidebarStatus.chatUnreadCount → a badge on the Chat nav
  item (mirrors the History/Peers badges).
- Viewing a thread marks it seen (markChatConversationSeen while displayed).
- Baseline on load: existing stored messages are marked seen, so only messages
  that arrive while the app is open badge as unread.
- Wiped in resetChatSession so unread state never leaks across a wallet switch.
In-memory only (resets on app restart); persistence is a later refinement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:56:58 -05:00
055685a4c4 feat(chat): copy, add-to-contacts, and new-message toast (Q2/Q3/Q4)
- Q3: copy the peer z-address from the thread header (SmallButton), and right-click
  any message to copy its body.
- Q2: an "Add contact" action in the header when the peer isn't already known —
  one click saves them to the address book (rename later in Contacts).
- Q4: capture ChatService::ingest's new-message count (previously discarded) and
  fire an in-app toast when new encrypted chat arrives while the user isn't on the
  Chat tab (main-thread MainCb sites only).

i18n (EN + 8 languages, additive; no new CJK glyphs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:52:12 -05:00
46f93e380b feat(chat): house-style polish — modal, tactile rows + avatars, DPI
Audit B3/V1/V2/V5/B6 — bring the Chat tab up to the contacts_tab bar:
- New-Conversation modal rebuilt on the house BlurFloat OverlayDialog with
  LabeledInput fields and an accented TactileButton footer (Send disabled until
  both fields are set); wipes the sent plaintext. Replaces the raw ImGui popup and
  its hardcoded widths (V1).
- Conversation rows: leading letter-avatars (deterministic palette color + the
  peer's UTF-8 initial), Primary-tinted selected fill + border, OnSurface hover —
  matching contacts_tab's tactile card rows (V2). Stable PushID(cid) instead of the
  re-sorted loop index (B6). Taller rows.
- Send / New are accented TactileButtons with press feedback (V5).
- All hardcoded px (Send width, modal) go through Layout::dpiScale() so nothing
  clips at 150% (B3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:46:42 -05:00
6f6ad89c9f fix(chat): wipe typed plaintext on a wallet switch + per-thread draft
Audit B1/B5/B8:
- The chat composer / new-conversation buffers are file-static char[]; on a wallet
  switch or lock the plaintext a user typed for wallet A (a private message, or a
  recipient z-address) resurfaced verbatim in wallet B's composer and lingered
  unwiped in RAM. Add ui::ResetChatTab() (sodium_memzero the buffers + clear the
  selection ids) and call it from App::resetChatSession().
- Wipe the single composer draft when the active conversation changes, so text
  typed for one contact can't be sent to another (B5).
- Refresh the stale "read-only / Phase 3" docs — composing/sending is wired (B8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 15:40:19 -05:00
d9911a9bc9 feat(wallet): detect a corrupt target wallet on switch + offer -salvagewallet repair
When a switch fails, the failure modal now distinguishes a CORRUPT target wallet
from other failures and offers a one-click repair:
- The switch worker watermarks the node's captured console output before the
  start and, if the node dies in init, scans this start's output for a corruption
  signature ("Failed to rename … .bak" / "salvage failed" / "wallet.dat corrupt" /
  "Error loading wallet") → sets switch_wallet_corrupt_.
- The Failed modal then shows an accurate "this wallet appears corrupt" message
  (instead of the generic "Couldn't open that wallet") plus a "Try to repair
  (salvage)" button that retries the switch with the target node started under
  -salvagewallet (recovers readable keypairs; implies -rescan).
- EmbeddedDaemon::setSalvageOnNextStart (one-shot, precedence salvage > zap >
  rescan) + controller forwarder; switchToWallet gains a salvage arg.

Salvage operates only on the corrupt target (never the good wallet, no fund
movement). Adversarially verified: output isolation, one-shot lifecycle, state
handling, re-entrancy, no success-path regression.

i18n (EN + 8 languages) + 2 new CJK glyphs baked into the subset font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 23:40:33 -05:00
faf77de9ce fix(console): stop the Clear button crashing the tab
render() computes visible_indices_ (indices into model_) once per frame at the
top, before the toolbar. The toolbar's Clear button called clear() → model_.clear(),
emptying model_ mid-frame (the "cleared" marker is only queued, drained next
frame). renderOutput() then indexed model_[visible_indices_[vi]] with the stale
indices → out-of-bounds → crash.

clear() now also drops visible_indices_ and the selection (both hold line indices
into model_), so this frame's renderOutput iterates zero lines and computeVisibleLines
rebuilds them next frame. The right-click "Clear console" menu item now routes
through clear() instead of a bare model_.clear() so it's covered too.

Adversarially verified: no other same-frame path indexes the emptied model
(fold-toggle, ingest, and selectAll are bounds-guarded).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 23:09:04 -05:00
7f414b3dcf fix(wallet): reliable process detection + drop the harmful start-retry
Two issues the latest live log exposed:
- The switch's start-retry spawned a SECOND dragonxd while the first was still
  shutting down, so the two held wallet.dat against each other (BDB "Failed to
  rename wallet-savings.dat … Error"), and it then span on a stale "Daemon
  already running". Revert to a single start — the stopDaemonForWalletSwitch()
  wait already ensures the old process is gone, so a valid wallet opens cleanly
  and a bad one exits during init and reverts, without overlapping spawns.
- findProcessByName() used the non-suffixed PROCESSENTRY32/Process32First with an
  ANSI _stricmp; if UNICODE is defined those map to the wide variants, so the
  compare comparing garbage would NEVER match — silently making the process-gone
  wait a no-op. Rewritten with the explicit wide Toolhelp API + lstrcmpiW so it's
  correct either way (verified to compile under mingw with and without -DUNICODE).

Note: a corrupt wallet.dat (BDB recovery failing) still can't be opened by any
node — that's a data issue needing a clean reset, not a switch-flow bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:53:05 -05:00
994ddea6cd feat(wallet): richer detail in the wallet-switch progress modal
The switch modal now shows more than a bare phase line:
- The title carries the target wallet ("Switching wallet — savings") and a
  "from <previous>" caption for context (names prettified: wallet.dat → "Default
  wallet", wallet-<name>.dat → "<name>", in-place links → "External wallet").
- During the Reconnecting phase — the ~30-60s where the node loads the block
  index, verifies, and rescans — it surfaces the node's LIVE init stage
  (state_.warmup_status/description via the existing translateWarmup mapping:
  "Loading blockchain data…", "Verifying blockchain…", "Scanning for
  transactions…") instead of a static "Reconnecting…".
- An elapsed timer (m:ss) so the wait visibly progresses.

i18n: from/elapsed/default-wallet/external-wallet labels (EN + 8 languages,
additive; no new CJK glyphs). No switch-flow logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 22:11:53 -05:00
41e4e73e63 fix(wallet): wait for IPv6 port + retry start so a switch survives the DB-env race
Live logs showed the switch's new node starting on the correct wallet, reaching
"Verifying wallet…", then aborting with "Binding RPC on ::1 port 21769 failed" +
"Failed to rename wallet-savings.dat" — the OLD node's RPC port (on ::1/IPv6) and
Berkeley DB environment weren't fully released yet, so the wallet-verify DB
recovery couldn't rename the file. The app then reverted, and the connect loop
brought a node up on the DEFAULT wallet.

Two causes fixed:
- isPortInUse() only probed 127.0.0.1 (IPv4). The daemon also binds ::1 (IPv6),
  which lingers after IPv4 releases — so the readiness wait returned "free"
  prematurely. Now probe BOTH families (Windows: IPv4 + ::1 via in6addr_loopback;
  Linux: /proc/net/tcp + tcp6). mingw-verified.
- The datadir/DB-env can still be briefly held right after the old node exits, so
  the first start can abort. Retry the start (up to 6×, 2s backoff) with the
  CORRECT -wallet — active_wallet_file isn't reverted until we give up — resetting
  the crash count and re-arming -rescan each attempt, until one survives.

Also: the "Wallet switch failed" modal no longer repeats its title in the warning
header — it now shows the actual reason there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:45:20 -05:00
88091bd77c feat(wallet): live progress modal for wallet switching
The "Stop the running node?" confirm modal now stays open through the entire
switch — stop → wait-for-exit → start → reconnect — showing a live phase, and
auto-closes the moment the new node connects. This turns the up-to-a-minute
graceful-shutdown wait from an apparent freeze into visible progress.

- WalletSwitchPhase (Stopping/Starting/Reconnecting/Failed) + atomic phase and
  dialog-open flags; the worker advances the phase, onConnected closes the modal,
  and a failed switch shows the accurate reason with a Close button.
- Owned switches (no confirm) also show the progress modal directly.
- "Continue in background" escape hatch so a long rescan / a hung startup never
  traps the user (the switch keeps running; a toast reports the result).
- isWalletSwitchInProgress() keeps the frame loop redrawing so the phase text and
  spinner animate while otherwise idle.
- i18n (EN + 8 languages, additive) + a modal-switch-progress sweep surface.

State machine adversarially verified 6/6 (thread-safety, no stuck modal,
confirm→progress transition, owned/unowned, no phase leak, redraw scoping).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:56:53 -05:00
02839dc415 fix(wallet): wait for the old node to fully exit before restarting on switch
After the stop, the switch started the replacement dragonxd too early — while the
old (direct-connected, unowned) node was still doing a slow graceful shutdown
(~70s; its network threads block on peer TLS timeouts) and holding the DATADIR
LOCK. The replacement couldn't acquire the lock, failed repeatedly, and the
crash-wedge left it stuck "starting". Root cause: the readiness poll used
isRpcPortInUse(), which on Windows is a connect() probe that reads "free" the
moment the daemon stops ACCEPTING RPC — early in shutdown, long before the
process exits and releases the datadir.

- EmbeddedDaemon::isDaemonProcessRunning(): true while any dragonxd process is
  alive (Windows findProcessByName; Linux /proc/<pid>/comm scan; macOS port
  fallback) — reflects the PROCESS, not just RPC acceptance.
- stopDaemonForWalletSwitch: for an UNOWNED node (no handle), after the RPC stop
  wait until BOTH the port is free AND isDaemonProcessRunning() is false, bounded
  ~120s (or ~5s if the stop couldn't be sent). Owned nodes are unchanged
  (stopEmbeddedDaemon() blocks for exit via the handle).
- Switch notification reworded to set the up-to-a-minute expectation (60s toast).

daemon_restarting_ stays set across the wait so the connect loop can't spawn a
competing daemon. Adversarially verified 6/6; fixes the seed-adopt path too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:27:46 -05:00
17b70a1388 fix(wallet): route switch stop by process-handle ownership, not the external latch
Live logs showed the app usually just DIRECT-CONNECTS to an already-running
dragonxd (config found → connect; EmbeddedDaemon::start() never called). Two
consequences broke the switch: no process handle, and externalDaemonDetected()
stays false (it's only latched inside start()). So a direct-connected node was
treated as "owned" → stopEmbeddedDaemon() → an autoDetectConfig() temp RPC stop
that never reached the daemon → the node never stopped → the ~40s port poll timed
out → "the running node didn't release its connection in time" revert.

Gate on the real ownership signal — whether we hold a live process handle
(isEmbeddedDaemonRunning()) — instead of the unreliable externalDaemonDetected():
- stopDaemonForWalletSwitch: owned (we spawned it) → stopEmbeddedDaemon() with
  SIGTERM/SIGKILL; NOT owned (adopted or direct-connect, no handle) → RPC "stop"
  over the exact creds we're connected with (saved_config_), which is guaranteed
  to reach our node. Then the unchanged port-free poll.
- switchToWallet confirm gate: show "Stop the running node?" when connected to a
  node this session didn't spawn (state_.connected && !isEmbeddedDaemonRunning()).
- Fixes beginAdoptSeedWallet's direct-connect case identically (shared helper).

Adversarially verified (6/6): ownership signal, saved_config_ delivery incl.
cookie auth, foreign-daemon safety, confirm gate, seed-adopt, re-ownership/revert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:46:11 -05:00
42d59709f9 feat(wallet): confirm before stopping an adopted node to switch wallets
When switching wallets, the node the app would restart may be one this session
ADOPTED — a dragonxd already running at launch (left up by "keep node running",
or started by the user). Rather than stopping it silently (or the old dead-code
"stop any external dragonxd first" refusal), show a "Stop the running node?"
confirmation first; switching then stops it (RPC stop + wait for the port to
fully free) and relaunches on the selected wallet.

- switchToWallet(walletFile, stopDaemonConfirmed=false): when the node is adopted
  (externalDaemonDetected) and not yet confirmed, defer to the dialog and return.
- renderSwitchStopDaemonDialog(): BlurFloat overlay (house style) with a warning
  header; confirm re-enters switchToWallet(w, true); cancel/X aborts, node keeps
  running. Owned nodes (started this session) still restart silently.
- i18n (EN + 8 languages, additive) + CJK subset rebuild; modal-switch-stopnode
  sweep surface for visual review.

Gate/re-entrancy adversarially verified (no state leak; owned switches ungated;
prompt reappears on a reverted switch; dead-daemon-before-confirm handled).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:17:08 -05:00
30bc80b2f2 fix(seed): stop an adopted daemon before the migrate-to-seed swap
beginAdoptSeedWallet had the identical adopted-external-daemon bug the wallet
switch fix (3808b3e) fixed: it gated the wallet.dat swap on isEmbeddedDaemonRunning(),
which is process-handle-only and reads false immediately for an adopted daemon —
so the swap could run while a live daemon still held wallet.dat, and the restart
fast-failed on the held RPC port ("wallet swapped but daemon didn't restart").

Route the stop through stopDaemonForWalletSwitch() (RPC-stop the adopted daemon,
wait for the RPC port to actually free) and gate the swap on that port_free
signal instead. Clear the external latch before relaunch only when we actually
stopped it (port_free), so a still-running foreign process is never marked owned.
Owned daemons are unchanged in effect: stopEmbeddedDaemon() already blocks for
full process exit, so wallet.dat is closed before the swap.

The funded new wallet is never at risk (read-only copy source; its isolated
creator daemon was already stopped). Two rounds of adversarial review (fund/swap
safety + control-flow) cleared it. Still pending per policy: a live mainnet run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:00:21 -05:00
3808b3ee82 fix(wallet): let a switch restart an adopted (external) daemon
Switching to a named wallet failed every time when the app had connected to a
pre-existing dragonxd at startup: the daemon is flagged externalDaemonDetected,
so stopEmbeddedDaemon()'s policy is DisconnectOnly ("not ours to stop") and the
switch skips the stop entirely. The old daemon keeps the RPC port, and the
relaunch fast-fails on EmbeddedDaemon::start()'s isPortInUse check — misread as a
bad wallet, reverting after a ~40s hang on the process-handle-only run-wait
(which reads false immediately for an adopted daemon).

A switch legitimately needs to restart the node, so:
- App::stopDaemonForWalletSwitch(): for an adopted daemon, send a graceful RPC
  "stop" using the creds we actually connected with (saved_config_) — only our
  own daemon obeys it, so a foreign dragonxd is a safe no-op — then wait (bounded
  ~40s) for the RPC port to actually free (isRpcPortInUse, the same gate start()
  uses). Owned daemons take the normal stopEmbeddedDaemon() path. No PID/name kill
  is ever issued at an adopted daemon.
- switchToWallet: gate start() on the port actually freeing; if it doesn't,
  abort with a distinct switch_stop_failed_ reason instead of starting into a busy
  port. Clear the external latch before relaunch so the fresh process is owned.
- EmbeddedDaemon::clearExternalDaemonDetected() (+ controller forwarder).
- Accurate revert message: "the running node didn't release its connection in
  time" vs. the bad-wallet message.

Root-caused from live Windows logs; design + implementation adversarially
verified. Note: beginAdoptSeedWallet has the identical pattern and is left for a
separate fund-critical review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:20:50 -05:00
bb5124c1ae feat(wallets): show "HD wallet" instead of "?" when the seed flag is unreadable
When a row is probed as an HD wallet (has hdseed/hdchain records) but the
mnemonic flag can't be read — e.g. the rare tier-1 byte-scan fallback on an
unusual BDB variant or a >256 MB file — it now shows a neutral "HD wallet"
badge (ICON_MD_ACCOUNT_TREE) rather than a bare "?", which read as alarming.
"Unknown" (?) is reserved for a scan that couldn't even establish it's HD
(incomplete, no HD marker seen). Both still yield to the Lock badge when
encrypted. Seed/legacy/hd/unknown remain mutually exclusive.

Adds wallets_badge_hd / _hd_short strings (EN source + all 8 languages,
additive) and rebuilds the CJK subset font for the new glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:47:17 -05:00
e6c78da062 fix(wallets): detect seed-phrase wallets offline via hdchain fMnemonicSeed
The wallet switcher couldn't tell a BIP39 seed-phrase wallet from a legacy /
raw-entropy HD wallet without loading it, so non-active rows fell back to bare
HD-record presence — which mislabeled every HD wallet as "Seed phrase".

The distinction is actually on disk: the daemon serializes CHDChain with an
fMnemonicSeed bool (VERSION_HD_MNEMONIC=3, byte offset 52), and the hdchain
record stays plaintext even in an encrypted wallet. Read it directly:

- wallet_file_probe.h: hdChainMnemonicFlag() decodes the flag from the hdchain
  value (1 mnemonic / 2 no-phrase / 0 undecidable); WalletBtreeStats.mnemonicSeed
  surfaces it from the tier-2 btree walk.
- wallets_dialog.h: the badge prefers runtime z_exportmnemonic for the active
  wallet, then the on-disk flag, then HD-record presence — so every row is
  classified correctly (or honestly shows "?" when the flag can't be read, e.g.
  the tier-1 byte-scan fallback).
- tests: decode-level cases (v3 set/clear, v1/v2, truncated, garbage version)
  plus an end-to-end btree walk asserting mnemonicSeed.

Offset math + badge logic adversarially verified against the daemon source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:24:06 -05:00
5874142186 fix(wallets): don't mislabel a legacy HD wallet as "Seed phrase"
The Wallets modal's "Seed phrase" vs "Legacy" badge came from the offline
wallet.dat probe, which flags hdseed/chdseed/hdchain. But hdchain is present in
BOTH a BIP39-mnemonic wallet AND a legacy HD wallet (DragonX has no separate
mnemonic DB record — the mnemonic is derived from the HD seed), so a legacy HD
wallet was shown as "Seed phrase".

For the ACTIVE wallet the app already knows the truth at runtime via
z_exportmnemonic (wallet_seed_status_). The active row's badge now uses that
authoritative status (activeWalletSeedBadge: seed-phrase / legacy / undecided)
and only falls back to the probe for non-active or not-yet-decided wallets; the
"unknown" seed badge is suppressed once the runtime status is authoritative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:02:50 -05:00
928335dd6b fix(wallet-switch): late-fail revert, shutdown-freeze, per-wallet PIN gate
The two deferred audit MEDs + the per-wallet PIN follow-up:

- Late-init failure revert (MED): the 1.5s start grace only catches a wallet that
  fails IMMEDIATELY. A wallet that fails LATE in dragonxd init (past the grace)
  used to persist as a broken active_wallet_file. Now a switch stays
  "pending confirm" until the daemon actually connects (onConnected clears it);
  if the connect loop instead hits the crash-wedge (crashCount >= 3) while a
  switch is pending, it flags the main-thread revert (which also resets the crash
  count so the restored wallet can start).
- Shutdown freeze (MED): the switch worker's 30s daemon-stop wait now breaks
  promptly when shutdown starts, so beginShutdown's join of the switch task can't
  freeze the UI for the full 30s (shutdown stops the daemon itself). The seed-
  adopt task is intentionally left to finish (fund-safety), as before.
- Per-wallet PIN (follow-up): App::hasPinVault() (and the lock-screen path) now
  gate on the per-wallet vault presence alone, not the GLOBAL getPinEnabled flag —
  so disabling PIN on one wallet no longer suppresses another wallet's PIN
  quick-unlock after a switch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:41:03 -05:00
63dcda0ad1 fix(wallet-switch): close gaps found verifying clusters A/B/C
Adversarial verification of the audit fixes found real gaps in them:

- HIGH throw-safety: the switch worker and the encryption-restart worker set
  daemon_restarting_=true but reset it only on their normal/early-return paths —
  a throw from stop/startEmbeddedDaemon left the flag stuck true, wedging
  reconnect and every future switch/rescan/encryption. Both now reset it on all
  paths (try/catch); a throw during a switch is treated as a failed switch and
  triggers the revert.
- HIGH residual chat leak: resetChatSession() cleared the flags but an already-
  posted z_exportmnemonic worker job still held wallet A's secret, and its
  completion callback (guarded only by isLocked(), false for an unencrypted
  wallet) would provision A's identity under B. Add a chat_session_generation_
  epoch bumped on every wallet change; the fetch captures it and its callback
  discards the (previous-wallet) secret if the epoch no longer matches.
- LOW vault-scope collision: the per-wallet vault tag was a lossy char-substitution
  (two distinct files could map to one vault). Append an 8-hex FNV-1a of the raw
  filename so distinct wallets never share a vault. +unit test.
- LOW seed-adopt: removeVault() on adopt so the legacy wallet's PIN passphrase
  isn't left associated with the new seed wallet (same file name).
- LOW: clear lock_unlock_in_progress_ on switch too.

Deferred (documented): the 1.5s start grace can't catch a wallet that fails LATE
in daemon init (the existing crash-wedge detection still applies); beginShutdown's
join of the switch task can briefly freeze the UI during quit (necessary to avoid
orphaning the daemon).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:06:56 -05:00
122db9d903 fix(wallet-switch): cluster C — no mis-scoping in the unknown-identity window
From the audit: while a switch/first-load is in flight the wallet identity hash
is empty, so contact/portfolio scope-writes silently fell back to "global"
(leaking a wallet-specific entry into every wallet) and scope-filters showed
every wallet's scoped entries.

- Contacts: a NEW wallet-scoped contact created while the identity is unknown is
  now refused with a clear message (tick global or wait); editing an existing
  scoped contact preserves its scope instead of demoting it to global.
- Portfolio: the "Add group" button is disabled while the identity is unknown
  (with a tooltip), so a new group can't get an empty/global scope.
- Both views' visibility filters now show ONLY global entries when the identity
  is unknown — never another wallet's scoped contacts/groups — instead of
  showing everything.
- +2 i18n strings (8 langs; reworded one zh string to stay within the CJK subset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:45:18 -05:00
fc509a9340 fix(wallet-switch): cluster B — daemon-switch robustness
From the wallet-switching audit:

- Revert on failure (HIGH): switchToWallet persisted active_wallet_file before
  confirming the new daemon started, so a missing/corrupt wallet wedged across
  restarts. The switch worker now confirms dragonxd survives a grace period; on
  failure it flags the main thread (processWalletSwitchRevert), which restores
  the previous wallet file + re-scopes the vault + re-arms reconnect (settings
  writes stay on the main thread).
- Cross-guard concurrent lifecycle ops (HIGH): switchToWallet now refuses during
  a rescan/repair (state_.sync.rescanning) or seed migration; rescan/repair now
  refuse while daemon_restarting_; and restartDaemonAfterEncryption now sets
  daemon_restarting_ (which also fixes a latent reconnect-to-a-stopped-daemon
  race during the encryption restart). So the switch/adopt/restart/rescan/repair/
  encryption ops are mutually exclusive.
- Quit during switch (MED): beginShutdown now joins the "Switch wallet" task (like
  the adopt task) so quitting can't orphan a freshly-started dragonxd.
- Reset the daemon crash count on switch (LOW) so a prior crash-wedge can't block
  reconnecting to the new wallet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:37:11 -05:00
f546d3e2b1 fix(wallet-switch): cluster A — identity/secret teardown on wallet switch
From the wallet-switching audit (HIGH-severity cross-wallet leaks):

- Chat identity leak: the full-node switch (switchToWallet) and seed-migration
  adopt reset state_ but NOT the HushChat identity, so wallet A's decrypted
  conversations surfaced under wallet B and outgoing chat was signed with A's
  keypair. Factor the existing teardown into App::resetChatSession() and call it
  on both wallet-change paths (the lite path already reset it via
  rebuildLiteWallet, which now uses the helper too).
- Global PIN vault: the PIN quick-unlock vault was a single vault.dat, so after
  a switch wallet A's stored passphrase was offered/applied to encrypted wallet
  B. SecureVault is now scoped per wallet (vault-<walletfile>.dat); the default
  wallet keeps the legacy vault.dat for back-compat. vault_ is constructed for
  the active wallet and re-scoped on switch, so B has its own (empty) vault.
- Lock-screen state: switching now clears the carried-over failed-attempt
  counter + lockout timer and secure-zeroes the passphrase/PIN entry buffers so
  the previous wallet's unlock state can't apply to the new one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:29:45 -05:00
6f0f95f4fb fix(image-picker): avoid shutdown UB in the async decode counter
Adversarial review of the off-thread decode found the one real defect: a
detached decode worker decremented the static s_animInFlight counter, which at
process exit could run after static destruction begins (shutdown UB). Make the
counter a heap std::shared_ptr<atomic> the worker co-owns, so it safely outlives
teardown; the worker now touches only heap objects it holds a share of. Also
noted s_animatingThisFrame is intentionally main-thread-only.

(Review confirmed the rest: worker never touches the thumb map, done
release/acquire publishes the frames, GL upload stays on the UI thread, stb is
thread_local + libwebp per-instance so concurrent decodes are safe, the
in-flight slot is never leaked, and growth is bounded/cleared on navigate.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:55:11 -05:00
a7c50ff19b perf(image-picker): decode hover animations off-thread (no UI hang)
Hovering an animated GIF/WebP previously decoded ALL frames + uploaded every
texture synchronously on the UI thread — a big/long animation froze the UI for
that first hover (stb's GIF decode is monolithic).

Now the decode runs on a detached background worker (bounded to 2 concurrent),
and frames are uploaded to GPU textures a few per UI frame; the still thumbnail
keeps showing until the sequence is ready, then it animates. A shared_ptr job
keeps the worker's result alive if the thumbnail is destroyed mid-decode (e.g.
navigating away), so nothing blocks. Only images the cheap badge-probe already
flagged as animated ever spawn a worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:50:27 -05:00
e7f2d2e3c7 feat(image-picker): animated-image badge on GIF/WebP thumbnails
Animated thumbnails now show a small play-arrow badge (bottom-right) so users can
spot which images move before hovering; it's hidden while the image plays on
hover.

Detection is cheap — a new util::IsAnimatedImageFile probes without a full
decode: animated WebP via WebPGetFeatures.has_animation, and a multi-frame GIF
via a lightweight image-descriptor block walk (stops at the 2nd frame). The
picker only probes .gif/.webp thumbnails and caches the result on the Thumb.

Verified: animated GIF + animated WebP report animated; still GIF/WebP/PNG do not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:44:57 -05:00
137147921c fix(contacts): don't keep the app awake for off-screen animated avatars
Adversarial review found that the contact list/table loops (no clipper) call
drawContactAvatar for every row, so an animated avatar scrolled out of the
list/table viewport still flagged the render loop as animating — pinning the app
at vsync-rate redraw instead of idling (power drain).

currentAvatarFrame now takes an onScreen flag: off-screen it shows frame 0 and
does NOT set the keep-redrawing flag. The list and table pass ImGui::IsRectVisible
for the row/avatar; the preview passes true; the library grid already yields a
null texture for culled cells. (Two other findings — stb GIF peak host RAM and
the session texture cache — were reviewed and judged bounded/local-only.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:26:31 -05:00
fd0cab41f0 feat(image-picker): play animated thumbnails on hover
Hovering a GIF/WebP thumbnail in the picker now previews its animation:

- The picker's Thumb gains a lazily-loaded frame sequence (via LoadAnimatedRGBA)
  fetched the first time an animatable (.gif/.webp) thumbnail is hovered; still
  images and other formats keep their single static thumbnail (no re-decode).
- On hover the current frame is drawn on the ImGui clock; leaving the thumbnail
  returns it to the still frame-0 preview.
- A clear-on-read flag (ImagePicker::consumeAnimationActive) is OR'd into
  ConsumeContactsAvatarAnimation so the render loop keeps drawing while a hover
  preview plays and idles otherwise. clearThumbs frees all frame textures too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:22:57 -05:00
4b8d80b2fc feat(contacts): animated avatars (GIF/WebP) with a Settings toggle
Animate contact avatars end to end:

- texture_loader gains LoadAnimatedRGBA: decodes an image into a downscaled
  RGBA frame sequence + per-frame durations — animated GIF via stb
  (stbi_load_gif_from_memory) and animated WebP via libwebp's WebPAnimDecoder;
  stills (and APNG, which stb reads as one image) return a single frame. Frames
  are box-downscaled (smaller cap for animations) to bound VRAM, capped at 300.
- The contacts avatar cache now holds a frame sequence; currentAvatarFrame()
  advances animated avatars by the ImGui clock and is used everywhere avatars
  draw (list, cards, table, grid, preview). When a live animated frame is drawn
  it flags the render loop (ConsumeContactsAvatarAnimation, clear-on-read) so
  main.cpp keeps producing frames while animation plays and idles when it stops
  or the contacts view is hidden.
- New animate_avatars setting (default on) + a Settings appearance toggle
  ("Animate avatars"); off shows the first frame only. currentAvatarFrame
  honors it. +i18n (8 langs, CJK subset rebuilt for 帧/播/첫).

Verified: a 3-frame GIF and a 3-frame animated WebP both decode to 3 frames
with correct 120ms delays through the exact libwebp/stb calls used here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:11:06 -05:00
923d086092 feat(images): WebP decode via libwebp + more stb formats
Broaden avatar/image support:

- Add libwebp (FetchContent, static, decode-only) so WebP loads. Built from
  source for Linux / mingw-Windows / macOS-osxcross identically — the cross
  sysroots have no webp, so vendoring from source is the one portable path;
  encode/tool builds are disabled to avoid pulling in libpng/zlib. Linked as
  webp + webpdemux (the latter for animated WebP, wired next).
- texture_loader routes all decode through DecodeImageRGBA: sniffs the RIFF/
  WEBP header and uses libwebp (WebPDecodeRGBAInto into a free()-able buffer),
  else stb — so every existing caller (avatars, QR, thumbnails) gains WebP for
  free with no allocator mismatch.
- Enable stb's TGA / PSD / PNM / PIC decoders and add .webp/.tga/.psd/.pnm/
  .ppm/.pgm/.pic to the image-picker + avatar-library extension lists.

Verified: libwebp builds static and a real .webp decodes to correct RGBA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:57:01 -05:00
6ac8f66644 fix(contacts): review fixes for the avatar library grid
From the adversarial review of the image-library grid:

- MED: the delete badge was an InvisibleButton positioned via SetCursorScreenPos,
  which left CursorPosPrevLine at the badge corner — the next cell's SameLine
  reads that, so a hovered/selected row's trailing cells jittered ~3px and their
  hit-rects overlapped. Hit-test the badge MANUALLY (no layout item, no cursor
  moves); it still takes click priority over selecting the thumbnail.
- MED: avatar textures were uploaded at full native resolution and cached for the
  session with no cap — a library of large photos could cost GBs of VRAM, and the
  contact list decoded every image avatar at once on tab open. Box-downscale to
  <=256px (an avatar renders at most ~112px) and budget decodes to a few per
  frame, so large libraries fill in progressively instead of stalling.

(A third finding — a symlink edge case in the delete guard — was reviewed and
judged below the bar; the weakly_canonical + parent-path guard already holds.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:33:51 -05:00
62fc202557 feat(contacts): image avatars are now a managed library grid
Redesign the Image avatar tab from a single-image chooser into a grid over a
persistent image library (mirrors the Icon tab):

- Images the user adds live in <config>/contact-avatars/ and PERSIST as a
  reusable, portable set — they travel with the wallet data dir, so avatars
  survive moving to another machine without remembering source paths.
- The grid's first cell is always the "+ add image" button (opens the picker,
  decode-verifies, copies into the library, auto-selects the new image).
- Each library image is a selectable thumbnail (Primary ring when selected)
  with a delete badge (top-right, red on hover) to remove it from the library;
  the delete is deferred past the grid loop and clears the selection if it
  pointed at the removed file. Contacts still referencing a deleted image fall
  back to their Z/T badge.
- Removes the previous auto-prune of "unused" images on edit/delete — images
  are only removed by the explicit delete badge now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:22:35 -05:00
f8034b843a fix(contacts): stricter path guard in pruneOrphanAvatar
Compare the file's parent path to the managed dir instead of a string prefix,
so a sibling dir like contact-avatars-x can't false-match. Our avatar copies
always live directly in the dir, so this is both correct and safer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:09:00 -05:00
731d4e04ff feat(contacts): avatars in Table view + prune orphaned avatar images
- Table view now shows the same avatar (image / icon / Z-T badge) before each
  contact's label, drawn after the row Selectable so its highlight doesn't
  paint over it — visual parity with Cards/List.
- When a contact's custom image avatar is replaced (edit) or the contact is
  deleted, its now-unused file in <config>/contact-avatars/ is removed
  (pruneOrphanAvatar) — but only if no other contact still references it and
  the path is inside our managed dir, and its cached texture is dropped too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:08:04 -05:00
9649654c3c fix(contacts): adversarial-review fixes — delete UB, JPEG decode, footer clip
From an adversarial review of the avatar edit-dialog + image picker:

- HIGH: the per-row Delete icon called doDelete() (which erases from
  book.entries()) INSIDE the loop iterating that same vector — out-of-bounds
  reads / wrong rows on the confirming click. Defer it until after the loop.
- HIGH: the decode stack was compiled PNG-only (STBI_ONLY_PNG) while the image
  picker accepts .jpg/.jpeg/.bmp/.gif, so picking a JPEG (the common photo
  case) silently produced a non-loading avatar + an orphaned copy on disk.
  Enable JPEG/BMP/GIF decoders, and guard the pick: verify the source decodes
  before copying/committing (new contact_avatar_bad_image string, 8 langs).
- MED: the fixed, non-scrolling edit card floored bodyH at 260*dp, which could
  push Save/Cancel below the card on short windows — floor lowered so the
  footer always stays inside.
- LOW: the live-preview panel rounding is now dp-scaled (10*dp) to match the
  real list card it mirrors.
- LOW: re-picking the same source path after its contents changed showed a
  stale cached texture — evict the avatar texture cache entry on re-pick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:49:34 -05:00
e1df5ea798 feat(image-picker): thumbnails fill width at 6 per row
Fix the thumbnail grid to a constant 6 columns whose square cells scale to the
available width, instead of fixed 96px cells that left dead space on the right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:43:23 -05:00
a53b13b6b6 feat(image-picker): smooth scroll, inset scrollbar, 2-column folder grid
- Smooth (lerped) wheel scrolling via ApplySmoothScroll on the list, matching
  the app's other modal lists.
- The list is now a bordered/rounded outer frame whose 6px padding insets the
  scrollbar so it clears the card's rounded corners; the inner scroll child is
  transparent (the frame draws the single background — no more box-in-a-box
  from the ChildBg being left on the stack across both BeginChild calls).
- Folders render as a 2-column grid of thin rounded rectangles (folder icon +
  name) instead of full-width rows, so more folders are visible at a glance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:31:48 -05:00
bc3c6037fc fix(image-picker): inset content from rounded card + center footer
- Wrap the picker body in a padded inner child so the filled directory list and
  thumbnail grid keep a clear margin from the card's rounded corners instead of
  running edge-to-edge past them.
- Center the Use image / Cancel buttons and drop the stray separator line above
  them (the faint artifact at the footer's left edge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:09:18 -05:00
a338fac208 fix(contacts): edit-dialog layout polish + per-row edit + hover address
Address five reported issues:

- Address field is now full width with a centered Paste button beneath it
  (was a narrow field with Paste crammed alongside).
- The pinned "Show in every wallet" checkbox no longer clips at the column
  bottom — the Notes fill reserves a clear margin for it.
- Image mode: more spacing between the preview circle and the Choose/Remove
  row so the button isn't crowding the avatar.
- Contact list: hovering a row now un-collapses the address to its full form
  inline (clipped to the text column) instead of popping a tooltip.
- Per-row copy/edit/delete icons fire on the first click on an unselected row:
  the action lambdas validate s_selected_index freshly (via selValid())
  instead of the frame-top has_selection bool, which was stale in the same
  frame the icon set the selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:53:05 -05:00
9bc2a0b62a feat(contacts): tall edit dialog — fill vertical space, accent Save
The dialog was a squat auto-height box with a big dead zone below and an icon
grid that clipped at ~3 rows. Make it a fixed, tall card (up to 84% of the
viewport) whose body flexes to fill the height:

- The icon grid grows into the space — ~8–9 rows visible instead of 3.
- Notes expands to fill the left column above the now bottom-pinned Global
  checkbox, so the left side uses the height too.
- The image-mode preview circle is bigger (r44 → r56) and vertically centered,
  with glyphs scaled to it.
- Footer pins to the bottom; Save/Add is now accent-colored so the primary
  action reads above Cancel.

Notes/actionButton schema lookups that only fed the old fixed heights are gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:29:04 -05:00
1b3446e43c feat(contacts): richer edit-dialog avatar picker (preview, chips, image, icons)
Four refinements now that the two-column layout has room:

1. Image mode shows a large circular preview of the chosen picture (or a
   placeholder circle with an add-photo glyph, or a broken-image glyph if the
   file went missing), the filename, and centered Choose/Remove buttons —
   instead of a bare button.
2. The live-preview avatar is larger (r20 → r26) and the address is now
   middle-truncated (head + tail) so both ends read, like the real list row.
3. Badge mode shows the two actual chips — Z (shielded) and T (transparent) —
   with labels, making clear the badge is auto-picked from the address type,
   rather than a line of text.
4. The Badge/Icon/Image segmented control gains glyphs (badge / palette /
   image) beside its labels via a small two-font inline control (the shared
   SegmentedControl helper is single-font).

Adds contact_avatar_shielded / _transparent keys and rewords the badge hint
(the chips now carry the Z/T meaning); +8-language translations, all within
the existing CJK subset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:07:07 -05:00
19dea53ef8 refactor(contacts): two-column edit dialog — wider, shorter, roomier grid
The edit dialog was a tall, narrow single column that wasted ~half the
horizontal space and cramped the icon grid into ~2.5 clipped rows crowding
the footer. Rework it into the portfolio-editor two-column shape:

- Card widened 560 → 880 logical.
- Full-width live preview stays on top (now shows more of the address).
- Body is two fixed-height columns: form (label / address+paste / notes /
  global) on the left, avatar picker (segmented + icon grid / image / badge
  hint) on the right, each filling its column so the grid gets ~6 columns and
  ~4 rows instead of clipping mid-row.
- Overall modal is much shorter, so Save/Cancel no longer crowd the grid.
- Badge-mode hint re-centered relative to the current cursor (it now sits
  below the segmented control inside the shared column, not a fresh child).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:23:19 -05:00
3d2b734541 refactor(contacts): drop now-unused addrInput schema lookup
The revamped edit dialog derives its input widths from the card content
width, so the address-input schema config is no longer read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:10:57 -05:00
2d4ba89c00 feat(contacts): revamp edit dialog with live preview + avatar picker
Rebuild the add/edit contact dialog on the portfolio-editor design language:

- A live preview card at the top shows the contact exactly as it renders in the
  list (avatar + name + address), updating as you type and pick an avatar.
- An avatar picker (Badge / Icon / Image segmented control) lets you keep the
  default Z/T type badge, choose a Material wallet icon from a searchable grid,
  or set a custom image. The picker area is fixed-height so the modal doesn't
  jump when switching modes.
- Custom images go through a new in-app ImagePicker (image_picker.h): a
  Material overlay that browses the filesystem starting at the user's Pictures
  folder, shows a thumbnail grid (decoded to raw pixels, box-downscaled to a
  small texture, cached per directory and freed on navigate/close, budgeted a
  few decodes per frame so large folders don't hitch), and returns the chosen
  path. The chosen image is copied into <config>/contact-avatars/ (named by an
  FNV hash of the source path, so re-picking is idempotent) and stored as
  "img:<path>". Like FolderPicker, it takes over the modal surface while open.
- Paste is now available on both add and edit; buttons are content-sized.

Adds three sweep surfaces (contacts-edit-{icon,badge,image}) that open the
dialog on a seeded contact in each avatar mode, plus i18n keys (+ 8-language
translations; reworded two zh/ja strings to stay within the existing CJK
subset, so no font rebuild).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:10:00 -05:00
2249bc31d5 feat(contacts): contact avatars — custom image / Material icon / Z-T badge
Add an `avatar` field to AddressBookEntry ("" = default Z/T type badge,
"icon:<name>" = a Material wallet-icon, "img:<path>" = a custom image),
serialized additively in addressbook.json (only written when non-empty, so
existing books are untouched).

Render it in the Cards/List views via a new drawContactAvatar helper: custom
images are loaded once through a path-keyed texture cache and drawn
circular-cropped (centre-cropped UVs + a thin border ring); icons reuse the
project-icon set (incl. the special pickaxe font path) in a tinted circle;
everything else falls back to the existing Z/T badge (also the fallback when
an image fails to load or an icon name is unknown).

Seed one sweep contact with an icon avatar to exercise the icon-badge path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 06:49:52 -05:00
1ce37aa0fa feat(contacts): deselect on empty click, actions left of globe, right-click menu
Three interaction refinements to the address list:
- The globe badge now stays pinned far-right; the per-row copy/edit/delete actions
  appear to its LEFT on hover/selection instead of replacing it.
- A left-click on empty space in the Cards/List area clears the current selection
  (no row/action hovered -> deselect).
- Right-clicking a row (any view) selects it and opens a shared context menu
  (Copy address / Edit / Delete), rendered once after the list.

Build + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 06:36:50 -05:00
33317c4e78 feat(contacts): per-row hover actions + fix add/edit modal HiDPI widths
Two follow-ups from the contacts audit:

- Per-row actions: in the Cards/List views, copy/edit/delete icon buttons now appear
  on the right of a row on hover or selection (the globe badge shows otherwise). The
  row Selectable uses SetNextItemAllowOverlap so the action InvisibleButtons take
  click priority; whole-row hover (IsMouseHoveringRect) drives the highlight so it
  survives hovering an icon; the delete icon turns red while armed (two-click confirm);
  each has a tooltip. Trailing space is reserved so the text never reflows on hover,
  and the cursor is restored after the manual action layout.

- Add/edit modal HiDPI: the Layout::kDialog* helpers fold dpiScale() (physical px)
  while raw schema widths are logical, so the schema-path formW/actionW/actionGap/
  notesH were unscaled vs their scaled fallbacks. Scale the schema-path values; and
  since BeginOverlayDialog re-applies dpiScale to cardWidth, divide the already-scaled
  dialogW back out (it was double-scaled). No visible change at 100%; correct at 150%.

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 06:23:17 -05:00
03dde25339 test(sweep): seeded contacts-cards/list/table surfaces for the view toggle
Add three sweep surfaces (contacts-cards / -list / -table) that force each Contacts
address-list view mode and seed a few demo contacts (Z + T types, some global) so
the modes render with data instead of the empty state. Teardown restores the real
book and resets the mode.

To avoid touching the user's persisted address book (the Windows sweep can run on
the real HOME, and addEntry/removeEntry call save()), add AddressBook::sweepSetEntries
— a no-save in-memory setter used only here: the surface snapshots the real entries,
swaps in the demo set, and restores the snapshot on teardown, so nothing is written
to disk even if the sweep is interrupted.

Build + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:10:24 -05:00
3fbb64d14c feat(contacts): switchable Cards / List / Table view for the address list
Add a persisted view toggle so the contacts address list can be rendered three
ways, moving it away from a bare data-grid toward a Material look:

- Cards: tactile rounded cards with a circular Z/T type-avatar, label over a muted
  truncated address, trailing globe badge, primary-tint + outline selection, hover
  fill, and a centred Material empty state.
- List: borderless two-line rows (same avatar + label/address) with hover tint and a
  thin per-row divider — denser than cards.
- Table: the previous 3-column table, material-ized (grid borders dropped, row
  backgrounds + interactive sort kept).

The toggle is an icon SegmentedControl (view-agenda / view-list / table-rows)
right-aligned on the toolbar; the choice persists via a new contacts_view_mode_
setting (0 cards / 1 list / 2 table, mirroring portfolio_style_). Cards/List sort by
label; Table keeps its sortable headers. All geometry is dpiScale()-aware; the globe
badge is drawn in the icon font in every mode. Default is Cards.

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:07:33 -05:00
1b0006a4af fix(contacts): audit fixes — clip, tofu badge, plural, DPI, columns, toolbar
Address the confirmed findings from a workflow audit of the Contacts tab body:

- Label clip: the Label column was WidthFixed 150px and clipped long labels
  mid-word ("drgx pool payout a…"). Make all three columns WidthStretch (label 1.5,
  address 2.6, notes 1.0) so the label grows with the tab and the notes column no
  longer reserves a fixed empty block on the right (notes = low weight, per request).
- Globe badge tofu: the global-contact ICON_MD_PUBLIC badge was drawn with the text
  font (no Material glyphs) → rendered as "?". Push Type().iconSmall() around it.
- Plural: "1 addresses saved" -> add address_book_count_one ("%zu address saved",
  8 langs, %zu kept so the format signature matches) and branch on count == 1.
- DPI: the SameLine badge gaps (6/8px) and the table-height floor (120px) are now
  * Layout::dpiScale(); the WidthStretch columns are relative so need no scaling.
- Toolbar: give the four actions leading Material icons (person-add / edit / delete /
  content-copy) and accent the primary "Add New" — via a small local icon+label
  tactile-button helper (ImGui has no two-font button), icons inherit the
  disabled-aware text alpha so they gray out with BeginDisabled().

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:49:15 -05:00
ba13875eaf fix(receive): size the QR popup to its content, not 85% of the window
The QR popup used the same window-relative card width the Export-Key dialog just
moved away from (its comment even said "Match the key-export modal: 85% of the
window width"). On a large monitor that left the centered AddressCopyField floating
far from its left-aligned "Address:" label and inflated the QR's empty margins.

Derive the card width from the address field's own natural (chunked) box width
(mirroring the key-export fix, incl. the overlay card's 28px content inset so the
address stays on one line), bounded to 85% of the window as an upper limit. Also
scale the responsive-QR size literals (280 fallback / 420 cap) by dpiScale() so the
QR renders at the intended size on HiDPI/font-scale. No change to the QR texture /
clipboard logic.

Found via a workflow audit of all 27 redesigned modals for the same anti-pattern —
the QR popup was the only other occurrence (0 false positives).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:10:49 -05:00
a5d44c8aca fix(overview): size the Export-Key modal to its content, not 85% of the window
The Export-Key dialog sized its card to a fixed 85% of the window width. On a large
monitor that's ~1570px, but the content is ~900px, and AddressCopyField centers its
text-clamped box within the available width — so the address/key fields floated far
from their left-aligned "Address:"/"Viewing Key:" labels and read as off-center
(more visible after the warning box became a left-aligned DialogWarningHeader).

Derive the card width from the address field's own natural (chunked) box width —
the widest deterministic element before the key is revealed — mirroring
AddressCopyField's boxW math, plus the overlay card's 28px-per-side content inset so
the address sits on one line under its label. Adapts to address type (z vs t) and
font scale with no magic width; bounded to 85% of the window as an upper safety
limit; the revealed key+QR layout adapts to whatever width results. Width-only
change — the key fetch/wipe/mask/close logic is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:10:49 -05:00
1a41dec8d8 test(sweep): add capture surfaces for the Wave-2 fund/secret modals
Register sweep surfaces for the redesigned Wave-2 modals so they get captured for
visual review: modal-shield + modal-merge (ShieldDialog's two modes),
modal-transfer (a z->t transfer so the converted deshielding DialogWarningHeader
renders), and modal-key-export (KeyExportDialog — named distinctly from the
settings modal-export-key). Each setup opens the dialog through its public show()
without clicking any button, so no async RPC fires; teardown closes it.

Adds a static hide() to ShieldDialog and KeyExportDialog for clean sweep teardown
(AddressTransferDialog already had close()). KeyExportDialog::hide() also clears the
revealed key + frees its QR, mirroring the dialog's own close/dismiss secret-wipe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:37:22 -05:00
88e10f3e06 feat(overview): migrate the Export-Key modal to the reference design
Presentation-only migration of KeyExportDialog::render (reveals a private or
viewing key) — a secret-handling modal:
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth cardW,
  idSuffix keyexport}) replacing the legacy positional overload
- replace the bespoke red-bordered "WARNING" box with material::DialogWarningHeader
  (user-approved) so the key-export warning matches the import-key reference; passes
  the correct per-key-type body key (private vs viewing)
- remove the two dividers (after the warning, above the Close footer)
- 4 StyledButton -> TactileButton (retry / reveal / show-hide / close)

The secret path is byte-for-byte unchanged: the z_exportkey/dumpprivkey/
z_exportviewingkey (and lite exportPrivateKeys) fetch + their secureWipeLiteSecret
calls, the "*"-masking when hidden, the Show/Hide toggle, copySecretToClipboard
(auto-clearing), the QR generate/cache/releaseQr, and the secret-clear on BOTH exit
paths (Close button + the post-EndOverlayDialog dismiss teardown).

Verified: build + ctest + hygiene clean; a 2-lens adversarial review
(secret-hygiene / presentation-fund) returned zero findings, proving every
secret-handling path diffs byte-for-byte identical to HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:23:14 -05:00
d29283a9b7 feat(overview): migrate the Transfer-funds modal to the reference design
Presentation-only migration of AddressTransferDialog::render (drag-one-address-
onto-another transfer) — a fund-moving modal:
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth 620,
  idSuffix addrtransfer}) replacing the legacy positional overload
- the top-of-dialog deshielding warning -> material::DialogWarningHeader; the green
  shielding notice stays colored text (it's a positive notice, not a warning)
- remove the footer divider and drop its "+1.0f" footerH reserve term (keep the
  form->result-preview divider)
- reuse the existing shield_operation_id key for the "Operation ID: %s" label (no
  new i18n key)

The fund path is byte-for-byte unchanged: the amountValid guard, totalDeduct/
newFromBal/newToBal math, maxSendableAmount, the Max button, s_app->sendTransaction
+ its ok/fail callback, and the BeginDisabled(!amountValid || s_sending) gate.

Verified: build + ctest + hygiene clean; a 2-lens adversarial review returned zero
findings, proving the fund path diffs byte-for-byte identical to HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:17:13 -05:00
ca65aa8bf7 feat(send): migrate the Shield/Merge modal to the reference design
Presentation-only migration of ShieldDialog::render (Shield coinbase / Merge funds)
— a fund-moving modal:
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth win.width,
  idSuffix shielddialog}) replacing the legacy positional overload
- drop the description divider and the form->footer divider (keep the post-submit
  operation-id section divider)
- 3 StyledButton -> TactileButton (submit / cancel / check-status)
- TR() the hardcoded "No shielded (z) address yet …" note -> new shield_no_zaddr_hint
  key, 8 langs (em-dash preserved); the "DRGX" ticker stays hardcoded per convention

The fund path is byte-for-byte unchanged: the can_submit guard, fee/utxo clamps, the
"ANY_TADDR" setup, the z_shieldcoinbase / z_mergetoaddress worker->post calls + their
opid/status/Notifications/trackOperation callbacks, and the BeginDisabled/tooltip logic.

Verified: build + ctest + hygiene clean; a 2-lens adversarial review
(fund-path-untouched / presentation-i18n) returned zero findings, proving the fund
path diffs byte-for-byte identical to HEAD via isolation diffs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:03:54 -05:00
dae677acd8 feat(send): migrate the Confirm-Send modal to the reference design
Presentation-only migration of RenderSendConfirmPopup (the confirm-before-broadcast
popup) — a fund-moving modal:
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth popupW,
  idSuffix confirmsend}) replacing the legacy positional overload; p_open stays
  nullptr (the popup keeps its own Escape + Cancel handling)
- TR() the one hardcoded status string in the synchronous live-revalidation guard
  ("Cannot send now …") -> new send_cannot_send_now key, 8 langs (em-dash preserved)

The fund path is byte-for-byte unchanged: the live re-validation guard, the
app->sendTransaction call + its success/failure callback, the lite-locked
requestLiteUnlock branch, the s_sending/s_show_confirm state, the Escape handler,
and the FROM/TO cards + fee-tier/summary rendering.

Verified: build + ctest + hygiene clean; a 2-lens adversarial review
(fund-path-untouched / ui-i18n) returned zero findings, with the fund-path lens
proving the send path diffs byte-for-byte identical to HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:58:11 -05:00
4ef09d5546 fix(ui): drop Validate-address results divider; remove ungapturable daemon-prompt surface
Two sweep-driven fixes:
- Validate Address: remove the input->results Separator. When no result is shown
  yet it sat directly above the Close button and read as a footer divider,
  inconsistent with the reference design (the description divider was already gone).
- Drop the modal-daemon-prompt sweep surface: renderDaemonUpdatePrompt is gated
  behind !capture_mode_, so it never renders during a sweep (the capture showed the
  Settings page behind it). Its migration stays verified via build + the shared
  BlurFloat/TactileButton pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:40:59 -05:00
21be39c725 test(sweep): add capture surfaces for the Wave-1 standalone modals
Register sweep surfaces so the redesigned Wave-1 modals get captured for visual
review: modal-qr-popup, modal-request-payment, modal-validate-address,
modal-address-label, modal-daemon-prompt, and modal-antivirus (the last renders
only on Windows, where its #ifdef body compiles). Each is driven through the
dialog's public show()/flag; teardown closes it. Adds a static hide() to
RequestPaymentDialog / ValidateAddressDialog / AddressLabelDialog for clean sweep
teardown (they had no public close). The contact add/edit and explorer block-detail
modals are tab-embedded and not yet surfaced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 17:15:24 -05:00
abbdf3f4f7 feat(mining): migrate the Windows Defender antivirus-help dialog to reference design
Bring renderAntivirusHelpDialog onto the reference design and remove its hardcoded
English (the last Wave-1 modal):
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth 560,
  idSuffix antivirus}) replacing the legacy positional overload
- the hardcoded "Windows Defender Blocked Miner" title and all 10 step/intro/bullet
  strings -> TR() (11 new av_* keys + reuse `close`; 8-lang translations, Windows
  env vars / xmrig.exe kept literal, menu names localized)
- 2 StyledButton -> TactileButton, drop the footer divider, dp-scale the button widths
- CJK subset rebuilt (glyphs 1741->1751; kept — new glyphs are used)

NOTE: this dialog is inside #ifdef _WIN32, so the Linux build compiles it out and
cannot verify it — needs a Windows build to compile-check. The edits mirror the
already-verified daemon-update-prompt / migrate-to-seed struct-overlay pattern and
leave the ShellExecuteA call untouched. i18n.cpp + the daemon prompt (cross-platform)
build + ctest + hygiene clean on Linux.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:38:07 -05:00
28288d7197 feat(ui): migrate Explorer detail/loading + daemon-update prompt to reference design
Presentation-only reference-design migration:
- Explorer loading modal -> struct-form overlay (idSuffix explorerloading); block-
  detail modal loses its header divider; the search button + the prev/next nav
  become TactileButton; TR() the hardcoded "Previous block"/"Next block" tooltips,
  "TxID:", and the "shielded" output fallback (3 new explorer_* keys + reuse the
  existing `shielded` key), all 8 langs. Block-fetch RPC path untouched.
- Daemon-update startup prompt -> struct-form overlay (idSuffix daemonupdprompt),
  2 StyledButton -> TactileButton, drop the footer divider. The reinstall/
  prompted-size persistence logic is unchanged.

Build + ctest + hygiene clean; "DRGX" ticker left hardcoded per convention.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:33:52 -05:00
304f65c3b4 feat(ui): migrate Validate-address, Address-label & Contacts modals to reference design
Presentation-only reference-design migration of three more modals:
- Validate Address: struct-form overlay (idSuffix validateaddr), 3 StyledButton ->
  TactileButton, drop the description divider, and TR() the hardcoded "Error: %s"
  (reuse the existing error_format key). The validateaddress RPC/worker path is untouched.
- Address label: struct-form overlay (idSuffix addrlabel); drop the footer divider and
  zero its layout-reserve term (separatorH) so the fixed-height icon-picker card math
  stays consistent. Buttons were already TactileButton.
- Contacts add/edit: struct-form overlay (keeps its idSuffix + BeginOverlayDialogFooter);
  StyledButton -> TactileButton across the file (3 modal + 4 contact-list buttons) for a
  consistent Contacts tab. The address-book add/update/notify logic is untouched.

Build + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:24:28 -05:00
04a5078133 feat(receive): migrate QR + Request-payment modals to the reference design
Bring the two Receive-tab modals onto the settings-modal reference language
(presentation-only):
- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth, idSuffix})
  replacing the legacy positional overload (idSuffix qrpopup / requestpay)
- StyledButton -> TactileButton (2 + 3 sites)
- drop Request-payment's description divider

No logic touched: the QR texture lifecycle, payment-URI builder, address combo,
and clipboard/notification calls are unchanged. The "DRGX" ticker stays hardcoded
per the existing codebase convention (7 sites, no currency TR key).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:20:02 -05:00
b6b4fdacb5 fix(material): light-theme modal backdrop reads light, not dark
BlurFloat overlays draw a full-window veil over the live blur so the card-less
floating modal content reads as foreground. The light-theme branch used a strong
neutral DARK veil (IM_COL32(16,18,24,200)) on the theory that "light content pops
on a dark veil" — but on a light theme the modal chrome is dark-on-light, so it
sank INTO the veil: the heading, description, Sort row, and the create/scan/
reveal/close buttons all washed out to low-contrast dark-on-dark, and a "light"
theme read dark whenever a modal opened.

UI controls already carry a dropshadow on light themes for figure/ground, so the
backdrop no longer needs to supply contrast by going dark. Switch the light-theme
veil to a theme-tinted LIGHT frost (WithAlpha(Background(), 205)); the dark-theme
branch is untouched. Fixes every BlurFloat modal at once.

Verified via full sweep: marble / light read as clean light frost with all chrome
legible, dune gets a theme-appropriate warm frost, and obsidian (dark) is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:09:16 -05:00
f6024557d5 refactor(updater): drop dead RenderReleaseList picker; keep shared ReleaseRow
The miner and node updater dialogs were rebuilt around their own two-pane
tactile-Material version lists, which superseded release_list_view.h's
RenderReleaseList() picker. That function had zero call sites — both dialogs
include the header only for the shared ReleaseRow struct. Remove the dead
function (and the material/i18n/imgui includes it alone needed), leaving just
the ReleaseRow model, and correct CLAUDE.md's stale "shared picker" description
to reflect that the two-pane list is the browse-all UI and only the row model
is shared.

No behavior change: build + ctest + hygiene clean; grep confirms no remaining
RenderReleaseList references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 14:51:02 -05:00
d82821ba06 feat(settings): redesign the migrate-to-seed modal onto reference design
Migrate App::renderSeedMigrationDialog onto the settings-modal reference design.
This is the most fund-critical dialog in the app (create isolated seed wallet ->
z_mergetoaddress sweep -> confirm -> adopt/rescan), already fully i18n'd and
dp-scaled, so the change is purely the presentation shell:

- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth 580,
  idSuffix "migrate"}) replacing the legacy positional overload
- all 26 StyledButton -> TactileButton (identical signature; height-0 auto-sizing)
- remove all 13 footer Separator dividers
- ShowSeed seed-phrase warning -> material::DialogWarningHeader (red text -> amber
  ⚠ header, matching the export-key reference dialog)

The fund pyramid is byte-for-byte unchanged: the busy/dismiss veto, the wipeSeed
(sodium_memzero) + close lambdas and the post-EndOverlayDialog seed scrub, all
seed_migration_step_ transitions, begin CreateSeedWallet/SweepToSeedWallet/
AdoptSeedWallet, refreshSeedMigrationBalance, pollSweepStatus, the snprintf
balance/confs/remaining formatting, both confirmation checkboxes and their gates,
RenderSeedWordGrid, copySecretToClipboard, writeFileAtomically, and the Discard
remove_all + settings cleanup.

Verified: build + ctest + hygiene clean; Spacing() count unchanged (28->28,
proving no collateral deletion); StyledButtons elsewhere in app.cpp untouched
(the swap was range-bounded to this function); a 3-lens adversarial review
(fund-path-untouched / secret-hygiene / widget-visual) returned zero findings,
with the fund-path lens proving the logic diffs byte-for-byte identical to HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:55:01 -05:00
8290e96ec4 test(sweep): add modal-pin-setup/-change/-remove capture surfaces
Register the three redesigned PIN dialogs as sweep surfaces for visual review.
Each setup only sets the show flag (never fires the async vault store/verify); the
teardown clears the status and zeroes the PIN/passphrase buffers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:29:02 -05:00
46f7da4fac feat(settings): redesign the PIN setup/change/remove modals onto reference design
Migrate the three PIN dialogs in App::renderPinDialogs (Set PIN / Change PIN /
Remove PIN) onto the settings-modal reference design — presentation-only, plus a
deliberate secret-hygiene addition:

- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth 420/420/400,
  idSuffix pinsetup/pinchange/pinremove}) replacing the legacy positional overload
- drop each dialog's redundant post-description Separator
- every rendered-chrome string -> TR(): titles/buttons reuse settings_set_pin /
  settings_change_pin / settings_remove_pin, the Set-PIN confirm reuses
  wiz_pin_confirm; 7 new pin_* keys added to the English source + all 8
  res/lang/*.json additively; ImGui::Text -> TextUnformatted for labels
- HARDENING: wipe the passphrase/PIN input buffers on BlurFloat dismiss
  (X/Esc/outside-click) for all three dialogs. Set PIN's wallet-passphrase buffer
  (pin_passphrase_buf_) was previously left resident on dismiss; it is now zeroed.

The vault/RPC path is byte-for-byte unchanged: the submit-time memsets, the
walletpassphrase/walletlock RPCs, vault_->store/changePin/retrieve/removeVault,
SecureVault::secureZero, and setPinEnabled/save are untouched. The pin_status_ and
toast strings inside the worker callbacks are left in English (a separate
service-layer i18n pass), to avoid editing the secret path.

Verified: build + ctest + hygiene clean; i18n complete in 8 langs with no format
specifiers; a 3-lens adversarial review (secret-hygiene / presentation-only /
ui-i18n) returned zero findings, with the presentation lens proving the vault path
diffs byte-for-byte identical against HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 13:29:02 -05:00
5bd07b0505 test(sweep): add modal-decrypt capture surface
Register the redesigned "Remove Wallet Encryption" dialog as a sweep surface so
its passphrase-entry phase is captured for visual review. The setup resets the
WalletSecurityWorkflow (keeping it in PassphraseEntry) and never fires the async
unlock/export/restart pyramid; the teardown resets and zeroes decrypt_pass_buf_.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:02:11 -05:00
90df19b8b6 feat(settings): redesign the remove-encryption (decrypt) modal onto reference design
Migrate App::renderDecryptWalletDialog ("Remove Wallet Encryption") onto the
settings-modal reference design — presentation-only for a fund-critical dialog,
plus one deliberate secret-hygiene addition:

- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth 480,
  idSuffix "decrypt"}) replacing the legacy positional overload
- Phase 0 amber TextWrapped warning -> material::DialogWarningHeader(decrypt_warning)
- drop the redundant Phase 0 Separator
- every rendered-chrome string across all 4 phases (PassphraseEntry / Working /
  Success / Error) -> TR() (14 new decrypt_*/try_again keys added to the English
  source + all 8 res/lang/*.json additively; the button reuses
  settings_remove_encryption, the label reuses change_pass_current, cancel/close reused)
- HARDENING: wipe decrypt_pass_buf_ on BlurFloat dismiss (X/Esc/outside-click),
  mirroring the change-passphrase fix; canClose() gates pOpen so this cannot fire
  during the Working phase

The fund-moving async worker pyramid (unlock -> export -> stop -> backup ->
restart -> re-import, vault/PIN cleanup) is byte-for-byte unchanged; the three
in-worker toast notifications and the "Incorrect passphrase" status are left in
English to avoid touching the fund path (a separate service-layer i18n pass).

Verified: build + ctest + hygiene clean; i18n complete in 8 langs with no format
specifiers and the wallet.dat.encrypted.bak literal preserved; a 3-lens adversarial
review (secret-hygiene / fund-path-untouched / ui-i18n) returned zero findings, with
the fund-path lens proving the async pyramid diffs IDENTICAL against HEAD.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 12:02:11 -05:00
5985f05fb2 test(sweep): add modal-encrypt & modal-change-passphrase capture surfaces
The redesigned settings-tab Encrypt Wallet and Change Passphrase dialogs had no
sweep surfaces, so the full UI sweep only captured the first-run wizard-encrypt /
wizard-pin variants — the settings modals were never rendered for visual review.
Register modal-encrypt (redesigned passphrase-entry phase) and
modal-change-passphrase, each with a defensive teardown that resets the phase and
zeroes the passphrase buffers. Neither setup path fires the async encrypt/change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:31:31 -05:00
60ee73bf1d fix(settings): wipe change-passphrase buffers on dialog dismiss
The Change Passphrase dialog only zeroed change_old/new/confirm_pass_buf_ on
the success path (inside changePassphrase); dismissing via X / Esc / outside-
click left the typed passphrases resident in the input buffers. Add a same-
frame teardown mirroring the encrypt dialog's X-button cleanup: when the
BlurFloat overlay clears show_change_passphrase_ mid-frame, memset the three
buffers. The failure path still keeps them intact for retry, and the async
change already copies the passphrases into worker-owned strings, so wiping the
UI buffers cannot disturb an in-flight change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:17:34 -05:00
625df8abe6 feat(settings): redesign encrypt & change-passphrase modals onto reference design
Migrate the Encrypt Wallet (3-phase) and Change Passphrase dialogs in
renderEncryptWalletDialog onto the settings-modal reference design —
presentation-only, no change to the crypto/state-machine/secret-wipe logic:

- struct-form BeginOverlayDialog(OverlayDialogSpec{BlurFloat, cardWidth, idSuffix})
  replacing the legacy positional overload (same visual style, dp-scaled width)
- amber TextWrapped warning -> material::DialogWarningHeader(wiz_encrypt_warning)
- drop redundant Separator dividers
- every hardcoded English string -> TR() (13 new enc_*/change_pass_* keys added to
  the English source + all 8 res/lang/*.json additively; CJK subset rebuilt)

The strength-meter math, 3-phase transitions, "cannot close while encrypting"
guard, memset/SecureVault buffer wipes, and the encrypt/changePassphrase/vault
async paths are byte-for-byte unchanged. Verified: build + ctest + hygiene clean,
wiz_strength %s format-string safe across all languages, and a 3-lens adversarial
review (secret-hygiene / presentation-only / ui-i18n) returned zero findings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 11:13:44 -05:00
5dd68dc9bd feat(settings): migrate Seed phrase backup modal to the reference design
Phase 2 (second security modal), presentation-only + the review's one nit.

Struct-form BlurFloat overlay (was the legacy positional overload); the phrase
state's plain red warning -> material::DialogWarningHeader; StyledButtons ->
TactileButtons with the Copy/Save/Close row and shared Close centered via
PlaceOverlayDialogActions (the existing helper) and their separators dropped;
success status now wraps. Already fully i18n'd.

UNCHANGED (verified by the adversarial review): the closeAndWipe sodium_memzero of
the 24-word phrase on every close path (Begin-false early return, outside-click/Esc
teardown, phrase Close, shared Close), the async exportSeedPhrase fetch + its
show_seed_backup_ callback guard, the atomic restricted-perms file-write +
sodium_memzero of its content buffer, and copySecretToClipboard / RenderSeedWordGrid.
No seed leak; Begin/EndOverlayDialog balanced on every return path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 00:28:25 -05:00
8a30578872 feat(settings): migrate Export Private Key modal + fix outside-click secret leak
Phase 2 (first security modal), presentation-only + the review's one finding.

Migration: struct-form BlurFloat overlay (was legacy positional); full i18n
(reusing export_private_key / key_export_private_warning / key_export_private_key /
select_address / key_export_fetching / export / copy / close, plus one new
key_export_failed); DialogWarningHeader; centered DialogActionFooter (Export +
Close) + a Copy TactileButton; async UX (export_in_progress_ spinner, export_error_
line). The exported key stays in the char[256] buffer shown via a read-only
InputTextMultiline (deliberately NOT AddressCopyField, which would copy the secret
into a lingering std::string). exportPrivateKey is untouched.

Security fix (found by the adversarial review): the 0a "outside-click/Esc wipe" via
the Begin-returns-false early-return was UNREACHABLE — BeginOverlayDialog clears
show_export_key_ mid-frame but still returns true, so an outside-click/Esc dismiss
left the exported spending key un-wiped in export_result_. Add the in-body
`if (!show_export_key_)` teardown before EndOverlayDialog (the proven import-dialog
pattern), which is the real wipe point for every dismiss path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 23:47:11 -05:00
63d5c817aa chore(sweep): add modal-bootstrap surface for the standalone bootstrap dialog
The settings-triggered BootstrapDownloadDialog wasn't captured (only the wizard's
bootstrap page was). Add BootstrapDownloadDialog::hide() + a modal-bootstrap sweep
surface (opens the confirm screen) so the migrated dialog is verifiable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:37:26 -05:00
3662df550c feat(settings): Bootstrap modal — DialogWarningHeader + centered confirm buttons
Phase 1 (last low-risk modal). Replace the hand-rolled amber warning child-card
with the standard material::DialogWarningHeader (⚠ + Warning color, wrapping),
and center the Download / Mirror / Cancel button row. Already struct-form,
TactileButton, and i18n'd. Completes Phase 1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:17:48 -05:00
067c96c425 feat(settings): Wallets modal — TactileButton + drop the footer divider
Phase 1. Swap the 5 StyledButton actions (Open / Create / Scan folder / Reveal /
Close) to TactileButton, and remove the ImGui::Separator above the footer (the
reference has no divider). Already struct-form + fully i18n'd. Deferred: R6
glass-panel grouping of the scanned-folders section (it interacts with the
dialog's pre-computed cardHeight — a separate, more careful change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:16:25 -05:00
a2c4a8df73 fix(material): wrap DialogWarningHeader so long warnings don't clip
Export All Keys' long DANGER warning was clipped at the card edge because the
header rendered a single unwrapped line. Add PushTextWrapPos so a long warning
flows to a second line; short one-line warnings (import/backup/settings) fit and
are unaffected. Surfaced by the Windows sweep on modal-export-all-keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:13:54 -05:00
822891d4ef feat(settings): migrate Export All Keys modal to the reference design
Phase 1. DialogWarningHeader care header (was a hand-rolled red icon+text);
centered DialogActionFooter (Export + Close) replacing the StyledButtons + two
separators; on full success the saved path shows in a copy field with a green
check, partial/error render inline; export progress uses LoadingDots. Full i18n:
the previously-hardcoded notification + status strings now use TR (8 new
export_keys_* keys in en + 8 langs). Added hide() + a modal-export-all-keys sweep
surface. Also fixes a latent Begin/EndOverlayDialog imbalance on the 0-address
early return. No change to the export RPC logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:43:53 -05:00
22638b094c feat(settings): migrate Backup Wallet modal to the reference design
Phase 1. Struct-form BlurFloat overlay (was the legacy positional overload);
DialogWarningHeader care header; centered DialogActionFooter (Create Backup +
Close) replacing the StyledButtons + separator; full i18n — title/destination/
button reuse existing backup_* keys, plus two new keys (backup_warn one-line care
line, backup_overwrite_confirm) added in en + 8 langs. No change to backupWallet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:37:58 -05:00
bf81ec1702 feat(settings): migrate Export-Transactions-CSV modal to the reference design
Phase 1 (first modal). Bring the CSV export dialog onto the key-import reference:
- Struct-form OverlayDialogSpec / BlurFloat (was the legacy positional overload).
- material::DialogActionFooter (centered TactileButton pair) replacing the
  StyledButtons + the two ImGui::Separators.
- Full i18n: TR("close") and the success/error status now via TR (were hardcoded
  English); on success the saved path shows in a widgets::AddressCopyField with a
  green check.
- Add ExportTransactionsDialog::hide() + a modal-export-transactions sweep surface
  so the dialog is captured (it wasn't before).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:22:41 -05:00
56cd9b6273 fix(settings): make the rescan slider visible on light themes
The scan-height slider drew its track, % label, and thumb in pure white, which
vanished on light skins because the glass sub-section goes opaque white there
(only the numeric input showed). Switch those to OnSurface-based colors so they
adapt: near-white on dark (unchanged look) and dark on light (now visible).
Surfaced by the full Windows UI sweep on the marble/light skins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:04:58 -05:00
8b9cd5bf80 feat(material): extract DialogActionFooter + GlassSectionScope helpers
Phase 0b of the settings-modal redesign — pull the reference dialog's two
repeated patterns into shared helpers so the upcoming migrations are mechanical
and identical:

- material::DialogActionFooter(primary, enabled, close, &outPrimary, &outClose)
  — the centered fixed-width primary+Close TactileButton pair with no divider.
- material::GlassSectionScope — an RAII auto-sized glass sub-section using a LOCAL
  ImDrawListSplitter (combo-safe, unlike GlassCardScope's shared ChannelsSplit) with
  the reference's 12/10 padding and 8dp rounding; interiorWidth() for full-width kids.

Dogfooded by refactoring renderImportKeyDialog's footer and scan-height panel onto
them — same padding/spec/centering, so the render is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:49:04 -05:00
2b191cea34 fix(settings): securely wipe the exported private key (no heap-lingering secret)
The Export Private Key dialog held the exported spending key in a plain
std::string cleared only with .clear() — never sodium_memzero'd — and on
outside-click/Esc the overlay's early return skipped even that, so the secret
lingered in freed heap. Hold it in a fixed char[256] and sodium_memzero on every
close path (Close button, outside-click/Esc early return, address change). Guard
the async export callback so it can't populate the buffer after the dialog closed.

Standalone security fix ahead of the settings-modal redesign (Phase 0a); the
dialog's full restyle is Phase 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:44:25 -05:00
6ecbf835ec refactor(settings): unify the viewing-key care header with the private-key one
Settle the key-import reference before propagating its design to the other
settings modals: the viewing-key import now uses the same DialogWarningHeader
(⚠ warning icon + Warning color) as the private-key import, instead of the milder
grey eye + grey note. The viewing-key note is longer, so that mode gets a wider
card (760 vs 640) to keep it on one line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 20:37:51 -05:00
a13190a6df refactor(settings): import dialog layout polish
Several layout refinements to the Import Private/Viewing Key dialog:
- Widen the dialog to 640px so the one-line warning under the heading no longer wraps.
- Vertically center DialogWarningHeader's label with the (taller) warning icon
  instead of top-aligning it — a consistent fix for every warning header.
- Add a small gap between the key input's text and the flush eye toggle.
- Center + slightly enlarge the Paste / Clear buttons.
- Center the Import/Sweep + Close action buttons.
- Scan-height control: put the numeric input first with the slider below it, and
  drop the redundant "0 = rescan from the start" hint (0 is the field default; the
  transparent-key note is kept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:59:53 -05:00
ffc24ee99b refactor(settings): drop the divider above the import dialog's action buttons
Remove the separator between the content and the Import/Sweep + Close buttons;
the spacing alone separates them cleanly enough.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:50:05 -05:00
3274c72a58 refactor(settings): drop the redundant "Key" label above the import field
The "Import Private Key" / "Import Viewing Key" dialog heading already gives the
context, so the "Key" / "Viewing key" label above the input was redundant. Remove
it; the field is the dialog's primary input and reads fine without it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 19:47:59 -05:00
b7b32bfde8 fix(settings): stop paste-preview hover flicker from layout shift
Hovering Paste reflected the clipboard's classification in the type-indicator
ROW, which appeared/disappeared with the hover. That grew/shrank the centered
dialog, moving the Paste button out from under the cursor → hover ended → row
vanished → dialog shrank back → cursor over the button again → rapid flicker.

Keep the in-field preview + verification but make them pure draw-list (no layout
change): the ghost overlay is now coloured by how the clipboard would classify
(green ok / amber wrong-type / red unrecognized), and the type indicator reverts
to the actual field content only. The dialog height is now identical whether or
not Paste is hovered, so there's no shift and no flicker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:52:11 -05:00
0b47134112 feat(settings): labelled Paste/Clear + in-field clipboard preview
Restore text labels on the import dialog's Paste and Clear buttons (the reveal
eye stays a compact icon flush in the field), and replace the paste-hover tooltip
with an in-field preview like the send tab: while Paste is hovered and the Key
field is empty, the trimmed clipboard is drawn as a transparent ghost overlay
inside the field — masked with '*' unless the key is revealed — so the user can
verify it before pasting.

The type indicator now reflects the hovered clipboard (indKey/iRecognized/…),
while the Import/Sweep button guard and the scan panel keep using the actual
field content only, so a hover can never enable or trigger the action. Removes
the now-unused RenderPasteHoverPreview tooltip helper and the dead wrongType local.

Reviewed via an adversarial pass (overlay masking/security, the indicator-vs-action
classification split, button regressions); the one finding (dead wrongType) is fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:43:17 -05:00
828018de2b feat(settings): sweep a private key's funds to your own wallet
Add a "Sweep to my wallet (don't keep the key)" option to the Import Private
Key dialog (spending keys only). Rather than keeping the key, it imports the
key, then sends ALL its funds to a destination you control — a freshly
generated shielded address by default, or an existing wallet address you pick.

Flow (App::sweepPrivateKey): import the key (rescan; the stock node has no
address index, so its UTXOs/notes can't be enumerated otherwise) → confirm it
holds spendable funds → resolve the destination → z_sendmany balance-minus-fee
from the key's address to the destination, via the existing tested submitZSendMany
wrapper + async-operation tracker. The imported key is left in the wallet with an
empty balance (there is no remove-key RPC) — stated plainly in the UI caveat.

Design notes forced by this daemon (verified against external/dragonx):
- z_importkey returns null here, so the shielded source address is found by
  diffing z_listaddresses before/after import (aborts if the pre-snapshot fails,
  so a wrong address can never be picked). Transparent uses importprivkey's return.
- z_sendmany, NOT z_mergetoaddress: moves a single-UTXO transparent source (the
  common paper-wallet case) and fails loudly instead of silently leaving a
  remainder. Amount computed in integer satoshis for an exact fixed-decimal string.
- Clear errors for no-funds / unconfirmed / dust-below-fee / already-imported.
- The mode + destination are locked during a running sweep; a sweep that finishes
  while the dialog is closed still shows its Done/Error+txid on reopen; sweep UI
  can't bleed into the watch-only viewing dialog.

i18n: 8 new sweep_* keys (en + 8 langs); dynamic status strings are English,
matching the beginSweepToSeedWallet precedent.

Reviewed across three adversarial multi-agent rounds (fund-safety focus): round 1
caught the feature was broken for shielded keys (z_importkey null) and rejected
single-UTXO transparent sources; round 2 caught reopen/double-submit/precision
issues; round 3 verified the fixes. NOT yet exercised against a live daemon —
needs a small-amount mainnet test before it's trusted, like migrate-to-seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 17:29:10 -05:00
2a96c41e07 feat(settings): polish the import-key dialog + paste verify preview
Design pass on the Import Private/Viewing Key dialog:

- Group the optional scan-from-height section (label, slider, field, hint) in a
  subtle glass panel via a draw-list splitter so it reads as one distinct,
  clearly-optional block.
- Replace the boxed reveal / Paste / Clear buttons with frameless Material icon
  buttons: the eye toggle sits flush at the Key field's right edge (rounded to
  match the input frame), Paste / Clear are faint icon pills with tooltips.
- Add a centered %-of-chain label to the slider (mirrors the send-tab amount bar).

New: hovering Paste peeks at the clipboard and verifies it before pasting —
a tooltip classifies the clipboard the same way the field indicator does
(✓ valid type for this dialog / ⚠ a key for the other button / ✗ not a key),
with a preview line masked with '*' unless the key is currently revealed (then a
truncated peek). Empty clipboard shows "Clipboard is empty".

i18n: added paste_clip_empty (en + 8 langs); the preview reuses the field
indicator's type / wrong-type keys.

Reviewed via three adversarial multi-agent passes (splitter/panel, icon buttons,
slider math, and the paste preview's correctness/security + i18n); the confirmed
findings (eye hover-pill rounding, unrecognized-state color) are addressed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:38:36 -05:00
c25da0d26d feat(settings): stack the rescan slider above the height field
Lay the optional scan-from-height control out vertically: the send-tab-style
slider spans the full row on top, with the numeric field full-width directly
beneath it (both were previously side by side). Reads more clearly and gives
the slider room; the unsynced fallback (field only) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 16:04:23 -05:00
0c55ed0fe4 feat(settings): send-tab-style slider for the rescan start height
The optional "scan from block height" control in the Import Private/Viewing
Key dialog now leads with a slider styled like the send-tab amount bar
(rounded track, accent fill, glass thumb) bounded by the chain tip, with the
numeric field beside it for exact entry and the current chain height
right-aligned above. When the node's height is unknown (state_.sync.blocks == 0,
e.g. not yet synced) the slider is omitted and the field spans the full row —
which also fixes the previous cramped 140px field that left the row half empty.

- New static helpers in app.cpp: formatIntWithCommas() and RenderScanHeightControl()
  (mirrors RenderAmountBar in send_tab.cpp). The height decimal string is the single
  source of truth shared by slider + field; the slider reserves/queries its
  InvisibleButton before drawing so a drag has no one-frame lag; the bar height is
  ImGui::GetFrameHeight() so it aligns with the numeric field.
- Import handler clamps the parsed start height to the chain tip.
- i18n: added import_scan_tip ("current height") in en + 8 langs.

Reviewed via an adversarial multi-agent pass (slider-math / state-sync /
dpi-layout / consistency); the one confirmed finding (bar/field height mismatch)
is fixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 15:17:15 -05:00
1fc3e03e6f feat(settings): rename to "Import Private Key" + add scan height to it
Follow-up to the key/viewing-key button split:

- Rename the "Import Key…" button to "Import Private Key…" (settings_import_key
  value in en + all 8 langs, reusing the existing import_key_title wording) so it
  reads as the natural counterpart to "Import Viewing Key…". The dialog title was
  already "Import Private Key". The viewing-key wrong-type redirect hint now names
  the renamed button.

- Show the "scan from block height" field in the private-key (spending) dialog too,
  not just the viewing-key one. z_importkey accepts a start height, so a shielded
  spend import of a recent key needn't rescan the whole chain. Transparent WIF
  (importprivkey) has no such param, so once a transparent key is recognized the
  field is disabled, its buffer cleared, and a "Transparent keys always rescan
  fully" note replaces the hint. importPrivateKey already gates the start-height
  append on (viewing || shielded), so transparent imports never receive it.

- i18n: shared scan keys renamed import_viewkey_scan_{label,hint} ->
  import_scan_{label,hint}; added import_scan_transparent (en + 8 langs).

Reviewed via an adversarial multi-agent pass (rpc-routing / i18n / ui-logic /
consistency); only two nits surfaced (stale disabled-field value + a stale
comment), both fixed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:38:40 -05:00
f428292ea4 feat(settings): split import into separate key + viewing-key buttons
Backup & Data now has two distinct import actions instead of one
auto-detecting dialog:

- "Import Key" imports a spending key (transparent WIF or shielded
  z-spending key) with the strong "grants access to funds" warning.
- "Import Viewing Key" imports a watch-only shielded viewing key
  (zxviews…) with a milder eye/watch-only note and an optional
  "scan from block height" field — z_importviewingkey accepts a start
  height, so watching a recent address needn't rescan the whole chain.

The shared Material dialog (renderImportKeyDialog) branches on
import_view_mode_: title, warning tone, field label, the live type
indicator, and the recognition guard. A key valid for the *other*
button (e.g. a spending key pasted into the viewing-key dialog) now
shows a redirect hint rather than a generic "unrecognized" error.

importPrivateKey gains a startHeight arg, appended to the shielded
import RPCs (z_importviewingkey / z_importkey) and ignored for
transparent WIF (importprivkey has no start-height param). The
scan-height buffer is wiped on open/close alongside the key buffer.

i18n: 9 new keys (button label + tooltip, viewing title/note/field,
scan label/hint, two wrong-type redirect hints) with translations for
all 8 languages; CJK subset rebuilt (+1 glyph). A modal-import-viewkey
sweep surface is added for the viewing-mode dialog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:53:35 -05:00
46f3001360 feat(settings): redesign the Import Key dialog
Rework the full-node Import Key dialog into a safer, Material-consistent, more
capable flow:

- Security: the key is masked by default (Password) with a reveal (eye) toggle,
  a DialogWarningHeader "only import a key you own" note, and the buffer is
  wiped with sodium_memzero on close and on successful import.
- Progress: import triggers a blocking full-chain rescan; the dialog now shows
  "Importing & rescanning — this can take several minutes" with LoadingDots and
  disables Import while it runs (no double-submit). Offline shows a "connect a
  running node" note and disables Import up front.
- Viewing keys: the classifier gains isViewingKey / isRecognizedImportKey;
  importPrivateKey auto-detects a shielded viewing key (zxviews…) and routes to
  z_importviewingkey (watch-only) vs z_importkey / importprivkey. The live type
  indicator shows Transparent / Shielded spending / Shielded viewing (watch-only).
- Result: on success the imported address is captured from the RPC result and
  shown via AddressCopyField.
- Material restyle (OverlayDialogSpec/BlurFloat + TactileButton + Esc) and full
  i18n (12 new keys x 8 languages; CJK subset rebuilt).

Verified: rendered on dark + light skins; a seeded zxviews… key shows the masked
field + "Shielded viewing key (watch-only)" indicator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:58:36 -05:00
8293f02e36 fix(console): RPC modal — pointer cursor + Material restyle
Two issues from the audit:

- Cursor: hovering the modal showed the console's text-select cursor. The
  output's mouse-interaction guard only suppressed itself under ImGui popups,
  but this modal is a BeginOverlayDialog (not a popup), so the console kept
  setting the text cursor underneath. Suppress output interaction while the
  reference modal is open (!show_commands_popup_).

- Design: the two panes used hard 1px child borders and floated unpolished.
  Restyle to match the portfolio/wallets modals — each pane now sits on a soft
  DrawGlassPanel surface (borderless child + inner padding), category headers
  are subtle accent labels (no heavy filled bars), and the selected/hovered row
  gets a rounded Material fill instead of the sharp Selectable highlight. The
  glass panes are theme-adaptive (light on light skins), so the command-name
  blue and the mono example green are floored to readable contrast (FloorLight)
  and the detail heading uses theme Primary().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 12:11:54 -05:00
b34069f57c fix(console): don't silently drop a filled param after an empty optional
The builder's "omit trailing optionals" loop stopped at the FIRST empty
optional, which discarded any later field the user had filled — e.g. setban
with bantime empty but absolute=true built `setban "ip" "add"`, dropping the
typed `true`, and (worse) left Insert & run enabled so it ran a command that
ignored the input.

Include fields up to the last one that is filled or required; only genuinely
trailing empty optionals are dropped. A gap (an empty field before a later
filled one) keeps its placeholder and marks the command incomplete, so Insert
& run disables and nothing typed is lost. Found by an adversarial pass over all
82 param templates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:38:25 -05:00
4416e01f9c feat(console): parameter builder in the command explorer (phase 3)
The detail pane's Parameters section is now an editable form: each parameter is
a type-hinted input field (string / number / json, optional marked), and a live
"Builds" preview shows the command being assembled. String values are
auto-quoted; a required field left empty keeps its placeholder and disables
"Insert & run"; trailing empty optionals are omitted.

Insert and Insert & run now use the assembled command (not just the template),
so Insert & run works for parameterized commands once the required fields are
filled — destructive commands still confirm first. Param fields reset when the
selected command changes or the modal opens. Keyboard nav (Up/Down/Enter) is
scoped to the search box so typing in a field doesn't move the selection.

A best-effort parseParamSpecs turns the human-readable param templates into
fields; JSON-array params (z_sendmany, createrawtransaction) surface as a single
"json" field the user pastes into. Adds the console_ref_builds label + 8
translations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:32:35 -05:00
44a17f3ad0 feat(console): enrich the remaining RPC commands for the explorer (phase 2)
Add plain-language details, a concrete example, and intent keywords to the
50 commands that Phase 1 left with only the terse description — so every entry
in the command explorer now explains itself and is searchable by task, not just
by name. Destructive flags are preserved.

Content was authored per-category and reviewed for technical accuracy (correct
RPC semantics, valid console example syntax, realistic DragonX placeholders).
Data-only change; command docs stay English (technical), consistent with the
existing policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:16:55 -05:00
c9e9c9f979 feat(console): redesign the RPC reference as a novice-friendly command explorer
Replace the flat, terse command-reference table with a guided two-pane
explorer built for users who don't know the RPC commands:

- Search by name OR task: keyword synonyms let "balance" find getbalance,
  "send money" find sendtoaddress; results are ranked (name-prefix > name >
  keyword > desc).
- Two panes (mirrors the portfolio editor): a category-grouped master list
  (mono names, a warning dot on consequential commands) + a rich detail pane
  with a plain-language explanation, a per-parameter type breakdown (string /
  number / json, optional dimmed), and a concrete example.
- Insert into console (fills the input with a template to review + run) and,
  for no-parameter commands, Insert & run — with a confirm for destructive
  ones. Deferred via pending_submit_ so the modal needs no executor.
- Keyboard: type to filter, Up/Down to move, Enter to insert; auto-focus and
  auto-select the top result.

Data model: ConsoleCommandEntry gains details/example/keywords/destructive
(C++17 defaults, so the ~60 un-enriched entries are untouched); ~20 common
commands enriched and 18 consequential ones flagged. Command docs stay English
(technical); the UI chrome + 7 category names are translated into all 8
languages (CJK subset rebuilt, +3 glyphs).

Verified: two-pane renders on dark + light skins and at font_scale 1.5; detail
pane shows explanation/params/example; danger dots + safety badge on
destructive commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 11:07:49 -05:00
ab4dd370c0 fix(console): RPC command-reference modal audit fixes
- Clicking a command now actually closes the modal. It called
  ImGui::CloseCurrentPopup(), a no-op here (BeginOverlayDialog is Begin/
  BeginChild, not an ImGui popup), so the picker stayed open covering the input
  and the insert looked like it did nothing. Set show_commands_popup_=false.
- Search filter is now a member cleared when the modal opens, so it isn't stale
  on reopen after an outside-click / command-click dismiss (was reset only by
  the Close button).
- Esc dismisses the modal (this overlay has no built-in Esc handling), and the
  search box auto-focuses on open so the user can type immediately.
- Scale the 2-vs-3 column breakpoint (cmd-min-width) by dpiScale() — it was a
  logical-px threshold compared against a physical-px available width.
- Give each category's table a unique ImGui id (##cmdsN) instead of sharing
  "##cmds", and translate the 7 category names (Control/Network/... — command
  descriptions stay English) into all 8 languages; CJK subset rebuilt (+2).

(Investigated but rejected: the command-name blue was flagged as low-contrast
on light skins, but the modal card is a dark glass panel on every theme, so the
original color is correct — verified by rendering marble/light/color-pop-light.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:25:51 -05:00
675d434958 fix(console): mop up audit minors — timestamp, filter count, new-line count
- D2: rpcTraceTimestamp used std::localtime (process-wide static tm) guarded by
  a private mutex that can't stop another thread's localtime from clobbering the
  shared buffer. Use localtime_r / localtime_s into a local tm (the codebase
  pattern) and drop the now-useless mutex. Runs on RPC worker threads.
- C1: the toolbar's "<N> matches" label read filter_match_count_ before
  renderOutput recomputed it, so it lagged one frame. Compute the visible/
  filtered set once at the top of render() (before the toolbar) and reuse it in
  renderOutput, so the count and the output share one consistent computation.
- C4: the "N new lines" indicator counted the raw drain count, inflating it with
  lines the active filter hides. Count only newly-added lines that pass the
  current filter, so the badge matches what the user sees on jumping to bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:10:27 -05:00
e8055888a5 feat(console): persist the output zoom level across launches
The console zoom (s_console_zoom) was a plain static reset to 1.0 each launch,
unlike the sibling toggles. Wire it through Settings (console_zoom float),
restored into the static at startup in the App ctor and written back via the
existing post-render diff-and-save. Loading clamps to [0.25, 4.0] (also
catching NaN) so a corrupt value can't feed the scanline divisor.

Verified: seeding console_zoom=1.5 survives a relaunch and the console text
renders larger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:54:47 -05:00
48eceb6593 fix(console): don't coerce quoted or malformed numeric args to JSON numbers
BuildConsoleRpcCall pushed any bare token that "looked numeric" as a JSON
number, and lost the tokenizer's quoting decision — so a genuinely-string
argument that happened to be all digits (e.g. a label) was sent with the wrong
type. std::stoll also silently truncated "1e999" to 1 (stops at the first
non-digit) and std::stod could yield inf/subnormal.

- ParseConsoleCommandArgsTagged returns {text, quoted}; quoted tokens are sent
  verbatim as JSON strings, never coerced. (ParseConsoleCommandArgs kept as a
  text-only wrapper so existing callers/tests are unchanged.)
- Bare tokens become a number only when the WHOLE token parses to a finite
  int/double; otherwise they're sent as a string.

Adds test coverage for quoted-numeric-string, bare-number, 1e999, and 123abc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:49:02 -05:00
4f71c18884 fix(console): audit fixes — HiDPI, fold UX, stop-confirm, RPC-trace UAF
From the console-tab audit:
- HiDPI: the "New output" pill (140x24 + margin/rounding/border) and the JSON
  indent-guide thickness were hand-drawn in raw px; scale them by dpiScale().
- Fold triangle: size it from the DPI/density-scaled gutter width (not the
  zoomed font) and center it in the gutter band so glyph and clickable cell
  stay aligned; only draw/handle it in the unfiltered view (folding_active_) —
  in a filtered flat view a click silently flipped the collapsed flag with no
  visible effect and the glyph disagreed with the rendered block.
- Selection: ignore left-clicks in the gutter (< output_origin_.x) so toggling
  a fold no longer clears the user's active text selection.
- 'stop' confirmation: promote the function-local static to a member and reset
  it in clear(), so a toolbar/context-menu clear between the two 'stop's can't
  leave a stale arm that skips the shutdown warning.
- RPC-trace callback: dereference the console pointer under the mutex (not
  after releasing it) so ~ConsoleTab can't destroy the object mid-call —
  closing a latent use-after-free on shutdown (fires on RPC worker threads).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 17:45:51 -05:00
232b81f8ee feat(settings): surface the console color toggles as checkboxes
Add "Color accents" and "Text colors" checkboxes under the effects section
(both layout variants) so the console output display prefs are discoverable
outside the console toolbar. They mirror the toolbar buttons — bound live to
the ConsoleTab statics and written through to settings on change.

Unlike scanline/theme-effects they carry no GPU cost, so a small helper closes
the effects row's BeginDisabled(low_spec), draws them on their own always-
enabled row, and reopens it for the acrylic sliders (net-balanced). Adds
console_accents / console_text_colors labels + 8 translations; the tooltips
reuse the existing toggle keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:40:25 -05:00
c40c252c9a feat(console): persist accent toggle + add monochrome-text toggle
Persist the two console output display preferences and add a second toolbar
toggle for monochrome text.

- Persistence: new Settings fields console_line_accents / console_text_color
  (both default true, matching the ConsoleTab statics so an upgrade re-save
  can't flip visible behavior). Restored into the statics at startup in the App
  constructor, and saved from a diff-and-save after the console renders (only
  when a value actually changed — save() is disk I/O). The startup restore also
  fixes a pre-existing bug: scanline was only synced from settings when the
  Settings page or first-run wizard ran, so an existing wallet ignored a saved
  scanline preference on launch.
- Monochrome text: a new toolbar toggle (FORMAT_COLOR_TEXT, dimmed when off)
  next to the accent toggle. channelTextColor() early-returns the neutral
  COLOR_RESULT for every channel (and JSON syntax role) when off, leaving the
  left accent bars independent. COLOR_RESULT is contrast-floored per theme, so
  text stays readable on light and dark terminals.

Adds console_toggle_text_color to the English source + all 8 translations.
Verified: both toggles independent, persistence round-trips across restart,
monochrome readable on light + dark skins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:26:44 -05:00
d156dddcd0 feat(console): toolbar toggle to hide per-line color accent bars
Adds a paint-bucket toggle button to the console toolbar (in the appearance
cluster next to zoom) that shows/hides the per-line left color accent bars for
a cleaner monochrome gutter. The icon reflects state (FORMAT_COLOR_FILL when on,
a dimmed FORMAT_COLOR_RESET when off) with a tooltip. Session-only, like the
console zoom factor; default on.

Adds console_toggle_accents to the English source and all eight translations
(no new CJK glyphs — the subset font already covers them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:36:51 -05:00
0e1957c5f9 feat(market): explain why the portfolio Save button is disabled
Hovering the disabled Save button now shows which requirement is missing
(name / at least one address / a manual price above 0), mirroring the
send-tab disabled-submit tooltip idiom (IsItemHovered(AllowWhenDisabled)).
The hint only appears for a validation gap — not the self-evident "nothing
changed" case.

Adds portfolio_save_need_{name,address,price} to the English source and all
eight translations; rebuilds the CJK subset font for the new JA/KO/ZH glyphs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:22:56 -05:00
3f95765dcc fix(balance): unique IDs + scope filter for the Add-to-portfolio menu
The address context menu's "Add to portfolio" submenu built each item with
MenuItem(label), whose ImGui ID is derived from the label — so two groups with
the same label (or two empty labels) collided, routing a click to the wrong
group, and a blank label rendered as an invisible row.

- PushID(pi) per item gives a stable unique ID regardless of label, and an
  empty label now shows the "new entry" placeholder.
- Filter the list to the active wallet's groups (or legacy/unscoped), matching
  the Market summary and editor, so an address can't be added to a different
  wallet's group. The "no entries" placeholder now also covers the case where
  every group is filtered out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:11:35 -05:00
62c92cc862 fix(market): scope the portfolio editor to the active wallet
The Market summary filters custom groups by wallet scope, but the editor did
not: its master list showed and edited *every* wallet's groups, and "Manage…"
opened raw storage index 0 — which could belong to a different wallet.
Separately, a group could be persisted with an empty scope (a placeholder
added before the wallet identity resolved, then filled in and saved), which the
filter treats as "legacy" and shows in *every* wallet's list.

- The editor master list now filters to the active wallet's groups (plus
  legacy/unscoped), mirroring the summary. Storage indices are preserved so
  selection/delete still target the right entry.
- "Manage…" opens the first in-scope group (or the empty state), never index 0.
- The selection clamp resets to the first visible group if the current one is
  out of range or out of scope.
- On commit, a group with no scope is claimed for the active wallet when an
  identity is available, so it can't linger in the global bucket.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:08:28 -05:00
3aeb847657 fix(market): make portfolio-editor dismiss gestures consistently auto-save
The Close button auto-committed the current group, but Escape, outside-click,
switching groups, adding a group, and deleting another group all *discarded*
uncommitted edits — so whether your work was kept depended on how you happened
to leave, and the prominent unsaved-dot / Save / Revert affordances implied a
save model the exits didn't honor.

Align every dismiss/switch gesture with the Close button's intent (auto-save):
- Escape and outside-click now commit before closing (outside-click is caught
  right after BeginOverlayDialog, which clears open mid-frame).
- Switching to another group, "Add entry", and deleting a *different* group
  commit the current group first. Deleting the selected group still discards
  its own working state.

Save remains "persist now", Revert "undo to last saved", and the dot marks an
in-progress group. Ordering is safe against the placeholder GC: that GC runs
earlier in the frame (mktDrawPortfolio, before RenderPortfolioEditor) and only
while the editor is closed, so it never shifts entry indices under a commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 15:03:17 -05:00
2a9d32e78b fix(market): close portfolio-editor gaps found in re-audit
Adversarial re-audit of the group editor surfaced follow-ups to the prior
hardening pass:

- Dirty-check regression: the empty-currency->"USD" coercion was applied on
  save but not in the dirty comparison, so clearing the currency field left a
  group perpetually "dirty" (unsaved dot + Revert/Save never settling, a
  redundant persist on every Close). Both sides now share pfEditLabel() /
  pfEditCurrency() so they can't disagree — this also trims the label, so a
  trailing space no longer wedges the dirty state.
- Whitespace-only labels ("   ") passed validation and persisted as an
  invisible group; the trimmed label is now required by pfWorkingValid.
- "Add entry" persists an empty placeholder up front; abandoning it (Close/
  Esc/outside-click without adding an address) left a phantom "$0 · 0" group.
  The summary now prunes address-less groups while the editor is closed.
- Manual price accepted inf/NaN (neither is < 0); sanitize with std::isfinite.
- Escape closed the whole editor even when it was only meant to dismiss the
  open custom-color popup, discarding edits; guard on !IsPopupOpen.
- HiDPI: scale the Z/T chip pill's vertical padding and the balance-breakdown
  baseline nudge that the prior pass missed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:51:32 -05:00
97a54f0e0c fix(market): harden portfolio group editor + HiDPI scaling
Portfolio audit fixes:
- Guard the BTC value line behind price_btc > 0 so an empty BTC price no
  longer renders a misleading "≈ 0.00000000 BTC".
- Validate the working group before commit/save (pfWorkingValid: needs a
  label, at least one address, and a positive manual price when the manual
  price basis is selected); the Save button disables to match, so a
  half-filled editor no longer persists a broken group on close/switch.
- Default an empty manual currency to "USD" instead of an empty string.
- Reload the editor after clamping a stale selection index so it can't
  point past the end of the group list.
- Scale hand-drawn geometry by dpiScale(): address-row rounding, chip
  y-offset, preview border, chart toggle buttons, and the shielded/
  transparent ratio-bar corner radii now render correctly on HiDPI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 14:14:45 -05:00
82da4af857 feat(market): per-pair candle cache — instant switch-back, no re-fetch
The per-exchange chart used a single buffer keyed by the loaded pair, so
bouncing between venues (Ourbit <-> NonKYC) re-fetched every switch (loading
spinner each time). Add a per-pair cache: each venue's fetched candles (OHLC +
derived close series) are kept keyed by "<identifier>:<BASE>/<QUOTE>". Switching
back to a recently-viewed pair loads from the cache instantly — no fetch, no
spinner — while entries older than 30 min re-fetch to stay fresh. Bounded to 16
entries (irrelevant for DRGX's 2 venues, but safe if it lists more). Cache is
main-thread-only (populated by the worker's completion MainCb), so no races.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:48:53 -05:00
bfe8b4d77d change(settings): default Windows window opacity to 90%
Raise the Windows window-opacity default (0.75 -> 0.90) so less of the
(often dark) desktop bleeds through the wallpaper behind the panels.
Mac/Linux stay fully opaque. Only affects fresh installs / unset values.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:40:40 -05:00
78c00daf92 change(modals): raise the fixed modal-backdrop blur to 120px
Bump the hardcoded modal blur radius (96 -> 120) for a softer, more
recognizable frost behind dialogs. Still independent of the acrylic slider.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 13:40:40 -05:00
e82514f46a feat(market): persist chart range + style across sessions
The chart interval (Live/1H/1D/1W/1M) and the line/candle style were
session-only statics. Persist both like the selected exchange/pair: new
chart_interval / chart_style settings (defaults 1M / candlestick),
loaded into the market view on first show and saved on each interval
click / style toggle.

Verified: writing chart_interval=2, chart_style=0 to settings.json and
launching restores them (not reset to defaults) and re-saves them intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 12:05:36 -05:00
76708e9191 feat(market): line/candle toggle + ~50% taller chart
- Add a chart-style toggle (line vs candlestick) next to the interval buttons,
  shown only when the selected range has per-exchange candles (the aggregate /
  Live view is line-only). It flips s_mkt.chartStyle; candles draw when OHLC
  exists AND the user hasn't switched to the line. Icon reflects the current
  style; tooltip says what a click switches to. Two i18n keys across 8 languages.
- Make the price chart ~50% taller: scale the height floor / desired / viewport
  cap (110->165, x1.5 desired, 0.22->0.33 of available) for a roomier plot.

Verified via a forced-OHLC render: the taller chart, the toggle button, and
candles with a correct week/day x-axis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:54:08 -05:00
a767702ff6 feat(market): OHLC hover readout on the candlestick chart
Candles had no hover (the line's close tooltip is gated off for them). Add a
candle-aware readout: the candle under the cursor gets a column highlight +
crosshair, and a small box shows its date and open/high/low/close (colored green
up / red down). Verified via a forced-hover render (date + O/H/L/C over the
highlighted candle). Line charts keep their existing single-price tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:46:26 -05:00
40e8128a30 feat(market): candlestick chart for per-exchange OHLC
The exchange candle APIs return full OHLC but we only kept the close (a line).
Now the per-exchange chart draws real candlesticks; the CoinGecko aggregate stays
a line (it's close-only).

- Adapter keeps OHLC: parseExchangeOHLC() returns open/high/low/close candles
  (parseExchangeCandles is now a close-only wrapper over it). New data/candle.h
  holds the dependency-free Candle + bucketOHLC (5-min -> hourly for the 1D view).
- Model stores exchange_ohlc_intraday/daily alongside the close series;
  refreshExchangeChart populates both. market_series::chartCandles() returns the
  bucketed OHLC for the range, empty unless the per-exchange series is active.
- The chart renders wick (low..high) + body (open..close), green up / red down,
  with a low..high y-range; the line-only bits (fill, hi/lo labels, hover tooltip)
  are gated off for candles. Falls back to the line for the aggregate / Live view.

Verified: OHLC parsers + bucketOHLC + chartCandles unit-tested; a forced-state
render shows correct candlesticks; the aggregate still draws a clean line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:16:44 -05:00
326192e75e feat(market): per-exchange 24h volume in the hero
The hero's "24H Vol" showed CoinGecko's cross-exchange aggregate even after the
chart/price went per-venue. Capture the per-exchange converted_volume.usd from
the tickers (previously discarded, alongside converted_last) and show the
SELECTED exchange's own 24h volume; fall back to the aggregate when unknown.
Market cap stays aggregate (it's coin-wide, not per-venue).

This is a big real difference — e.g. Ourbit ~$14.3K vs NonKYC ~$253 for DRGX/USDT
— so the header now reflects the venue you're actually looking at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 10:58:50 -05:00
a107fda9b8 feat(market): loading spinner while the price chart fetches (pair switches)
Switching exchange pairs (or first load) briefly leaves the chart with no series
while the venue's candles fetch, which showed the bare "No price history
available" empty state. When a chart fetch is in flight — App::isMarketChartLoading()
(the CoinGecko aggregate OR the per-exchange fetch) — draw a spinner + animated
"Loading price history…" in the plot area instead; the "no history" text only
shows when genuinely empty and idle. New i18n key market_chart_loading across all
8 languages. Verified via a forced-state render (spinner + label centered).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 09:38:44 -05:00
d1ff58c374 feat(market): per-exchange price chart from each venue's own candle API
The market chart was hardwired to CoinGecko's cross-exchange USD aggregate
(vs_currency=usd), so selecting a trading pair only changed the "Trade" link —
never the chart or price. Now the SELECTED exchange drives both.

CoinGecko gives us which venues list DRGX (the ticker `market.identifier`) but
not their APIs, so add data/exchange_candles.h: a hand-maintained map from that
identifier to each venue's public candle endpoint, with two adapters verified
against the live APIs — Ourbit (MEXC-style /api/v3/klines, array format) and
NonKYC (TradingView-UDF /market/candles, `bars`). Unmapped venues return no URL,
so the chart falls back to the CoinGecko aggregate and nothing regresses.

- App::refreshExchangeChart() resolves the selected pair, fetches its intraday
  (5-min) + daily candles via the TLS-verified httpGetString on the worker, and
  stores them on MarketInfo (exchange_chart_intraday/daily + exchange_chart_active).
  Re-fetches on pair change; self-throttled to ~30 min otherwise; falls back to
  aggregate on any failure.
- chartSeries() draws the per-exchange series when active (portfolio sparklines
  stay on the aggregate). The hero shows the selected venue's real price
  (converted_last) — Ourbit vs NonKYC differ ~7%. parseCoinGeckoTickers now
  captures the identifier + per-exchange USD price (previously discarded).

Adapters unit-tested against real captured responses (URL builder + both parsers
+ the chartSeries switch) and validated against the full live feeds (Ourbit 20d,
NonKYC 370d, ascending, correct closes). Build + ctest + hygiene green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 09:19:55 -05:00
156735eb06 feat(modals): fixed modal-backdrop blur, independent of the acrylic slider
The modal backdrop blur was params.blurRadius (96) scaled by the user's global
acrylic blur-strength slider (blurRadiusMultiplier), so lowering the slider also
weakened every modal's backdrop. Make the modal blur a fixed hardcoded value.

Add AcrylicParams::absoluteBlurRadius: when set, applyBlur() uses the radius
as-is and skips the multiplier (threaded through both the GL and DX11 blur paths
+ the no-op stub). DrawFullWindowBlurBackdrop sets it, so the modal backdrop is
always a 96px blur regardless of the slider. Panels/other acrylic still scale
with the slider as before.

Verified: rendering a modal at blur_multiplier 0.1 vs 2.0 now produces an
identical backdrop (max pixel diff 1/255) — previously those were ~9.6px vs
~192px of blur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:41:53 -05:00
2f86bec98e feat(wallets): subtle empty-state hint in the fixed-height list
With the list now pinned at max height, a few wallets leave blank space below
the cards. Fill it — only when there's real room to spare — with a subtle
centered folder glyph + "Scan a folder to find more wallets" (OnSurfaceDisabled),
so the area reads as a gentle nudge toward the scan action rather than dead
space. Purely decorative (draw-list only, clipped to the list); the actions stay
below. New i18n key wallets_empty_hint, translated across all 8 languages.

Verified on the sweep: the hint centers in the gap and reads subtly on both dark
and light themes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:17:34 -05:00
6310f51f65 feat(wallets): fixed max-height list + manage scanned folders
Two wallets-modal changes:

- The wallet list now always renders at its max height (kMaxVisibleRows) instead
  of shrinking to the wallet count, so the modal is a consistent size whether you
  have one wallet or many — fewer rows leave empty space, more than 7 scroll.

- Add a "Scanned folders" manager: each user-added scan folder is listed with a
  control to stop scanning it (removeExtraFolder + save + re-scan so its wallets
  drop out). Paths front-elide to keep the identifying leaf visible, with a
  full-path tooltip. The card-height math reserves the manager's rows so nothing
  clips. Two new i18n keys (wallets_scanned_folders / wallets_remove_folder),
  translated across all 8 languages; CJK subset rebuilt.

Verified on the sweep at 1.0 and 1.5x DPI: max-height list on both the few- and
many-wallet surfaces, the folder manager renders and stays clear of the footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:04:43 -05:00
09e0b962c3 i18n(wizard): translate the first-run wizard (was all hardcoded English)
The first-run wizard predated the i18n system — every label, button, and help
line was hardcoded English. Route them all through TR():

- 62 literals in app_wizard.cpp replaced with TR(). Where a wizard string is a
  parallel of existing content, it reuses the existing key (the whole bootstrap_*
  family, plus theme / language / balance_layout / low_spec_mode / ui_opacity /
  console_scanline / download / retry / cancel) so it inherits their translations
  and stays consistent with Settings. Button labels drop their ##id suffix and use
  TR(key) directly (the app's established TactileButton(TR(...)) pattern; the
  wizard's phase buttons never share a frame, so no id collision).
- 55 new wiz_* English keys in i18n.cpp for the wizard-specific strings, and their
  translations back-filled across all 8 languages (es/de/fr/pt/ru/zh/ja/ko);
  format specifiers (%s, %zu) and newlines preserved. CJK subset rebuilt.

Verified on the sweep: English renders with correct labels in place (all keys
resolve), and a German run shows the reused + new keys translated end-to-end
(Darstellung / Acryl-Glaseffekte / Verschlüsseln & Weiter …).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 01:41:18 -05:00
758ea06ab7 feat(wizard): modernize the first-run wizard to the current design language
The first-run wizard predated the modal-unification pass and was the least
on-brand surface left — a flat Surface() slab with a 3-card masonry whose
inactive steps were heavily greyed (alpha 165) and whose focused card used a
hard 2px border + a hand-drawn offset shadow.

Keeping the working phase logic and the "see all steps at once" masonry:
- Reveal the skin backdrop: the app already paints its marble / gradient /
  acrylic backdrop behind every window (drawWindowBackdrop), so drop the opaque
  Surface() fill (transparent WindowBg) and lay a gentle theme-tinted scrim over
  it. The wizard now sits on the same rich backdrop as the rest of the app.
- Focused card lifts with the shared DrawCardDropShadow (the uniform, non-
  clipping card shadow) instead of a bespoke offset rect, and a softer 1.5px
  accent ring.
- Inactive cards get lighter veils (upcoming 165->115, completed 110->70) so
  they read as "waiting" / "done" rather than disabled-grey.
- Warmer header: a one-line subtitle under the welcome title.

Verified on the headless sweep across all four phases (appearance / bootstrap /
encrypt / pin) on marble + obsidian at 1.0 and 1.5x DPI — backdrop shows, cards
float cleanly, geometry scales, nothing clipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:48:22 -05:00
d189ee5ddb fix(ui): readable error text on dark modal backdrops
The Material --error reds are muted by design, so error text drawn with the
theme Error() color read low-contrast on the (now deeper) dark modal backdrops —
the audit flagged the seed-migration warning/error lines on dark / dragonx.

Add material::ReadableError(): the theme error lifted toward a legible light red
on dark themes, unchanged on light themes (where it's already a dark red that
reads on a light background). Apply it to the modal error-TEXT sites (lite
first-run, seed backup, seed migration). This is targeted — it never touches the
app-wide Error() used by toasts / badges / status dots — so nothing else shifts.

Verified on the sweep: the "node too old" warning (dark) and "daemon did not
respond" error (dragonx) now read clearly; light themes are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:27:44 -05:00
32d6e34c45 fix(ui): theme-aware success green for modal status text
Modal success/status lines (import, backup, seed backup, seed migration) used a
hardcoded ImVec4(0.3, 0.8, 0.3) green that ignores the theme — fine on dark, but
faint on the light skins, where the audit flagged it as low-contrast. Switch
those 8 sites to ui::material::SuccessVec4(), so light themes get their tuned
dark green (#3D7A42 etc., readable on white) and dark themes their light green
(#81C784). The 2 status-bar mining indicators keep the vivid attention-green.

Verified on the sweep: the "already has a seed phrase" / "migration complete"
greens are now clearly readable on light, marble, and color-pop-light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:18:48 -05:00
088e3371d9 feat(modals): stronger backdrop blur + dim, and anchor floating modals on light skins
The modal audit found card-less (floating) dialogs read slightly unanchored on
the light skins: the blur backdrop's dim is a theme-tinted frost, which darkens
on dark themes (content pops) but WHITENS on light themes — so sparse content
floated in a near-white expanse with no figure/ground separation.

- Light themes now get a neutral dark veil instead of the whitening frost, which
  darkens the surround and makes the modal's fields/buttons stand out.
- Dark themes keep the theme-tinted frost, a touch stronger (alpha 70 -> 90).
- Full-window blur deepened (64 -> 96) so the backdrop reads clearly frosted.

Verified on the headless sweep across light + dark: backup / debug-gate now
read as anchored groups; content-heavy modals (daemon-update, settings, wallets)
are unaffected (their own surfaces stand out), and dark-skin text contrast
improves for free from the deeper dim.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 22:18:48 -05:00
198 changed files with 31272 additions and 4659 deletions

14
.gitignore vendored
View File

@@ -11,8 +11,8 @@ prebuilt-binaries/dragonxd-win/*
!prebuilt-binaries/dragonxd-win/.gitkeep
prebuilt-binaries/dragonxd-mac/*
!prebuilt-binaries/dragonxd-mac/.gitkeep
prebuilt-binaries/xmrig-hac/*
!prebuilt-binaries/xmrig-hac/.gitkeep
prebuilt-binaries/drg-xmrig/*
!prebuilt-binaries/drg-xmrig/.gitkeep
# External sources / toolchains (created by scripts/setup.sh)
@@ -33,7 +33,11 @@ imgui.ini
*.bak*
*.params
asmap.dat
/external/xmrig-hac
# Wallet files hold PRIVATE KEYS — never commit them
wallet.dat
wallet-*.dat
wallet.dat.*
/external/drg-xmrig
/memory
/todo.md
/.github/
@@ -54,3 +58,7 @@ third_party/silentdragonxlite/lib/vendor/
# Generated by configure_file from res/ObsidianDragon.manifest.in (do not track)
res/ObsidianDragon.manifest
# Cross-built mingw FreeType (color emoji) — regenerated by scripts/build-freetype-mingw.sh
third_party/freetype-mingw/
third_party/.freetype-mingw-build/

58
CHANGELOG.md Normal file
View File

@@ -0,0 +1,58 @@
# Changelog
All notable user-facing changes to ObsidianDragon are documented here. The format loosely
follows [Keep a Changelog](https://keepachangelog.com/); the project uses Conventional Commits.
## [Unreleased]
### ⚠️ Breaking changes
- **Remote RPC over plain HTTP is now refused by default.** If your wallet is configured to
reach a **remote** `rpchost`/`rpcconnect` **without TLS**, it will no longer connect — it
previously sent your `rpcuser`/`rpcpassword` in cleartext (capturable by anyone on the
network path) after only a dismissible warning. To reconnect, either:
- add **`rpctls=1`** to `DRAGONX.conf` (preferred, if your daemon supports TLS), or
- add **`rpcallowplaintext=1`** to `DRAGONX.conf` to explicitly accept the plaintext link.
Local and embedded daemons (`127.0.0.0/8`, `localhost`, `::1`) are unaffected.
### Security
- Refuse remote plaintext RPC credential transmission by default (see Breaking changes above).
- Tightened localhost detection: a hostname that merely *starts* with `127.` (e.g.
`127.evil.com`) is no longer mistaken for a loopback address, so it can no longer bypass the
plaintext-RPC protection.
- Sapling parameters are now integrity-checked (SHA-256) against pinned canonical digests
before use, instead of only checking that the files exist. A truncated or corrupt parameter
file is caught up front rather than surfacing later as a confusing shielded-operation failure.
(Cached via a `size:mtime` marker so it doesn't re-hash ~48 MB on every launch.)
### Fixed
- Daemon crashes are no longer occasionally missed: a race between the UI thread and the
process monitor could consume the daemon's exit status, hiding a crash and defeating the
automatic-restart cap. The monitor is now the sole reaper.
- A daemon that fails to launch (missing execute permission, wrong architecture, corrupt
binary) now reports a precise error immediately instead of briefly showing "running" and
then a generic "exited unexpectedly (exit code 127)".
- A quick stop→start no longer triggers a restart storm: the wallet now waits briefly for a
previous daemon to release the data-directory lock and shows a clear, non-crash message
instead of exhausting the crash-restart budget.
- Failures while writing the daemon binaries or Sapling parameters (disk full, permission
denied) are now surfaced clearly up front instead of failing opaquely when the daemon later
can't start.
- Directory-creation failures on startup (read-only home, permission denied) now produce a
clear "Cannot create <dir>" message instead of a confusing downstream "config missing" /
"binary not found" error (or, in one path, an uncaught exception).
### Added
- A "Taking longer than expected" notice now appears if the daemon is reachable but hasn't
finished initializing after ~45 s (configurable via `ui.toml`), with guidance to restart the
daemon or open the Console — instead of an indefinite silent spinner. It clears itself
automatically once the daemon connects.
---
Engineering detail and the finding-by-finding rationale for this batch live in
`docs/daemon-startup-hardening.md`.

View File

@@ -79,13 +79,13 @@ The detailed milestone plan and design history (the v2 plan, backend artifact/AB
## Miner updater (xmrig)
The mining tab's pool section has an **"Update miner…"** button that downloads/verifies/installs the latest DRG-XMRig from the project Gitea (`util/XmrigUpdater` + `ui/windows/xmrig_download_dialog.h`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest` → pick the asset for this platform (`linux-x64` / `win-x64` / `macos-x86_64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (from the release body) **and** a detached **ed25519 signature** → miniz-extract the binary (flattening the versioned subdir) into `resources::getDaemonDirectory()`. The whole archive is verified, so extracted members are trusted by transitivity (no per-member hash check). The pure, no-I/O core is split into `xmrig_updater_core.cpp` for unit tests; an env-gated (`DRAGONX_TEST_NETWORK=1`) test exercises the worker live. A **"Browse all releases…"** button (the `/releases` list, newest first, pre-releases included) lets users pin an older or pre-release build — same verify/install path via `startInstallRelease()`; the picker UI is shared with the daemon updater (`ui/windows/release_list_view.h`).
The mining tab's pool section has an **"Update miner…"** button that downloads/verifies/installs the latest DRG-XMRig from the project Gitea (`util/XmrigUpdater` + `ui/windows/xmrig_download_dialog.h`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest` → pick the asset for this platform (`linux-x64` / `win-x64` / `macos-x86_64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (from the release body) **and** a detached **ed25519 signature** → miniz-extract the binary (flattening the versioned subdir) into `resources::getDaemonDirectory()`. The whole archive is verified, so extracted members are trusted by transitivity (no per-member hash check). The pure, no-I/O core is split into `xmrig_updater_core.cpp` for unit tests; an env-gated (`DRAGONX_TEST_NETWORK=1`) test exercises the worker live. The dialog is a two-pane version picker (every `/releases` entry on the left, newest first, pre-releases included) so users can pin an older or pre-release build — same verify/install path via `startInstallRelease()`. It shares only the `ReleaseRow` row model with the daemon updater (`ui/windows/release_list_view.h`); each dialog renders its own tactile Material list.
**Signature verification is enforced** (`kXmrigRequireSignature = true` in `src/util/xmrig_updater.h`), checked against the public key pinned in `kXmrigSignaturePublicKeyBase64`. **Consequence for releases:** every `drg-xmrig` release MUST ship a detached signature per archive or the in-app updater refuses it. To cut a release: build the archives, then `scripts/sign-xmrig-release.sh sign <secret.key> <archive.zip>...` (OpenSSL-based, no extra deps) and upload each `<archive>.sig` as a release asset alongside its `.zip`. The signing **secret key must stay offline** (it is gitignored: `*.ed25519.key`); only its base64 public key is pinned in the source. To rotate the key, regenerate (`scripts/sign-xmrig-release.sh keygen`) and update `kXmrigSignaturePublicKeyBase64`. An emergency env override is not provided — disabling verification means setting `kXmrigSignaturePublicKeyBase64` empty (and rebuilding).
## Daemon updater (dragonxd)
Settings → **NODE & SECURITY → DAEMON BINARY** has a **"Check for updates…"** button that downloads/verifies/installs the latest **dragonxd full node** from the project Gitea — the full-node sibling of the xmrig updater (`util/DaemonUpdater` + `ui/windows/daemon_download_dialog.h`, pure no-I/O core in `daemon_updater_core.cpp`; gated full-node-only via `supportsFullNodeLifecycleActions()`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/latest` → pick the archive for this platform (`linux-amd64` / `macos` / `win64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (parsed from the release body's markdown **checksum table**, not xmrig's `<hash> <name>` lines) **and** a detached **ed25519 signature** → miniz-extract the three executables (`dragonxd`/`dragonx-cli`/`dragonx-tx`, flattening the versioned subdir) into `resources::getDaemonDirectory()`. The archive also bundles Sapling params/asmap, which the updater deliberately leaves to the wallet's own resource extraction. Install is **atomic and safe while the node runs** (POSIX `rename()` replaces the in-use binary; Windows moves the locked `.exe` aside to `.old`); the new binary takes effect on the **next daemon start**, so the Done screen offers **"Restart daemon now"** (`App::restartDaemon()`). A **"Browse all releases…"** button (shared `release_list_view.h` picker) lets users pin a specific/older/pre-release node build via `startInstallRelease()` — with a downgrade caution, since an older binary may not match current chain data.
Settings → **NODE & SECURITY → DAEMON BINARY** has a **"Check for updates…"** button that downloads/verifies/installs the latest **dragonxd full node** from the project Gitea — the full-node sibling of the xmrig updater (`util/DaemonUpdater` + `ui/windows/daemon_download_dialog.h`, pure no-I/O core in `daemon_updater_core.cpp`; gated full-node-only via `supportsFullNodeLifecycleActions()`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/latest` → pick the archive for this platform (`linux-amd64` / `macos` / `win64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (parsed from the release body's markdown **checksum table**, not xmrig's `<hash> <name>` lines) **and** a detached **ed25519 signature** → miniz-extract the three executables (`dragonxd`/`dragonx-cli`/`dragonx-tx`, flattening the versioned subdir) into `resources::getDaemonDirectory()`. The archive also bundles Sapling params/asmap, which the updater deliberately leaves to the wallet's own resource extraction. Install is **atomic and safe while the node runs** (POSIX `rename()` replaces the in-use binary; Windows moves the locked `.exe` aside to `.old`); the new binary takes effect on the **next daemon start**, so the Done screen offers **"Restart daemon now"** (`App::restartDaemon()`). The dialog is a two-pane version picker (every `/releases` entry on the left) so users can pin a specific/older/pre-release node build via `startInstallRelease()` — with a downgrade caution, since an older binary may not match current chain data. It shares only the `ReleaseRow` row model (`ui/windows/release_list_view.h`) with the miner updater; each renders its own tactile Material list.
**Signature verification is enforced** (`kDaemonRequireSignature = true` in `src/util/daemon_updater.h`), checked against `kDaemonSignaturePublicKeyBase64`. **Consequence for releases:** every `dragonx` release MUST ship a detached `<archive>.sig` per platform archive or the in-app updater refuses it (as of v1.0.2 the releases publish SHA-256 but **no** signatures yet — sign + upload them to enable in-app updates). To cut a release: `scripts/sign-daemon-release.sh sign <secret.key> dragonx-<ver>-{linux-amd64,macos,win64}.zip` (OpenSSL-based) and upload each `.sig` next to its `.zip`. The signing **secret key stays offline** (gitignored `*.ed25519.key`; this repo's is `dragonx-daemon.ed25519.key`); only the base64 public key is pinned. To rotate: `scripts/sign-daemon-release.sh keygen` and update `kDaemonSignaturePublicKeyBase64`. The generic SHA-256 / ed25519 primitives are shared with the miner updater (`util::sha256Hex` / `util::verifyXmrigSignature`).

View File

@@ -15,7 +15,7 @@ if(APPLE)
endif()
project(ObsidianDragon
VERSION 2.0.0
VERSION 2.0.1
LANGUAGES C CXX
DESCRIPTION "DragonX Cryptocurrency Wallet"
)
@@ -53,7 +53,6 @@ set_property(CACHE DRAGONX_LITE_BACKEND_LINK_MODE PROPERTY STRINGS imported)
set(DRAGONX_LITE_BACKEND_ABI "sdxl-c-v1" CACHE STRING "Expected lite backend C ABI version")
set(DRAGONX_LITE_BACKEND_SYMBOLS_FILE "" CACHE FILEPATH "Path to generated lite backend exported-symbol inventory")
set(DRAGONX_LITE_BACKEND_MANIFEST "" CACHE FILEPATH "Optional path to generated lite backend artifact manifest")
option(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE "Require verified signature metadata in the lite backend artifact manifest" OFF)
set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
litelib_wallet_exists
litelib_initialize_new
@@ -126,36 +125,24 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
endif()
if(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE)
if(NOT DRAGONX_LITE_BACKEND_MANIFEST)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires DRAGONX_LITE_BACKEND_MANIFEST")
endif()
file(READ "${DRAGONX_LITE_BACKEND_MANIFEST}" DRAGONX_LITE_BACKEND_MANIFEST_JSON)
string(JSON DRAGONX_LITE_SIGNATURE_STATUS ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_STATUS_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_status)
if(DRAGONX_LITE_SIGNATURE_STATUS_ERROR)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing signature verification status")
endif()
if(NOT DRAGONX_LITE_SIGNATURE_STATUS STREQUAL "verified")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verified signature metadata")
endif()
string(JSON DRAGONX_LITE_SIGNATURE_VERIFIED_SHA ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verified_artifact_sha256)
string(JSON DRAGONX_LITE_ARTIFACT_SHA ERROR_VARIABLE DRAGONX_LITE_ARTIFACT_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" artifact sha256)
if(DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR OR DRAGONX_LITE_ARTIFACT_SHA_ERROR)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing artifact/signature SHA-256 metadata")
endif()
if(NOT DRAGONX_LITE_SIGNATURE_VERIFIED_SHA STREQUAL DRAGONX_LITE_ARTIFACT_SHA)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST signature metadata does not verify the artifact SHA-256")
endif()
string(JSON DRAGONX_LITE_SIGNATURE_PERFORMED ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_performed)
if(DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR OR NOT DRAGONX_LITE_SIGNATURE_PERFORMED)
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verification_performed=true")
endif()
endif()
# Note (F15-1): the former signature-metadata gate was removed. It trusted a
# "verification_status: verified" field that scripts/build-lite-backend-artifact.sh
# self-attested with no cryptographic check (the "verified" SHA was just the artifact's
# own SHA). The trust root is now build-from-source: that script builds the backend from
# the vendored in-tree source and refuses prebuilt artifacts, so the library linked here
# is the one built from reviewed source. The required-symbol inventory check above stays.
add_library(dragonx_lite_backend UNKNOWN IMPORTED)
set_target_properties(dragonx_lite_backend PROPERTIES
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
)
if(APPLE)
# The Rust backend's TLS stack (security-framework / core-foundation crates)
# references Secure Transport (SSL*) + CoreFoundation symbols. Link the frameworks
# that provide them, or the static lib leaves ~130 symbols undefined at link time.
set_property(TARGET dragonx_lite_backend APPEND PROPERTY
INTERFACE_LINK_LIBRARIES "-framework Security" "-framework CoreFoundation")
endif()
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
@@ -226,7 +213,7 @@ include(FetchContent)
FetchContent_Declare(
json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.3
GIT_TAG 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03 # v3.11.3 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(json)
@@ -235,7 +222,7 @@ FetchContent_MakeAvailable(json)
FetchContent_Declare(
tomlplusplus
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
GIT_TAG v3.4.0
GIT_TAG 30172438cee64926dc41fdd9c11fb3ba5b2ba9de # v3.4.0 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(tomlplusplus)
@@ -295,6 +282,38 @@ else()
set(CURL_INCLUDE_DIRS ${CURL_INCLUDE_DIR})
endif()
# libwebp - WebP decode (still + animated via WebPAnimDecoder). Built from source, static, decode-only
# so Linux / mingw-Windows / macOS-osxcross all build it identically (the mingw/osx sysroots have no
# webp). Encode tools are disabled to avoid pulling in libpng/zlib that the cross sysroots lack.
message(STATUS "Fetching libwebp (decode-only, static)...")
FetchContent_Declare(
libwebp
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
GIT_TAG 845d5476a866141ba35ac133f856fa62f0b7445f # v1.4.0 — pinned to immutable commit (L-08); tags are mutable
GIT_SHALLOW TRUE
# libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP
# files when it can't probe SSE support. Under a macOS universal build
# (-arch arm64;x86_64) that probe fails, so the flags land on the x86_64 slice,
# where -mno-sse2 disables _Float16 and breaks the SDK's <math.h>. Neutralize
# those disable flags (SSE2 is x86_64 baseline). Portable + idempotent; a no-op
# for single-arch Linux/Windows/x86_64 builds. See cmake/patch-libwebp-simd.cmake.
PATCH_COMMAND ${CMAKE_COMMAND}
-DCPU_CMAKE=<SOURCE_DIR>/cmake/cpu.cmake
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch-libwebp-simd.cmake
)
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_CWEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_DWEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_GIF2WEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_IMG2WEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_VWEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_WEBPINFO OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_LIBWEBPMUX OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_WEBPMUX OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_EXTRAS OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(libwebp)
# libsodium - platform-specific
# Search order per platform:
# 1. Local pre-built in libs/libsodium{-mac,-win}/ (downloaded by scripts/fetch-libsodium.sh)
@@ -383,6 +402,35 @@ else()
list(APPEND IMGUI_HEADERS ${IMGUI_DIR}/backends/imgui_impl_opengl3.h)
endif()
# Optional FreeType font loader — enables color-emoji rendering (COLR/CPAL Twemoji) when the chat
# "color emoji" setting is on; otherwise the wallet falls back to the monochrome emoji subset.
# - Native Linux/macOS: use the system FreeType via find_package.
# - Windows (mingw cross): the toolchain ships no FreeType, so build.sh --win-release cross-builds a
# static one (scripts/build-freetype-mingw.sh) and passes -DDRAGONX_MINGW_FREETYPE_PREFIX here.
# - Other cross builds (osxcross) without FreeType: silently fall back to monochrome.
set(DRAGONX_FREETYPE OFF)
set(DRAGONX_FREETYPE_LIB "")
set(DRAGONX_FREETYPE_INC "")
if(DEFINED DRAGONX_MINGW_FREETYPE_PREFIX AND EXISTS "${DRAGONX_MINGW_FREETYPE_PREFIX}/lib/libfreetype.a")
set(DRAGONX_FREETYPE ON)
set(DRAGONX_FREETYPE_LIB "${DRAGONX_MINGW_FREETYPE_PREFIX}/lib/libfreetype.a")
set(DRAGONX_FREETYPE_INC "${DRAGONX_MINGW_FREETYPE_PREFIX}/include/freetype2")
message(STATUS "FreeType (mingw cross-built) found — chat color emoji enabled")
elseif(NOT CMAKE_CROSSCOMPILING)
find_package(Freetype QUIET)
if(FREETYPE_FOUND)
set(DRAGONX_FREETYPE ON)
set(DRAGONX_FREETYPE_LIB Freetype::Freetype) # imported target carries include dirs
message(STATUS "FreeType ${FREETYPE_VERSION_STRING} found — chat color emoji enabled")
endif()
endif()
if(DRAGONX_FREETYPE)
list(APPEND IMGUI_SOURCES ${IMGUI_DIR}/misc/freetype/imgui_freetype.cpp)
list(APPEND IMGUI_HEADERS ${IMGUI_DIR}/misc/freetype/imgui_freetype.h)
else()
message(STATUS "FreeType not found — chat color emoji falls back to monochrome")
endif()
# -----------------------------------------------------------------------------
# QR Code library (bundled)
# -----------------------------------------------------------------------------
@@ -496,6 +544,7 @@ set(APP_SOURCES
src/util/async_task_manager.cpp
src/util/amount_format.cpp
src/util/address_validation.cpp
src/util/seed_phrase.cpp
src/util/base64.cpp
src/util/single_instance.cpp
src/util/i18n.cpp
@@ -503,6 +552,7 @@ set(APP_SOURCES
src/util/platform.cpp
src/util/payment_uri.cpp
src/util/texture_loader.cpp
src/util/svg_texture.cpp
src/util/noise_texture.cpp
src/daemon/embedded_daemon.cpp
src/daemon/seed_wallet_creator.cpp
@@ -707,7 +757,9 @@ ${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Medium.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/UbuntuMono-R.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/MaterialIcons-Regular.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/NotoSansCJK-Subset.ttf"
${CMAKE_SOURCE_DIR}/res/fonts/NotoSansCJK-Subset.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/NotoEmoji-Subset.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/TwemojiMozilla-Color.ttf"
)
add_executable(ObsidianDragon
@@ -735,6 +787,7 @@ target_include_directories(ObsidianDragon PRIVATE
${GLAD_INCLUDE}
${CURL_INCLUDE_DIRS}
${MINIZ_DIR}
${libwebp_SOURCE_DIR}/src # <webp/decode.h>, <webp/demux.h> (FetchContent build tree)
)
target_link_libraries(ObsidianDragon PRIVATE
@@ -744,6 +797,8 @@ target_link_libraries(ObsidianDragon PRIVATE
sqlite3_amalgamation
${CURL_LIBRARIES}
${SODIUM_LIBRARY}
webp
webpdemux # WebPAnimDecoder (animated WebP); transitively pulls in webp + sharpyuv
)
if(DRAGONX_LITE_BACKEND_READY)
@@ -837,6 +892,15 @@ else()
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_HAS_GLAD)
endif()
# Color-emoji font loader (FreeType) — linked + flagged only when found (see DRAGONX_FREETYPE above).
if(DRAGONX_FREETYPE)
target_link_libraries(ObsidianDragon PRIVATE ${DRAGONX_FREETYPE_LIB})
if(DRAGONX_FREETYPE_INC)
target_include_directories(ObsidianDragon PRIVATE ${DRAGONX_FREETYPE_INC})
endif()
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_HAVE_FREETYPE)
endif()
add_executable(HushChatFixtureCheck
tools/hushchat_fixture_check.cpp
src/chat/chat_protocol.cpp
@@ -1011,6 +1075,32 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
OPTIONAL
)
# -----------------------------------------------------------------------------
# dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
# Bundled next to the daemon; the app spawns it out-of-process. It is the ONLY thing that links
# Berkeley DB, so the AGPLv3 BDB never contaminates the GPLv3 GUI (same boundary as the daemon).
# Release builds should point DRAGONX_BDB_ROOT at the vendored static libdb (external/dragonx/depends);
# a dev build falls back to the system Berkeley DB. Skipped (with a note) if no BDB is found.
# -----------------------------------------------------------------------------
find_path(BDB_INCLUDE_DIR db.h HINTS ${DRAGONX_BDB_ROOT}/include /usr/include /usr/local/include)
find_library(BDB_LIBRARY NAMES db-6.2 db-6.0 db-5.3 db libdb
HINTS ${DRAGONX_BDB_ROOT}/lib /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu)
if(BDB_INCLUDE_DIR AND BDB_LIBRARY)
add_executable(dragonx-wallet-rebuild tools/wallet_rebuild/main.cpp)
target_include_directories(dragonx-wallet-rebuild PRIVATE ${CMAKE_SOURCE_DIR}/src ${BDB_INCLUDE_DIR})
target_link_libraries(dragonx-wallet-rebuild PRIVATE ${BDB_LIBRARY})
if(WIN32)
target_link_libraries(dragonx-wallet-rebuild PRIVATE ws2_32) # static libdb-6.2 pulls in winsock
else()
find_package(Threads REQUIRED)
target_link_libraries(dragonx-wallet-rebuild PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) # static libdb needs pthread/dl
endif()
set_target_properties(dragonx-wallet-rebuild PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
message(STATUS "wallet-rebuild helper: ON (Berkeley DB ${BDB_LIBRARY})")
else()
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
endif()
# -----------------------------------------------------------------------------
# Tests
# -----------------------------------------------------------------------------
@@ -1059,6 +1149,7 @@ if(BUILD_TESTING)
src/util/payment_uri.cpp
src/util/amount_format.cpp
src/util/address_validation.cpp
src/util/seed_phrase.cpp
src/util/i18n.cpp
src/util/text_format.cpp
src/data/wallet_state.cpp
@@ -1066,6 +1157,7 @@ if(BUILD_TESTING)
src/data/address_book.cpp
src/data/wallet_index.cpp
src/daemon/lifecycle_adapters.cpp
src/daemon/embedded_daemon.cpp
src/rpc/connection.cpp
src/config/settings.cpp
src/resources/embedded_resources.cpp
@@ -1137,5 +1229,5 @@ message(STATUS " Lite backend: ${DRAGONX_LITE_BACKEND_READY}")
message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}")
message(STATUS " Lite signature: ${DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE}")
message(STATUS " Lite trust: built-from-source (vendored third_party/silentdragonxlite)")
message(STATUS "")

View File

@@ -81,8 +81,8 @@ Download linux and windows binaries of latest releases and place in binary direc
- prebuilt-binaries/dragonxd-win/
- prebuilt-binaries/dragonxd-mac/
**xmrig HAC fork** (https://git.dragonx.is/dragonx/xmrig-hac):
- prebuilt-binaries/xmrig-hac/
**DRG-XMRig fork** (https://git.dragonx.is/DragonX/drg-xmrig):
- prebuilt-binaries/drg-xmrig/
## Build Steps

196
build.sh
View File

@@ -131,7 +131,7 @@ fi
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
_cml="$SCRIPT_DIR/CMakeLists.txt"
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]][[:space:]]*\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
@@ -195,6 +195,24 @@ should_bundle_full_node_assets() {
! $DO_LITE
}
# The offline wallet-rebuild helper is the ONLY thing that repairs a genuinely BDB-inconsistent
# wallet.dat — plain "Restore" just re-triggers the daemon's salvage cascade. A full-node release must
# NEVER ship without it (the in-app "Repair automatically" option silently disappears otherwise), so
# treat a missing helper as a HARD build failure instead of degrading recovery to Restore-only.
# $1 = built helper path (e.g. bin/dragonx-wallet-rebuild[.exe]); $2 = the BDB depends dir for the hint.
require_wallet_rebuild_helper() {
local helper="$1" depends="$2"
should_bundle_full_node_assets || return 0 # lite builds have no BDB wallet.dat to rebuild
if [[ ! -f "$helper" ]]; then
err "wallet-rebuild helper was NOT built: $helper"
err " → the recovery 'Repair automatically' option would be MISSING from this release."
err " Cause: the vendored Berkeley DB depends are absent, so CMake skipped the dragonx-wallet-rebuild target."
err " Fix: provide ${depends}/{lib/libdb-6.2.a,include/db.h} (same static libdb the daemon links), then rebuild."
exit 1
fi
info " wallet-rebuild helper present: $helper"
}
# ── Helper: find resource files ──────────────────────────────────────────────
find_sapling_params() {
local dirs=(
@@ -286,6 +304,9 @@ bundle_linux_daemon() {
# asmap.dat
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
# (The dragonx-wallet-rebuild recovery helper is built into bin/ by CMake and packaged explicitly
# by each release path — required via require_wallet_rebuild_helper — so it is not copied here.)
return $found
}
@@ -336,11 +357,24 @@ build_release_linux() {
mkdir -p "$bd" && cd "$bd"
# ── Compile ──────────────────────────────────────────────────────────────
# Point the wallet-rebuild helper at the vendored static Berkeley DB (same libdb the daemon links)
# so its output is a v6.2 btree the bundled dragonxd reads. Pass the paths EXPLICITLY (bypasses
# find_library + its cache); the helper target is simply not built if the depends tree is absent.
local lin_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-unknown-linux-gnu"
local BDB_ARGS=()
if [[ -f "$lin_bdb/lib/libdb-6.2.a" && -f "$lin_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$lin_bdb/include" -DBDB_LIBRARY="$lin_bdb/lib/libdb-6.2.a" )
elif should_bundle_full_node_assets; then
err "Vendored Berkeley DB depends missing at $lin_bdb — the wallet-rebuild recovery helper cannot be built."
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
exit 1
fi
info "Configuring ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=ON \
"${BDB_ARGS[@]}" \
"${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..."
@@ -348,8 +382,12 @@ build_release_linux() {
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$lin_bdb"
info "Stripping ..."
strip "bin/${APP_BASENAME}"
[[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
if should_bundle_full_node_assets; then
@@ -384,9 +422,12 @@ build_release_linux() {
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
cp bin/dragonx-wallet-rebuild "$dist_dir/" && chmod +x "$dist_dir/dragonx-wallet-rebuild"
info " Bundled dragonx-wallet-rebuild"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
[[ -f "$XMRIG_LINUX" ]] && { cp "$XMRIG_LINUX" "$dist_dir/"; chmod +x "$dist_dir/xmrig"; info "Bundled xmrig"; } || warn "xmrig not found — mining unavailable in zip"
cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -417,9 +458,11 @@ build_release_linux() {
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
cp bin/dragonx-wallet-rebuild "$APPDIR/usr/bin/" && chmod +x "$APPDIR/usr/bin/dragonx-wallet-rebuild"
fi
# Bundle xmrig for mining support
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
# Desktop entry
@@ -478,18 +521,28 @@ APPRUN
done
[[ -f "$bd/_deps/sdl3-build/libSDL3.so" ]] && cp "$bd/_deps/sdl3-build/libSDL3.so"* "$APPDIR/usr/lib/" 2>/dev/null || true
# appimagetool
# appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
# The old "continuous" tag is a MOVING build fetched over the network and run on the release
# builder; a compromised/MITM'd artifact would execute here. Verify, or refuse to package.
local APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
local APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
local APPIMAGETOOL=""
if command -v appimagetool &>/dev/null; then
APPIMAGETOOL="appimagetool"
elif [[ -f "$bd/appimagetool-x86_64.AppImage" ]]; then
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
else
info "Downloading appimagetool ..."
wget -q -O "$bd/appimagetool-x86_64.AppImage" \
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x "$bd/appimagetool-x86_64.AppImage"
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
local at="$bd/appimagetool-x86_64.AppImage"
# Re-verify any cached copy too; a stale unverified download must not be trusted.
if [[ ! -f "$at" ]] || ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
info "Downloading appimagetool 1.9.0 (pinned) ..."
wget -q -O "$at" "$APPIMAGETOOL_URL"
if ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
err "appimagetool SHA-256 verification failed — refusing to use it"
rm -f "$at"
return 1
fi
chmod +x "$at"
fi
APPIMAGETOOL="$at"
fi
local ARCH
@@ -628,8 +681,32 @@ HDR
info "Lite mode: skipping embedded daemon binaries"
fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
# ── Wallet-rebuild recovery helper ───────────────────────────────
# Built in-tree (not a prebuilt like the daemon), so compile it standalone HERE — before the
# main app compiles embedded_resources.cpp — and INCBIN it, so a bare, self-extracting
# ObsidianDragon.exe carries the recovery tool exactly like it does the daemon.
if should_bundle_full_node_assets; then
local WBDB="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
if [[ -f "$WBDB/lib/libdb-6.2.a" && -f "$WBDB/include/db.h" ]]; then
info "Compiling + embedding wallet-rebuild helper ..."
x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
-I"$SCRIPT_DIR/src" -I"$WBDB/include" \
"$SCRIPT_DIR/tools/wallet_rebuild/main.cpp" \
"$WBDB/lib/libdb-6.2.a" -lws2_32 \
-o "$RES/dragonx-wallet-rebuild.exe" \
|| { err "wallet-rebuild helper failed to compile for embedding"; exit 1; }
x86_64-w64-mingw32-strip "$RES/dragonx-wallet-rebuild.exe" 2>/dev/null || true
echo -e "\n#define HAS_EMBEDDED_WALLET_REBUILD 1" >> "$GEN/embedded_data.h"
echo "INCBIN(dragonx_wallet_rebuild_exe, \"$RES/dragonx-wallet-rebuild.exe\");" >> "$GEN/embedded_data.h"
info " Embedded dragonx-wallet-rebuild.exe ($(du -h "$RES/dragonx-wallet-rebuild.exe" | cut -f1))"
else
err "Vendored mingw Berkeley DB missing at $WBDB — cannot embed the wallet-rebuild recovery helper."
exit 1
fi
fi
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
@@ -725,12 +802,40 @@ HDR
"$SCRIPT_DIR/scripts/fetch-libsodium.sh" --win
fi
# ── FreeType for Windows (color-emoji rendering) ───────────────────────
# The mingw toolchain ships no FreeType; cross-build a minimal static one (COLR/CPAL, no external
# deps). Failure is non-fatal — the wallet just falls back to monochrome emoji.
local FT_MINGW_PREFIX="$SCRIPT_DIR/third_party/freetype-mingw"
if [[ ! -f "$FT_MINGW_PREFIX/lib/libfreetype.a" ]]; then
info "Cross-building FreeType for Windows (color emoji) ..."
"$SCRIPT_DIR/scripts/build-freetype-mingw.sh" "$FT_MINGW_PREFIX" \
|| warn "FreeType cross-build failed — Windows build will use monochrome emoji"
fi
local FT_CMAKE_ARG=()
if [[ -f "$FT_MINGW_PREFIX/lib/libfreetype.a" ]]; then
FT_CMAKE_ARG=(-DDRAGONX_MINGW_FREETYPE_PREFIX="$FT_MINGW_PREFIX")
fi
# ── CMake + build ────────────────────────────────────────────────────────
# The wallet-rebuild helper links the vendored mingw static Berkeley DB (the mingw toolchain's
# find_library is sysroot-only, so pass the depends paths EXPLICITLY to bypass the search). Only
# enabled if the depends tree is present; guarded with -DBDB_* left empty otherwise.
local win_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
local BDB_ARGS=()
if [[ -f "$win_bdb/lib/libdb-6.2.a" && -f "$win_bdb/include/db.h" ]]; then
BDB_ARGS=( -DBDB_INCLUDE_DIR="$win_bdb/include" -DBDB_LIBRARY="$win_bdb/lib/libdb-6.2.a" )
elif should_bundle_full_node_assets; then
err "Vendored Berkeley DB depends missing at $win_bdb — the wallet-rebuild recovery helper cannot be built."
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
exit 1
fi
info "Configuring (cross-compile) ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
"${BDB_ARGS[@]}" \
"${FT_CMAKE_ARG[@]}" \
"${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..."
@@ -739,6 +844,9 @@ HDR
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb"
# ── Package: release/windows/ ────────────────────────────────────────────
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
mkdir -p "$out"
@@ -754,6 +862,9 @@ HDR
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
done
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat) — required.
cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/" && info " Bundled dragonx-wallet-rebuild.exe"
[[ -f "$dist_dir/dragonx-wallet-rebuild.exe" ]] || { err "Failed to bundle dragonx-wallet-rebuild.exe"; exit 1; }
# Bundle Sapling params + asmap for the zip distribution
# (The single-file exe has these embedded via INCBIN, but the zip
@@ -766,7 +877,7 @@ HDR
fi
# Bundle xmrig for mining support
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig.exe"
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -876,8 +987,26 @@ build_release_mac() {
fi
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
else
# Native macOS: build universal binary (arm64 + x86_64)
# Native macOS: build universal (arm64 + x86_64) by default. Override with
# DRAGONX_MAC_ARCHS (e.g. "x86_64").
MAC_ARCHS="${DRAGONX_MAC_ARCHS:-arm64;x86_64}"
# When linking the real lite backend, the app can only include architectures
# the backend static library actually provides. Its pinned ring 0.16.11 has no
# Apple-Silicon assembly, so that artifact is x86_64-only — constrain the app
# arch to the backend's (unless the user explicitly forced DRAGONX_MAC_ARCHS),
# otherwise the arm64 slice fails to link.
if $DO_LITE_BACKEND && [[ -z "${DRAGONX_MAC_ARCHS:-}" && -n "${lb_lib:-}" ]] && command -v lipo &>/dev/null; then
local _backend_archs; _backend_archs=$(lipo -archs "$lb_lib" 2>/dev/null | tr ' ' ';')
if [[ -n "$_backend_archs" && "$_backend_archs" != "$MAC_ARCHS" ]]; then
warn "Lite backend provides only [$_backend_archs] — building the app for that instead of universal."
MAC_ARCHS="$_backend_archs"
fi
fi
if [[ "$MAC_ARCHS" == *";"* || "$MAC_ARCHS" == *","* ]]; then
MAC_ARCH="universal"
else
MAC_ARCH="$MAC_ARCHS"
fi
export MACOSX_DEPLOYMENT_TARGET="11.0"
fi
@@ -965,7 +1094,7 @@ TOOLCHAIN
need_sodium=true
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
# Rebuild if existing lib is not universal (single-arch won't link)
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -q "arm64.*x86_64\|x86_64.*arm64"; then
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -Eq "arm64.*x86_64|x86_64.*arm64"; then
info "Existing libsodium is not universal — rebuilding ..."
rm -rf "$SCRIPT_DIR/libs/libsodium"
need_sodium=true
@@ -976,13 +1105,13 @@ TOOLCHAIN
"$SCRIPT_DIR/scripts/fetch-libsodium.sh"
fi
info "Configuring (native universal arm64+x86_64) ..."
info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
"${CMAKE_LITE_ARGS[@]}"
fi
@@ -991,6 +1120,11 @@ TOOLCHAIN
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
# A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
# berkeley-db for a native build, or a vendored external/dragonx/depends/<triple>) — otherwise CMake
# skips the target and this fails loudly rather than shipping a mac release with no recovery option.
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/external/dragonx/depends/aarch64-apple-darwin"
# Strip — use osxcross strip for cross-builds
if $IS_CROSS; then
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
@@ -1012,8 +1146,12 @@ TOOLCHAIN
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
# ── Create .app bundle ───────────────────────────────────────────────────
rm -rf "$out"
mkdir -p "$out"
# Clean only THIS variant's prior artifacts so full-node and lite releases can
# coexist in release/mac/ (Linux/Windows scope their cleanup the same way). The
# "ObsidianDragon-" glob never matches "ObsidianDragonLite-" (and vice versa),
# and the ".app" names are exact.
rm -rf "$out/${APP_BASENAME}.app" "$out/${APP_BASENAME}-"*.app.zip "$out/${APP_BASENAME}-"*.dmg
local APP="$out/${APP_BASENAME}.app"
local CONTENTS="$APP/Contents"
@@ -1059,12 +1197,14 @@ TOOLCHAIN
else
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
fi
# Offline wallet-rebuild recovery helper — required (asserted after build); next to the daemon.
cp "bin/dragonx-wallet-rebuild" "$MACOS/" && chmod +x "$MACOS/dragonx-wallet-rebuild" && info " Bundled dragonx-wallet-rebuild"
else
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
fi
# xmrig binary (from prebuilt-binaries/xmrig-hac/)
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
# xmrig binary (from prebuilt-binaries/drg-xmrig/)
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
if [[ -f "$XMRIG_MAC" ]]; then
cp "$XMRIG_MAC" "$MACOS/xmrig"
chmod +x "$MACOS/xmrig"
@@ -1213,8 +1353,10 @@ PLIST
fi
# ── Create DMG ───────────────────────────────────────────────────────────
local DMG_BASENAME="DragonX_Wallet"
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite"
# DMG filename matches the app bundle name (ObsidianDragon / ObsidianDragonLite).
# The mounted volume + CFBundleName keep the "DragonX Wallet" display branding
# (APP_DISPLAY_NAME above).
local DMG_BASENAME="${APP_BASENAME}"
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
if command -v create-dmg &>/dev/null; then
@@ -1296,3 +1438,9 @@ if $DO_LINUX || $DO_WIN || $DO_MAC; then
[[ -d "$SCRIPT_DIR/release/windows" ]] && echo -e " ${CYAN}windows/${NC} — .exe + .zip"
[[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg"
fi
# Reaching here means the build completed (real failures exit 1 at their point of failure).
# Exit 0 explicitly: the final `[[ -d release/mac ]] && echo` above returns non-zero on a
# non-mac build — and since set -e exempts the left side of an &&, that status would otherwise
# become the script's exit code and make a successful build report failure (e.g. to CI).
exit 0

View File

@@ -0,0 +1,31 @@
# patch-libwebp-simd.cmake — portable, idempotent FetchContent patch for libwebp.
#
# libwebp's cmake/cpu.cmake compiles its scalar *reference* DSP files with the
# SSE-disable flags "-mno-sse4.1;-mno-sse2" whenever it can't positively detect
# SSE support. Under a macOS *universal* build (-arch arm64;x86_64) the per-arch
# SSE flag probe fails (a flag valid for x86_64 is invalid for arm64), so those
# disable flags get applied to the x86_64 slice. clang gates the _Float16 type on
# SSE2 for x86_64, and the macOS 15+/26 SDK's <math.h> declares _Float16 math
# functions unconditionally — so any TU including <math.h> fails to compile with
# "_Float16 is not supported on this target".
#
# SSE2 is part of the x86_64 baseline ABI, so disabling it on the reference files
# is unnecessary on every platform we target. Blanking the SSE entries (indices
# must stay aligned with WEBP_SIMD_FLAGS) fixes the universal build and is a no-op
# for single-arch Linux/Windows/x86_64 builds. Idempotent: re-running is a no-op.
if(NOT DEFINED CPU_CMAKE OR NOT EXISTS "${CPU_CMAKE}")
message(FATAL_ERROR "patch-libwebp-simd: cpu.cmake not found at '${CPU_CMAKE}'")
endif()
file(READ "${CPU_CMAKE}" _contents)
string(REPLACE
"set(SIMD_DISABLE_FLAGS \"-mno-sse4.1;-mno-sse2;;-mno-dspr2;;-mno-msa\")"
"set(SIMD_DISABLE_FLAGS \";;;-mno-dspr2;;-mno-msa\")"
_patched "${_contents}")
if(_patched STREQUAL _contents)
message(STATUS "patch-libwebp-simd: no change (already patched or pattern absent)")
else()
file(WRITE "${CPU_CMAKE}" "${_patched}")
message(STATUS "patch-libwebp-simd: neutralized x86 SSE-disable flags in cpu.cmake")
endif()

View File

@@ -0,0 +1,549 @@
# Daemon Startup Hardening — Implementation Plan
Eight verified edge-case defects in how ObsidianDragon brings up (and watches) the
`dragonxd` daemon at launch. Each entry is a buildable fix: the defect (with exact
line references), the chosen approach, the call sites, a representative change, and how
to verify it.
- **Scope:** full-node startup path (`--lite` excludes the embedded daemon entirely).
- **Source:** line references are exact against branch `dev` @ `45b652f`.
- **Provenance:** findings verified by direct source read; each fix designed by an
independent agent grounded in the cited files, with a sequencing pass for ordering,
shared helpers, and merge conflicts.
**Severity:** 2 High, 6 Medium · **Effort:** ≈ 2535 engineering-hours · **7 landing steps.**
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
**Status: all 8 landed & verified** (build-clean, `ctest` green after each) across four commits on
`dev` — lifecycle cluster (F1/F2/F4), filesystem+params cluster (F7/F6/F5), F3, and F8. Six new
pure-helper unit tests added.
**Wrap-up done:** release notes added (`CHANGELOG.md`, F8 breaking change front and center); i18n
back-fill applied additively to `res/lang/*.json` (42 keys — all 6 for es/de/fr/pt/ru; 6 zh/ja/ko
entries whose glyphs aren't in the current `NotoSansCJK-Subset.ttf` were left on English fallback
rather than render as tofu).
**Still owed before release:** a **CJK subset-font rebuild** (`scripts/build_cjk_subset.py`, needs
the Noto CJK source font) to cover the 6 deferred zh/ja/ko strings. *(F1 and F2 now have headless
integration-test coverage — see the progress log — so their GUI repros are optional, not blocking.)*
---
## Recommended rollout sequence
A real dependency order, not a checklist. The daemon-lifecycle cluster lands first
because it makes the `State::Error` / `crash_count_` contract trustworthy — which the
connect-stall panel and the lock gate both build on. The filesystem cluster lands
around a single shared helper. The connectivity-breaking security flip lands last.
| Step | Finding(s) | Site | Why here | Status |
|------|-----------|------|----------|--------|
| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ |
| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ |
| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ |
| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | ☑ |
| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | ☑ |
| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☑ |
| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☑ |
---
## F1 — Double-`waitpid` race can swallow a daemon crash
**Severity:** High · **Effort:** S (~12h) · **Status:** ☑ landed & verified
### The defect
`EmbeddedDaemon::isRunning()` (`embedded_daemon.cpp:1136`, POSIX branch) calls
`waitpid(WNOHANG)` — from the **UI thread, nearly every frame** — racing
`monitorProcess()`'s own reap at `:1244`. `waitpid` is one-shot: if the UI thread wins,
the monitor never decodes the exit, so `crash_count_` never increments, `State::Error`
never fires, and the 3-strike auto-restart cap (`app_network.cpp:479`) is defeated. The
sibling `XmrigManager::isRunning()` (`xmrig_manager.cpp:512`) already fixed exactly this
with an atomic read.
### The fix
Make `isRunning()` read the existing `std::atomic<State> state_` (member at
`embedded_daemon.h:253`) instead of calling `waitpid`, leaving `monitorProcess()` as the
sole reaper. Predicate is `Running || Stopping``Stopping` must stay "alive" because
`stop()`'s graceful/SIGTERM wait loops poll `isRunning()` before the process has exited.
### Files touched
- `src/daemon/embedded_daemon.cpp``isRunning()`, POSIX branch (~1136)
### Core change
```cpp
bool EmbeddedDaemon::isRunning() const // POSIX branch
{
// Read the atomic state_ instead of waitpid() — monitorProcess() is the
// sole reaper. Previously both threads reaped; if the UI thread won, the
// monitor never saw the exit (crash_count_ / exit code / Error all lost).
if (process_pid_ <= 0) return false;
State s = state_.load(std::memory_order_relaxed);
// Stopping stays "alive": stop()'s wait loops poll isRunning() while
// state_ == Stopping, before the process has actually terminated.
return (s == State::Running || s == State::Stopping);
}
```
### Verification
- Manual: `kill -SEGV` the daemon 1020×; the monitor must report the exit and increment `crash_count_` every time (previously intermittent).
- Regression: a normal Settings-driven stop still escalates SIGTERM→SIGKILL (the `Stopping` predicate).
- Not unit-testable (real fork/exec/waitpid) — consistent with the no-process-spawn harness.
### Dependencies
Mirrors `XmrigManager::isRunning()`. Flags a separate latent hazard (out of scope):
`stop()`'s final blocking `waitpid` (`:1220`) can still race a mid-sleep monitor
iteration — file as its own ticket.
---
## F2 — exec-after-fork silent failure: "Running" for a daemon that never started
**Severity:** High · **Effort:** S (~23h) · **Status:** ☑ landed & verified
### The defect
In `startProcess()` (`embedded_daemon.cpp:9571061`, POSIX) the parent runs
`process_pid_ = pid; return true;` **unconditionally** after `fork()` — with no
exec-status handshake. On a non-executable / wrong-arch / corrupt binary the child's
`execv` fails and it `_exit(127)`s, but `start()` has already set `State::Running`
(`:565`). The real cause never reaches `last_error_`; it surfaces later, generically,
as "exited unexpectedly (exit code 127)".
### The fix
Add a **close-on-exec self-pipe** handshake — `pipe() + fcntl(FD_CLOEXEC)`, deliberately
**not** `pipe2()` (macOS lacks it; the POSIX branch is shared). The child writes `errno`
only on `execv` failure; a successful exec closes the write end for free. Parent reads:
EOF ⇒ success; 4 bytes ⇒ reap the zombie, set a precise `last_error_` ("not executable
or wrong architecture"), and return `false` so `start()` never reports Running. EINTR-safe
on both ends. Also comments the unchecked parent-side `setpgid` at `:1053`.
### Files touched
- `src/daemon/embedded_daemon.cpp``startProcess()` parent read path
- `src/daemon/embedded_daemon.cpp` — child `execv`-failure write (~1043)
- `src/daemon/embedded_daemon.cpp``setpgid` best-effort comment (~1053)
### Core change
```cpp
// Self-pipe exec handshake (pipe()+FD_CLOEXEC; NOT pipe2 — macOS lacks it).
int execpipe[2]; pipe(execpipe);
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
pid_t pid = fork();
if (pid == 0) { // child
close(execpipe[0]);
/* setpgid / chdir / dup2 / argv … */
execv(binary_path.c_str(), argv.data());
int e = errno; // execv failed
while (write(execpipe[1], &e, sizeof e) < 0 && errno == EINTR) {}
_exit(127);
}
close(execpipe[1]); // parent: must close or read() never EOFs
int child_errno = 0, total = 0;
for (;;) { // EOF ⇒ exec ok; 4 bytes ⇒ exec failed
ssize_t n = read(execpipe[0], (char*)&child_errno + total, sizeof(int) - total);
if (n == 0) break;
if (n < 0) { if (errno == EINTR) continue; break; }
if ((total += n) >= (int)sizeof(int)) break;
}
close(execpipe[0]);
if (total >= (int)sizeof(int)) { // exec never happened
waitpid(pid, nullptr, 0); // reap the zombie
last_error_ = "dragonxd could not be executed: " +
std::string(strerror(child_errno)) +
" — not executable or wrong architecture";
return false; // start() no longer reports Running
}
```
### Verification
- Point at a `chmod -x` / wrong-arch file → `start()` returns false immediately, precise message, no leftover zombie.
- Success path: real binary still starts with no perceptible added latency.
- Optional pure `formatExecFailureError(errno)` helper for a `test_phase4.cpp` unit test.
### Dependencies
F1 (same function family; sequence F1→F2). **Highest-risk mistake:** forgetting
`FD_CLOEXEC` makes every successful start hang the parent read forever.
---
## F4 — Stale datadir-lock start → restart storm that wedges the UI
**Severity:** Medium · **Effort:** S (~35h) · **Status:** ☑ landed & verified
### The defect
`start()` (`embedded_daemon.cpp:466`) gates only on the RPC port (`:482`), never on
`isDaemonProcessRunning()` (`:1292`). A graceful shutdown frees the port but keeps the
datadir `.lock` for up to ~90s. A rapid stop→start spawns a daemon that dies "Cannot
obtain a lock on data directory" — routed to the generic crash path. With a ~4s retry
cadence, **three lock races in ~12s exhaust the 3-strike budget** and wedge the UI long
before the lock actually clears.
### The fix
Fail-fast with a **short bounded local wait (~300ms), not a 90s block**. After the port
bail, consult `isDaemonProcessRunning()` — gated by `!skip_port_check_` and exempt when
`override_datadir_` is set, so the isolated migrate-to-seed daemon still works. A pure
`evaluateDatadirLockGate()` returns a **distinct non-crash Error** that never increments
`crash_count_`. The connect loop's own retry then absorbs the transient.
### Files touched
- `src/daemon/embedded_daemon.h` — decision struct, helper decl, poll constants
- `src/daemon/embedded_daemon.cpp``start()` gate + `evaluateDatadirLockGate()`
### Core change
```cpp
static StartLockGateDecision evaluateDatadirLockGate(
bool skipPortCheck, bool isolatedOverride, bool stillRunningAfterWait) {
if (skipPortCheck || isolatedOverride) return {true, ""}; // migrate-to-seed exempt
if (!stillRunningAfterWait) return {true, ""};
return {false, "A previous dragonxd is still shutting down and holding the "
"data directory lock. Retrying shortly…"};
}
// start() — after the isPortInUse() bail, before setState(Starting):
if (!skip_port_check_ && override_datadir_.empty()) {
bool stillLocked = false; // ~300ms bounded wait, NOT ~90s
for (int i = 0; i < kDatadirLockWaitMaxPolls; ++i) {
if (!isDaemonProcessRunning()) { stillLocked = false; break; }
stillLocked = true;
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
}
auto gate = evaluateDatadirLockGate(false, false, stillLocked);
if (!gate.proceed) { setState(State::Error, gate.errorMessage); return false; }
}
```
### Verification
- Unit: `evaluateDatadirLockGate()` across the skip / isolated / still-running matrix.
- Manual: rapid restart into a lingering lock → distinct message, no crash-cap wedge.
- Migrate-to-seed second daemon still starts (isolated exemption).
### Dependencies
F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor's
"exited unexpectedly"). Same TU, different function.
---
## F5 — Extraction / copy write-failures never surfaced up front
**Severity:** Medium · **Effort:** S (~23h) · **Status:** ☑ landed & verified
### The defect
`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return
(`app.cpp:4152`) and the second copy-fallback loop drops `copy_file`'s `error_code`
entirely (`:4236`). Only Sapling params **existence** is re-checked — never the daemon
binary/CLI/tx/asmap. A disk-full or truncated `dragonxd` write falls straight through to
spawn and fails opaquely. The innermost write already returns `false`
(`embedded_resources.cpp:307`) — the signal is simply thrown away.
### The fix
Minimal, surgical wiring — no new abstraction. Capture the extraction return and, on
failure, set `daemon_status_ = TR("sb_daemon_extract_failed")` and `return false` before
spawning. In the second copy loop, check `ec` after each `copy_file`, track `copyFailed`,
and abort with a dir-parameterized `sb_daemon_files_failed`. An **absent source** stays
fine (optional files); only an actual `error_code` counts. Written so F6/F7 slot in later
without re-touching this control flow.
### Files touched
- `src/app.cpp``startEmbeddedDaemon()` extraction check (~4152)
- `src/app.cpp` — second copy-fallback loop (~42104242)
- `src/util/i18n.cpp` + `res/lang/*.json` — 2 additive keys
### Core change
```cpp
// stop discarding the extraction result (~4152)
if (!resources::extractEmbeddedResources()) {
daemon_status_ = TR("sb_daemon_extract_failed"); // disk full / permission denied
return false; // abort before spawning
}
// second copy-fallback loop — was dropping ec entirely (~4236)
bool copyFailed = false;
for (const char* name : { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }) {
fs::path dst = fs::path(daemon_dir) / name;
if (fs::exists(dst)) continue; // already present — skip
for (const auto& dir : searchDirs) {
fs::path src = fs::path(dir) / name;
if (!fs::exists(src)) continue; // absent source is OK, not a failure
fs::copy_file(src, dst, ec);
if (ec) { copyFailed = true; ec.clear(); }
break;
}
}
if (copyFailed) {
char buf[512];
snprintf(buf, sizeof buf, TR("sb_daemon_files_failed"), daemon_dir.c_str());
daemon_status_ = buf;
return false; // don't fall through to spawn
}
```
### Verification
- Unit: `extractEmbeddedResources()` returns false without embedded resources.
- Extract the copy loop into a testable helper; force one dst write to fail (dst is an existing directory).
- Manual: near-full tmpfs / read-only dir → clear status, daemon controller never constructed.
### Dependencies
Shares the `daemon_status_` surfacing convention with F6; its early-return pattern is the
template F7 matches. Open item: remove truncated dst files so a retry re-copies.
---
## F6 — Sapling params validated by existence/size only, never hashed
**Severity:** Medium · **Effort:** S (~35h) · **Status:** ☑ landed & verified
> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable
> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable
> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were
> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via
> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch,
> per the cross-cutting note. Non-English locales fall back to English until then.
### The defect
`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`;
`resourceNeedsUpdate()` (`embedded_resources.cpp:250`) is size-only. On Linux (no
embedded resources) a **truncated-but-present** param passes and is handed to the daemon,
which then fails to build shielded proofs mid-operation — far from the real cause.
### The fix
Add a pinned `{ filename → size, sha256 }` table (one source of truth, cross-referenced
to `scripts/build-lite-backend-artifact.sh`) and hash-check each param after the
existence check, reusing the existing `util::sha256Hex` (no second implementation). Since
these are ~48 MB, **cache the result** via a `.sapling_verified` marker keyed on
`size:mtime` — re-hash only when the stat line changes, so startup isn't slowed.
### Files touched
- `src/rpc/connection.h``verifySaplingParams` decl
- `src/rpc/connection.cpp` — digest table, marker helpers, rewrite
### Core change
```cpp
// connection.cpp — pinned known-good digests
// (source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params)
constexpr SaplingParamDigest kSaplingParamDigests[] = {
{ "sapling-spend.params", 47958396, "8e48ffd2…efc13" },
{ "sapling-output.params", 3592860, "2f0ebbcb…fb0e4" },
};
bool Connection::verifySaplingParams() {
// existence check (unchanged) …
// cache: skip re-hashing a ~48 MB file unless size:mtime changed
if (readMarkerMatches(marker, statLines)) return true;
for (auto& d : kSaplingParamDigests)
if (util::sha256Hex(bytes) != d.sha256) return false; // reuse existing helper
writeMarker(marker, statLines);
return true;
}
```
### Verification
- Unit: good params pass; truncated / wrong-bytes rejected; marker cache short-circuits re-hash unless size/mtime changed. Real temp-file fixtures (matches existing `sha256Hex` tests).
### Dependencies
F7 (reuse fs-error idiom; shares the `startEmbeddedDaemon`/`verifySaplingParams` block).
Third caller of the existing `util::sha256Hex`.
---
## F7 — Directory-create errors universally ignored on the daemon-env path
**Severity:** Medium · **Effort:** S (~34h) · **Status:** ☑ landed & verified
> **As-built notes.** Two deviations from the original design, both confirmed against the code:
> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched.
> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired.
### The defect
Five startup directory-create sites either drop the `error_code` or use the throwing
overload with no `catch`: `main.cpp:730`, `connection.cpp:216` (can throw **uncaught**
through its callers), `embedded_resources.cpp:270`, `app.cpp:4172`/`4218`. A read-only
home or permission-denied yields a confusing "conf missing" / "binary not found"
downstream — or an uncaught `filesystem_error` — instead of a clear cause.
### The fix
One shared, non-throwing `Platform::ensureDirectory(dir, outError)` in
`util/platform.{h,cpp}` that produces a single consistent message. Replace all five
sites; `autoDetectConfig()` moves off the throwing overload and sets a new
`ConnectionConfig::dir_error` that its four callers check and bail on. This is the
**structural owner** of the fs-error idiom that F5 and F6 reuse.
### Files touched
- `src/util/platform.h` / `.cpp``ensureDirectory()`
- `src/rpc/connection.h` / `.cpp``dir_error` + `autoDetectConfig`
- `main.cpp`, `app.cpp`, `app_network.cpp`, `app_wizard.cpp`, `settings_page.cpp`, `embedded_resources.cpp` — 5 sites + 4 callers
- `tests/test_phase4.cpp``TestPlatformEnsureDirectory`
### Core change
```cpp
// util/platform.cpp — one shared, non-throwing helper
bool Platform::ensureDirectory(const std::string& dir, std::string* outError) {
std::error_code ec;
if (std::filesystem::is_directory(dir, ec)) return true;
ec.clear();
std::filesystem::create_directories(dir, ec);
if (ec) {
if (outError)
*outError = "Cannot create " + dir + ": " + ec.message() +
". Check permissions / free space.";
return false;
}
return true;
}
// Replaces 5 ad-hoc sites; autoDetectConfig() now sets ConnectionConfig::dir_error,
// and its 4 callers bail on it.
```
### Verification
- Unit `TestPlatformEnsureDirectory`: existing dir → true; fresh nested → created; POSIX unwritable → false + message.
- All four `autoDetectConfig` callers tolerate `dir_error`. Pre-App-init site (main.cpp) reports via stderr / MessageBox.
### Dependencies
**Owns** `Platform::ensureDirectory` (used by F5, F6) and the `ConnectionConfig`
extension (coordinated with F8). Land before F5/F6/F8.
---
## F8 — Plaintext-remote RPC credential transmission is warn-only
**Severity:** Medium · **Effort:** M (~69h) · **Status:** ☑ landed & verified
> **⚠️ RELEASE NOTES REQUIRED — breaking default flip.** A wallet configured to talk to a
> **remote** `rpchost` over **plain HTTP** (no `rpctls=1`) will now be **refused** at connect
> time instead of warned. Affected users must add **`rpcallowplaintext=1`** to `DRAGONX.conf`
> (or switch to `rpctls=1`) to reconnect. Local/embedded daemons (`127.0.0.0/8`, `localhost`,
> `::1`) are unaffected. Call this out prominently in the release notes.
>
> **As-built note.** Shipped the security-complete core: `isLocalHost` tightened to exact
> loopback (`isExactIPv4Loopback` — `127.evil.com` no longer passes), refuse-by-default in
> `tryConnect`, and the `rpcallowplaintext` conf-key opt-in. The **Settings toggle UI was
> deferred** — the RPC section of `settings_page.cpp` is read-only display and a security
> toggle there is riskier surface; the conf-key opt-in fully covers recovery, and the refusal
> status/notification tells the user exactly what to add. The toggle can be added later
> (persist a `Settings` flag and OR it into `allowsPlaintextRemote`).
### The defect
A remote `rpchost` without `rpctls=1` sends Basic-auth `rpcuser:rpcpassword` over
cleartext HTTP. `tryConnect()` (`app_network.cpp:314`) only shows a **dismissible
warning** then proceeds — a local-network MITM sees the credentials. Compounding it,
`isLocalHost()`'s naive `rfind("127.",0)==0` misclassifies `127.evil.com` as local,
suppressing even the warning.
### The fix
Change the policy to **refuse-by-default with an explicit, persisted opt-in** — a
`rpcallowplaintext=1` conf key (for hand-editors) and a Settings toggle. Block the
connect and show a **blocking modal** explaining the risk and how to enable TLS or opt
in; localhost is unaffected. Tighten `isLocalHost()` to exact `127.x.y.z` / `::1` /
`localhost` via `isExactIPv4Loopback()`. **Back-compat:** default off ⇒ existing remote
users hit a hard stop until they opt in — **ship with prominent release notes.**
### Files touched
- `src/rpc/connection.h` / `.cpp``isLocalHost`, `allow_plaintext_remote`, `parseConfFile`
- `src/config/settings.h` / `.cpp` — persisted opt-in
- `src/app_network.cpp`, `src/app.h` — refuse + modal dispatch
- `src/ui/windows/plaintext_remote_rpc_dialog.h` — new blocking modal
- `src/ui/pages/settings_page.cpp` — toggle UI
### Core change
```cpp
// Tightened loopback test — "127.evil.com" is NOT local
bool Connection::isLocalHost(const std::string& host) {
std::string h = stripBrackets(lowercase(host));
return h == "localhost" || h == "::1" || isExactIPv4Loopback(h); // exact 127.x.y.z
}
// Refuse-by-default with an explicit, persisted opt-in
const bool plaintextRemote = rpc::Connection::usesPlaintextRemote(config);
const bool plaintextAllowed = config.allow_plaintext_remote // rpcallowplaintext=1
|| settings_.getAllowPlaintextRemoteRpc(); // Settings toggle
if (plaintextRemote && !plaintextAllowed) {
connection_status_ = TR("sb_plaintext_remote_blocked");
showPlaintextRemoteRpcDialog(config.host + ":" + config.port); // blocking modal
return; // no creds sent
}
```
### Verification
- Unit: `isLocalHost``127.evil.com` false, `127.0.0.1`/`::1`/`localhost` true; `allowsPlaintextRemote` honors conf key + settings flag.
- Manual: remote plaintext blocked; modal fires; opt-in persists across restart.
### Dependencies
F7 (second extender of `ConnectionConfig`/`parseConfFile`; land after so the struct grows
once). Wire `renderPlaintextRemoteRpcDialog` into the app modal-dispatch list.
---
## Shared helpers & coordination points
| Helper | Purpose | Used by |
|--------|---------|---------|
| `Platform::ensureDirectory()` | Single non-throwing directory-create with one consistent message; replaces five ad-hoc sites. Owned by F7. | F7, F5, F6 |
| `ConnectionConfig` extension | Coordination point, not a function: F7 adds `dir_error`, F8 adds `allow_plaintext_remote`. Land F7→F8 so it grows once per step. | F7, F8 |
| `util::sha256Hex` *(existing)* | Already-compiled, curl-free SHA-256. F6 becomes its third caller — no second hash routine. | F6 |
| `connectHasStalled()` *(new, pure)* | Stall predicate split out of the ImGui/App code for unit testing, per the `*_updater_core.cpp` precedent. | F3 |
| `evaluateDatadirLockGate()` *(new, pure)* | Lock-gate decision as `{proceed, message}` from three booleans — unit-testable without real process/fs I/O. | F4 |
## F3 — Unbounded connect spinner (deferred to step 6)
**Severity:** Medium · **Effort:** S (~35h) · **Status:** ☑ landed & verified
> **As-built note.** `renderLoadingOverlay()` is a pure draw-list overlay with **no interactive
> widgets** (the existing crash case at ~5289 already communicates via guidance *text*, relying on
> the sidebar staying reachable). So rather than inject `ActionButton`s — which would fight the
> non-interactive overlay — the stall notice follows that same idiom: a "Taking longer than
> expected" title + a reassuring body (with elapsed seconds) + a full-node-gated hint ("Open
> Settings → Restart Daemon, or check the Console"). This let me drop the planned
> `WalletState::connect_stalled` flag too: the stalled state is computed locally in the overlay
> from `connect_stall_since_`, so the only new member is `App::connect_stall_since_`.
The connect loop retries forever while `!state_.connected` (`app.cpp:1239`);
`loading_timer_` only animates the spinner. Stamp `connect_stall_since_` when
"reachable but not ready" is first seen; a pure `connectHasStalled()` helper (new
`util/connect_stall.h`, default 45s from `ui.toml`) flips `state_.connect_stalled` at
threshold, and `renderLoadingOverlay()` shows a "Taking longer than expected" panel with
Retry / Restart daemon / Open console (full-node gated). The background retry keeps
firing — recovery clears the panel automatically. Guarded off while the daemon is in
`State::Error` (owned by F1's crash-count hint). Full detail lives in the sequencing/
design record; see the shared-helper table above.
---
## Cross-cutting notes
- **One TU, three functions.** `embedded_daemon.cpp` is edited by F1 (`isRunning`),
F2 (`startProcess`) and F4 (`start`) — no literal hunk overlap, but land in order to
keep "monitorProcess is the sole reaper" coherent.
- **Connection struct grows twice.** `connection.h/.cpp` is touched by F6, F7 and F8;
F7 and F8 both extend `ConnectionConfig` and `parseConfFile` — highest collision risk.
Sequence F7→F6→F8.
- **Testability split.** The three new pure predicates all get `tests/test_phase4.cpp`
coverage. F1/F2's fork/exec/waitpid changes are **not** unit-testable — they rely on
manual `kill` / non-executable-binary repros, consistent with the no-process-spawn harness.
- **i18n is additive-only.** Add each finding's English keys to `strings_`, then run
`scripts/add_missing_translations.py` **once at the very end**
(`json.dump indent=4, sort_keys=True, ensure_ascii=False`) — never bulk-regenerate a
`res/lang/*.json`.
- **F8 is a breaking default flip.** Refuse-plaintext-by-default stops existing
remote-RPC users cold until they opt in. Lands last, gated behind a persisted opt-in,
with release notes calling out the new `rpcallowplaintext` key and the Settings toggle.
- **Latent hazard, out of scope.** F1 surfaces (but doesn't fix) a second
double-`waitpid` window between `stop()`'s final blocking reap (`:1220`) and a
mid-sleep monitor iteration — file it as its own ticket.
---
## Progress log
- **F1/F2 integration tests** — ☑ added `testExecFailureReported` (F2) and `testDaemonCrashDetected` (F1) to `test_phase4.cpp`, driving the **real** `EmbeddedDaemon` fork/exec/waitpid code headlessly (POSIX; required linking `embedded_daemon.cpp` into the test target — its deps were already there). The F1 test hammers `isRunning()` from the test thread while the child exits, so it's a genuine regression test for the reap race. **The F2 test caught a real bug:** `start()`'s failure branch overwrote `startProcess()`'s precise `last_error_` ("…not executable or wrong architecture") with a generic "Failed to start dragonxd process" (because `setState(Error, …)` stores its message into `last_error_`), so the precise reason never reached `getLastError()`/the UI — **fixed** to preserve the detail (now also surfaced via the state callback / crash panel). Build-clean; `ctest` 1/1.
- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release.
- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release.
- **F8** — ☑ landed: `isLocalHost()` tightened to exact loopback via `isExactIPv4Loopback` (a `127.`-prefixed *hostname* like `127.evil.com` is no longer misclassified as local). `tryConnect()` now **refuses** a plaintext connection to a remote host instead of warn-and-proceeding — a local-network MITM can no longer capture `rpcuser:rpcpassword` — unless the user opts in with `rpcallowplaintext=1` in `DRAGONX.conf` (new `ConnectionConfig::allow_plaintext_remote` + `allowsPlaintextRemote()` policy). The refusal surfaces via status line + a one-time notification. New `testIsLocalHost` (12 assertions) + `testAllowsPlaintextRemote` (5). Clean build; `ctest` 1/1 passing. **Breaking — needs release notes; Settings-toggle UI deferred (see as-built note).**
- **F3** — ☑ landed: the connect loop now stamps `connect_stall_since_ = ImGui::GetTime()` the moment the daemon first goes "reachable but not ready" (warmup branch + `applyDaemonInitStatus`), and clears it in `onConnected` / `onDisconnected` / warmup-complete — all in `app_network.cpp`. The pure `util::connectHasStalled(stallSince, now, threshold)` helper (new `util/connect_stall.h`, default 45 s from `ui.toml`) drives a draw-list "Taking longer than expected" notice in `renderLoadingOverlay()` (title + elapsed-seconds body + full-node hint), guarded off while the daemon is in `State::Error`. Background retry continues, so the notice self-clears on connect. New `testConnectHasStalled` unit test (7 assertions). Clean build; `ctest` 1/1 passing. (Draw-list text, not buttons — see as-built note above.)
- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `<params_dir>/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing.
- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing.
- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing.
- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing.

167
docs/wallet-hardening.md Normal file
View File

@@ -0,0 +1,167 @@
# Wallet Loading & Management — Hardening Plan
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
(W1-5), 1 raised (W5-3 Low→Med).
- **Severity:** 8 High · 12 Medium · 13 Low.
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
---
## Roadmap (ordered by risk; shared fixes grouped)
| Phase | Findings | Theme | Status |
|-------|----------|-------|--------|
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
---
## P0-A — Secret hardening
Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
key/passphrase paths, plus console redaction and deleting the plaintext export.
- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
`encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
`signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
unit testing. **← implementing first (self-contained + testable).**
- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport<ts>` plaintext
key dump after `z_importwallet` succeeds (overwrite-then-unlink).
- **W4-3 (High)** `app_network.cpp:4481``sodium_memzero` the concatenated all-keys string in
`exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.)
- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey`
(local + worker-lambda copies).
- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain.
- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed;
at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase.
- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the
lite create/open/restore requests (unused; a secret copied for nothing).
## P0-B — Encryption integrity
- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is
in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a
wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight
`encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when
`beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it
only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to
complete it.
- **W2-4 (Med)** `app_security.cpp:480``lockWallet` only sets `locked` on RPC success; log the
failure and notify (currently a silent no-op that can leave the wallet unlocked).
## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use
`settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file.
- **W3-2 (High)** `seed_wallet_creator.cpp:57``remove_all(<config>/seed-migrate)` unconditionally
at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted
swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry.
- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending*
(`getSeedMigrationPending()`), not only while the dialog is open.
- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can
resume/re-poll it instead of silently dropping the txid.
## P1-B — Missing/wrong wallet-file safety
- **W1-1 (High)** `app_network.cpp:1109``fs::exists()`-check the target wallet file in
`switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit
"Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the
daemon fabricate an empty wallet.
- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful
address/balance readback (idHash non-empty), not bare `onConnected()`.
- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error
loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer).
- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match
the out-of-datadir path).
## P2 — State & lite persistence
- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a
"refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current.
- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603``liteLog()` the failed save and bubble a
one-shot UI warning (both call sites currently discard the bool).
- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not).
- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead
of discarding the whole list.
## F — Diagnostics foundation + QoL
Land W7-2 first — it unblocks the rest.
- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(<config>/dragonx-debug.log)` early in
`main()` on all platforms; add an "Open log folder" action.
- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on
POSIX (mirror the Windows SEH path).
- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`.
- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner;
refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured
switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
---
## Progress log
- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed:
- **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas.
- **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`).
- **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`.
- **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 14s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h``evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
- **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(<config>/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
- **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
- **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
- **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
- **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).
- **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops).
- **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries.
Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed:
- **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
- **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
Build-clean; `ctest` 1/1.
- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
- **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
- **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
- **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
- **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
- **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
- **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(<config>/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one.
- **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`.
Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed:
- **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice.
- **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed.
Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1.
- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore**`encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open**`unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)*
- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision.
- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:
- **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy.
- **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`.
- **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use).
Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file).
- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression).
- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.)

View File

@@ -67,7 +67,8 @@
//#define IMGUI_USE_LEGACY_CRC32_ADLER
//---- Use 32-bit for ImWchar (default is 16-bit) to support Unicode planes 1-16. (e.g. point beyond 0xFFFF like emoticons, dingbats, symbols, shapes, ancient languages, etc...)
//#define IMGUI_USE_WCHAR32
//---- Enabled so chat can render emoji (U+1F300+, above the BMP) — see Typography::loadFont emoji merge (Q12).
#define IMGUI_USE_WCHAR32
//---- Avoid multiple STB libraries implementations, or redefine path/filenames to prioritize another version
// By default the embedded implementations are declared static and not available outside of Dear ImGui sources files.

View File

@@ -0,0 +1,744 @@
// dear imgui: FreeType font builder (used as a replacement for the stb_truetype builder)
// (code)
// Get the latest version at https://github.com/ocornut/imgui/tree/master/misc/freetype
// Original code by @vuhdo (Aleksei Skriabin) in 2017, with improvements by @mikesart.
// Maintained since 2019 by @ocornut.
// CHANGELOG
// (minor and older changes stripped away, please see git history for details)
// 2025/06/11: refactored for the new ImFontLoader architecture, and ImGuiBackendFlags_RendererHasTextures support.
// 2024/10/17: added plutosvg support for SVG Fonts (seems faster/better than lunasvg). Enable by using '#define IMGUI_ENABLE_FREETYPE_PLUTOSVG'. (#7927)
// 2023/11/13: added support for ImFontConfig::RasterizationDensity field for scaling render density without scaling metrics.
// 2023/08/01: added support for SVG fonts, enable by using '#define IMGUI_ENABLE_FREETYPE_LUNASVG'. (#6591)
// 2023/01/04: fixed a packing issue which in some occurrences would prevent large amount of glyphs from being packed correctly.
// 2021/08/23: fixed crash when FT_Render_Glyph() fails to render a glyph and returns nullptr.
// 2021/03/05: added ImGuiFreeTypeBuilderFlags_Bitmap to load bitmap glyphs.
// 2021/03/02: set 'atlas->TexPixelsUseColors = true' to help some backends with deciding of a preferred texture format.
// 2021/01/28: added support for color-layered glyphs via ImGuiFreeTypeBuilderFlags_LoadColor (require Freetype 2.10+).
// 2021/01/26: simplified integration by using '#define IMGUI_ENABLE_FREETYPE'. renamed ImGuiFreeType::XXX flags to ImGuiFreeTypeBuilderFlags_XXX for consistency with other API. removed ImGuiFreeType::BuildFontAtlas().
// 2020/06/04: fix for rare case where FT_Get_Char_Index() succeed but FT_Load_Glyph() fails.
// 2019/02/09: added RasterizerFlags::Monochrome flag to disable font anti-aliasing (combine with ::MonoHinting for best results!)
// 2019/01/15: added support for imgui allocators + added FreeType only override function SetAllocatorFunctions().
// 2019/01/10: re-factored to match big update in STB builder. fixed texture height waste. fixed redundant glyphs when merging. support for glyph padding.
// 2018/06/08: added support for ImFontConfig::GlyphMinAdvanceX, GlyphMaxAdvanceX.
// 2018/02/04: moved to main imgui repository (away from http://www.github.com/ocornut/imgui_club)
// 2018/01/22: fix for addition of ImFontAtlas::TexUvscale member.
// 2017/10/22: minor inconsequential change to match change in master (removed an unnecessary statement).
// 2017/09/26: fixes for imgui internal changes.
// 2017/08/26: cleanup, optimizations, support for ImFontConfig::RasterizerFlags, ImFontConfig::RasterizerMultiply.
// 2017/08/16: imported from https://github.com/Vuhdo/imgui_freetype into http://www.github.com/ocornut/imgui_club, updated for latest changes in ImFontAtlas, minor tweaks.
// About Gamma Correct Blending:
// - FreeType assumes blending in linear space rather than gamma space.
// - See https://www.freetype.org/freetype2/docs/reference/ft2-base_interface.html#FT_Render_Glyph
// - For correct results you need to be using sRGB and convert to linear space in the pixel shader output.
// - The default dear imgui styles will be impacted by this change (alpha values will need tweaking).
// FIXME: cfg.OversampleH, OversampleV are not supported, but generally not necessary with this rasterizer because Hinting makes everything look better.
#include "imgui.h"
#ifndef IMGUI_DISABLE
#include "imgui_freetype.h"
#include "imgui_internal.h" // ImMin,ImMax,ImFontAtlasBuild*,
#include <stdint.h>
#include <ft2build.h>
#include FT_FREETYPE_H // <freetype/freetype.h>
#include FT_MODULE_H // <freetype/ftmodapi.h>
#include FT_GLYPH_H // <freetype/ftglyph.h>
#include FT_SIZES_H // <freetype/ftsizes.h>
#include FT_SYNTHESIS_H // <freetype/ftsynth.h>
// Handle LunaSVG and PlutoSVG
#if defined(IMGUI_ENABLE_FREETYPE_LUNASVG) && defined(IMGUI_ENABLE_FREETYPE_PLUTOSVG)
#error "Cannot enable both IMGUI_ENABLE_FREETYPE_LUNASVG and IMGUI_ENABLE_FREETYPE_PLUTOSVG"
#endif
#ifdef IMGUI_ENABLE_FREETYPE_LUNASVG
#include FT_OTSVG_H // <freetype/otsvg.h>
#include FT_BBOX_H // <freetype/ftbbox.h>
#include <lunasvg.h>
#endif
#ifdef IMGUI_ENABLE_FREETYPE_PLUTOSVG
#include <plutosvg.h>
#endif
#if defined(IMGUI_ENABLE_FREETYPE_LUNASVG) || defined (IMGUI_ENABLE_FREETYPE_PLUTOSVG)
#if !((FREETYPE_MAJOR >= 2) && (FREETYPE_MINOR >= 12))
#error IMGUI_ENABLE_FREETYPE_PLUTOSVG or IMGUI_ENABLE_FREETYPE_LUNASVG requires FreeType version >= 2.12
#endif
#endif
#ifdef _MSC_VER
#pragma warning (push)
#pragma warning (disable: 4505) // unreferenced local function has been removed (stb stuff)
#pragma warning (disable: 26812) // [Static Analyzer] The enum type 'xxx' is unscoped. Prefer 'enum class' over 'enum' (Enum.3).
#endif
#ifdef __GNUC__
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wpragmas" // warning: unknown option after '#pragma GCC diagnostic' kind
#pragma GCC diagnostic ignored "-Wunused-function" // warning: 'xxxx' defined but not used
#ifndef __clang__
#pragma GCC diagnostic ignored "-Wsubobject-linkage" // warning: 'xxxx' has a field 'xxxx' whose type uses the anonymous namespace
#endif
#endif
//-------------------------------------------------------------------------
// Data
//-------------------------------------------------------------------------
// Default memory allocators
static void* ImGuiFreeTypeDefaultAllocFunc(size_t size, void* user_data) { IM_UNUSED(user_data); return IM_ALLOC(size); }
static void ImGuiFreeTypeDefaultFreeFunc(void* ptr, void* user_data) { IM_UNUSED(user_data); IM_FREE(ptr); }
// Current memory allocators
static void* (*GImGuiFreeTypeAllocFunc)(size_t size, void* user_data) = ImGuiFreeTypeDefaultAllocFunc;
static void (*GImGuiFreeTypeFreeFunc)(void* ptr, void* user_data) = ImGuiFreeTypeDefaultFreeFunc;
static void* GImGuiFreeTypeAllocatorUserData = nullptr;
// Lunasvg support
#ifdef IMGUI_ENABLE_FREETYPE_LUNASVG
static FT_Error ImGuiLunasvgPortInit(FT_Pointer* state);
static void ImGuiLunasvgPortFree(FT_Pointer* state);
static FT_Error ImGuiLunasvgPortRender(FT_GlyphSlot slot, FT_Pointer* _state);
static FT_Error ImGuiLunasvgPortPresetSlot(FT_GlyphSlot slot, FT_Bool cache, FT_Pointer* _state);
#endif
//-------------------------------------------------------------------------
// Code
//-------------------------------------------------------------------------
#define FT_CEIL(X) (((X + 63) & -64) / 64) // From SDL_ttf: Handy routines for converting from fixed point
#define FT_SCALEFACTOR 64.0f
// Glyph metrics:
// --------------
//
// xmin xmax
// | |
// |<-------- width -------->|
// | |
// | +-------------------------+----------------- ymax
// | | ggggggggg ggggg | ^ ^
// | | g:::::::::ggg::::g | | |
// | | g:::::::::::::::::g | | |
// | | g::::::ggggg::::::gg | | |
// | | g:::::g g:::::g | | |
// offsetX -|-------->| g:::::g g:::::g | offsetY |
// | | g:::::g g:::::g | | |
// | | g::::::g g:::::g | | |
// | | g:::::::ggggg:::::g | | |
// | | g::::::::::::::::g | | height
// | | gg::::::::::::::g | | |
// baseline ---*---------|---- gggggggg::::::g-----*-------- |
// / | | g:::::g | |
// origin | | gggggg g:::::g | |
// | | g:::::gg gg:::::g | |
// | | g::::::ggg:::::::g | |
// | | gg:::::::::::::g | |
// | | ggg::::::ggg | |
// | | gggggg | v
// | +-------------------------+----------------- ymin
// | |
// |------------- advanceX ----------->|
// Stored in ImFontAtlas::FontLoaderData. ALLOCATED BY US.
struct ImGui_ImplFreeType_Data
{
FT_Library Library;
FT_MemoryRec_ MemoryManager;
ImGui_ImplFreeType_Data() { memset((void*)this, 0, sizeof(*this)); }
};
// Stored in ImFontConfig::FontLoaderData. ALLOCATED BY US.
struct ImGui_ImplFreeType_FontSrcData
{
// Initialize from an external data buffer. Doesn't copy data, and you must ensure it stays valid up to this object lifetime.
bool InitFont(FT_Library ft_library, const ImFontConfig* src, ImGuiFreeTypeLoaderFlags extra_user_flags);
void CloseFont();
ImGui_ImplFreeType_FontSrcData() { memset((void*)this, 0, sizeof(*this)); }
~ImGui_ImplFreeType_FontSrcData() { CloseFont(); }
// Members
FT_Face FtFace;
ImGuiFreeTypeLoaderFlags UserFlags; // = ImFontConfig::FontLoaderFlags
FT_Int32 LoadFlags;
ImFontBaked* BakedLastActivated;
};
// Stored in ImFontBaked::FontLoaderDatas: pointer to SourcesCount instances of this. ALLOCATED BY CORE.
struct ImGui_ImplFreeType_FontSrcBakedData
{
FT_Size FtSize; // This represent a FT_Face with a given size.
ImGui_ImplFreeType_FontSrcBakedData() { memset((void*)this, 0, sizeof(*this)); }
};
bool ImGui_ImplFreeType_FontSrcData::InitFont(FT_Library ft_library, const ImFontConfig* src, ImGuiFreeTypeLoaderFlags extra_font_loader_flags)
{
FT_Error error = FT_New_Memory_Face(ft_library, (const FT_Byte*)src->FontData, (FT_Long)src->FontDataSize, (FT_Long)src->FontNo, &FtFace);
if (error != 0)
return false;
error = FT_Select_Charmap(FtFace, FT_ENCODING_UNICODE);
if (error != 0)
return false;
// Convert to FreeType flags (NB: Bold and Oblique are processed separately)
UserFlags = (ImGuiFreeTypeLoaderFlags)(src->FontLoaderFlags | extra_font_loader_flags);
LoadFlags = 0;
if ((UserFlags & ImGuiFreeTypeLoaderFlags_Bitmap) == 0)
LoadFlags |= FT_LOAD_NO_BITMAP;
if (UserFlags & ImGuiFreeTypeLoaderFlags_NoHinting)
LoadFlags |= FT_LOAD_NO_HINTING;
if (UserFlags & ImGuiFreeTypeLoaderFlags_NoAutoHint)
LoadFlags |= FT_LOAD_NO_AUTOHINT;
if (UserFlags & ImGuiFreeTypeLoaderFlags_ForceAutoHint)
LoadFlags |= FT_LOAD_FORCE_AUTOHINT;
if (UserFlags & ImGuiFreeTypeLoaderFlags_LightHinting)
LoadFlags |= FT_LOAD_TARGET_LIGHT;
else if (UserFlags & ImGuiFreeTypeLoaderFlags_MonoHinting)
LoadFlags |= FT_LOAD_TARGET_MONO;
else
LoadFlags |= FT_LOAD_TARGET_NORMAL;
if (UserFlags & ImGuiFreeTypeLoaderFlags_LoadColor)
LoadFlags |= FT_LOAD_COLOR;
return true;
}
void ImGui_ImplFreeType_FontSrcData::CloseFont()
{
if (FtFace)
{
FT_Done_Face(FtFace);
FtFace = nullptr;
}
}
static const FT_Glyph_Metrics* ImGui_ImplFreeType_LoadGlyph(ImGui_ImplFreeType_FontSrcData* src_data, uint32_t codepoint)
{
uint32_t glyph_index = FT_Get_Char_Index(src_data->FtFace, codepoint);
if (glyph_index == 0)
return nullptr;
// If this crash for you: FreeType 2.11.0 has a crash bug on some bitmap/colored fonts.
// - https://gitlab.freedesktop.org/freetype/freetype/-/issues/1076
// - https://github.com/ocornut/imgui/issues/4567
// - https://github.com/ocornut/imgui/issues/4566
// You can use FreeType 2.10, or the patched version of 2.11.0 in VcPkg, or probably any upcoming FreeType version.
FT_Error error = FT_Load_Glyph(src_data->FtFace, glyph_index, src_data->LoadFlags);
if (error)
return nullptr;
// Need an outline for this to work
FT_GlyphSlot slot = src_data->FtFace->glyph;
#if defined(IMGUI_ENABLE_FREETYPE_LUNASVG) || defined(IMGUI_ENABLE_FREETYPE_PLUTOSVG)
IM_ASSERT(slot->format == FT_GLYPH_FORMAT_OUTLINE || slot->format == FT_GLYPH_FORMAT_BITMAP || slot->format == FT_GLYPH_FORMAT_SVG);
#else
#if ((FREETYPE_MAJOR >= 2) && (FREETYPE_MINOR >= 12))
IM_ASSERT(slot->format != FT_GLYPH_FORMAT_SVG && "The font contains SVG glyphs, you'll need to enable IMGUI_ENABLE_FREETYPE_PLUTOSVG or IMGUI_ENABLE_FREETYPE_LUNASVG in imconfig.h and install required libraries in order to use this font");
#endif
IM_ASSERT(slot->format == FT_GLYPH_FORMAT_OUTLINE || slot->format == FT_GLYPH_FORMAT_BITMAP);
#endif // IMGUI_ENABLE_FREETYPE_LUNASVG
// Apply convenience transform (this is not picking from real "Bold"/"Italic" fonts! Merely applying FreeType helper transform. Oblique == Slanting)
if (src_data->UserFlags & ImGuiFreeTypeLoaderFlags_Bold)
FT_GlyphSlot_Embolden(slot);
if (src_data->UserFlags & ImGuiFreeTypeLoaderFlags_Oblique)
{
FT_GlyphSlot_Oblique(slot);
//FT_BBox bbox;
//FT_Outline_Get_BBox(&slot->outline, &bbox);
//slot->metrics.width = bbox.xMax - bbox.xMin;
//slot->metrics.height = bbox.yMax - bbox.yMin;
}
return &slot->metrics;
}
static void ImGui_ImplFreeType_BlitGlyph(const FT_Bitmap* ft_bitmap, uint32_t* dst, uint32_t dst_pitch)
{
IM_ASSERT(ft_bitmap != nullptr);
const uint32_t w = ft_bitmap->width;
const uint32_t h = ft_bitmap->rows;
const uint8_t* src = ft_bitmap->buffer;
const uint32_t src_pitch = ft_bitmap->pitch;
switch (ft_bitmap->pixel_mode)
{
case FT_PIXEL_MODE_GRAY: // Grayscale image, 1 byte per pixel.
{
for (uint32_t y = 0; y < h; y++, src += src_pitch, dst += dst_pitch)
for (uint32_t x = 0; x < w; x++)
dst[x] = IM_COL32(255, 255, 255, src[x]);
break;
}
case FT_PIXEL_MODE_MONO: // Monochrome image, 1 bit per pixel. The bits in each byte are ordered from MSB to LSB.
{
for (uint32_t y = 0; y < h; y++, src += src_pitch, dst += dst_pitch)
{
uint8_t bits = 0;
const uint8_t* bits_ptr = src;
for (uint32_t x = 0; x < w; x++, bits <<= 1)
{
if ((x & 7) == 0)
bits = *bits_ptr++;
dst[x] = IM_COL32(255, 255, 255, (bits & 0x80) ? 255 : 0);
}
}
break;
}
case FT_PIXEL_MODE_BGRA:
{
// FIXME: Converting pre-multiplied alpha to straight. Doesn't smell good.
#define DE_MULTIPLY(color, alpha) ImMin((ImU32)(255.0f * (float)color / (float)(alpha + FLT_MIN) + 0.5f), 255u)
for (uint32_t y = 0; y < h; y++, src += src_pitch, dst += dst_pitch)
for (uint32_t x = 0; x < w; x++)
{
uint8_t r = src[x * 4 + 2], g = src[x * 4 + 1], b = src[x * 4], a = src[x * 4 + 3];
dst[x] = IM_COL32(DE_MULTIPLY(r, a), DE_MULTIPLY(g, a), DE_MULTIPLY(b, a), a);
}
#undef DE_MULTIPLY
break;
}
default:
IM_ASSERT(0 && "FreeTypeFont::BlitGlyph(): Unknown bitmap pixel mode!");
}
}
// FreeType memory allocation callbacks
static void* FreeType_Alloc(FT_Memory /*memory*/, long size)
{
return GImGuiFreeTypeAllocFunc((size_t)size, GImGuiFreeTypeAllocatorUserData);
}
static void FreeType_Free(FT_Memory /*memory*/, void* block)
{
GImGuiFreeTypeFreeFunc(block, GImGuiFreeTypeAllocatorUserData);
}
static void* FreeType_Realloc(FT_Memory /*memory*/, long cur_size, long new_size, void* block)
{
// Implement realloc() as we don't ask user to provide it.
if (block == nullptr)
return GImGuiFreeTypeAllocFunc((size_t)new_size, GImGuiFreeTypeAllocatorUserData);
if (new_size == 0)
{
GImGuiFreeTypeFreeFunc(block, GImGuiFreeTypeAllocatorUserData);
return nullptr;
}
if (new_size > cur_size)
{
void* new_block = GImGuiFreeTypeAllocFunc((size_t)new_size, GImGuiFreeTypeAllocatorUserData);
memcpy(new_block, block, (size_t)cur_size);
GImGuiFreeTypeFreeFunc(block, GImGuiFreeTypeAllocatorUserData);
return new_block;
}
return block;
}
static bool ImGui_ImplFreeType_LoaderInit(ImFontAtlas* atlas)
{
IM_ASSERT(atlas->FontLoaderData == nullptr);
ImGui_ImplFreeType_Data* bd = IM_NEW(ImGui_ImplFreeType_Data)();
// FreeType memory management: https://www.freetype.org/freetype2/docs/design/design-4.html
bd->MemoryManager.user = nullptr;
bd->MemoryManager.alloc = &FreeType_Alloc;
bd->MemoryManager.free = &FreeType_Free;
bd->MemoryManager.realloc = &FreeType_Realloc;
// https://www.freetype.org/freetype2/docs/reference/ft2-module_management.html#FT_New_Library
FT_Error error = FT_New_Library(&bd->MemoryManager, &bd->Library);
if (error != 0)
{
IM_DELETE(bd);
return false;
}
// If you don't call FT_Add_Default_Modules() the rest of code may work, but FreeType won't use our custom allocator.
FT_Add_Default_Modules(bd->Library);
#ifdef IMGUI_ENABLE_FREETYPE_LUNASVG
// Install svg hooks for FreeType
// https://freetype.org/freetype2/docs/reference/ft2-properties.html#svg-hooks
// https://freetype.org/freetype2/docs/reference/ft2-svg_fonts.html#svg_fonts
SVG_RendererHooks hooks = { ImGuiLunasvgPortInit, ImGuiLunasvgPortFree, ImGuiLunasvgPortRender, ImGuiLunasvgPortPresetSlot };
FT_Property_Set(bd->Library, "ot-svg", "svg-hooks", &hooks);
#endif // IMGUI_ENABLE_FREETYPE_LUNASVG
#ifdef IMGUI_ENABLE_FREETYPE_PLUTOSVG
// With plutosvg, use provided hooks
FT_Property_Set(bd->Library, "ot-svg", "svg-hooks", plutosvg_ft_svg_hooks());
#endif // IMGUI_ENABLE_FREETYPE_PLUTOSVG
// Store our data
atlas->FontLoaderData = (void*)bd;
return true;
}
static void ImGui_ImplFreeType_LoaderShutdown(ImFontAtlas* atlas)
{
ImGui_ImplFreeType_Data* bd = (ImGui_ImplFreeType_Data*)atlas->FontLoaderData;
IM_ASSERT(bd != nullptr);
FT_Done_Library(bd->Library);
IM_DELETE(bd);
atlas->FontLoaderData = nullptr;
}
static bool ImGui_ImplFreeType_FontSrcInit(ImFontAtlas* atlas, ImFontConfig* src)
{
ImGui_ImplFreeType_Data* bd = (ImGui_ImplFreeType_Data*)atlas->FontLoaderData;
ImGui_ImplFreeType_FontSrcData* bd_font_data = IM_NEW(ImGui_ImplFreeType_FontSrcData);
IM_ASSERT(src->FontLoaderData == nullptr);
src->FontLoaderData = bd_font_data;
if (!bd_font_data->InitFont(bd->Library, src, (ImGuiFreeTypeLoaderFlags)atlas->FontLoaderFlags))
{
IM_DELETE(bd_font_data);
src->FontLoaderData = nullptr;
return false;
}
return true;
}
static void ImGui_ImplFreeType_FontSrcDestroy(ImFontAtlas* atlas, ImFontConfig* src)
{
IM_UNUSED(atlas);
ImGui_ImplFreeType_FontSrcData* bd_font_data = (ImGui_ImplFreeType_FontSrcData*)src->FontLoaderData;
IM_DELETE(bd_font_data);
src->FontLoaderData = nullptr;
}
static bool ImGui_ImplFreeType_FontBakedInit(ImFontAtlas* atlas, ImFontConfig* src, ImFontBaked* baked, void* loader_data_for_baked_src)
{
IM_UNUSED(atlas);
float size = baked->Size;
if (src->MergeMode && src->SizePixels != 0.0f)
size *= (src->SizePixels / baked->OwnerFont->Sources[0]->SizePixels);
size *= src->ExtraSizeScale;
ImGui_ImplFreeType_FontSrcData* bd_font_data = (ImGui_ImplFreeType_FontSrcData*)src->FontLoaderData;
bd_font_data->BakedLastActivated = baked;
// We use one FT_Size per (source + baked) combination.
ImGui_ImplFreeType_FontSrcBakedData* bd_baked_data = (ImGui_ImplFreeType_FontSrcBakedData*)loader_data_for_baked_src;
IM_ASSERT(bd_baked_data != nullptr);
IM_PLACEMENT_NEW(bd_baked_data) ImGui_ImplFreeType_FontSrcBakedData();
FT_New_Size(bd_font_data->FtFace, &bd_baked_data->FtSize);
FT_Activate_Size(bd_baked_data->FtSize);
// Vuhdo 2017: "I'm not sure how to deal with font sizes properly. As far as I understand, currently ImGui assumes that the 'pixel_height'
// is a maximum height of an any given glyph, i.e. it's the sum of font's ascender and descender. Seems strange to me.
// FT_Set_Pixel_Sizes() doesn't seem to get us the same result."
// (FT_Set_Pixel_Sizes() essentially calls FT_Request_Size() with FT_SIZE_REQUEST_TYPE_NOMINAL)
const float rasterizer_density = src->RasterizerDensity * baked->RasterizerDensity;
FT_Size_RequestRec req;
req.type = (bd_font_data->UserFlags & ImGuiFreeTypeLoaderFlags_Bitmap) ? FT_SIZE_REQUEST_TYPE_NOMINAL : FT_SIZE_REQUEST_TYPE_REAL_DIM;
req.width = 0;
req.height = (uint32_t)(size * 64 * rasterizer_density);
req.horiResolution = 0;
req.vertResolution = 0;
FT_Request_Size(bd_font_data->FtFace, &req);
// Output
if (src->MergeMode == false)
{
// Read metrics
FT_Size_Metrics metrics = bd_baked_data->FtSize->metrics;
const float scale = 1.0f / (rasterizer_density * src->ExtraSizeScale);
baked->Ascent = (float)FT_CEIL(metrics.ascender) * scale; // The pixel extents above the baseline in pixels (typically positive).
baked->Descent = (float)FT_CEIL(metrics.descender) * scale; // The extents below the baseline in pixels (typically negative).
//LineSpacing = (float)FT_CEIL(metrics.height) * scale; // The baseline-to-baseline distance. Note that it usually is larger than the sum of the ascender and descender taken as absolute values. There is also no guarantee that no glyphs extend above or below subsequent baselines when using this distance. Think of it as a value the designer of the font finds appropriate.
//LineGap = (float)FT_CEIL(metrics.height - metrics.ascender + metrics.descender) * scale; // The spacing in pixels between one row's descent and the next row's ascent.
//MaxAdvanceWidth = (float)FT_CEIL(metrics.max_advance) * scale; // This field gives the maximum horizontal cursor advance for all glyphs in the font.
}
return true;
}
static void ImGui_ImplFreeType_FontBakedDestroy(ImFontAtlas* atlas, ImFontConfig* src, ImFontBaked* baked, void* loader_data_for_baked_src)
{
IM_UNUSED(atlas);
IM_UNUSED(baked);
IM_UNUSED(src);
ImGui_ImplFreeType_FontSrcBakedData* bd_baked_data = (ImGui_ImplFreeType_FontSrcBakedData*)loader_data_for_baked_src;
IM_ASSERT(bd_baked_data != nullptr);
FT_Done_Size(bd_baked_data->FtSize);
bd_baked_data->~ImGui_ImplFreeType_FontSrcBakedData(); // ~IM_PLACEMENT_DELETE()
}
static bool ImGui_ImplFreeType_FontBakedLoadGlyph(ImFontAtlas* atlas, ImFontConfig* src, ImFontBaked* baked, void* loader_data_for_baked_src, ImWchar codepoint, ImFontGlyph* out_glyph, float* out_advance_x)
{
ImGui_ImplFreeType_FontSrcData* bd_font_data = (ImGui_ImplFreeType_FontSrcData*)src->FontLoaderData;
uint32_t glyph_index = FT_Get_Char_Index(bd_font_data->FtFace, codepoint);
if (glyph_index == 0)
return false;
if (bd_font_data->BakedLastActivated != baked) // <-- could use id
{
// Activate current size
ImGui_ImplFreeType_FontSrcBakedData* bd_baked_data = (ImGui_ImplFreeType_FontSrcBakedData*)loader_data_for_baked_src;
FT_Activate_Size(bd_baked_data->FtSize);
bd_font_data->BakedLastActivated = baked;
}
const FT_Glyph_Metrics* metrics = ImGui_ImplFreeType_LoadGlyph(bd_font_data, codepoint);
if (metrics == nullptr)
return false;
FT_Face face = bd_font_data->FtFace;
FT_GlyphSlot slot = face->glyph;
const float rasterizer_density = src->RasterizerDensity * baked->RasterizerDensity;
// Load metrics only mode
const float advance_x = (slot->advance.x / FT_SCALEFACTOR) / rasterizer_density;
if (out_advance_x != NULL)
{
IM_ASSERT(out_glyph == NULL);
*out_advance_x = advance_x;
return true;
}
// Render glyph into a bitmap (currently held by FreeType)
FT_Render_Mode render_mode = (bd_font_data->UserFlags & ImGuiFreeTypeLoaderFlags_Monochrome) ? FT_RENDER_MODE_MONO : FT_RENDER_MODE_NORMAL;
FT_Error error = FT_Render_Glyph(slot, render_mode);
const FT_Bitmap* ft_bitmap = &slot->bitmap;
if (error != 0 || ft_bitmap == nullptr)
return false;
const int w = (int)ft_bitmap->width;
const int h = (int)ft_bitmap->rows;
const bool is_visible = (w != 0 && h != 0);
// Prepare glyph
out_glyph->Codepoint = codepoint;
out_glyph->AdvanceX = advance_x;
// Pack and retrieve position inside texture atlas
if (is_visible)
{
ImFontAtlasRectId pack_id = ImFontAtlasPackAddRect(atlas, w, h);
if (pack_id == ImFontAtlasRectId_Invalid)
{
// Pathological out of memory case (TexMaxWidth/TexMaxHeight set too small?)
IM_ASSERT(pack_id != ImFontAtlasRectId_Invalid && "Out of texture memory.");
return false;
}
ImTextureRect* r = ImFontAtlasPackGetRect(atlas, pack_id);
// Render pixels to our temporary buffer
atlas->Builder->TempBuffer.resize(w * h * 4);
uint32_t* temp_buffer = (uint32_t*)atlas->Builder->TempBuffer.Data;
ImGui_ImplFreeType_BlitGlyph(ft_bitmap, temp_buffer, w);
const float ref_size = baked->OwnerFont->Sources[0]->SizePixels;
const float offsets_scale = (ref_size != 0.0f) ? (baked->Size / ref_size) : 1.0f;
float font_off_x = ImFloor(src->GlyphOffset.x * offsets_scale + 0.5f); // Snap scaled offset.
float font_off_y = ImFloor(src->GlyphOffset.y * offsets_scale + 0.5f) + baked->Ascent;
float recip_h = 1.0f / rasterizer_density;
float recip_v = 1.0f / rasterizer_density;
// Register glyph
float glyph_off_x = (float)face->glyph->bitmap_left;
float glyph_off_y = (float)-face->glyph->bitmap_top;
out_glyph->X0 = glyph_off_x * recip_h + font_off_x;
out_glyph->Y0 = glyph_off_y * recip_v + font_off_y;
out_glyph->X1 = (glyph_off_x + w) * recip_h + font_off_x;
out_glyph->Y1 = (glyph_off_y + h) * recip_v + font_off_y;
out_glyph->Visible = true;
out_glyph->Colored = (ft_bitmap->pixel_mode == FT_PIXEL_MODE_BGRA);
out_glyph->PackId = pack_id;
ImFontAtlasBakedSetFontGlyphBitmap(atlas, baked, src, out_glyph, r, (const unsigned char*)temp_buffer, ImTextureFormat_RGBA32, w * 4);
}
return true;
}
static bool ImGui_ImplFreetype_FontSrcContainsGlyph(ImFontAtlas* atlas, ImFontConfig* src, ImWchar codepoint)
{
IM_UNUSED(atlas);
ImGui_ImplFreeType_FontSrcData* bd_font_data = (ImGui_ImplFreeType_FontSrcData*)src->FontLoaderData;
int glyph_index = FT_Get_Char_Index(bd_font_data->FtFace, codepoint);
return glyph_index != 0;
}
const ImFontLoader* ImGuiFreeType::GetFontLoader()
{
static ImFontLoader loader;
loader.Name = "FreeType";
loader.LoaderInit = ImGui_ImplFreeType_LoaderInit;
loader.LoaderShutdown = ImGui_ImplFreeType_LoaderShutdown;
loader.FontSrcInit = ImGui_ImplFreeType_FontSrcInit;
loader.FontSrcDestroy = ImGui_ImplFreeType_FontSrcDestroy;
loader.FontSrcContainsGlyph = ImGui_ImplFreetype_FontSrcContainsGlyph;
loader.FontBakedInit = ImGui_ImplFreeType_FontBakedInit;
loader.FontBakedDestroy = ImGui_ImplFreeType_FontBakedDestroy;
loader.FontBakedLoadGlyph = ImGui_ImplFreeType_FontBakedLoadGlyph;
loader.FontBakedSrcLoaderDataSize = sizeof(ImGui_ImplFreeType_FontSrcBakedData);
return &loader;
}
void ImGuiFreeType::SetAllocatorFunctions(void* (*alloc_func)(size_t sz, void* user_data), void (*free_func)(void* ptr, void* user_data), void* user_data)
{
GImGuiFreeTypeAllocFunc = alloc_func;
GImGuiFreeTypeFreeFunc = free_func;
GImGuiFreeTypeAllocatorUserData = user_data;
}
bool ImGuiFreeType::DebugEditFontLoaderFlags(unsigned int* p_font_loader_flags)
{
bool edited = false;
edited |= ImGui::CheckboxFlags("NoHinting", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_NoHinting);
edited |= ImGui::CheckboxFlags("NoAutoHint", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_NoAutoHint);
edited |= ImGui::CheckboxFlags("ForceAutoHint",p_font_loader_flags, ImGuiFreeTypeLoaderFlags_ForceAutoHint);
edited |= ImGui::CheckboxFlags("LightHinting", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_LightHinting);
edited |= ImGui::CheckboxFlags("MonoHinting", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_MonoHinting);
edited |= ImGui::CheckboxFlags("Bold", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_Bold);
edited |= ImGui::CheckboxFlags("Oblique", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_Oblique);
edited |= ImGui::CheckboxFlags("Monochrome", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_Monochrome);
edited |= ImGui::CheckboxFlags("LoadColor", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_LoadColor);
edited |= ImGui::CheckboxFlags("Bitmap", p_font_loader_flags, ImGuiFreeTypeLoaderFlags_Bitmap);
return edited;
}
#ifdef IMGUI_ENABLE_FREETYPE_LUNASVG
// For more details, see https://gitlab.freedesktop.org/freetype/freetype-demos/-/blob/master/src/rsvg-port.c
// The original code from the demo is licensed under CeCILL-C Free Software License Agreement (https://gitlab.freedesktop.org/freetype/freetype/-/blob/master/LICENSE.TXT)
struct LunasvgPortState
{
FT_Error err = FT_Err_Ok;
lunasvg::Matrix matrix;
std::unique_ptr<lunasvg::Document> svg = nullptr;
};
static FT_Error ImGuiLunasvgPortInit(FT_Pointer* _state)
{
*_state = IM_NEW(LunasvgPortState)();
return FT_Err_Ok;
}
static void ImGuiLunasvgPortFree(FT_Pointer* _state)
{
IM_DELETE(*(LunasvgPortState**)_state);
}
static FT_Error ImGuiLunasvgPortRender(FT_GlyphSlot slot, FT_Pointer* _state)
{
LunasvgPortState* state = *(LunasvgPortState**)_state;
// If there was an error while loading the svg in ImGuiLunasvgPortPresetSlot(), the renderer hook still get called, so just returns the error.
if (state->err != FT_Err_Ok)
return state->err;
// rows is height, pitch (or stride) equals to width * sizeof(int32)
lunasvg::Bitmap bitmap((uint8_t*)slot->bitmap.buffer, slot->bitmap.width, slot->bitmap.rows, slot->bitmap.pitch);
#if LUNASVG_VERSION_MAJOR >= 3
state->svg->render(bitmap, state->matrix); // state->matrix is already scaled and translated
#else
state->svg->setMatrix(state->svg->matrix().identity()); // Reset the svg matrix to the default value
state->svg->render(bitmap, state->matrix); // state->matrix is already scaled and translated
#endif
state->err = FT_Err_Ok;
return state->err;
}
static FT_Error ImGuiLunasvgPortPresetSlot(FT_GlyphSlot slot, FT_Bool cache, FT_Pointer* _state)
{
FT_SVG_Document document = (FT_SVG_Document)slot->other;
LunasvgPortState* state = *(LunasvgPortState**)_state;
FT_Size_Metrics& metrics = document->metrics;
// This function is called twice, once in the FT_Load_Glyph() and another right before ImGuiLunasvgPortRender().
// If it's the latter, don't do anything because it's // already done in the former.
if (cache)
return state->err;
state->svg = lunasvg::Document::loadFromData((const char*)document->svg_document, document->svg_document_length);
if (state->svg == nullptr)
{
state->err = FT_Err_Invalid_SVG_Document;
return state->err;
}
#if LUNASVG_VERSION_MAJOR >= 3
lunasvg::Box box = state->svg->boundingBox();
#else
lunasvg::Box box = state->svg->box();
#endif
double scale = std::min(metrics.x_ppem / box.w, metrics.y_ppem / box.h);
double xx = (double)document->transform.xx / (1 << 16);
double xy = -(double)document->transform.xy / (1 << 16);
double yx = -(double)document->transform.yx / (1 << 16);
double yy = (double)document->transform.yy / (1 << 16);
double x0 = (double)document->delta.x / 64 * box.w / metrics.x_ppem;
double y0 = -(double)document->delta.y / 64 * box.h / metrics.y_ppem;
#if LUNASVG_VERSION_MAJOR >= 3
// Scale, transform and pre-translate the matrix for the rendering step
state->matrix = lunasvg::Matrix::translated(-box.x, -box.y);
state->matrix.multiply(lunasvg::Matrix(xx, xy, yx, yy, x0, y0));
state->matrix.scale(scale, scale);
// Apply updated transformation to the bounding box
box.transform(state->matrix);
#else
// Scale and transform, we don't translate the svg yet
state->matrix.identity();
state->matrix.scale(scale, scale);
state->matrix.transform(xx, xy, yx, yy, x0, y0);
state->svg->setMatrix(state->matrix);
// Pre-translate the matrix for the rendering step
state->matrix.translate(-box.x, -box.y);
// Get the box again after the transformation
box = state->svg->box();
#endif
// Calculate the bitmap size
slot->bitmap_left = FT_Int(box.x);
slot->bitmap_top = FT_Int(-box.y);
slot->bitmap.rows = (unsigned int)(ImCeil((float)box.h));
slot->bitmap.width = (unsigned int)(ImCeil((float)box.w));
slot->bitmap.pitch = slot->bitmap.width * 4;
slot->bitmap.pixel_mode = FT_PIXEL_MODE_BGRA;
// Compute all the bearings and set them correctly. The outline is scaled already, we just need to use the bounding box.
double metrics_width = box.w;
double metrics_height = box.h;
double horiBearingX = box.x;
double horiBearingY = -box.y;
double vertBearingX = slot->metrics.horiBearingX / 64.0 - slot->metrics.horiAdvance / 64.0 / 2.0;
double vertBearingY = (slot->metrics.vertAdvance / 64.0 - slot->metrics.height / 64.0) / 2.0;
slot->metrics.width = FT_Pos(IM_ROUND(metrics_width * 64.0)); // Using IM_ROUND() assume width and height are positive
slot->metrics.height = FT_Pos(IM_ROUND(metrics_height * 64.0));
slot->metrics.horiBearingX = FT_Pos(horiBearingX * 64);
slot->metrics.horiBearingY = FT_Pos(horiBearingY * 64);
slot->metrics.vertBearingX = FT_Pos(vertBearingX * 64);
slot->metrics.vertBearingY = FT_Pos(vertBearingY * 64);
if (slot->metrics.vertAdvance == 0)
slot->metrics.vertAdvance = FT_Pos(metrics_height * 1.2 * 64.0);
state->err = FT_Err_Ok;
return state->err;
}
#endif // #ifdef IMGUI_ENABLE_FREETYPE_LUNASVG
//-----------------------------------------------------------------------------
#ifdef __GNUC__
#pragma GCC diagnostic pop
#endif
#ifdef _MSC_VER
#pragma warning (pop)
#endif
#endif // #ifndef IMGUI_DISABLE

View File

@@ -0,0 +1,83 @@
// dear imgui: FreeType font builder (used as a replacement for the stb_truetype builder)
// (headers)
#pragma once
#include "imgui.h" // IMGUI_API
#ifndef IMGUI_DISABLE
// Usage:
// - Add '#define IMGUI_ENABLE_FREETYPE' in your imconfig to automatically enable support
// for imgui_freetype in imgui. It is equivalent to selecting the default loader with:
// io.Fonts->SetFontLoader(ImGuiFreeType::GetFontLoader())
// Optional support for OpenType SVG fonts:
// - Add '#define IMGUI_ENABLE_FREETYPE_PLUTOSVG' to use plutosvg (not provided). See #7927.
// - Add '#define IMGUI_ENABLE_FREETYPE_LUNASVG' to use lunasvg (not provided). See #6591.
// Forward declarations
struct ImFontAtlas;
struct ImFontLoader;
// Hinting greatly impacts visuals (and glyph sizes).
// - By default, hinting is enabled and the font's native hinter is preferred over the auto-hinter.
// - When disabled, FreeType generates blurrier glyphs, more or less matches the stb_truetype.h
// - The Default hinting mode usually looks good, but may distort glyphs in an unusual way.
// - The Light hinting mode generates fuzzier glyphs but better matches Microsoft's rasterizer.
// You can set those flags globally in ImFontAtlas::FontLoaderFlags
// You can set those flags on a per font basis in ImFontConfig::FontLoaderFlags
typedef unsigned int ImGuiFreeTypeLoaderFlags;
enum ImGuiFreeTypeLoaderFlags_
{
ImGuiFreeTypeLoaderFlags_NoHinting = 1 << 0, // Disable hinting. This generally generates 'blurrier' bitmap glyphs when the glyph are rendered in any of the anti-aliased modes.
ImGuiFreeTypeLoaderFlags_NoAutoHint = 1 << 1, // Disable auto-hinter.
ImGuiFreeTypeLoaderFlags_ForceAutoHint = 1 << 2, // Indicates that the auto-hinter is preferred over the font's native hinter.
ImGuiFreeTypeLoaderFlags_LightHinting = 1 << 3, // A lighter hinting algorithm for gray-level modes. Many generated glyphs are fuzzier but better resemble their original shape. This is achieved by snapping glyphs to the pixel grid only vertically (Y-axis), as is done by Microsoft's ClearType and Adobe's proprietary font renderer. This preserves inter-glyph spacing in horizontal text.
ImGuiFreeTypeLoaderFlags_MonoHinting = 1 << 4, // Strong hinting algorithm that should only be used for monochrome output.
ImGuiFreeTypeLoaderFlags_Bold = 1 << 5, // Styling: Should we artificially embolden the font?
ImGuiFreeTypeLoaderFlags_Oblique = 1 << 6, // Styling: Should we slant the font, emulating italic style?
ImGuiFreeTypeLoaderFlags_Monochrome = 1 << 7, // Disable anti-aliasing. Combine this with MonoHinting for best results!
ImGuiFreeTypeLoaderFlags_LoadColor = 1 << 8, // Enable FreeType color-layered glyphs
ImGuiFreeTypeLoaderFlags_Bitmap = 1 << 9, // Enable FreeType bitmap glyphs
#ifndef IMGUI_DISABLE_OBSOLETE_FUNCTIONS
ImGuiFreeTypeBuilderFlags_NoHinting = ImGuiFreeTypeLoaderFlags_NoHinting,
ImGuiFreeTypeBuilderFlags_NoAutoHint = ImGuiFreeTypeLoaderFlags_NoAutoHint,
ImGuiFreeTypeBuilderFlags_ForceAutoHint = ImGuiFreeTypeLoaderFlags_ForceAutoHint,
ImGuiFreeTypeBuilderFlags_LightHinting = ImGuiFreeTypeLoaderFlags_LightHinting,
ImGuiFreeTypeBuilderFlags_MonoHinting = ImGuiFreeTypeLoaderFlags_MonoHinting,
ImGuiFreeTypeBuilderFlags_Bold = ImGuiFreeTypeLoaderFlags_Bold,
ImGuiFreeTypeBuilderFlags_Oblique = ImGuiFreeTypeLoaderFlags_Oblique,
ImGuiFreeTypeBuilderFlags_Monochrome = ImGuiFreeTypeLoaderFlags_Monochrome,
ImGuiFreeTypeBuilderFlags_LoadColor = ImGuiFreeTypeLoaderFlags_LoadColor,
ImGuiFreeTypeBuilderFlags_Bitmap = ImGuiFreeTypeLoaderFlags_Bitmap,
#endif
};
// Obsolete names (will be removed)
#ifndef IMGUI_DISABLE_OBSOLETE_FUNCTIONS
typedef ImGuiFreeTypeLoaderFlags_ ImGuiFreeTypeBuilderFlags_;
#endif
namespace ImGuiFreeType
{
// This is automatically assigned when using '#define IMGUI_ENABLE_FREETYPE'.
// If you need to dynamically select between multiple builders:
// - you can manually assign this builder with 'atlas->SetFontLoader(ImGuiFreeType::GetFontLoader())'
// - prefer deep-copying this into your own ImFontLoader instance if you use hot-reloading that messes up static data.
IMGUI_API const ImFontLoader* GetFontLoader();
// Override allocators. By default ImGuiFreeType will use IM_ALLOC()/IM_FREE()
// However, as FreeType does lots of allocations we provide a way for the user to redirect it to a separate memory heap if desired.
IMGUI_API void SetAllocatorFunctions(void* (*alloc_func)(size_t sz, void* user_data), void (*free_func)(void* ptr, void* user_data), void* user_data = nullptr);
// Display UI to edit ImFontAtlas::FontLoaderFlags (shared) or ImFontConfig::FontLoaderFlags (single source)
IMGUI_API bool DebugEditFontLoaderFlags(ImGuiFreeTypeLoaderFlags* p_font_loader_flags);
// Obsolete names (will be removed)
#ifndef IMGUI_DISABLE_OBSOLETE_FUNCTIONS
//IMGUI_API const ImFontBuilderIO* GetBuilderForFreeType(); // Renamed/changed in 1.92. Change 'io.Fonts->FontBuilderIO = ImGuiFreeType::GetBuilderForFreeType()' to 'io.Fonts->SetFontLoader(ImGuiFreeType::GetFontLoader())' if you need runtime selection.
//static inline bool BuildFontAtlas(ImFontAtlas* atlas, unsigned int flags = 0) { atlas->FontBuilderIO = GetBuilderForFreeType(); atlas->FontLoaderFlags = flags; return atlas->Build(); } // Prefer using '#define IMGUI_ENABLE_FREETYPE'
#endif
}
#endif // #ifndef IMGUI_DISABLE

3278
libs/nanosvg/nanosvg.h Normal file

File diff suppressed because it is too large Load Diff

1472
libs/nanosvg/nanosvgrast.h Normal file

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #d82652;
}
</style>
</defs>
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
<g>
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
<g>
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@
"address_book_added": "アドレスをアドレス帳に追加しました",
"address_book_confirm_delete": "削除しますか?",
"address_book_count": "%zu 件のアドレスを保存済み",
"address_book_count_one": "%zu 件のアドレスを保存",
"address_book_deleted": "エントリを削除しました",
"address_book_edit": "アドレスを編集",
"address_book_empty": "保存されたアドレスがありません。「新規追加」をクリックして追加してください。",
@@ -47,21 +48,41 @@
"advanced": "詳細設定",
"advanced_effects": "高度なエフェクト...",
"ago": "前",
"alerts_clear": "通知履歴を消去",
"alerts_history_tooltip": "最近の通知",
"alerts_none": "通知はまだありません",
"alerts_recent": "最近の通知",
"all_filter": "すべて",
"allow_custom_fees": "カスタム手数料を許可",
"amount": "金額",
"amount_details": "金額の詳細",
"amount_exceeds_balance": "金額が残高を超えています",
"amount_label": "金額:",
"animate_avatars": "アバターをアニメーション",
"appearance": "外観",
"auto_shield": "マイニング自動シールド",
"av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。",
"av_open_security": "Windows セキュリティを開く",
"av_step1": "ステップ1: 除外の追加",
"av_step1_b1": "Windows セキュリティ > ウイルスと脅威の防止 を開く",
"av_step1_b2": "設定の管理 > 除外 > 追加または削除 をクリック",
"av_step1_b3": "フォルダーを追加: %APPDATA%\\ObsidianDragon\\",
"av_step2": "ステップ2: 検疫から復元(必要な場合)",
"av_step2_b1": "Windows セキュリティ > 保護の履歴",
"av_step2_b2": "xmrig.exe を見つけて「復元」をクリック",
"av_step3": "ステップ3: ウォレットを再起動して再試行",
"av_title": "Windows Defender がマイナーをブロックしました",
"available": "利用可能",
"backup_backing_up": "バックアップ中...",
"backup_col_backup": "バックアップ",
"backup_col_export": "エクスポート",
"backup_col_import": "インポートと復元",
"backup_create": "バックアップを作成",
"backup_created": "ウォレットのバックアップを作成しました",
"backup_data": "バックアップとデータ",
"backup_description": "wallet.datファイルのバックアップを作成します。このファイルにはすべての秘密鍵と取引履歴が含まれています。バックアップは安全な場所に保管してください。",
"backup_destination": "バックアップ先:",
"backup_overwrite_confirm": "そこには既にファイルがあります。もう一度保存すると上書きされます。",
"backup_source": "ソース:%s",
"backup_tip_external": "外部ドライブまたはクラウドストレージにバックアップを保存",
"backup_tip_multiple": "異なる場所に複数のバックアップを作成",
@@ -70,10 +91,14 @@
"backup_title": "ウォレットのバックアップ",
"backup_wallet": "ウォレットをバックアップ...",
"backup_wallet_not_found": "警告予想される場所にwallet.datが見つかりません",
"backup_warn": "このファイルにはすべての秘密鍵が含まれています。安全な場所に保管してください。",
"balance": "残高",
"balance_history_collecting": "残高履歴 — データを収集中...",
"balance_layout": "残高レイアウト",
"balance_layout_switched": "レイアウト: %s",
"balance_mining_rate": "マイニング中 %s",
"balance_shielded_fmt": "シールド: %.8f",
"balance_syncing_pct": "同期中 %.1f%%",
"balance_transparent_fmt": "透明: %.8f",
"ban": "ブロック",
"banned_peers": "ブロック済みピア",
@@ -113,29 +138,107 @@
"bootstrap_verifying": "チェックサムを検証中...",
"bootstrap_wallet_protected": "(wallet.dat は保護されています)",
"bootstrap_warning": "既存のブロックデータblocks、chainstate、notarizationsは削除され置き換えられます。wallet.dat は変更・削除されません。",
"byte_count_fmt": "%zu / %zu バイト",
"cancel": "キャンセル",
"change_pass_confirm": "新しいパスフレーズ(確認):",
"change_pass_current": "現在のパスフレーズ:",
"change_pass_new": "新しいパスフレーズ:",
"change_pass_title": "パスフレーズを変更",
"characters": "文字",
"chat": "チャット",
"chat_accent_amber": "琥珀",
"chat_accent_blue": "青",
"chat_accent_green": "緑",
"chat_accent_pink": "ピンク",
"chat_accent_purple": "紫",
"chat_accent_theme": "テーマ",
"chat_add_contact": "連絡先に追加",
"chat_awaiting_key": "返信待ち",
"chat_bubble_minimal": "ミニマル",
"chat_bubble_rounded": "角丸",
"chat_bubble_square": "角ばった",
"chat_buffer_loading": "チャットバッファ:…",
"chat_buffer_preparing": "チャットバッファ:%d/%d を準備中…",
"chat_buffer_ready": "チャットバッファ:%d/%d 準備完了",
"chat_buffer_sending": "チャット:%d 件のメッセージを送信中…",
"chat_buffer_sending_one": "チャット:%d 件のメッセージを送信中…",
"chat_cancel": "キャンセル",
"chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます",
"chat_contact_request": "連絡リクエスト",
"chat_copy_address_tip": "クリックしてアドレスをコピー",
"chat_density_comfortable": "ゆったり",
"chat_density_compact": "コンパクト",
"chat_emoji_color": "カラー",
"chat_emoji_mono": "モノクロ",
"chat_emoji_search": "絵文字を検索",
"chat_empty_hint": "まだ会話はありません。受信したメッセージはここに表示されます。",
"chat_empty_start": "「新しい会話」から始めましょう。",
"chat_empty_title": "会話はまだありません",
"chat_export": "チャットをエクスポート…",
"chat_export_done": "会話をエクスポートしました",
"chat_export_failed": "エクスポートファイルを書き込めませんでした。",
"chat_export_warn": "復号したメッセージを平文で保存します。ファイルは安全に保管してください。",
"chat_filter": "チャット",
"chat_hidden_toast": "会話を非表示にしました — 新しいメッセージが届くと再表示されます",
"chat_hide": "非表示",
"chat_hide_hidden": "非表示を隠す",
"chat_jump_latest": "最新",
"chat_len_over": "メッセージが長すぎます",
"chat_locked_hint": "チャットを読み込むにはウォレットのロックを解除してください。",
"chat_new_button": "新しい会話",
"chat_mute": "ミュート",
"chat_new_button": "新しいチャット",
"chat_new_message": "メッセージ",
"chat_new_message_toast": "新しい暗号化チャットメッセージ",
"chat_new_send": "リクエストを送信",
"chat_new_title": "新しい会話",
"chat_new_title": "新しいチャット",
"chat_new_zaddr": "宛先Zアドレス",
"chat_no_matches": "検索に一致する会話がありません。",
"chat_no_z_contacts": "シールドアドレスの連絡先はまだありません",
"chat_opt_bubble_accent": "吹き出しの色",
"chat_opt_bubble_style": "吹き出しスタイル",
"chat_opt_density": "メッセージ密度",
"chat_opt_emoji": "絵文字スタイル",
"chat_opt_enter_sends": "Enterで送信",
"chat_opt_font_size": "文字サイズ",
"chat_opt_global_clock": "全体の時刻形式",
"chat_opt_poll": "取得間隔",
"chat_opt_timestamp": "タイムスタンプ",
"chat_pick_contact": "連絡先から選択…",
"chat_rename": "連絡先の名前を変更",
"chat_rename_hint": "連絡先名",
"chat_renamed": "連絡先の名前を変更しました",
"chat_retry": "再送信",
"chat_search": "会話を検索",
"chat_sec_appearance": "外観",
"chat_sec_messaging": "メッセージ",
"chat_select_hint": "表示する会話を選択してください。",
"chat_send": "送信",
"chat_send_failed": "未送信",
"chat_sending": "送信中…",
"chat_settings_done": "完了",
"chat_settings_section": "チャットと連絡先",
"chat_settings_tip": "チャットのカスタマイズ",
"chat_settings_title": "チャット設定",
"chat_show_hidden": "非表示を表示",
"chat_time_now": "たった今",
"chat_toast_compose_failed": "メッセージを作成できませんでした(長すぎませんか?)。",
"chat_toast_lite_busy": "すでに送信処理が進行中か、ウォレットが開かれていません。",
"chat_toast_need_funds": "チャットを送信するには、手数料を賄うための少額のシールド残高が必要です。",
"chat_toast_no_zaddr": "送信元に使えるZアドレスがありません。",
"chat_toast_not_connected": "未接続 — チャットメッセージは送信されませんでした。",
"chat_toast_request_compose_failed": "連絡リクエストを作成できませんでした(アドレスまたはテキストが無効?)。",
"chat_toast_request_queued": "連絡リクエストを送信待ちに追加しました。",
"chat_toast_waiting_reply": "メッセージを送るには、この相手からの返信を待つ必要があります。",
"chat_today": "今日",
"chat_ts_12h": "12時間",
"chat_ts_24h": "24時間",
"chat_ts_global": "全体設定に従う",
"chat_ts_global_short": "全体",
"chat_unhide": "再表示",
"chat_unmute": "ミュート解除",
"chat_verify_key": "識別鍵 — 照合して確認",
"chat_waiting_reply": "この相手からの返信を待っています — 返信があればメッセージを送れます。",
"chat_yesterday": "昨日",
"chat_you": "自分",
"choose_icon": "アイコンを選択",
"clear": "クリア",
@@ -147,6 +250,7 @@
"click_copy_address": "クリックしてアドレスをコピー",
"click_copy_uri": "クリックしてURIをコピー",
"click_to_copy": "クリックしてコピー",
"clock_format": "時刻形式",
"close": "閉じる",
"conf_count": "%d 確認",
"confirm_and_send": "確認して送金",
@@ -180,10 +284,24 @@
"connected_peers": "接続中のピア",
"connecting": "接続中...",
"console": "コンソール",
"console_accents": "カラーアクセント",
"console_app": "アプリ",
"console_auto_scroll": "自動スクロール",
"console_available_commands": "利用可能なコマンド:",
"console_backend_reference": "バックエンドコマンドリファレンス",
"console_backend_unavailable": "バックエンドなし",
"console_capturing_output": "デーモン出力をキャプチャ中...",
"console_cat_advanced": "詳細設定",
"console_cat_blockchain": "ブロックチェーン",
"console_cat_control": "制御",
"console_cat_keys": "鍵とセキュリティ",
"console_cat_mining": "マイニング",
"console_cat_network": "ネットワーク",
"console_cat_raw_transactions": "生トランザクション",
"console_cat_send": "送金",
"console_cat_sync": "同期",
"console_cat_utility": "ユーティリティ",
"console_cat_wallet": "ウォレット",
"console_clear": "クリア",
"console_clear_console": "コンソールをクリア",
"console_cleared": "コンソールをクリアしました",
@@ -215,18 +333,37 @@
"console_help_help": " help - このヘルプを表示",
"console_help_setgenerate": " setgenerate - マイニングを制御",
"console_help_stop": " stop - デーモンを停止",
"console_last_error": "最後のエラー:",
"console_line_count": "%zu 行",
"console_matches": "件一致",
"console_new_lines": "%d 新しい行",
"console_no_daemon": "デーモンなし",
"console_no_output": "(出力なし)",
"console_not_connected": "エラー:デーモンに接続されていません",
"console_not_connected_lite": "エラー:ウォレットが開かれていません",
"console_quit_note": "ここでは 'quit''exit' は不要です — ウィンドウを閉じるだけで構いません。",
"console_ref_builds": "生成",
"console_ref_cancel": "キャンセル",
"console_ref_destructive": "要注意",
"console_ref_example": "例",
"console_ref_insert": "コンソールに挿入",
"console_ref_insert_run": "挿入して実行",
"console_ref_no_match": "一致するコマンドはありません。",
"console_ref_no_params": "パラメーターは不要です。",
"console_ref_optional": "任意",
"console_ref_parameters": "パラメーター",
"console_ref_run": "実行",
"console_ref_run_confirm": "%s を今すぐ実行しますか? 影響の大きいコマンドです。",
"console_ref_search_hint": "名前または用途で検索…",
"console_ref_select_hint": "コマンドを選ぶと内容が表示されます。",
"console_ref_value": "値",
"console_rpc_reference": "RPCコマンドリファレンス",
"console_rpc_trace": "RPC",
"console_scanline": "コンソールスキャンライン",
"console_search_commands": "コマンドを検索...",
"console_select_all": "すべて選択",
"console_show_app_output": "[app] ウォレットのログ行を表示",
"console_show_backend_ref": "バックエンドコマンドリファレンスを表示",
"console_show_daemon_output": "デーモン出力を表示",
"console_show_errors_only": "エラーのみ表示",
"console_show_rpc_ref": "RPCコマンドリファレンスを表示",
@@ -239,17 +376,43 @@
"console_status_stopped": "停止済み",
"console_status_stopping": "停止中",
"console_status_unknown": "不明",
"console_stop_confirm_node": "'stop' はノードを停止し、ウォレットを切断します。確認するにはもう一度 'stop' と入力してください。",
"console_tab_completion": "Tabで補完",
"console_text_colors": "テキスト色",
"console_toggle_accents": "行のカラーアクセントを切り替え",
"console_toggle_text_color": "行のテキスト色を切り替え",
"console_type_help": "'help'と入力して利用可能なコマンドを表示",
"console_welcome": "ObsidianDragonコンソールへようこそ",
"console_zoom_in": "拡大",
"console_zoom_out": "縮小",
"contact_avatar": "アバター",
"contact_avatar_bad_image": "その画像を読み込めませんでした。",
"contact_avatar_badge": "バッジ",
"contact_avatar_badge_hint": "バッジはアドレスの種類に応じて自動的に選ばれます。",
"contact_avatar_choose": "画像を選択…",
"contact_avatar_copy_failed": "その画像をコピーできませんでした。",
"contact_avatar_icon": "アイコン",
"contact_avatar_image": "画像",
"contact_avatar_image_hint": "画像はアプリ内にコピーされ、元のファイルが移動しても利用できます。",
"contact_avatar_remove": "削除",
"contact_avatar_shielded": "シールド",
"contact_avatar_transparent": "透明",
"contact_global": "すべてのウォレットで表示(グローバル連絡先)",
"contact_global_badge_tt": "グローバル連絡先 — すべてのウォレットで表示",
"contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
"contact_preview_addr": "ここにアドレスが表示されます",
"contact_preview_name": "連絡先名",
"contact_wallet_loading": "ウォレットを読み込み中です。「すべてのウォレットに表示」にチェックするか、少し待ってから再試行してください。",
"contacts": "連絡先",
"contacts_avatar_shape": "アバターの形",
"contacts_list_scale": "リストの拡大率",
"contacts_search_no_match": "一致する連絡先がありません",
"contacts_search_placeholder": "連絡先を検索...",
"contacts_settings_tip": "連絡先のカスタマイズ",
"contacts_settings_title": "連絡先設定",
"contacts_shape_circle": "円",
"contacts_shape_square": "四角",
"contacts_shape_tab": "左タブ",
"copied": "コピーしました!",
"copy": "コピー",
"copy_address": "完全なアドレスをコピー",
@@ -263,6 +426,7 @@
"daemon_bundled": "バンドル版",
"daemon_install_bundled": "バンドル版をインストール",
"daemon_installed": "インストール済み",
"daemon_maintenance_label": "メンテナンス",
"daemon_none_bundled": "このビルドにはなし",
"daemon_not_installed": "未インストール",
"daemon_status_differ": "インストール済みのバイナリはバンドル版と異なります。",
@@ -285,6 +449,7 @@
"daemon_update_latest": "最新:",
"daemon_update_loading": "リリースを読み込み中…",
"daemon_update_now": "今すぐ更新",
"daemon_update_prompt_title": "ノードデーモンを更新しますか?",
"daemon_update_reinstall": "再インストール",
"daemon_update_restart_note": "新しいバージョンを実行するにはデーモンを再起動してください。",
"daemon_update_restart_now": "今すぐデーモンを再起動",
@@ -297,11 +462,27 @@
"daemon_update_verify_note": "ダウンロードは、インストール前にリリースで公開された SHA-256 と固定された ed25519 署名で検証されます。",
"daemon_update_verifying": "検証中…",
"daemon_update_version": "バージョン:",
"daemon_updates_label": "アップデート",
"daemon_version": "デーモン",
"dark": "ダーク",
"data_stale_prefix": "更新",
"data_stale_tooltip": "残高が最新でない可能性があります。ウォレットは最近更新を受信していません。ノード接続を確認してください。",
"date": "日付",
"date_label": "日付:",
"debug_logging": "デバッグログ",
"decrypt_desc": "ウォレットがエクスポートされ、デーモンが新しい暗号化されていないウォレットで再起動し、すべての鍵が再インポートされます。ウォレットのサイズによっては数分かかる場合があります。",
"decrypt_error_title": "復号に失敗しました",
"decrypt_step_backup": "暗号化されたウォレットをバックアップしています",
"decrypt_step_export": "ウォレットの鍵をエクスポートしています",
"decrypt_step_restart": "デーモンを再起動しています",
"decrypt_step_stop": "デーモンを停止しています",
"decrypt_step_unlock": "ウォレットのロックを解除しています",
"decrypt_success_desc": "ウォレットは暗号化されていない状態になりました。暗号化されたウォレットのバックアップが、データディレクトリに wallet.dat.encrypted.bak として保存されました。",
"decrypt_success_title": "ウォレットの復号に成功しました!",
"decrypt_title": "ウォレットの暗号化を解除",
"decrypt_wait_general": "お待ちください。デーモンが鍵をエクスポートし、再起動して再インポートしています。数分かかる場合があります。",
"decrypt_wait_restart": "デーモンの起動が完了するのを待っています...",
"decrypt_warning": "ウォレットの暗号化が解除されます。秘密鍵は保護されずにディスクに保存されます。",
"delete": "削除",
"delete_blockchain": "ブロックチェーンを削除",
"delete_blockchain_confirm": "削除して再同期",
@@ -314,6 +495,26 @@
"download_bootstrap": "ブートストラップをダウンロード",
"dragonx_green": "DragonXグリーン",
"edit": "編集",
"empty_wallet_keys_suffix": "個の鍵",
"empty_wallet_open_manager": "ウォレットマネージャーを開く",
"empty_wallet_restore": "ウォレットを復元",
"empty_wallet_salvage_body": "このウォレットが空なのは、以前の自動修復によって元のウォレットがバックアップとして脇に保存されたためです。コインはほぼ確実にそのバックアップの中にあり、失われていません。復元すれば資金を再び読み込めます。何も削除されません。現在のファイルは先に脇へ保存されます。",
"empty_wallet_salvage_headline": "コインはバックアップファイルに安全に保管されています。",
"empty_wallet_salvage_title": "ウォレットが修復された可能性があります",
"empty_wallet_warning_body": "このウォレットにはアドレスも資金もありませんが、DragonX フォルダー内の別のウォレットファイルに鍵が含まれています。コインはおそらくそちらにあり、失われていません。ウォレットマネージャーを開いて、資金のあるウォレットに切り替えてください。",
"empty_wallet_warning_dismiss": "このウォレットでは今後警告しない",
"empty_wallet_warning_dismiss_tip": "現在のウォレットファイルに対してのみこの警告を停止します。後で別の空のウォレットに切り替えると、再び警告される場合があります。",
"empty_wallet_warning_headline": "間違ったウォレットを開いた可能性があります。",
"empty_wallet_warning_title": "このウォレットは空です",
"enc_confirm": "確認:",
"enc_desc": "ウォレットを暗号化すると、パスフレーズで秘密鍵が保護されます。暗号化後、デーモンが再起動します。",
"enc_encrypting": "ウォレットを暗号化しています...",
"enc_pin_desc": "4〜8桁のPINを使うと、毎回パスフレーズ全体を入力せずにウォレットのロックを解除できます。",
"enc_pin_set_ok": "PINを設定しました",
"enc_pin_skipped": "PINをスキップしました。後で設定で設定できます。",
"enc_pin_vault_fail": "PINボルトの作成に失敗しました",
"enc_success": "ウォレットの暗号化に成功しました!",
"enc_wait": "お待ちください。アプリケーションを閉じないでください。",
"error": "エラー",
"error_format": "エラー:%s",
"est_time_to_block": "予測ブロック時間",
@@ -333,23 +534,34 @@
"explorer_mempool": "メモリプール",
"explorer_mempool_size": "サイズ",
"explorer_mempool_txs": "トランザクション",
"explorer_next_block": "次のブロック",
"explorer_no_results": "一致するキャッシュ済みブロックがありません",
"explorer_not_connected": "デーモンに未接続 — ブロックまたはトランザクションハッシュを検索できません",
"explorer_prev_block": "前のブロック",
"explorer_recent_blocks": "最近のブロック",
"explorer_search": "検索",
"explorer_section": "エクスプローラー",
"explorer_tx_outputs": "出力",
"explorer_tx_size": "サイズ",
"explorer_txid": "TxID",
"export": "エクスポート",
"export_csv": "CSVエクスポート",
"export_keys_btn": "鍵をエクスポート",
"export_keys_danger": "危険:ウォレットからすべての秘密鍵がエクスポートされます!このファイルにアクセスできる人は誰でもあなたの資金を盗めます。安全に保管し、使用後は削除してください。",
"export_keys_include_t": "Tアドレスを含める透明",
"export_keys_include_z": "Zアドレスを含めるシールド",
"export_keys_none_addrs": "エクスポートするアドレスがありません",
"export_keys_none_result": "鍵をエクスポートできませんでした(%d 件中 0 件)。ウォレットのロックを解除して再試行してください。",
"export_keys_none_toast": "鍵をエクスポートできませんでした。ウォレットはロック解除されていますか?",
"export_keys_not_connected": "デーモンに接続されていません",
"export_keys_options": "エクスポートオプション:",
"export_keys_partial": "%d 件中 %d 件の鍵をエクスポート — 不完全(支払い鍵がない、またはウォレットがロックされています)。",
"export_keys_partial_toast": "部分エクスポート: %d 件中 %d 件",
"export_keys_progress": "エクスポート中 %d/%d...",
"export_keys_select_type": "アドレスの種類を少なくとも1つ選択してください",
"export_keys_success": "鍵のエクスポートに成功しました",
"export_keys_title": "すべての秘密鍵をエクスポート",
"export_keys_write_fail": "鍵ファイルの書き込みに失敗しました。",
"export_private_key": "秘密鍵をエクスポート",
"export_tx_count": "%zu件の取引をCSVファイルにエクスポート。",
"export_tx_file_fail": "CSVファイルの作成に失敗しました",
@@ -389,23 +601,50 @@
"hide_qr": "QRを非表示",
"hide_zero_balances": "残高0を非表示",
"history": "履歴",
"img_picker_count": "このフォルダの画像:%d",
"img_picker_empty": "このフォルダにはサブフォルダも画像もありません。",
"img_picker_none": "このフォルダに画像はありません",
"img_picker_pictures": "画像フォルダ",
"img_picker_title": "画像を選択",
"img_picker_use": "この画像を使用",
"immature_type": "未成熟",
"import": "インポート",
"import_key_address": "アドレス:",
"import_key_btn": "鍵をインポート",
"import_key_done": "インポートしました。ウォレットを再スキャンしています。",
"import_key_field": "鍵",
"import_key_formats": "サポートされる鍵形式:",
"import_key_full_rescan": "0 = 完全再スキャン)",
"import_key_import": "インポート",
"import_key_label": "秘密鍵:",
"import_key_need_node": "鍵をインポートするには稼働中のノードに接続してください。",
"import_key_no_valid": "入力に有効な鍵が見つかりません",
"import_key_progress": "インポート中 %d/%d...",
"import_key_rescan": "インポート後にブロックチェーンを再スキャン",
"import_key_rescanning": "インポートと再スキャン中 — 数分かかることがあります",
"import_key_reveal_tip": "鍵の表示/非表示",
"import_key_start_height": "開始高:",
"import_key_success": "鍵のインポートに成功しました",
"import_key_t_format": "TアドレスWIF秘密鍵",
"import_key_title": "秘密鍵をインポート",
"import_key_tooltip": "1行に1つずつ秘密鍵を入力してください。\nzアドレスとtアドレスの鍵の両方に対応しています。\n#で始まる行はコメントとして扱われます。",
"import_key_type_tkey": "透明な秘密鍵",
"import_key_type_unknown": "認識できない鍵の形式",
"import_key_type_zspend": "シールド支払鍵",
"import_key_type_zview": "シールド閲覧鍵(監視のみ)",
"import_key_warn": "自分が所有する鍵のみをインポートしてください — その資金へのアクセスを許可します。",
"import_key_warning": "警告:秘密鍵を決して共有しないでください!信頼できないソースからの鍵のインポートはウォレットを危険にさらす可能性があります。",
"import_key_wrong_type": "これは閲覧鍵のようです。代わりに「閲覧鍵をインポート」を使用してください。",
"import_key_z_format": "Zアドレス支出鍵 (secret-extended-key-...)",
"import_private_key": "秘密鍵をインポート...",
"import_scan_hint": "0 = 最初から再スキャン",
"import_scan_label": "スキャン開始ブロック高(任意)",
"import_scan_tip": "現在の高さ",
"import_scan_transparent": "透明鍵は常に全体を再スキャンします",
"import_viewkey_field": "閲覧鍵",
"import_viewkey_note": "監視のみ:閲覧鍵はアドレスの残高と取引を表示できますが、資金を送金することはできません。",
"import_viewkey_title": "閲覧鍵をインポート",
"import_viewkey_wrong_type": "これは支払い鍵のようです。代わりに「秘密鍵をインポート」を使用してください。",
"incorrect_passphrase": "パスフレーズが正しくありません",
"incorrect_pin": "PINが正しくありません",
"insufficient_funds": "この金額と手数料に対して残高が不足しています。",
@@ -414,6 +653,7 @@
"keep": "保持",
"keep_daemon": "デーモンを実行し続ける",
"key_export_click_retrieve": "クリックしてウォレットからキーを取得",
"key_export_failed": "鍵をエクスポートできませんでした。ウォレットのロックを解除して(暗号化されている場合)再試行してください。",
"key_export_fetching": "ウォレットから鍵を取得中...",
"key_export_private_key": "秘密鍵:",
"key_export_private_warning": "この鍵は秘密にしてください!この鍵を持つ人は誰でもあなたの資金を使えます。オンラインや信頼できない相手と共有しないでください。",
@@ -427,15 +667,20 @@
"light": "ライト",
"lite_account_label": "アカウント",
"lite_action": "アクション",
"lite_backend_unavailable": "ライトウォレットのバックエンドが利用できません",
"lite_backup_keys": "バックアップと鍵",
"lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)",
"lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください完全スキャンが遅くなります。",
"lite_birthday_label": "バースデー",
"lite_console_backend_commands": "バックエンドコマンド:",
"lite_console_help_passthrough": "その他の入力はライトウォレットのコンソールコマンドとして実行されます。",
"lite_copy": "コピー",
"lite_could_not_start": "操作を開始できませんでした",
"lite_could_not_write": "書き込めませんでした ",
"lite_encrypt_wallet": "ウォレットを暗号化",
"lite_encryption_removed": "暗号化を解除しました",
"lite_enter_all_seed_words": "復元するには24個のシードワードをすべて入力してください現在 %d 個)",
"lite_enter_wallet_path": "ウォレットのパスを入力してください",
"lite_hide_wipe": "非表示にして消去",
"lite_import": "インポート",
"lite_import_key_label": "鍵をインポート",
@@ -448,6 +693,7 @@
"lite_net_add_url_hint": "https://your-lite-server",
"lite_net_checking": "確認中…",
"lite_net_connected": "接続済み",
"lite_net_connecting": "接続中…",
"lite_net_custom": "カスタム",
"lite_net_disconnected": "未接続",
"lite_net_hidden_section": "非表示のサーバー",
@@ -519,6 +765,9 @@
"lite_working": "処理中…",
"loading": "読み込み中...",
"loading_addresses": "アドレスを読み込み中...",
"loading_stall_body": "デーモンは %.0f 秒間初期化しています。アップデート後や初回起動時(ブロックインデックスの読み込みや再スキャン)は正常な場合があります。準備ができ次第、自動的に接続します。",
"loading_stall_hint": "まだ動かない場合は、設定を開いて「デーモンを再起動」を使うか、コンソールで詳細を確認してください。",
"loading_stall_title": "予想より時間がかかっています",
"loading_transactions": "トランザクションを読み込み中",
"local_hashrate": "ローカルハッシュレート",
"low_spec_mode": "省電力モード",
@@ -534,6 +783,10 @@
"market_btc_price": "BTC価格",
"market_cap": "時価総額",
"market_cap_short": "時価総額",
"market_chart_loading": "価格履歴を読み込み中",
"market_col_name": "名前",
"market_col_trend": "トレンド",
"market_col_value": "価値",
"market_iv_1d": "1日",
"market_iv_1h": "1時間",
"market_iv_1m": "1ヶ月",
@@ -542,11 +795,18 @@
"market_no_history": "価格履歴がありません",
"market_no_price": "価格データなし",
"market_now": "現在",
"market_opt_chart_style": "チャートスタイル",
"market_pct_shielded": "%.0f%% シールド済み",
"market_portfolio": "ポートフォリオ",
"market_price_loading": "価格データを読み込み中...",
"market_price_unavailable": "価格データが利用できません",
"market_refresh_price": "価格データを更新",
"market_settings_tip": "マーケットオプション",
"market_settings_title": "マーケット設定",
"market_style_candle": "ローソク足に切り替え",
"market_style_candle_label": "ローソク足",
"market_style_line": "折れ線チャートに切り替え",
"market_style_line_label": "ライン",
"market_trade_on": "%s で取引",
"market_updated": "\\xc2\\xb7 更新: %s",
"market_vol_short": "出来高",
@@ -640,6 +900,7 @@
"mining_difficulty_copied": "難易度をコピーしました",
"mining_est_block": "予測ブロック",
"mining_est_daily": "予測日収",
"mining_est_daily_pool_sub": "おおよそのソロ換算(プール手数料前)",
"mining_filter_all": "すべて",
"mining_filter_tip_all": "すべての収益を表示",
"mining_filter_tip_pool": "プール収益のみ表示",
@@ -668,10 +929,12 @@
"mining_open_in_explorer": "エクスプローラーで開く",
"mining_payout_address": "支払いアドレス",
"mining_payout_foreign": "⚠ この支払いアドレスは現在のウォレットに含まれていません — マイニング報酬が別のウォレットに送られます。ウォレットを切り替えた場合は更新してください。",
"mining_payout_invalid": "有効な DragonX アドレスではありません — 開始前に修正してください。さもないとマイニング報酬が失われます。",
"mining_payout_tooltip": "マイニング報酬の受取アドレス",
"mining_pool": "プール",
"mining_pool_fee": "手数料",
"mining_pool_hashrate": "プールハッシュレート",
"mining_pool_needs_payout_tooltip": "先に支払い先アドレスを入力してくださいZアドレスを生成",
"mining_pool_url": "プールURL",
"mining_pools_header": "プール",
"mining_recent_blocks": "最近のブロック",
@@ -701,6 +964,9 @@
"mining_syncing_tooltip": "ブロックチェーン同期中...",
"mining_tag": " · マイニング",
"mining_threads": "マイニングスレッド",
"mining_threads_input_tooltip": "正確なスレッド数を入力Enter で適用)",
"mining_threads_minus_tooltip": "スレッドを減らす",
"mining_threads_plus_tooltip": "スレッドを増やす",
"mining_to_save": "保存する",
"mining_today": "今日",
"mining_uptime": "稼働時間",
@@ -727,6 +993,11 @@
"no_transactions": "取引が見つかりません",
"no_transactions_yet": "まだ取引がありません",
"node": "ノード",
"node_banner_crashed_title": "ノードが予期せず停止しました",
"node_banner_lite_open_failed": "ウォレットを開けませんでした",
"node_banner_offline_title": "DragonX ノードに接続されていません",
"node_banner_reconnect": "再接続",
"node_banner_restart": "ノードを再起動",
"node_security": "ノードとセキュリティ",
"noise": "ノイズ",
"not_connected": "デーモンに未接続...",
@@ -736,6 +1007,7 @@
"output_filename": "出力ファイル名:",
"overview": "概要",
"paste": "貼り付け",
"paste_clip_empty": "クリップボードは空です",
"paste_from_clipboard": "クリップボードから貼り付け",
"pay_from": "支払い元",
"payment_request": "支払い請求",
@@ -787,7 +1059,14 @@
"peers_upper": "ピア",
"peers_version": "バージョン",
"pending": "保留中",
"pin_change_desc": "ロック解除PINを変更します。現在のPINと新しいPINが必要です。",
"pin_confirm_new_label": "新しいPINの確認:",
"pin_current_label": "現在のPIN:",
"pin_new_label": "新しいPIN4〜8桁:",
"pin_not_set": "PINが設定されていません。パスフレーズで解除してください。",
"pin_remove_desc": "削除を確認するには現在のPINを入力してください。ロック解除には完全なパスフレーズが必要になります。",
"pin_setup_desc": "ウォレットをすばやくロック解除するための4〜8桁のPINを設定します。ウォレットのパスフレーズはこのPINで暗号化され、ローカルに保存されます。",
"pin_wallet_passphrase": "ウォレットのパスフレーズ:",
"ping": "Ping",
"portfolio_add_entry": "エントリを追加",
"portfolio_add_to": "ポートフォリオに追加",
@@ -826,6 +1105,9 @@
"portfolio_remove_from": "ポートフォリオから削除",
"portfolio_revert": "元に戻す",
"portfolio_save": "保存",
"portfolio_save_need_address": "保存するにはアドレスを1つ以上追加してください。",
"portfolio_save_need_name": "このグループを保存するには名前を入力してください。",
"portfolio_save_need_price": "保存するには0より大きい手動価格を入力してください。",
"portfolio_search": "アドレスを検索…",
"portfolio_search_icons": "アイコンを検索…",
"portfolio_select_all": "すべて",
@@ -839,11 +1121,12 @@
"portfolio_spark_min": "分",
"portfolio_spark_month": "月",
"portfolio_spark_week": "週",
"portfolio_style_compact": "コンパクト行",
"portfolio_style_detailed": "詳細行",
"portfolio_style_featured": "注目行",
"portfolio_style_compact": "テーブル",
"portfolio_style_detailed": "カード",
"portfolio_style_featured": "スポットライト",
"portfolio_style_label": "ポートフォリオスタイル",
"portfolio_untitled": "無題",
"portfolio_wallet_loading": "ウォレットの読み込みが終わってからグループを追加してください。",
"price_chart": "価格チャート",
"privacy_great": "優れたプライバシーです!",
"privacy_low": "プライバシーが低い — 資金をシールドしてください",
@@ -853,6 +1136,8 @@
"qr_failed": "QRコードの生成に失敗しました",
"qr_title": "QRコード",
"qr_unavailable": "QR利用不可",
"quick_receive": "クイック受取",
"quick_send": "クイック送金",
"ram_daemon_gb": "デーモン:%.1f GB (%s)",
"ram_daemon_mb": "デーモン:%.0f MB (%s)",
"ram_system_gb": "システム:%.1f / %.0f GB",
@@ -902,6 +1187,7 @@
"rpc_connection": "RPC接続...",
"rpc_host": "RPCホスト",
"rpc_pass": "パスワード",
"rpc_plaintext_remote_warning": "リモートRPCは暗号化されていないHTTPを使用しています。デーモンがTLSに対応している場合は、DRAGONX.confにrpctls=1を追加してください。",
"rpc_port": "ポート",
"rpc_user": "ユーザー名",
"save": "保存",
@@ -944,6 +1230,7 @@
"sb_waiting_daemon_err": "dragonxd を待機中 — %s",
"sb_warming_up": "ウォームアップ中...",
"sb_witness_cache": "ウィットネスを再構築中",
"scale_effects": "スケールとエフェクト",
"screenshot_open_dir": "場所を開く",
"screenshot_sweep": "スクリーンショットスイープを実行",
"screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。",
@@ -973,6 +1260,7 @@
"send_amount": "金額",
"send_amount_details": "金額の詳細",
"send_amount_upper": "金額",
"send_cannot_send_now": "現在送信できません — 接続、同期、利用可能な残高を確認してください。",
"send_clear_fields": "すべてのフォームフィールドをクリアしますか?",
"send_contacts_button": "連絡先から選択",
"send_copy_error": "エラーをコピー",
@@ -1007,6 +1295,7 @@
"send_tooltip_not_connected": "デーモンに未接続",
"send_tooltip_select_source": "まず送信元アドレスを選択してください",
"send_tooltip_syncing": "ブロックチェーンの同期をお待ちください",
"send_tooltip_view_only": "閲覧専用アドレス — 送金鍵がないため送金できません",
"send_total": "合計",
"send_transaction": "取引を送信",
"send_tx_failed": "取引に失敗しました",
@@ -1026,16 +1315,16 @@
"sent_filter": "送信済み",
"sent_type": "送信済み",
"sent_upper": "送信済み",
"set_label": "ラベルを設定...",
"set_label": "ラベルを設定",
"settings": "設定",
"settings_about_text": "DragonX (DRGX) 用のシールド暗号通貨ウォレット。Dear ImGui で構築された軽量でポータブルな体験。",
"settings_acrylic_level": "アクリルレベル:",
"settings_address_book": "アドレス帳...",
"settings_address_book": "アドレス帳",
"settings_auto_detected": "DRAGONX.conf から自動検出",
"settings_auto_lock": "オートロック",
"settings_auto_shield_desc": "透明資金を自動的にシールドアドレスに移動",
"settings_auto_shield_funds": "透明資金を自動シールド",
"settings_backup": "バックアップ...",
"settings_backup": "バックアップ",
"settings_block_explorer_urls": "ブロックエクスプローラーURL",
"settings_builtin": "内蔵",
"settings_change_passphrase": "パスフレーズを変更",
@@ -1046,52 +1335,62 @@
"settings_configure_explorer": "外部ブロックエクスプローラーリンクを設定",
"settings_configure_rpc": "dragonxd デーモンへの接続を設定",
"settings_connection": "接続",
"settings_copy_diagnostics": "診断情報をコピー",
"settings_copyright": "Copyright 2024-2026 DragonX 開発者 | GPLv3 ライセンス",
"settings_custom": "カスタム",
"settings_data_dir": "データディレクトリ:",
"settings_debug_changed": "デバッグカテゴリが変更されました — デーモンを再起動して適用",
"settings_debug_restart_note": "変更はデーモンの再起動後に有効になります。",
"settings_debug_select": "デーモンのデバッグログを有効にするカテゴリを選択(-debug= フラグ)。",
"settings_diagnostics_copied": "診断情報をクリップボードにコピーしました",
"settings_encrypt_first_pin": "PIN を有効にするには、まずウォレットを暗号化してください",
"settings_encrypt_wallet": "ウォレットを暗号化",
"settings_explorer_hint": "URLには末尾のスラッシュを含めてください。txid/アドレスが追加されます。",
"settings_export_all": "すべてエクスポート...",
"settings_export_csv": "CSV エクスポート...",
"settings_export_key": "鍵をエクスポート...",
"settings_export_all": "すべてエクスポート",
"settings_export_csv": "CSV エクスポート",
"settings_export_key": "鍵をエクスポート",
"settings_gradient_bg": "グラデーション背景",
"settings_gradient_desc": "テクスチャ背景を滑らかなグラデーションに置換",
"settings_idle_after": "経過後",
"settings_import_key": "鍵をインポート...",
"settings_import_key": "秘密鍵をインポート",
"settings_import_viewkey": "閲覧鍵をインポート…",
"settings_language_note": "注意:一部のテキストは更新に再起動が必要です",
"settings_lock_now": "今すぐロック",
"settings_locked": "ロック済み",
"settings_merge_to_address": "アドレスにマージ...",
"settings_merge_to_address": "アドレスにマージ",
"settings_noise_opacity": "ノイズ不透明度:",
"settings_not_connected": "デーモンに接続されていません",
"settings_not_encrypted": "暗号化されていません",
"settings_not_found": "見つかりません",
"settings_open_app_dir": "アプリフォルダを開く",
"settings_open_data_dir": "データフォルダを開く",
"settings_open_log_folder": "ログフォルダを開く",
"settings_other": "その他",
"settings_pin_active": "PIN",
"settings_privacy": "プライバシー",
"settings_quick_unlock_pin": "クイックアンロック PIN",
"settings_reduce_transparency": "透明度を下げる",
"settings_reloaded": "ディスクから設定を再読み込みしました",
"settings_remove_encryption": "暗号化を解除",
"settings_remove_pin": "PIN を削除",
"settings_request_payment": "支払い請求...",
"settings_request_payment": "支払い請求",
"settings_rescan_desc": "欠落したトランザクションのためにブロックチェーンを再スキャン",
"settings_restart_daemon": "デーモンを再起動",
"settings_rpc_connection": "RPC 接続",
"settings_rpc_error_prefix": "RPCエラー: ",
"settings_rpc_note": "注意:接続設定は通常 DRAGONX.conf から自動検出されます",
"settings_rpc_ok": "RPC接続は正常です",
"settings_save_shielded_desc": "z-addr トランザクションをローカルファイルに保存して表示",
"settings_save_shielded_local": "シールドトランザクション履歴をローカルに保存",
"settings_saved": "設定を保存しました",
"settings_set_pin": "PIN を設定",
"settings_shield_mining": "マイニングシールド...",
"settings_shield_mining": "マイニングシールド",
"settings_solid_colors_desc": "ぼかし効果の代わりに単色を使用(アクセシビリティ)",
"settings_theme_refreshed": "テーマ一覧を更新しました",
"settings_tor_desc": "プライバシー向上のため全接続を Tor 経由にする",
"settings_unlocked": "ロック解除",
"settings_use_tor_network": "ネットワーク接続に Tor を使用",
"settings_validate_address": "アドレス検証...",
"settings_validate_address": "アドレス検証",
"settings_visual_effects": "視覚効果",
"settings_wallet_file_size": "ウォレットファイルサイズ:%s",
"settings_wallet_info": "ウォレット情報",
@@ -1099,6 +1398,8 @@
"settings_wallet_maintenance": "ウォレットメンテナンス",
"settings_wallet_not_found": "ウォレットファイルが見つかりません",
"settings_wallet_size_label": "ウォレットサイズ:",
"settings_ztx_cleared": "Zトランザクション履歴を消去しました",
"settings_ztx_not_found": "履歴ファイルが見つかりません",
"setup_wizard": "セットアップウィザード",
"share": "共有",
"shield_check_status": "ステータスを確認",
@@ -1110,6 +1411,7 @@
"shield_in_progress": "操作進行中...",
"shield_max_utxos": "1回の操作あたりの最大UTXO数",
"shield_merge_done": "シールド/統合が完了しました!",
"shield_no_zaddr_hint": "シールドアドレスzがまだありません — まず「受取」タブで作成してください。",
"shield_op_failed": "操作に失敗しました: ",
"shield_op_submitted": "操作を送信しました: ",
"shield_operation_id": "オペレーション ID%s",
@@ -1148,6 +1450,31 @@
"submitting_transaction": "取引を送信中...",
"success": "成功",
"summary": "概要",
"sweep_button": "集約",
"sweep_caveat": "鍵をインポートして、すべての資金をあなたのアドレスへ移す取引に署名します。鍵は残高ゼロでウォレットに残ります。",
"sweep_dest_label": "集約先",
"sweep_dest_new": "新しいシールドアドレス(推奨)",
"sweep_done": "完了 — 資金をあなたのアドレスに集約しました。",
"sweep_to": "集約先:",
"sweep_toggle": "ウォレットに集約(鍵は保持しない)",
"sweep_tx": "取引:",
"switch_corrupt_body": "このウォレットは破損しているようです。ノードが開けませんでした。バックアップから復元するか、作り直すか、修復を試してください。",
"switch_corrupt_repair": "修復を試すsalvage",
"switch_progress_background": "バックグラウンドで続行",
"switch_progress_default_wallet": "既定のウォレット",
"switch_progress_elapsed": "経過",
"switch_progress_external_wallet": "外部ウォレット",
"switch_progress_failed_title": "ウォレットの切り替えに失敗しました",
"switch_progress_from_label": "元:",
"switch_progress_hint": "正常なシャットダウンには最大1分かかることがあります。",
"switch_progress_reconnecting": "再接続しています",
"switch_progress_starting": "新しいウォレットでノードを起動しています",
"switch_progress_stopping": "現在のノードを停止しています",
"switch_progress_title": "ウォレットを切り替え中",
"switch_stopnode_body": "ウォレットを切り替えると、選択したウォレットでノードが再起動します。実行中のノードは停止され、新しいウォレットで再起動されます。意図的に起動したままにしていた場合は、自動的に復帰します。",
"switch_stopnode_confirm": "ノードを停止して切り替え",
"switch_stopnode_title": "実行中のノードを停止しますか?",
"switch_stopnode_warn": "このウォレットが起動していないノードがすでに実行中です。",
"syncing": "同期中...",
"t_address": "Tアドレス",
"t_addresses": "Tアドレス",
@@ -1155,6 +1482,7 @@
"theme": "テーマ",
"theme_effects": "テーマ効果",
"theme_language": "テーマと言語",
"tile_click_to_open": "クリックして開く",
"time_days_ago": "%d日前",
"time_hours_ago": "%d時間前",
"time_minutes_ago": "%d分前",
@@ -1169,7 +1497,9 @@
"to_upper": "宛先",
"tools": "ツール",
"tools_actions": "ツールとアクション...",
"tools_actions_hdr": "ツールと操作",
"total": "合計",
"total_balance_label": "総残高",
"transaction_id": "取引ID",
"transaction_sent": "取引の送信に成功しました",
"transaction_sent_msg": "取引を送信しました!",
@@ -1183,19 +1513,32 @@
"transfer_to": "送金先:",
"transparent": "透明",
"transparent_address": "トランスペアレントアドレス",
"try_again": "再試行",
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
"tt_animate_avatars": "アニメーション連絡先アバターGIF / WebPを再生。オフでは最初のフレームのみ表示",
"tt_auto_lock": "この無操作時間後にウォレットをロック",
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
"tt_backup": "wallet.dat のバックアップを作成",
"tt_block_explorer": "ブラウザで DragonX ブロックエクスプローラーを開く",
"tt_blur": "ぼかし量0%% = オフ、100%% = 最大)",
"tt_blur": "ぼかし量0% = オフ、100% = 最大)",
"tt_change_pass": "ウォレットの暗号化パスフレーズを変更",
"tt_change_pin": "アンロック PIN を変更",
"tt_chat_bubble_accent": "送信メッセージの吹き出しのアクセントカラー(または現在のテーマに従う)",
"tt_chat_bubble_style": "メッセージの吹き出しの形:角丸、四角、またはミニマル(フラット、枠なし)",
"tt_chat_density": "メッセージ間の間隔:ゆったりは余白を増やし、コンパクトは画面に多く表示します",
"tt_chat_emoji_style": "絵文字をモノクロの輪郭またはフルカラーで表示します",
"tt_chat_enter_sends": "オンのとき、Enterでメッセージを送信し、Shift+Enterで改行します。オフのとき、Enterで改行します",
"tt_chat_font_size": "チャットのメッセージ文字を0.8xから1.5xで拡大縮小します。チャットタブのみに影響し、アプリの他の部分には影響しません",
"tt_chat_poll_rate": "新着および0-confメッセージを確認する頻度0.5-15 s。速いほど反応が良くなりますが、CPUをより多く使います",
"tt_chat_timestamp": "このタブのみのタイムスタンプ形式アプリ全体の時計に従うか、24-hourまたは12-hourを強制します",
"tt_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除",
"tt_clock_format": "24時間または12時間表示アプリ全体。チャットで上書きできます。",
"tt_copy_diagnostics": "サポート用の概要(バージョン、デーモン/ウォレット/ログの状態 — 秘密情報なし)をクリップボードにコピーします",
"tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化",
"tt_custom_theme": "カスタムテーマがアクティブ",
"tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します",
"tt_daemon_refresh": "上に表示されているインストール済みおよび同梱のdragonxdのバージョン、サイズ、日付を再読み込みします",
"tt_daemon_update_check": "プロジェクトの Gitea から最新の dragonxd フルノードをダウンロードして検証し、再起動して適用します",
"tt_debug_collapse": "デバッグログオプションを折りたたむ",
"tt_debug_expand": "デバッグログオプションを展開",
@@ -1209,18 +1552,43 @@
"tt_font_scale": "すべてのテキストと UI をスケーリング1.0x = デフォルト、最大 1.5x)。",
"tt_idle_delay": "マイニング開始前の待機時間",
"tt_import_key": "このウォレットに秘密鍵zkey または tkeyをインポート",
"tt_import_viewkey": "シールド閲覧鍵をインポートしてアドレスを閲覧(読み取り専用)",
"tt_keep_daemon": "セットアップウィザード実行時にデーモンは停止します",
"tt_language": "ウォレット UI のインターフェース言語",
"tt_layout_hotkey": "ホットキー:左右矢印キーでバランスレイアウトを切り替え",
"tt_lite_copy": "表示された秘密情報をクリップボードにコピーします",
"tt_lite_decrypt_pass": "ウォレットの暗号化を解除するためにパスフレーズを入力します",
"tt_lite_encrypt": "上のパスフレーズでウォレットを暗号化します。すぐにロックされ、解除にはパスフレーズが必要です",
"tt_lite_encrypt_pass": "ウォレットを暗号化するパスフレーズ。失うとウォレットのロック解除も復元もできなくなります",
"tt_lite_hide_wipe": "表示された秘密情報を隠し、メモリから安全に消去します",
"tt_lite_import_key": "インポートする秘密鍵(送金用または閲覧用)を貼り付けます。その履歴は次回の同期後に表示されます",
"tt_lite_import_key_btn": "入力した秘密鍵をこのウォレットにインポートします。資金と履歴は次回の同期後に表示されます",
"tt_lite_lifecycle_op": "新しいウォレットを作成するか、既存のものを開くか、シードフレーズから復元するかを選びます",
"tt_lite_lifecycle_pass": "この作成/開く/復元の操作でウォレットのロック解除または設定に使うパスフレーズ",
"tt_lite_lifecycle_run": "上の値で、選択した作成/開く/復元の操作を実行します",
"tt_lite_lifecycle_toggle": "ライトウォレットファイルを管理する作成/開く/復元のコントロールを表示または非表示にします",
"tt_lite_lock": "ウォレットを今すぐロックします。解除にはパスフレーズが必要で、チャットセッションはすべて終了します",
"tt_lite_redownload": "ライトサーバーからすべてのブロックを再ダウンロードして再スキャン",
"tt_lite_remove_encrypt": "暗号化を解除し、ウォレットを保護なしで保存します。開くのにパスフレーズは不要になります",
"tt_lite_restore_account": "復元するHDアカウントのインデックス。このシードで複数のアカウントを使っていない限り0のままにします",
"tt_lite_restore_birthday": "ウォレットが作成されたブロック高。スキャンはここから始まります。不明な場合は0または最も古い高さを使ってください",
"tt_lite_restore_overwrite": "既存のウォレットファイルをこの復元で置き換えます。警告:現在のウォレットデータを上書きします",
"tt_lite_restore_seed": "このウォレットを復元するための24-wordのリカバリーシードフレーズ。入力中は非表示になります",
"tt_lite_save_seed_file": "シードと作成時期を、設定フォルダ内の所有者のみが読めるファイルlite-seed-backup.txtに書き出します",
"tt_lite_show_keys": "このウォレットの秘密鍵(送金用)を表示します。鍵を持つ人は誰でもそれが管理する資金を使えます",
"tt_lite_show_seed": "このウォレットのリカバリーシードフレーズと作成時期を表示します。シードを持つ人は誰でもあなたの資金を使えます",
"tt_lite_unlock": "上のパスフレーズを使って暗号化されたウォレットのロックを解除します",
"tt_lite_unlock_pass": "暗号化されたウォレットのロックを解除するためにパスフレーズを入力します",
"tt_lite_wallet_path": "開く、または復元先となるウォレットファイルのパスまたは名前",
"tt_lock": "ウォレットを即座にロック",
"tt_low_spec": "すべての重い視覚効果を無効化\\nホットキーCtrl+Shift+Down",
"tt_merge": "複数の UTXO を一つのアドレスに統合",
"tt_mine_idle": "システムがアイドル状態(キーボード/マウス入力なし)\\nのとき自動的にマイニングを開始",
"tt_noise": "グレインテクスチャ強度0%% = オフ、100%% = 最大)",
"tt_noise": "グレインテクスチャ強度0% = オフ、100% = 最大)",
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
"tt_open_dir": "クリックしてファイルエクスプローラーで開く",
"tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます",
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",
@@ -1233,12 +1601,17 @@
"tt_rpc_host": "DragonX デーモンのホスト名",
"tt_rpc_pass": "RPC 認証パスワード",
"tt_rpc_port": "デーモン RPC 接続用ポート",
"tt_rpc_toggle": "デーモンの読み取り専用のRPC接続情報ホスト、ポート、ユーザー、パスワードを表示または非表示にします",
"tt_rpc_user": "RPC 認証ユーザー名",
"tt_save_settings": "すべての設定をディスクに保存",
"tt_save_ztx": "z-address トランザクション履歴をローカルに保存して高速読み込み",
"tt_scan_themes": "新しいテーマをスキャン。\\nテーマフォルダーをここに配置\\n%s",
"tt_scanline": "コンソールでの CRT スキャンライン効果",
"tt_screenshot_open_dir": "スクリーンショットフォルダ(設定ディレクトリ内)をファイルマネージャーで開きます",
"tt_screenshot_sweep": "すべてのタブですべてのテーマを順に切り替え、それぞれのスクリーンショットを設定のスクリーンショットフォルダに保存します(前回の実行を上書きします)",
"tt_screenshot_sweep_full": "テーマの実行と同様ですが、一時的なオフラインのデモウォレットデータを使って、すべてのモーダル/ダイアログ/フローも撮影します",
"tt_seed_backup": "ウォレットの24単語の復元シードフレーズを表示してバックアップします",
"tt_seed_demo_chat": "実行でUIを撮影できるように、チャットタブにサンプルの会話を挿入します。メモリ上のみで、再起動で消えます",
"tt_seed_migrate": "新しいシードフレーズウォレットを作成し、資金をそこへ移動します",
"tt_set_pin": "クイックアンロック用の 4-8 桁 PIN を設定",
"tt_shield_mining": "透明マイニング報酬をシールドアドレスに移動",
@@ -1250,13 +1623,14 @@
"tt_theme_hotkey": "ホットキーCtrl+左/右でテーマを切り替え",
"tt_tor": "匿名性のためにデーモン接続を Tor ネットワーク経由でルーティング",
"tt_tx_url": "ブロックエクスプローラーでトランザクションを表示するためのベース URL",
"tt_ui_opacity": "カードとサイドバーの不透明度100%% = 完全不透明、低い = より透過)",
"tt_ui_opacity": "カードとサイドバーの不透明度100% = 完全不透明、低い = より透過)",
"tt_validate": "DragonX アドレスが有効かどうかを確認",
"tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録",
"tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます",
"tt_website": "DragonX ウェブサイトを開く",
"tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)",
"tt_wizard": "初期セットアップウィザードを再実行\\nデーモンは再起動されます",
"tx_chat_badge": "メッセージ",
"tx_confirmations": "%d 確認",
"tx_details_title": "取引の詳細",
"tx_from_address": "送信元アドレス:",
@@ -1302,6 +1676,7 @@
"validate_not_mine": "このウォレットに属していません",
"validate_ownership": "所有者:",
"validate_results": "結果:",
"validate_results_placeholder": "ここに結果が表示されます",
"validate_shielded_type": "シールドzアドレス",
"validate_status": "ステータス:",
"validate_title": "アドレスを検証",
@@ -1325,6 +1700,8 @@
"wallets_add_folder_toggle": "+ 別のフォルダーをウォレット検索…",
"wallets_badge_encrypted": "暗号化済み(パスフレーズ保護)",
"wallets_badge_encrypted_short": "暗号化",
"wallets_badge_hd": "HDウォレット — 開かないとシードフレーズを確認できません",
"wallets_badge_hd_short": "HDウォレット",
"wallets_badge_legacy": "レガシーウォレット(シードフレーズなし)",
"wallets_badge_legacy_short": "レガシー",
"wallets_badge_seed": "シードフレーズウォレット (HD)",
@@ -1343,6 +1720,7 @@
"wallets_created": "作成",
"wallets_creating": "ウォレットを作成中 — ノードが再起動します…",
"wallets_current": "現在",
"wallets_empty_hint": "フォルダーをスキャンして他のウォレットを探す",
"wallets_exists": "その名前のウォレットは既に存在します。",
"wallets_external_tt": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。",
"wallets_folder_hint": "wallet .dat ファイルのあるフォルダのパス",
@@ -1356,7 +1734,9 @@
"wallets_open_failed": "このウォレットをその場で開けませんでした。データディレクトリとは別のドライブにある可能性があります — 同じドライブに移動してくださいWindows では開発者モードを有効にするとドライブ間のリンクも可能になります)。",
"wallets_open_folder": "フォルダーの場所を開く",
"wallets_open_inplace_tt": "このウォレットをその場で開きます — データディレクトリにリンク(コピーなし)",
"wallets_remove_folder": "このフォルダーのスキャンを停止",
"wallets_reveal": "フォルダを開く",
"wallets_scanned_folders": "スキャン対象フォルダー:",
"wallets_sort_addresses": "アドレス",
"wallets_sort_asc": "昇順(古い/少ない/小さい順)",
"wallets_sort_by": "並べ替え:",
@@ -1369,6 +1749,61 @@
"warning_upper": "警告!",
"website": "ウェブサイト",
"window_opacity": "ウィンドウ透明度",
"wiz_acrylic": "アクリルガラス効果",
"wiz_acrylic_desc": "パネルの半透明ぼかし(オフで無効化)",
"wiz_already_encrypted": "ウォレットはすでに暗号化されています",
"wiz_already_encrypted_desc": "ウォレットはパスフレーズで保護されています。これ以上の操作は不要です。",
"wiz_appearance": "外観",
"wiz_bootstrap": "ブートストラップ",
"wiz_bootstrap_desc": "ブロックチェーンのブートストラップをダウンロードして初回同期を大幅に高速化します。\\n\\n既存の wallet.dat は変更も置き換えもされません。",
"wiz_bootstrap_failed": "ブートストラップに失敗しました",
"wiz_confirm": "確認:",
"wiz_continue": "続行",
"wiz_daemon_sending_stop": "停止コマンドを送信中...",
"wiz_daemon_stop_failed": "デーモンが停止しませんでした — 手動でお試しください。",
"wiz_daemon_stopped_ok": "デーモンを停止しました。",
"wiz_daemon_waiting_stop": "デーモンの終了を待機中...",
"wiz_download_failed": "ダウンロードに失敗しました",
"wiz_encrypt_bg": "暗号化はバックグラウンドで完了します",
"wiz_encrypt_continue": "暗号化して続行",
"wiz_encrypt_desc": "ウォレットを暗号化して、パスフレーズで秘密鍵を保護します。",
"wiz_encrypt_warning": "パスフレーズを紛失すると、資金にアクセスできなくなります。",
"wiz_encryption": "暗号化",
"wiz_ext_daemon_running": "外部デーモンが実行中です",
"wiz_ext_daemon_warning": "ブートストラップをダウンロードする前に停止する必要があります。停止しないとチェーンデータが破損する可能性があります。",
"wiz_level": "レベル:",
"wiz_lowspec_desc": "重い視覚効果をすべて無効にする",
"wiz_off": "オフ",
"wiz_pass_mismatch": "パスフレーズが一致しません",
"wiz_pass_spaces": "パスフレーズの先頭または末尾に空白があります — 削除してください",
"wiz_pass_too_short": "パスフレーズは 8 文字以上にしてください(%zu/8",
"wiz_passphrase": "パスフレーズ:",
"wiz_pin_confirm": "PIN の確認:",
"wiz_pin_invalid": "PIN は 4〜8 桁にしてください",
"wiz_pin_label": "PIN4〜8 桁):",
"wiz_pin_mismatch": "PIN が一致しません",
"wiz_pin_optional": "クイックロック解除 PIN任意",
"wiz_pin_title": "クイックロック解除 PIN",
"wiz_scanline_desc": "コンソールの CRT 走査線効果",
"wiz_skip": "スキップ",
"wiz_skip_confirm": "暗号化せずに続行しますか? 鍵は暗号化されずに保存されます — もう一度スキップをクリックして確認してください。",
"wiz_step1": "ステップ 1",
"wiz_step2": "ステップ 2",
"wiz_step3": "ステップ 3",
"wiz_stop_daemon": "デーモンを停止",
"wiz_strength": "強度:%s",
"wiz_strength_fair": "まずまず",
"wiz_strength_good": "良い",
"wiz_strength_strong": "強い",
"wiz_strength_weak": "弱い",
"wiz_theme_builtin": "内蔵",
"wiz_theme_custom": "カスタム",
"wiz_theme_effects": "テーマの視覚効果",
"wiz_theme_effects_desc": "枠のアニメーション、カラーウォッシュ",
"wiz_theme_invalid": " (無効)",
"wiz_ui_opacity_desc": "カードとサイドバーの透明度1.0 = 不透明)",
"wiz_welcome_sub": "いくつか選ぶだけでフルノードの準備が整います。",
"wiz_welcome_title": "ObsidianDragon へようこそ!",
"wizard_daemon_start_failed": "デーモンの起動に失敗しました — 自動的に再試行されます",
"xmrig_browse_releases": "すべてのリリースを表示…",
"xmrig_checking": "最新のマイナーを確認中…",
@@ -1382,6 +1817,7 @@
"xmrig_loading_releases": "リリースを読み込み中…",
"xmrig_none": "なし",
"xmrig_reinstall": "再インストール",
"xmrig_releases": "xmrig リリース",
"xmrig_stop_mining_first": "マイナーを更新する前にマイニングを停止してください。",
"xmrig_unavailable_body": "このプラットフォーム向けのマイナービルドは利用できません。",
"xmrig_unavailable_title": "マイナーの更新は利用できません",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,7 @@
"address_book_added": "地址已添加到通讯录",
"address_book_confirm_delete": "确认删除?",
"address_book_count": "已保存 %zu 个地址",
"address_book_count_one": "已保存 %zu 个地址",
"address_book_deleted": "条目已删除",
"address_book_edit": "编辑地址",
"address_book_empty": "没有保存的地址。点击'添加新地址'创建一个。",
@@ -47,21 +48,41 @@
"advanced": "高级",
"advanced_effects": "高级特效...",
"ago": "前",
"alerts_clear": "清除通知历史",
"alerts_history_tooltip": "最近通知",
"alerts_none": "暂无通知",
"alerts_recent": "最近通知",
"all_filter": "全部",
"allow_custom_fees": "允许自定义手续费",
"amount": "金额",
"amount_details": "金额详情",
"amount_exceeds_balance": "金额超过余额",
"amount_label": "金额:",
"animate_avatars": "动画头像",
"appearance": "外观",
"auto_shield": "自动屏蔽挖矿",
"av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:",
"av_open_security": "打开 Windows 安全中心",
"av_step1": "第 1 步:添加排除项",
"av_step1_b1": "打开 Windows 安全中心 > 病毒和威胁防护",
"av_step1_b2": "单击 管理设置 > 排除项 > 添加或删除",
"av_step1_b3": "添加文件夹:%APPDATA%\\ObsidianDragon\\",
"av_step2": "第 2 步:从隔离区还原(如果需要)",
"av_step2_b1": "Windows 安全中心 > 保护历史记录",
"av_step2_b2": "找到 xmrig.exe 并单击“还原”",
"av_step3": "第 3 步:重启钱包并重试",
"av_title": "Windows Defender 已阻止矿工程序",
"available": "可用",
"backup_backing_up": "正在备份...",
"backup_col_backup": "备份",
"backup_col_export": "导出",
"backup_col_import": "导入与恢复",
"backup_create": "创建备份",
"backup_created": "钱包备份已创建",
"backup_data": "备份与数据",
"backup_description": "创建 wallet.dat 文件的备份。此文件包含您所有的私钥和交易历史。请将备份存放在安全的地方。",
"backup_destination": "备份目标:",
"backup_overwrite_confirm": "该位置已存在文件,再次保存将覆盖它。",
"backup_source": "来源:%s",
"backup_tip_external": "将备份存储在外部驱动器或云存储中",
"backup_tip_multiple": "在不同位置创建多个备份",
@@ -70,10 +91,14 @@
"backup_title": "备份钱包",
"backup_wallet": "备份钱包...",
"backup_wallet_not_found": "警告:在预期位置未找到 wallet.dat",
"backup_warn": "此文件包含您的所有私钥,请妥善保管。",
"balance": "余额",
"balance_history_collecting": "余额历史——正在收集数据…",
"balance_layout": "余额布局",
"balance_layout_switched": "布局:%s",
"balance_mining_rate": "挖矿中 %s",
"balance_shielded_fmt": "屏蔽:%.8f",
"balance_syncing_pct": "同步中 %.1f%%",
"balance_transparent_fmt": "透明:%.8f",
"ban": "封禁",
"banned_peers": "已封禁节点",
@@ -113,29 +138,107 @@
"bootstrap_verifying": "正在验证校验和...",
"bootstrap_wallet_protected": "(wallet.dat 已受保护)",
"bootstrap_warning": "现有区块数据blocks、chainstate、notarizations将被删除并替换。您的 wallet.dat 不会被修改或删除。",
"byte_count_fmt": "%zu / %zu 字节",
"cancel": "取消",
"change_pass_confirm": "确认新密码:",
"change_pass_current": "当前密码短语:",
"change_pass_new": "新密码短语:",
"change_pass_title": "更改密码短语",
"characters": "字符",
"chat": "聊天",
"chat_accent_amber": "琥珀色",
"chat_accent_blue": "蓝色",
"chat_accent_green": "绿色",
"chat_accent_pink": "粉色",
"chat_accent_purple": "紫色",
"chat_accent_theme": "主题",
"chat_add_contact": "添加联系人",
"chat_awaiting_key": "等待回复",
"chat_bubble_minimal": "极简",
"chat_bubble_rounded": "圆角",
"chat_bubble_square": "方形",
"chat_buffer_loading": "聊天缓冲:…",
"chat_buffer_preparing": "聊天缓冲:正在准备 %d/%d…",
"chat_buffer_ready": "聊天缓冲:%d/%d 已就绪",
"chat_buffer_sending": "聊天:正在发送 %d 条消息…",
"chat_buffer_sending_one": "聊天:正在发送 %d 条消息…",
"chat_cancel": "取消",
"chat_contact_added": "已添加联系人——可在联系人中重命名",
"chat_contact_request": "联系人请求",
"chat_copy_address_tip": "点击复制地址",
"chat_density_comfortable": "宽松",
"chat_density_compact": "紧凑",
"chat_emoji_color": "彩色",
"chat_emoji_mono": "单色",
"chat_emoji_search": "搜索表情",
"chat_empty_hint": "暂无对话。您收到的消息将显示在此处。",
"chat_empty_start": "点击\"新建会话\"开始。",
"chat_empty_title": "还没有会话",
"chat_export": "导出聊天…",
"chat_export_done": "会话已导出",
"chat_export_failed": "无法写入导出文件。",
"chat_export_warn": "将解密后的消息保存为纯文本。请妥善保管该文件。",
"chat_filter": "聊天",
"chat_hidden_toast": "会话已隐藏——收到新消息后会重新显示",
"chat_hide": "隐藏",
"chat_hide_hidden": "收起已隐藏",
"chat_jump_latest": "最新",
"chat_len_over": "消息过长",
"chat_locked_hint": "解锁钱包以加载您的聊天记录。",
"chat_new_button": "新建对话",
"chat_mute": "静音",
"chat_new_button": "新聊天",
"chat_new_message": "消息",
"chat_new_message_toast": "新的加密聊天消息",
"chat_new_send": "发送请求",
"chat_new_title": "新建对话",
"chat_new_title": "新聊天",
"chat_new_zaddr": "收款方 z 地址",
"chat_no_matches": "没有与搜索匹配的会话。",
"chat_no_z_contacts": "暂无使用隐私地址的联系人",
"chat_opt_bubble_accent": "气泡颜色",
"chat_opt_bubble_style": "气泡样式",
"chat_opt_density": "消息密度",
"chat_opt_emoji": "表情样式",
"chat_opt_enter_sends": "回车发送消息",
"chat_opt_font_size": "文字大小",
"chat_opt_global_clock": "全局时间格式",
"chat_opt_poll": "轮询频率",
"chat_opt_timestamp": "时间戳",
"chat_pick_contact": "从联系人中选择…",
"chat_rename": "重命名联系人",
"chat_rename_hint": "联系人名称",
"chat_renamed": "联系人已重命名",
"chat_retry": "重试",
"chat_search": "搜索会话",
"chat_sec_appearance": "外观",
"chat_sec_messaging": "消息",
"chat_select_hint": "选择一个对话以查看。",
"chat_send": "发送",
"chat_send_failed": "未发送",
"chat_sending": "发送中…",
"chat_settings_done": "完成",
"chat_settings_section": "聊天与联系人",
"chat_settings_tip": "聊天自定义",
"chat_settings_title": "聊天设置",
"chat_show_hidden": "显示已隐藏",
"chat_time_now": "刚刚",
"chat_toast_compose_failed": "无法编写该消息(内容过长?)。",
"chat_toast_lite_busy": "已有发送正在进行中,或未打开任何钱包。",
"chat_toast_need_funds": "发送聊天需要少量屏蔽余额(用于支付手续费)。",
"chat_toast_no_zaddr": "没有可用于发送聊天的 z 地址。",
"chat_toast_not_connected": "未连接——聊天消息未发送。",
"chat_toast_request_compose_failed": "无法编写联系人请求(地址或文本无效?)。",
"chat_toast_request_queued": "联系人请求已排队。",
"chat_toast_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
"chat_today": "今天",
"chat_ts_12h": "12小时",
"chat_ts_24h": "24小时",
"chat_ts_global": "跟随全局",
"chat_ts_global_short": "全局",
"chat_unhide": "取消隐藏",
"chat_unmute": "取消静音",
"chat_verify_key": "身份密钥 — 对比以验证",
"chat_waiting_reply": "等待该联系人回复——对方回复后您即可向其发送消息。",
"chat_yesterday": "昨天",
"chat_you": "我",
"choose_icon": "选择图标",
"clear": "清除",
@@ -147,6 +250,7 @@
"click_copy_address": "点击复制地址",
"click_copy_uri": "点击复制 URI",
"click_to_copy": "点击复制",
"clock_format": "时间格式",
"close": "关闭",
"conf_count": "%d 确认",
"confirm_and_send": "确认并发送",
@@ -180,10 +284,24 @@
"connected_peers": "已连接节点",
"connecting": "连接中...",
"console": "控制台",
"console_accents": "颜色强调",
"console_app": "应用",
"console_auto_scroll": "自动滚动",
"console_available_commands": "可用命令:",
"console_backend_reference": "后端命令参考",
"console_backend_unavailable": "无后端",
"console_capturing_output": "正在捕获守护进程输出...",
"console_cat_advanced": "高级",
"console_cat_blockchain": "区块链",
"console_cat_control": "控制",
"console_cat_keys": "密钥与安全",
"console_cat_mining": "挖矿",
"console_cat_network": "网络",
"console_cat_raw_transactions": "原始交易",
"console_cat_send": "发送",
"console_cat_sync": "同步",
"console_cat_utility": "实用工具",
"console_cat_wallet": "钱包",
"console_clear": "清除",
"console_clear_console": "清除控制台",
"console_cleared": "控制台已清除",
@@ -215,18 +333,37 @@
"console_help_help": " help - 显示此帮助信息",
"console_help_setgenerate": " setgenerate - 控制挖矿",
"console_help_stop": " stop - 停止守护进程",
"console_last_error": "上次错误:",
"console_line_count": "%zu 行",
"console_matches": "个匹配",
"console_new_lines": "%d 新行",
"console_no_daemon": "无守护进程",
"console_no_output": "(无输出)",
"console_not_connected": "错误:未连接到守护进程",
"console_not_connected_lite": "错误:没有打开的钱包",
"console_quit_note": "这里不需要 'quit'/'exit'——直接关闭窗口即可。",
"console_ref_builds": "生成",
"console_ref_cancel": "取消",
"console_ref_destructive": "谨慎",
"console_ref_example": "示例",
"console_ref_insert": "插入到控制台",
"console_ref_insert_run": "插入并运行",
"console_ref_no_match": "没有匹配的命令。",
"console_ref_no_params": "无需参数。",
"console_ref_optional": "可选",
"console_ref_parameters": "参数",
"console_ref_run": "运行",
"console_ref_run_confirm": "立即运行 %s这是一个有重大影响的命令。",
"console_ref_search_hint": "按名称或用途搜索…",
"console_ref_select_hint": "选择一个命令以查看其功能。",
"console_ref_value": "值",
"console_rpc_reference": "RPC 命令参考",
"console_rpc_trace": "RPC",
"console_scanline": "控制台扫描线",
"console_search_commands": "搜索命令...",
"console_select_all": "全选",
"console_show_app_output": "显示[应用]钱包日志行",
"console_show_backend_ref": "显示后端命令参考",
"console_show_daemon_output": "显示守护进程输出",
"console_show_errors_only": "仅显示错误",
"console_show_rpc_ref": "显示 RPC 命令参考",
@@ -239,17 +376,43 @@
"console_status_stopped": "已停止",
"console_status_stopping": "停止中",
"console_status_unknown": "未知",
"console_stop_confirm_node": "'stop' 将关闭节点并断开钱包连接。再次输入 'stop' 以确认。",
"console_tab_completion": "Tab 补全",
"console_text_colors": "文本颜色",
"console_toggle_accents": "切换行颜色强调",
"console_toggle_text_color": "切换行文本颜色",
"console_type_help": "输入 'help' 查看可用命令",
"console_welcome": "欢迎使用 ObsidianDragon 控制台",
"console_zoom_in": "放大",
"console_zoom_out": "缩小",
"contact_avatar": "头像",
"contact_avatar_bad_image": "无法加载该图片。",
"contact_avatar_badge": "标记",
"contact_avatar_badge_hint": "标记会根据地址类型自动选择。",
"contact_avatar_choose": "选择图片…",
"contact_avatar_copy_failed": "无法复制该图片。",
"contact_avatar_icon": "图标",
"contact_avatar_image": "图片",
"contact_avatar_image_hint": "图片会复制到应用中,即使原文件移动也能保持可用。",
"contact_avatar_remove": "移除",
"contact_avatar_shielded": "隐蔽",
"contact_avatar_transparent": "透明",
"contact_global": "在每个钱包中显示(全局联系人)",
"contact_global_badge_tt": "全局联系人——在每个钱包中可见",
"contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
"contact_preview_addr": "地址将显示在此处",
"contact_preview_name": "联系人名称",
"contact_wallet_loading": "钱包仍在加载——请选中“在每个钱包中显示”,或稍后再试。",
"contacts": "联系人",
"contacts_avatar_shape": "头像形状",
"contacts_list_scale": "列表缩放",
"contacts_search_no_match": "没有匹配的联系人",
"contacts_search_placeholder": "搜索联系人...",
"contacts_settings_tip": "联系人自定义",
"contacts_settings_title": "联系人设置",
"contacts_shape_circle": "圆形",
"contacts_shape_square": "方形",
"contacts_shape_tab": "左标签",
"copied": "已复制!",
"copy": "复制",
"copy_address": "复制完整地址",
@@ -263,6 +426,7 @@
"daemon_bundled": "内置",
"daemon_install_bundled": "安装内置版本",
"daemon_installed": "已安装",
"daemon_maintenance_label": "维护",
"daemon_none_bundled": "此版本未内置",
"daemon_not_installed": "未安装",
"daemon_status_differ": "已安装的程序文件与内置版本不同。",
@@ -285,6 +449,7 @@
"daemon_update_latest": "最新:",
"daemon_update_loading": "正在加载版本…",
"daemon_update_now": "立即更新",
"daemon_update_prompt_title": "是否更新节点守护进程?",
"daemon_update_reinstall": "重新安装",
"daemon_update_restart_note": "重启守护进程以开始运行新版本。",
"daemon_update_restart_now": "立即重启守护进程",
@@ -297,11 +462,27 @@
"daemon_update_verify_note": "在安装前,会根据该版本发布的 SHA-256 和固定的 ed25519 签名对下载内容进行校验。",
"daemon_update_verifying": "正在验证…",
"daemon_update_version": "版本:",
"daemon_updates_label": "更新",
"daemon_version": "守护进程",
"dark": "深色",
"data_stale_prefix": "更新于",
"data_stale_tooltip": "余额可能已过时 — 钱包最近未收到更新。请检查您的节点连接。",
"date": "日期",
"date_label": "日期:",
"debug_logging": "调试日志",
"decrypt_desc": "钱包将被导出,守护进程将使用全新的未加密钱包重新启动,并重新导入所有密钥。根据钱包大小,这可能需要几分钟。",
"decrypt_error_title": "解密失败",
"decrypt_step_backup": "正在备份加密钱包",
"decrypt_step_export": "正在导出钱包密钥",
"decrypt_step_restart": "正在重启守护进程",
"decrypt_step_stop": "正在停止守护进程",
"decrypt_step_unlock": "正在解锁钱包",
"decrypt_success_desc": "您的钱包现在未加密。加密钱包的备份已保存为 wallet.dat.encrypted.bak位于您的数据目录中。",
"decrypt_success_title": "钱包解密成功!",
"decrypt_title": "移除钱包加密",
"decrypt_wait_general": "请稍候。守护进程正在导出密钥、重启并重新导入。这可能需要几分钟。",
"decrypt_wait_restart": "正在等待守护进程完成启动...",
"decrypt_warning": "这将移除您钱包的加密。您的私钥将以未受保护的方式存储在磁盘上。",
"delete": "删除",
"delete_blockchain": "删除区块链",
"delete_blockchain_confirm": "删除并重新同步",
@@ -314,6 +495,26 @@
"download_bootstrap": "下载引导程序",
"dragonx_green": "DragonX绿色",
"edit": "编辑",
"empty_wallet_keys_suffix": "个密钥",
"empty_wallet_open_manager": "打开钱包管理器",
"empty_wallet_restore": "恢复我的钱包",
"empty_wallet_salvage_body": "此钱包为空,因为先前的一次自动修复已将您的原始钱包作为备份保存到一旁。您的币几乎肯定在该备份中,并未丢失。恢复它即可重新加载您的资金——不会删除任何内容;当前文件会先被保存到一旁。",
"empty_wallet_salvage_headline": "您的币安全地存放在备份文件中。",
"empty_wallet_salvage_title": "您的钱包可能已被修复",
"empty_wallet_warning_body": "此钱包没有地址也没有资金,但您的 DragonX 文件夹中的另一个钱包文件包含密钥。您的币很可能在其中,并未丢失。打开钱包管理器以切换到持有您资金的钱包。",
"empty_wallet_warning_dismiss": "不再为此钱包提示",
"empty_wallet_warning_dismiss_tip": "仅对当前钱包文件停止此提示。如果您以后切换到另一个空钱包,可能会再次提示。",
"empty_wallet_warning_headline": "您可能打开了错误的钱包。",
"empty_wallet_warning_title": "此钱包为空",
"enc_confirm": "确认:",
"enc_desc": "加密钱包会用密码短语保护您的私钥。加密后,守护进程将重新启动。",
"enc_encrypting": "正在加密钱包...",
"enc_pin_desc": "4-8 位 PIN 码可让您无需每次输入完整密码短语即可解锁钱包。",
"enc_pin_set_ok": "PIN 码设置成功",
"enc_pin_skipped": "已跳过 PIN。您可以稍后在设置中设置。",
"enc_pin_vault_fail": "无法创建 PIN 保险库",
"enc_success": "钱包加密成功!",
"enc_wait": "请稍候,不要关闭应用程序。",
"error": "错误",
"error_format": "错误:%s",
"est_time_to_block": "预计出块时间",
@@ -333,23 +534,34 @@
"explorer_mempool": "内存池",
"explorer_mempool_size": "大小",
"explorer_mempool_txs": "交易",
"explorer_next_block": "下一个区块",
"explorer_no_results": "没有匹配的缓存区块",
"explorer_not_connected": "未连接到守护进程——无法查询区块或交易哈希",
"explorer_prev_block": "上一个区块",
"explorer_recent_blocks": "最近区块",
"explorer_search": "搜索",
"explorer_section": "浏览器",
"explorer_tx_outputs": "输出",
"explorer_tx_size": "大小",
"explorer_txid": "TxID",
"export": "导出",
"export_csv": "导出 CSV",
"export_keys_btn": "导出密钥",
"export_keys_danger": "危险:这将导出您钱包中的所有私钥!任何获得此文件的人都可以窃取您的资金。请安全保管并在使用后删除。",
"export_keys_include_t": "包含 T 地址(透明)",
"export_keys_include_z": "包含 Z 地址(屏蔽)",
"export_keys_none_addrs": "没有可导出的地址",
"export_keys_none_result": "未导出任何密钥(%d 个中 0 个)——请解锁钱包后重试。",
"export_keys_none_toast": "无法导出密钥——钱包已解锁吗?",
"export_keys_not_connected": "未连接到守护进程",
"export_keys_options": "导出选项:",
"export_keys_partial": "已导出 %d/%d 个密钥——不完整(部分没有花费密钥,或钱包已锁定)。",
"export_keys_partial_toast": "部分导出:%d/%d 个密钥",
"export_keys_progress": "正在导出 %d/%d...",
"export_keys_select_type": "请至少选择一种地址类型",
"export_keys_success": "密钥导出成功",
"export_keys_title": "导出所有私钥",
"export_keys_write_fail": "写入密钥文件失败。",
"export_private_key": "导出私钥",
"export_tx_count": "导出 %zu 笔交易到 CSV 文件。",
"export_tx_file_fail": "无法创建 CSV 文件",
@@ -389,23 +601,50 @@
"hide_qr": "隐藏二维码",
"hide_zero_balances": "隐藏零余额",
"history": "历史",
"img_picker_count": "此文件夹中的图片:%d",
"img_picker_empty": "此文件夹没有子文件夹或图片。",
"img_picker_none": "此文件夹中没有图片",
"img_picker_pictures": "图片文件夹",
"img_picker_title": "选择图片",
"img_picker_use": "使用图片",
"immature_type": "未成熟",
"import": "导入",
"import_key_address": "地址:",
"import_key_btn": "导入密钥",
"import_key_done": "已导入。钱包正在重新扫描。",
"import_key_field": "密钥",
"import_key_formats": "支持的密钥格式:",
"import_key_full_rescan": "0 = 完整重扫)",
"import_key_import": "导入",
"import_key_label": "私钥:",
"import_key_need_node": "请连接一个运行中的节点以导入密钥。",
"import_key_no_valid": "输入中未找到有效密钥",
"import_key_progress": "正在导入 %d/%d...",
"import_key_rescan": "导入后重新扫描区块链",
"import_key_rescanning": "正在导入并重新扫描——可能需要几分钟",
"import_key_reveal_tip": "显示/隐藏密钥",
"import_key_start_height": "起始高度:",
"import_key_success": "密钥导入成功",
"import_key_t_format": "T 地址 WIF 私钥",
"import_key_title": "导入私钥",
"import_key_tooltip": "输入一个或多个私钥,每行一个。\n支持 z 地址和 t 地址密钥。\n以 # 开头的行视为注释。",
"import_key_type_tkey": "透明私钥",
"import_key_type_unknown": "无法识别的密钥格式",
"import_key_type_zspend": "屏蔽花费密钥",
"import_key_type_zview": "屏蔽查看密钥(仅查看)",
"import_key_warn": "只导入你自己拥有的密钥——它可访问该地址的资金。",
"import_key_warning": "警告:切勿分享您的私钥!从不可信来源导入密钥可能会危及您的钱包安全。",
"import_key_wrong_type": "这看起来像查看密钥。请改用\"导入查看密钥\"。",
"import_key_z_format": "Z 地址花费密钥 (secret-extended-key-...)",
"import_private_key": "导入私钥...",
"import_scan_hint": "0 = 从头重新扫描",
"import_scan_label": "从区块高度开始扫描(可选)",
"import_scan_tip": "当前高度",
"import_scan_transparent": "透明密钥始终完整重新扫描",
"import_viewkey_field": "查看密钥",
"import_viewkey_note": "仅查看:查看密钥可显示地址的余额和交易,但无法花费其资金。",
"import_viewkey_title": "导入查看密钥",
"import_viewkey_wrong_type": "这看起来像花费密钥。请改用\"导入私钥\"。",
"incorrect_passphrase": "密码错误",
"incorrect_pin": "PIN 错误",
"insufficient_funds": "余额不足以支付此金额加手续费。",
@@ -414,6 +653,7 @@
"keep": "保留",
"keep_daemon": "保持守护进程运行",
"key_export_click_retrieve": "点击从钱包中获取密钥",
"key_export_failed": "无法导出密钥——请解锁钱包(如已加密)后重试。",
"key_export_fetching": "正在从钱包获取密钥...",
"key_export_private_key": "私钥:",
"key_export_private_warning": "请保密此密钥!任何拥有此密钥的人都可以花费您的资金。切勿在网上或与不可信的人分享。",
@@ -427,15 +667,20 @@
"light": "浅色",
"lite_account_label": "账户",
"lite_action": "操作",
"lite_backend_unavailable": "轻量钱包后端不可用",
"lite_backup_keys": "备份与密钥",
"lite_birthday_backup": "生日区块:%llu (也请一并备份)",
"lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0完整扫描更慢。",
"lite_birthday_label": "诞生区块",
"lite_console_backend_commands": "后端命令:",
"lite_console_help_passthrough": "其他任何输入都将作为轻钱包控制台命令运行。",
"lite_copy": "复制",
"lite_could_not_start": "无法启动该操作",
"lite_could_not_write": "无法写入 ",
"lite_encrypt_wallet": "加密钱包",
"lite_encryption_removed": "已移除加密",
"lite_enter_all_seed_words": "请输入全部 24 个助记词以恢复(已输入 %d 个)",
"lite_enter_wallet_path": "请输入钱包路径",
"lite_hide_wipe": "隐藏并清除",
"lite_import": "导入",
"lite_import_key_label": "导入密钥",
@@ -448,6 +693,7 @@
"lite_net_add_url_hint": "https://your-lite-server",
"lite_net_checking": "检查中…",
"lite_net_connected": "已连接",
"lite_net_connecting": "连接中…",
"lite_net_custom": "自定义",
"lite_net_disconnected": "未连接",
"lite_net_hidden_section": "隐藏的服务器",
@@ -519,6 +765,8 @@
"lite_working": "处理中…",
"loading": "加载中...",
"loading_addresses": "正在加载地址...",
"loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。",
"loading_stall_title": "耗时超出预期",
"loading_transactions": "正在加载交易",
"local_hashrate": "本地算力",
"low_spec_mode": "低配模式",
@@ -534,6 +782,10 @@
"market_btc_price": "BTC 价格",
"market_cap": "市值",
"market_cap_short": "市值",
"market_chart_loading": "正在加载价格历史",
"market_col_name": "名称",
"market_col_trend": "趋势",
"market_col_value": "价值",
"market_iv_1d": "1天",
"market_iv_1h": "1时",
"market_iv_1m": "1M",
@@ -542,11 +794,18 @@
"market_no_history": "无价格历史",
"market_no_price": "无价格数据",
"market_now": "现在",
"market_opt_chart_style": "图表样式",
"market_pct_shielded": "%.0f%% 屏蔽",
"market_portfolio": "投资组合",
"market_price_loading": "正在加载价格数据...",
"market_price_unavailable": "价格数据不可用",
"market_refresh_price": "刷新价格数据",
"market_settings_tip": "市场选项",
"market_settings_title": "市场设置",
"market_style_candle": "切换到蜡烛图",
"market_style_candle_label": "K线",
"market_style_line": "切换到折线图",
"market_style_line_label": "折线",
"market_trade_on": "在 %s 交易",
"market_updated": "\\xc2\\xb7 已更新 %s",
"market_vol_short": "成交量",
@@ -640,6 +899,7 @@
"mining_difficulty_copied": "难度已复制",
"mining_est_block": "预计区块",
"mining_est_daily": "预计日收益",
"mining_est_daily_pool_sub": "粗略的单人挖矿等值,扣除矿池费用前",
"mining_filter_all": "全部",
"mining_filter_tip_all": "显示所有收益",
"mining_filter_tip_pool": "仅显示矿池收益",
@@ -668,10 +928,12 @@
"mining_open_in_explorer": "在浏览器中打开",
"mining_payout_address": "支付地址",
"mining_payout_foreign": "⚠ 此支付地址不在您当前的钱包中——挖矿奖励将进入另一个钱包。如果您切换过钱包,请更新它。",
"mining_payout_invalid": "不是有效的 DragonX 地址——启动前请更正,否则挖矿奖励将丢失。",
"mining_payout_tooltip": "接收挖矿奖励的地址",
"mining_pool": "矿池",
"mining_pool_fee": "费用",
"mining_pool_hashrate": "矿池算力",
"mining_pool_needs_payout_tooltip": "请先输入收款地址(生成一个 Z 地址)",
"mining_pool_url": "矿池 URL",
"mining_pools_header": "矿池",
"mining_recent_blocks": "最近区块",
@@ -701,6 +963,9 @@
"mining_syncing_tooltip": "区块链同步中...",
"mining_tag": " · 挖矿",
"mining_threads": "挖矿线程",
"mining_threads_input_tooltip": "输入精确的线程数(按 Enter 应用)",
"mining_threads_minus_tooltip": "减少线程",
"mining_threads_plus_tooltip": "增加线程",
"mining_to_save": "保存",
"mining_today": "今天",
"mining_uptime": "运行时间",
@@ -727,6 +992,11 @@
"no_transactions": "未找到交易",
"no_transactions_yet": "尚无交易",
"node": "节点",
"node_banner_crashed_title": "节点意外停止",
"node_banner_lite_open_failed": "无法打开您的钱包",
"node_banner_offline_title": "未连接到 DragonX 节点",
"node_banner_reconnect": "重新连接",
"node_banner_restart": "重启节点",
"node_security": "节点与安全",
"noise": "噪点",
"not_connected": "未连接到守护进程...",
@@ -736,6 +1006,7 @@
"output_filename": "输出文件名:",
"overview": "概览",
"paste": "粘贴",
"paste_clip_empty": "剪贴板为空",
"paste_from_clipboard": "从剪贴板粘贴",
"pay_from": "付款来源",
"payment_request": "付款请求",
@@ -787,7 +1058,14 @@
"peers_upper": "节点",
"peers_version": "版本",
"pending": "待处理",
"pin_change_desc": "更改您的解锁 PIN 码。您需要当前 PIN 码和新 PIN 码。",
"pin_confirm_new_label": "确认新 PIN 码:",
"pin_current_label": "当前 PIN 码:",
"pin_new_label": "新 PIN 码4-8 位):",
"pin_not_set": "未设置 PIN。使用密码解锁。",
"pin_remove_desc": "输入当前 PIN 码以确认移除。解锁时您需要使用完整的密码短语。",
"pin_setup_desc": "设置一个 4-8 位 PIN 码以快速解锁钱包。您的钱包密码短语将使用此 PIN 码加密并存储在本地。",
"pin_wallet_passphrase": "钱包密码短语:",
"ping": "延迟",
"portfolio_add_entry": "添加条目",
"portfolio_add_to": "添加到投资组合",
@@ -826,6 +1104,9 @@
"portfolio_remove_from": "从投资组合中移除",
"portfolio_revert": "还原",
"portfolio_save": "保存",
"portfolio_save_need_address": "添加至少一个地址以保存。",
"portfolio_save_need_name": "输入名称以保存此分组。",
"portfolio_save_need_price": "输入大于 0 的手动价格以保存。",
"portfolio_search": "搜索地址…",
"portfolio_search_icons": "搜索图标…",
"portfolio_select_all": "全部",
@@ -839,11 +1120,12 @@
"portfolio_spark_min": "分钟",
"portfolio_spark_month": "月",
"portfolio_spark_week": "周",
"portfolio_style_compact": "紧凑行",
"portfolio_style_detailed": "详细行",
"portfolio_style_featured": "特色行",
"portfolio_style_compact": "表格",
"portfolio_style_detailed": "卡片",
"portfolio_style_featured": "聚焦",
"portfolio_style_label": "投资组合样式",
"portfolio_untitled": "未命名",
"portfolio_wallet_loading": "请等待钱包加载完成后再添加分组。",
"price_chart": "价格图表",
"privacy_great": "隐私性极佳!",
"privacy_low": "隐私性低——请屏蔽资金",
@@ -853,6 +1135,8 @@
"qr_failed": "无法生成二维码",
"qr_title": "二维码",
"qr_unavailable": "二维码不可用",
"quick_receive": "快速接收",
"quick_send": "快速发送",
"ram_daemon_gb": "守护进程:%.1f GB (%s)",
"ram_daemon_mb": "守护进程:%.0f MB (%s)",
"ram_system_gb": "系统:%.1f / %.0f GB",
@@ -902,6 +1186,7 @@
"rpc_connection": "RPC 连接...",
"rpc_host": "RPC 主机",
"rpc_pass": "密码",
"rpc_plaintext_remote_warning": "远程 RPC 正在使用明文 HTTP。如果您的守护进程支持 TLS请在 DRAGONX.conf 中添加 rpctls=1。",
"rpc_port": "端口",
"rpc_user": "用户名",
"save": "保存",
@@ -916,6 +1201,8 @@
"sb_connecting_external": "正在连接外部守护进程...",
"sb_connecting_generic": "正在连接守护进程...",
"sb_daemon_crashed": "守护进程崩溃 %d 次",
"sb_daemon_extract_failed": "无法写入守护进程文件——请检查磁盘剩余空间和权限。",
"sb_daemon_files_failed": "无法将守护进程文件写入 %s——请检查磁盘剩余空间和权限。",
"sb_daemon_not_found": "未找到守护进程",
"sb_daemon_start_failed": "无法启动 dragonxd",
"sb_dragonxd_running": "dragonxd 运行中",
@@ -944,6 +1231,7 @@
"sb_waiting_daemon_err": "等待 dragonxd — %s",
"sb_warming_up": "正在预热...",
"sb_witness_cache": "正在重建见证",
"scale_effects": "缩放与效果",
"screenshot_open_dir": "打开位置",
"screenshot_sweep": "运行截图批处理",
"screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。",
@@ -973,6 +1261,7 @@
"send_amount": "金额",
"send_amount_details": "金额详情",
"send_amount_upper": "金额",
"send_cannot_send_now": "现在无法发送 — 请检查连接、同步和可用余额。",
"send_clear_fields": "清除所有表单字段?",
"send_contacts_button": "从联系人中选择",
"send_copy_error": "复制错误",
@@ -1007,6 +1296,7 @@
"send_tooltip_not_connected": "未连接到守护进程",
"send_tooltip_select_source": "请先选择来源地址",
"send_tooltip_syncing": "请等待区块链同步",
"send_tooltip_view_only": "仅查看地址 — 无花费密钥,无法发送",
"send_total": "合计",
"send_transaction": "发送交易",
"send_tx_failed": "交易失败",
@@ -1026,16 +1316,16 @@
"sent_filter": "已发送",
"sent_type": "已发送",
"sent_upper": "已发送",
"set_label": "设置标签...",
"set_label": "设置标签",
"settings": "设置",
"settings_about_text": "DragonX (DRGX) 屏蔽加密货币钱包,使用 Dear ImGui 构建,提供轻量、便携的体验。",
"settings_acrylic_level": "亚克力级别:",
"settings_address_book": "地址簿...",
"settings_address_book": "地址簿",
"settings_auto_detected": "从 DRAGONX.conf 自动检测",
"settings_auto_lock": "自动锁定",
"settings_auto_shield_desc": "自动将透明资金转移到屏蔽地址",
"settings_auto_shield_funds": "自动屏蔽透明资金",
"settings_backup": "备份...",
"settings_backup": "备份",
"settings_block_explorer_urls": "区块浏览器网址",
"settings_builtin": "内置",
"settings_change_passphrase": "更改密码",
@@ -1046,52 +1336,62 @@
"settings_configure_explorer": "配置外部区块浏览器链接",
"settings_configure_rpc": "配置 dragonxd 守护进程连接",
"settings_connection": "连接",
"settings_copy_diagnostics": "复制诊断信息",
"settings_copyright": "版权所有 2024-2026 DragonX 开发者 | GPLv3 许可证",
"settings_custom": "自定义",
"settings_data_dir": "数据目录:",
"settings_debug_changed": "调试类别已更改——重启守护进程以应用",
"settings_debug_restart_note": "更改将在重启守护进程后生效。",
"settings_debug_select": "选择要启用的守护进程调试日志类别(-debug= 标志)。",
"settings_diagnostics_copied": "诊断信息已复制到剪贴板",
"settings_encrypt_first_pin": "请先加密钱包以启用 PIN",
"settings_encrypt_wallet": "加密钱包",
"settings_explorer_hint": "URL 应包含尾部斜杠。将自动附加 txid/地址。",
"settings_export_all": "全部导出...",
"settings_export_csv": "导出 CSV...",
"settings_export_key": "导出密钥...",
"settings_export_all": "全部导出",
"settings_export_csv": "导出 CSV",
"settings_export_key": "导出密钥",
"settings_gradient_bg": "渐变背景",
"settings_gradient_desc": "用平滑渐变替换纹理背景",
"settings_idle_after": "之后",
"settings_import_key": "导入密钥...",
"settings_import_key": "导入私钥…",
"settings_import_viewkey": "导入查看密钥…",
"settings_language_note": "注意:部分文本需要重启才能更新",
"settings_lock_now": "立即锁定",
"settings_locked": "已锁定",
"settings_merge_to_address": "合并到地址...",
"settings_merge_to_address": "合并到地址",
"settings_noise_opacity": "噪点不透明度:",
"settings_not_connected": "未连接到守护进程",
"settings_not_encrypted": "未加密",
"settings_not_found": "未找到",
"settings_open_app_dir": "打开应用文件夹",
"settings_open_data_dir": "打开数据文件夹",
"settings_open_log_folder": "打开日志文件夹",
"settings_other": "其他",
"settings_pin_active": "PIN",
"settings_privacy": "隐私",
"settings_quick_unlock_pin": "快速解锁 PIN",
"settings_reduce_transparency": "降低透明度",
"settings_reloaded": "已从磁盘重新加载设置",
"settings_remove_encryption": "移除加密",
"settings_remove_pin": "移除 PIN",
"settings_request_payment": "请求付款...",
"settings_request_payment": "请求付款",
"settings_rescan_desc": "重新扫描区块链以查找丢失的交易",
"settings_restart_daemon": "重启守护进程",
"settings_rpc_connection": "RPC 连接",
"settings_rpc_error_prefix": "RPC 错误:",
"settings_rpc_note": "注意:连接设置通常从 DRAGONX.conf 自动检测",
"settings_rpc_ok": "RPC 连接正常",
"settings_save_shielded_desc": "将 z-addr 交易存储在本地文件中以供查看",
"settings_save_shielded_local": "将屏蔽交易历史保存到本地",
"settings_saved": "设置已保存",
"settings_set_pin": "设置 PIN",
"settings_shield_mining": "屏蔽挖矿...",
"settings_shield_mining": "屏蔽挖矿",
"settings_solid_colors_desc": "使用纯色代替模糊效果(无障碍功能)",
"settings_theme_refreshed": "主题列表已刷新",
"settings_tor_desc": "通过 Tor 路由所有连接以增强隐私",
"settings_unlocked": "已解锁",
"settings_use_tor_network": "使用 Tor 进行网络连接",
"settings_validate_address": "验证地址...",
"settings_validate_address": "验证地址",
"settings_visual_effects": "视觉效果",
"settings_wallet_file_size": "钱包文件大小:%s",
"settings_wallet_info": "钱包信息",
@@ -1099,6 +1399,8 @@
"settings_wallet_maintenance": "钱包维护",
"settings_wallet_not_found": "未找到钱包文件",
"settings_wallet_size_label": "钱包大小:",
"settings_ztx_cleared": "Z 交易历史记录已清除",
"settings_ztx_not_found": "未找到历史记录文件",
"setup_wizard": "设置向导",
"share": "分享",
"shield_check_status": "检查状态",
@@ -1110,6 +1412,7 @@
"shield_in_progress": "操作进行中...",
"shield_max_utxos": "每次操作最大 UTXO 数",
"shield_merge_done": "屏蔽/合并完成!",
"shield_no_zaddr_hint": "还没有屏蔽z地址 — 请先在“接收”标签页创建一个。",
"shield_op_failed": "操作失败:",
"shield_op_submitted": "操作已提交:",
"shield_operation_id": "操作 ID%s",
@@ -1148,6 +1451,31 @@
"submitting_transaction": "正在提交交易...",
"success": "成功",
"summary": "摘要",
"sweep_button": "归集",
"sweep_caveat": "导入密钥以签署一笔将其所有资金转移到您地址的交易。密钥将保留在钱包中,余额为零。",
"sweep_dest_label": "将归集资金发送到",
"sweep_dest_new": "新的屏蔽地址(推荐)",
"sweep_done": "完成 — 资金已归集到您的地址。",
"sweep_to": "归集到:",
"sweep_toggle": "归集到我的钱包(不保留密钥)",
"sweep_tx": "交易:",
"switch_corrupt_body": "此钱包似乎已损坏——节点无法打开它。请从备份恢复、重新创建,或尝试修复。",
"switch_corrupt_repair": "尝试修复salvage",
"switch_progress_background": "在后台继续",
"switch_progress_default_wallet": "默认钱包",
"switch_progress_elapsed": "已用时",
"switch_progress_external_wallet": "外部钱包",
"switch_progress_failed_title": "切换钱包失败",
"switch_progress_from_label": "来自",
"switch_progress_hint": "正常关闭最多可能需要一分钟。",
"switch_progress_reconnecting": "正在重新连接",
"switch_progress_starting": "正在以新钱包启动节点",
"switch_progress_stopping": "正在停止当前节点",
"switch_progress_title": "正在切换钱包",
"switch_stopnode_body": "切换钱包会以所选钱包重启节点。正在运行的节点将被停止并以新钱包重新启动——如果你是特意让它保持运行的,它会自动重新启动。",
"switch_stopnode_confirm": "停止节点并切换",
"switch_stopnode_title": "停止正在运行的节点?",
"switch_stopnode_warn": "已有一个并非由此钱包启动的节点正在运行。",
"syncing": "同步中...",
"t_address": "T 地址",
"t_addresses": "T 地址",
@@ -1155,6 +1483,7 @@
"theme": "主题",
"theme_effects": "主题效果",
"theme_language": "主题与语言",
"tile_click_to_open": "点击打开",
"time_days_ago": "%d 天前",
"time_hours_ago": "%d 小时前",
"time_minutes_ago": "%d 分钟前",
@@ -1169,7 +1498,9 @@
"to_upper": "至",
"tools": "工具",
"tools_actions": "工具与操作...",
"tools_actions_hdr": "工具与操作",
"total": "合计",
"total_balance_label": "总余额",
"transaction_id": "交易 ID",
"transaction_sent": "交易发送成功",
"transaction_sent_msg": "交易已发送!",
@@ -1183,19 +1514,32 @@
"transfer_to": "转账至:",
"transparent": "透明",
"transparent_address": "透明地址",
"try_again": "重试",
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
"tt_address_book": "管理已保存的地址以快速发送",
"tt_animate_avatars": "播放动画联系人头像GIF / WebP关闭时仅显示第一帧",
"tt_auto_lock": "在此不活动时间后锁定钱包",
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
"tt_backup": "创建 wallet.dat 的备份",
"tt_block_explorer": "在浏览器中打开 DragonX 区块浏览器",
"tt_blur": "模糊程度0%% = 关闭100%% = 最大)",
"tt_blur": "模糊程度0% = 关闭100% = 最大)",
"tt_change_pass": "更改钱包加密密码",
"tt_change_pin": "更改您的解锁 PIN",
"tt_chat_bubble_accent": "你发出的消息气泡的强调色(或跟随当前主题)",
"tt_chat_bubble_style": "消息气泡形状:圆角、方形或极简(扁平、无边框)",
"tt_chat_density": "消息之间的间距:宽松增加更多留白;紧凑在屏幕上容纳更多内容",
"tt_chat_emoji_style": "以单色轮廓或全彩渲染表情符号",
"tt_chat_enter_sends": "开启时Enter 发送消息Shift+Enter 换行关闭时Enter 换行",
"tt_chat_font_size": "将聊天消息文字从 0.8x 缩放到 1.5x。仅影响聊天标签页,不影响应用的其余部分",
"tt_chat_poll_rate": "检查新消息和 0-conf 消息的频率0.5-15 s。越快响应越及时但占用更多 CPU",
"tt_chat_timestamp": "仅此标签页的时间戳格式:跟随全应用时钟,或强制使用 24-hour 或 12-hour",
"tt_clear_ztx": "删除本地缓存的 z-交易历史",
"tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。",
"tt_copy_diagnostics": "将支持诊断摘要(版本、守护进程/钱包/日志状态 — 不含机密)复制到剪贴板",
"tt_custom_fees": "发送交易时启用手动费用输入",
"tt_custom_theme": "自定义主题已激活",
"tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启",
"tt_daemon_refresh": "重新读取上方显示的已安装及内置 dragonxd 版本、大小和日期",
"tt_daemon_update_check": "从项目 Gitea 下载并验证最新的 dragonxd 全节点,然后重启以应用",
"tt_debug_collapse": "折叠调试日志选项",
"tt_debug_expand": "展开调试日志选项",
@@ -1209,18 +1553,43 @@
"tt_font_scale": "缩放所有文本和界面1.0x = 默认,最大 1.5x)。",
"tt_idle_delay": "开始挖矿前等待多长时间",
"tt_import_key": "将私钥zkey 或 tkey导入此钱包",
"tt_import_viewkey": "导入屏蔽查看密钥以查看某个地址(只读)",
"tt_keep_daemon": "运行设置向导时守护进程仍会停止",
"tt_language": "钱包界面语言",
"tt_layout_hotkey": "快捷键:左/右箭头键切换余额布局",
"tt_lite_copy": "将显示的机密复制到剪贴板",
"tt_lite_decrypt_pass": "输入你的密码以移除钱包的加密",
"tt_lite_encrypt": "用上方的密码加密钱包;加密后立即锁定,需要该密码才能解锁",
"tt_lite_encrypt_pass": "用于加密钱包的密码。若丢失,钱包将无法解锁或恢复",
"tt_lite_hide_wipe": "隐藏显示的机密并将其从内存中安全擦除",
"tt_lite_import_key": "粘贴要导入的私有花费或查看密钥;其历史记录会在下次同步后出现",
"tt_lite_import_key_btn": "将输入的私钥导入此钱包;资金和历史记录会在下次同步后出现",
"tt_lite_lifecycle_op": "选择是创建新钱包、打开现有钱包,还是从助记词恢复钱包",
"tt_lite_lifecycle_pass": "在此次创建 / 打开 / 恢复操作中用于解锁或设置钱包的密码",
"tt_lite_lifecycle_run": "使用上方的值执行所选的创建 / 打开 / 恢复操作",
"tt_lite_lifecycle_toggle": "显示或隐藏用于管理轻钱包文件的创建 / 打开 / 恢复控件",
"tt_lite_lock": "立即锁定钱包;解锁需要密码,任何聊天会话都会被中断",
"tt_lite_redownload": "从轻钱包服务器重新下载并重新扫描所有区块",
"tt_lite_remove_encrypt": "移除加密并以未受保护的方式存储钱包;打开它将不再需要密码",
"tt_lite_restore_account": "要恢复的 HD 账户索引;除非你在此助记词下使用了多个账户,否则保持为 0",
"tt_lite_restore_birthday": "钱包创建时的区块高度;扫描从此处开始。不确定时请填 0 或最早的高度",
"tt_lite_restore_overwrite": "用此次恢复替换现有的钱包文件。警告:这会覆盖当前的钱包数据",
"tt_lite_restore_seed": "用于恢复此钱包的 24-word 助记词恢复短语;输入时会隐藏",
"tt_lite_save_seed_file": "将助记词和创建高度写入配置文件夹中一个仅所有者可读的文件lite-seed-backup.txt",
"tt_lite_show_keys": "显示此钱包的私有花费密钥。任何拥有密钥的人都能动用它所控制的资金",
"tt_lite_show_seed": "显示此钱包的助记词恢复短语和创建高度。任何拥有助记词的人都能动用你的资金",
"tt_lite_unlock": "使用上方的密码解锁已加密的钱包",
"tt_lite_unlock_pass": "输入你的密码以解锁已加密的钱包",
"tt_lite_wallet_path": "要打开或恢复到的钱包文件路径或名称",
"tt_lock": "立即锁定钱包",
"tt_low_spec": "禁用所有重度视觉效果\\n快捷键Ctrl+Shift+Down",
"tt_merge": "将多个 UTXO 合并到一个地址",
"tt_mine_idle": "系统空闲时自动开始挖矿\\n无键盘/鼠标输入)",
"tt_noise": "颗粒纹理强度0%% = 关闭100%% = 最大)",
"tt_noise": "颗粒纹理强度0% = 关闭100% = 最大)",
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
"tt_open_dir": "点击在文件管理器中打开",
"tt_open_log_folder": "打开包含调试和崩溃日志的文件夹",
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
"tt_remove_pin": "移除 PIN 并要求密码解锁",
@@ -1233,12 +1602,17 @@
"tt_rpc_host": "DragonX 守护进程主机名",
"tt_rpc_pass": "RPC 认证密码",
"tt_rpc_port": "守护进程 RPC 连接端口",
"tt_rpc_toggle": "显示或隐藏守护进程的只读 RPC 连接详情(主机、端口、用户、密码)",
"tt_rpc_user": "RPC 认证用户名",
"tt_save_settings": "将所有设置保存到磁盘",
"tt_save_ztx": "将 z-address 交易历史存储在本地以加快加载速度",
"tt_scan_themes": "扫描新主题。\\n将主题文件夹放在\\n%s",
"tt_scanline": "控制台中的 CRT 扫描线效果",
"tt_screenshot_open_dir": "在你的文件管理器中打开截图文件夹(位于配置目录下)",
"tt_screenshot_sweep": "在每个标签页遍历每种主题,将每种主题的截图保存到配置文件夹的 screenshots 目录(覆盖上一次遍历)",
"tt_screenshot_sweep_full": "与主题遍历类似,但同时捕获每个使用临时离线演示钱包数据的模态框 / 对话框 / 流程",
"tt_seed_backup": "显示并备份您钱包的 24 词恢复助记词",
"tt_seed_demo_chat": "向聊天标签页注入示例对话,以便遍历能捕获其界面;仅在内存中,重启后消失",
"tt_seed_migrate": "创建一个新的助记词钱包并将您的资金转入其中",
"tt_set_pin": "设置 4-8 位 PIN 以快速解锁",
"tt_shield_mining": "将透明挖矿奖励转移到屏蔽地址",
@@ -1250,13 +1624,14 @@
"tt_theme_hotkey": "快捷键Ctrl+左/右箭头切换主题",
"tt_tor": "通过 Tor 网络路由守护进程连接以实现匿名",
"tt_tx_url": "在区块浏览器中查看交易的基础 URL",
"tt_ui_opacity": "卡片和侧边栏不透明度100%% = 完全不透明,越低越透明)",
"tt_ui_opacity": "卡片和侧边栏不透明度100% = 完全不透明,越低越透明)",
"tt_validate": "检查 DragonX 地址是否有效",
"tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡",
"tt_wallets_button": "列出您的钱包文件并在它们之间切换",
"tt_website": "打开 DragonX 网站",
"tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)",
"tt_wizard": "重新运行初始设置向导\\n守护进程将被重启",
"tx_chat_badge": "消息",
"tx_confirmations": "%d 次确认",
"tx_details_title": "交易详情",
"tx_from_address": "发送地址:",
@@ -1302,6 +1677,7 @@
"validate_not_mine": "不属于此钱包",
"validate_ownership": "所有权:",
"validate_results": "结果:",
"validate_results_placeholder": "结果将显示在此处",
"validate_shielded_type": "屏蔽z 地址)",
"validate_status": "状态:",
"validate_title": "验证地址",
@@ -1325,6 +1701,8 @@
"wallets_add_folder_toggle": "+ 扫描其他文件夹中的钱包…",
"wallets_badge_encrypted": "已加密(密码保护)",
"wallets_badge_encrypted_short": "已加密",
"wallets_badge_hd": "HD 钱包 — 打开钱包才能确认助记词",
"wallets_badge_hd_short": "HD 钱包",
"wallets_badge_legacy": "旧版钱包(无助记词)",
"wallets_badge_legacy_short": "旧版",
"wallets_badge_seed": "助记词钱包 (HD)",
@@ -1343,6 +1721,7 @@
"wallets_created": "创建于",
"wallets_creating": "正在创建钱包——节点将重启…",
"wallets_current": "当前",
"wallets_empty_hint": "扫描文件夹以查找更多钱包",
"wallets_exists": "已存在同名钱包。",
"wallets_external_tt": "在数据目录之外 —「打开」会就地链接(不复制)。",
"wallets_folder_hint": "/含 .dat 钱包文件的文件夹路径",
@@ -1356,7 +1735,9 @@
"wallets_open_failed": "无法在原位置打开此钱包。它可能与数据目录位于不同的驱动器上 — 请将其移动到同一驱动器(在 Windows 上,启用开发者模式也可跨驱动器链接)。",
"wallets_open_folder": "打开文件夹位置",
"wallets_open_inplace_tt": "在原位置打开此钱包 — 链接到数据目录(不复制)",
"wallets_remove_folder": "停止扫描此文件夹",
"wallets_reveal": "打开文件夹",
"wallets_scanned_folders": "已扫描的文件夹:",
"wallets_sort_addresses": "地址",
"wallets_sort_asc": "升序(最早/最少/最小优先)",
"wallets_sort_by": "排序:",
@@ -1369,6 +1750,61 @@
"warning_upper": "警告!",
"website": "网站",
"window_opacity": "窗口透明度",
"wiz_acrylic": "亚克力玻璃效果",
"wiz_acrylic_desc": "面板上的半透明模糊(关闭则禁用)",
"wiz_already_encrypted": "钱包已加密",
"wiz_already_encrypted_desc": "您的钱包已使用密码短语进行保护。无需进一步操作。",
"wiz_appearance": "外观",
"wiz_bootstrap": "引导数据",
"wiz_bootstrap_desc": "下载区块链引导数据以大幅加快初始同步速度。\n\n您现有的 wallet.dat 不会被修改或替换。",
"wiz_bootstrap_failed": "引导数据失败",
"wiz_confirm": "确认:",
"wiz_continue": "继续",
"wiz_daemon_sending_stop": "正在发送停止命令…",
"wiz_daemon_stop_failed": "守护进程未停止——请尝试手动停止。",
"wiz_daemon_stopped_ok": "守护进程已停止。",
"wiz_daemon_waiting_stop": "正在等待守护进程关闭…",
"wiz_download_failed": "下载失败",
"wiz_encrypt_bg": "加密将在后台完成",
"wiz_encrypt_continue": "加密并继续",
"wiz_encrypt_desc": "加密您的钱包,用密码短语保护私钥。",
"wiz_encrypt_warning": "如果您丢失密码短语,将无法访问您的资金。",
"wiz_encryption": "加密",
"wiz_ext_daemon_running": "外部守护进程正在运行",
"wiz_ext_daemon_warning": "下载引导数据前必须先停止它,否则链数据可能会损坏。",
"wiz_level": "级别:",
"wiz_lowspec_desc": "禁用所有重度视觉效果",
"wiz_off": "关闭",
"wiz_pass_mismatch": "密码短语不匹配",
"wiz_pass_spaces": "密码短语含有首尾空格——请将其删除",
"wiz_pass_too_short": "密码短语至少需要 8 个字符 (%zu/8)",
"wiz_passphrase": "密码短语:",
"wiz_pin_confirm": "确认 PIN",
"wiz_pin_invalid": "PIN 必须为 4-8 位数字",
"wiz_pin_label": "PIN4-8 位数字):",
"wiz_pin_mismatch": "PIN 不匹配",
"wiz_pin_optional": "快速解锁 PIN可选",
"wiz_pin_title": "快速解锁 PIN",
"wiz_scanline_desc": "控制台中的 CRT 扫描线效果",
"wiz_skip": "跳过",
"wiz_skip_confirm": "确定不加密继续吗?私钥将以未加密方式存储——请再次点击“跳过”以确认。",
"wiz_step1": "第 1 步",
"wiz_step2": "第 2 步",
"wiz_step3": "第 3 步",
"wiz_stop_daemon": "停止守护进程",
"wiz_strength": "强度:%s",
"wiz_strength_fair": "一般",
"wiz_strength_good": "良好",
"wiz_strength_strong": "强",
"wiz_strength_weak": "弱",
"wiz_theme_builtin": "内置",
"wiz_theme_custom": "自定义",
"wiz_theme_effects": "主题视觉效果",
"wiz_theme_effects_desc": "动态边框、色彩渲染",
"wiz_theme_invalid": " (无效)",
"wiz_ui_opacity_desc": "卡片与侧边栏透明度1.0 = 不透明)",
"wiz_welcome_sub": "只需几项快速设置,即可启用您的全节点。",
"wiz_welcome_title": "欢迎使用 ObsidianDragon",
"wizard_daemon_start_failed": "启动守护进程失败 — 将自动重试",
"xmrig_browse_releases": "浏览所有版本…",
"xmrig_checking": "正在检查最新的矿工程序…",
@@ -1382,6 +1818,7 @@
"xmrig_loading_releases": "正在加载发行版…",
"xmrig_none": "无",
"xmrig_reinstall": "重新安装",
"xmrig_releases": "xmrig 版本",
"xmrig_stop_mining_first": "更新矿工程序前请先停止挖矿。",
"xmrig_unavailable_body": "此平台没有可用的矿工构建版本。",
"xmrig_unavailable_title": "矿工更新不可用",

190
res/themes/jade.toml Normal file
View File

@@ -0,0 +1,190 @@
[theme]
name = "Jade"
author = "The Hush Developers"
dark = true
elevation = { --elevation-0 = "#071210", --elevation-1 = "#0C1A16", --elevation-2 = "#16261F", --elevation-3 = "#1D3128", --elevation-4 = "#243B30" }
images = { background_image = "backgrounds/texture/jade_bg.png", logo = "logos/logo_ObsidianDragon_dark.png" }
[theme.palette]
--primary = "#2FA07A"
--primary-variant = "#1E7357"
--primary-light = "#7FD1B5"
--secondary = "#C9A24E"
--secondary-variant = "#A8842F"
--secondary-light = "#E0C583"
--background = "#071210"
--surface = "#0C1A16"
--surface-variant = "#16261F"
--on-primary = "#FFFFFF"
--on-secondary = "#000000"
--on-background = "#DCEDE4"
--on-surface = "#DCEDE4"
--on-surface-medium = "rgba(220,237,228,0.85)"
--on-surface-disabled = "rgba(220,237,228,0.58)"
--error = "#CF6679"
--on-error = "#000000"
--success = "#81C784"
--on-success = "#000000"
--warning = "#FFB74D"
--on-warning = "#000000"
--divider = "rgba(130,205,170,0.14)"
--outline = "rgba(130,205,170,0.16)"
--scrim = "rgba(0,0,0,0.6)"
--surface-hover = "rgba(130,205,170,0.07)"
--surface-alt = "rgba(130,205,170,0.05)"
--surface-active = "rgba(130,205,170,0.10)"
--glass-button = "rgba(130,205,170,0.06)"
--glass-button-hover = "rgba(130,205,170,0.12)"
--card-border = "rgba(130,205,170,0.26)"
--text-shadow = "rgba(0,0,0,0.50)"
--input-overlay-text = "rgba(220,237,228,0.30)"
--slider-text = "rgba(220,237,228,0.85)"
--thumb-fill = "rgba(130,205,170,0.15)"
--thumb-border = "rgba(130,205,170,0.50)"
--disabled-label = "rgba(130,205,170,0.18)"
--chart-grid = "rgba(130,205,170,0.05)"
--chart-crosshair = "rgba(130,205,170,0.15)"
--chart-hover-ring = "rgba(130,205,170,0.30)"
--tooltip-bg = "rgba(9,20,16,0.92)"
--tooltip-border = "rgba(130,205,170,0.12)"
--glass-fill = "rgba(130,205,170,0.08)"
--glass-border = "rgba(47,160,122,0.30)"
--glass-noise-tint = "rgba(130,205,170,0.03)"
--tactile-top = "rgba(130,205,170,0.06)"
--tactile-bottom = "rgba(130,205,170,0.0)"
--hover-overlay = "rgba(130,205,170,0.05)"
--active-overlay = "rgba(130,205,170,0.10)"
--rim-light = "rgba(130,205,170,0.14)"
--status-divider = "rgba(130,205,170,0.08)"
--sidebar-hover = "rgba(130,205,170,0.10)"
--sidebar-icon = "rgba(130,205,170,0.42)"
--sidebar-badge = "rgba(220,237,228,1.0)"
--sidebar-divider = "rgba(130,205,170,0.06)"
--chart-line = "rgba(130,205,170,0.10)"
--window-control = "rgba(220,237,228,0.78)"
--window-control-hover = "rgba(130,205,170,0.12)"
--window-close-hover = "rgba(232,17,35,0.78)"
--spinner-track = "rgba(130,205,170,0.10)"
--spinner-active = "rgba(79,184,154,0.85)"
--shutdown-panel-bg = "rgba(7,18,14,0.90)"
--shutdown-panel-border = "rgba(130,205,170,0.07)"
--ram-bar-app = "#2FA07A"
--ram-bar-system = "rgba(255,255,255,0.18)"
--accent-total = "#7FD1B5"
--accent-shielded = "#4FB89A"
--accent-transparent = "#C9A24E"
--accent-action = "#2FA07A"
--accent-market = "#4FB89A"
--accent-portfolio = "#7FD1B5"
--toast-info-accent = "#2FA07A"
--toast-info-text = "#7FD1B5"
--toast-success-accent = "rgba(50,180,80,1.0)"
--toast-success-text = "rgba(180,255,180,1.0)"
--toast-warning-accent = "rgba(204,166,50,1.0)"
--toast-warning-text = "rgba(255,230,130,1.0)"
--toast-error-accent = "rgba(204,64,64,1.0)"
--toast-error-text = "rgba(255,153,153,1.0)"
--snackbar-bg = "rgba(24,40,34,0.95)"
--snackbar-text = "rgba(220,237,228,0.87)"
--snackbar-action = "rgba(79,184,154,1.0)"
--snackbar-action-hover = "rgba(127,209,181,1.0)"
--switch-track-off = "rgba(130,205,170,0.12)"
--switch-track-on = "rgba(47,160,122,0.50)"
--switch-thumb-off = "#A0C0B4"
--switch-thumb-on = "#DCEDE4"
--control-shadow = "rgba(0,0,0,0.24)"
--checkbox-check = "#000000"
--app-bar-shadow = "rgba(0,0,0,0.25)"
[backdrop]
base-color-top = "rgba(14,32,26,210)"
base-color-bottom = "rgba(6,18,14,210)"
texture-tint-alpha = 120
gradient-top-r = 10
gradient-top-g = 30
gradient-top-b = 22
gradient-top-a = 90
gradient-bottom-r = 5
gradient-bottom-g = 16
gradient-bottom-b = 12
gradient-bottom-a = 70
background-alpha = 0.42
surface-alpha = 0.56
frame-alpha = 0.78
surface-inline-alpha = 0.58
background-inline-alpha = 0.40
# ---------------------------------------------------------------------------
# Theme Visual Effects — Jade (veins of gold shifting through the stone)
# Jade's signature is a slow jade→gold color-shifting border on every glass
# panel + the active nav button — a vein of gold surfacing through nephrite.
# It's drawn via AddRect so it hugs the real rounded corners (no polygonal
# edge-trace). Sparse jade motes drift up the viewport. No other theme turns
# gradient-border-panels on, so the panel-wide vein is Jade's own —
# deliberately NOT Obsidian's specular glare.
# ---------------------------------------------------------------------------
[effects]
hue-cycle-enabled = { size = 0.0 }
rainbow-border-enabled = { size = 0.0 }
# No shimmer sweep — replaced by specular glare
shimmer-enabled = { size = 0.0 }
positional-hue-enabled = { size = 0.0 }
glow-pulse-enabled = { size = 0.0 }
# Edge-trace OFF — its hand-walked perimeter chamfers rounded corners.
# Jade's vein is the gradient-border below (corner-clean via AddRect).
edge-trace-enabled = { size = 0.0 }
edge-trace-speed = { size = 0.16 }
edge-trace-length = { size = 0.34 }
edge-trace-thickness = { size = 1.6 }
edge-trace-alpha = { size = 0.55 }
edge-trace-color = { color = "#C9A24E" }
# Specular glare OFF — that's Obsidian's signature; Jade shouldn't echo it.
specular-glare-enabled = { size = 0.0 }
specular-glare-speed = { size = 0.018 }
specular-glare-intensity = { size = 0.008 }
specular-glare-radius = { size = 0.65 }
specular-glare-count = { size = 1.0 }
specular-glare-color = { color = "rgba(150,220,180,1.0)" }
# HERO — vein of gold: a slow jade→gold color-shifting border on the active
# nav button AND (via gradient-border-panels) every glass panel. Drawn with
# AddRect so it follows the rounded corners exactly. Panels drift at a softer
# alpha and position-phased offset, so a screenful reads like veins at
# different depths rather than one synchronized pulse.
gradient-border-enabled = { size = 1.0 }
gradient-border-panels = { size = 1.0 }
gradient-border-speed = { size = 0.10 }
gradient-border-thickness = { size = 1.5 }
gradient-border-alpha = { size = 0.55 }
gradient-border-color-a = { color = "#7FD1B5" }
gradient-border-color-b = { color = "#C9A24E" }
# Ambient jade motes — sparse, slow, cool green particles drifting up the
# viewport (recolored ember-rise; a different mood from dragonx's fire embers).
ember-rise-enabled = { size = 1.0 }
ember-rise-count = { size = 5.0 }
ember-rise-speed = { size = 0.18 }
ember-rise-particle-size = { size = 1.4 }
ember-rise-alpha = { size = 0.26 }
ember-rise-color = { color = "#7FD1B5" }
# Shader-like viewport overlay — deep green stone atmosphere
viewport-wash-enabled = { size = 1.0 }
viewport-wash-alpha = { size = 0.05 }
viewport-wash-tl = { color = "#12402E" }
viewport-wash-tr = { color = "#0E3828" }
viewport-wash-bl = { color = "#16442E" }
viewport-wash-br = { color = "#1A4A34" }
viewport-wash-rotate = { size = 0.015 }
viewport-wash-pulse = { size = 0.0 }
viewport-wash-pulse-depth = { size = 0.0 }
viewport-vignette-enabled = { size = 1.0 }
viewport-vignette-color = { color = "#04140D" }
viewport-vignette-radius = { size = 0.22 }
viewport-vignette-alpha = { size = 0.15 }

View File

@@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 }
status-pill-y-offset = { size = 1 }
confirmed-threshold = { size = 10 }
# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner).
# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action.
[banners.node-status]
min-height = { size = 26.0 }
height = { size = 30.0 }
[tabs.transactions]
search-max-width = 300.0
search-width-ratio = 0.3
@@ -1503,6 +1509,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
progress-width = { size = 260.0 }
backdrop-alpha = { opacity = 0.80 }
vertical-gap = { size = 8.0 }
stall-timeout-sec = { size = 45.0 }
# ---------------------------------------------------------------------------
# First-Run Wizard Screens

View File

@@ -2232,6 +2232,46 @@ TRANSLATIONS = {
"ja": "データディレクトリの外 —「開く」はその場でリンクします(コピーなし)。",
"ko": "데이터 디렉터리 밖 — '열기'는 제자리에 링크합니다 (복사 없음)."
},
"wallets_scanned_folders": {
"es": "Carpetas escaneadas:", "de": "Durchsuchte Ordner:",
"fr": "Dossiers analysés :", "pt": "Pastas verificadas:",
"ru": "Просканированные папки:", "zh": "已扫描的文件夹:",
"ja": "スキャン対象フォルダー:", "ko": "스캔한 폴더:"
},
"wallets_remove_folder": {
"es": "Dejar de escanear esta carpeta", "de": "Diesen Ordner nicht mehr durchsuchen",
"fr": "Ne plus analyser ce dossier", "pt": "Parar de verificar esta pasta",
"ru": "Больше не сканировать эту папку", "zh": "停止扫描此文件夹",
"ja": "このフォルダーのスキャンを停止", "ko": "이 폴더 스캔 중지"
},
"wallets_empty_hint": {
"es": "Escanea una carpeta para encontrar más carteras",
"de": "Ordner durchsuchen, um weitere Wallets zu finden",
"fr": "Analysez un dossier pour trouver d'autres portefeuilles",
"pt": "Verifique uma pasta para encontrar mais carteiras",
"ru": "Просканируйте папку, чтобы найти другие кошельки",
"zh": "扫描文件夹以查找更多钱包",
"ja": "フォルダーをスキャンして他のウォレットを探す",
"ko": "폴더를 스캔하여 다른 지갑 찾기"
},
"market_chart_loading": {
"es": "Cargando historial de precios", "de": "Preisverlauf wird geladen",
"fr": "Chargement de l'historique des prix", "pt": "Carregando histórico de preços",
"ru": "Загрузка истории цен", "zh": "正在加载价格历史",
"ja": "価格履歴を読み込み中", "ko": "가격 기록 불러오는 중"
},
"market_style_line": {
"es": "Cambiar a gráfico de líneas", "de": "Zum Liniendiagramm wechseln",
"fr": "Passer au graphique en ligne", "pt": "Mudar para gráfico de linhas",
"ru": "Переключить на линейный график", "zh": "切换到折线图",
"ja": "折れ線チャートに切り替え", "ko": "선형 차트로 전환"
},
"market_style_candle": {
"es": "Cambiar a velas", "de": "Zu Kerzenchart wechseln",
"fr": "Passer aux chandeliers", "pt": "Mudar para velas",
"ru": "Переключить на свечи", "zh": "切换到蜡烛图",
"ja": "ローソク足に切り替え", "ko": "캔들차트로 전환"
},
}
def main():

94
scripts/build-freetype-mingw.sh Executable file
View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Cross-build a MINIMAL static FreeType for the mingw-w64 (Windows) target.
#
# Why: the wallet's optional color-emoji rendering needs FreeType (to rasterize the COLR/CPAL Twemoji
# font). Native Linux/macOS pick up the system FreeType via find_package; the Debian/Ubuntu mingw-w64
# cross toolchain ships no FreeType, so we build one here. The Twemoji font is COLRv0 (layered vector),
# which FreeType renders WITHOUT libpng / harfbuzz / brotli / zlib — so this is a dependency-free static
# build (no external libs to also cross-compile), producing a self-contained libfreetype.a.
#
# Output: <prefix>/include/freetype2/... + <prefix>/lib/libfreetype.a (default prefix: third_party/freetype-mingw)
# build.sh --win-release runs this automatically and passes -DDRAGONX_MINGW_FREETYPE_PREFIX to CMake.
set -euo pipefail
FT_VERSION="2.13.3"
FT_SHA256="5c3a8e78f7b24c20b25b54ee575d6daa40007a5f4eea2845861c3409b3021747" # freetype-2.13.3.tar.gz
FT_URL="https://download.savannah.gnu.org/releases/freetype/freetype-${FT_VERSION}.tar.gz"
FT_URL_MIRROR="https://downloads.sourceforge.net/project/freetype/freetype2/${FT_VERSION}/freetype-${FT_VERSION}.tar.gz"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PREFIX="${1:-$SCRIPT_DIR/third_party/freetype-mingw}"
WORK="$SCRIPT_DIR/third_party/.freetype-mingw-build"
# Already built? (libfreetype.a present) → nothing to do.
if [[ -f "$PREFIX/lib/libfreetype.a" && -d "$PREFIX/include/freetype2" ]]; then
echo "FreeType (mingw) already built at: $PREFIX"
exit 0
fi
# Pick the mingw compilers (posix threads variant preferred, matching build.sh).
if command -v x86_64-w64-mingw32-gcc-posix &>/dev/null; then
MINGW_GCC=x86_64-w64-mingw32-gcc-posix; MINGW_GXX=x86_64-w64-mingw32-g++-posix
elif command -v x86_64-w64-mingw32-gcc &>/dev/null; then
MINGW_GCC=x86_64-w64-mingw32-gcc; MINGW_GXX=x86_64-w64-mingw32-g++
else
echo "ERROR: x86_64-w64-mingw32-gcc not found (install mingw-w64)." >&2
exit 1
fi
mkdir -p "$WORK"
cd "$WORK"
TARBALL="freetype-${FT_VERSION}.tar.gz"
if [[ ! -f "$TARBALL" ]]; then
echo "Downloading FreeType ${FT_VERSION} ..."
curl -fsSL -o "$TARBALL" "$FT_URL" || curl -fsSL -o "$TARBALL" "$FT_URL_MIRROR"
fi
echo "Verifying SHA-256 ..."
echo "${FT_SHA256} ${TARBALL}" | sha256sum -c - || {
echo "ERROR: FreeType tarball checksum mismatch (expected ${FT_SHA256})." >&2
echo " got: $(sha256sum "$TARBALL" | cut -d' ' -f1)" >&2
exit 1
}
rm -rf "freetype-${FT_VERSION}"
tar xf "$TARBALL"
SRC="$WORK/freetype-${FT_VERSION}"
# Minimal mingw toolchain for FreeType's own CMake.
cat > "$WORK/ft-mingw-toolchain.cmake" <<TOOLCHAIN
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
set(CMAKE_C_COMPILER ${MINGW_GCC})
set(CMAKE_CXX_COMPILER ${MINGW_GXX})
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32)
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
TOOLCHAIN
echo "Configuring FreeType (static, no external deps) ..."
rm -rf "$WORK/build"
cmake -S "$SRC" -B "$WORK/build" \
-DCMAKE_TOOLCHAIN_FILE="$WORK/ft-mingw-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DBUILD_SHARED_LIBS=OFF \
-DFT_DISABLE_ZLIB=ON \
-DFT_DISABLE_BZIP2=ON \
-DFT_DISABLE_PNG=ON \
-DFT_DISABLE_HARFBUZZ=ON \
-DFT_DISABLE_BROTLI=ON
echo "Building + installing FreeType ..."
cmake --build "$WORK/build" -j "$(nproc)"
cmake --install "$WORK/build"
if [[ -f "$PREFIX/lib/libfreetype.a" ]]; then
echo "OK: mingw FreeType -> $PREFIX/lib/libfreetype.a"
else
echo "ERROR: build did not produce libfreetype.a" >&2
exit 1
fi

View File

@@ -1,5 +1,17 @@
#!/usr/bin/env bash
# This script uses bash 4+ features (mapfile, safe empty-array expansion under
# `set -u`). macOS ships bash 3.2, so re-exec under a newer bash when one is
# present (Homebrew), and fail with a clear message otherwise.
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
for _newer_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do
[ -x "$_newer_bash" ] && exec "$_newer_bash" "$0" "$@"
done
echo "ERROR: build-lite-backend-artifact.sh requires bash 4+ (found ${BASH_VERSION:-unknown})." >&2
echo " On macOS: brew install bash" >&2
exit 1
fi
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -67,25 +79,9 @@ Options:
--backend-dir PATH SilentDragonXLite/lib source directory.
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
--out-dir PATH Output directory for copied artifact and metadata.
--artifact PATH Inventory an existing artifact instead of building.
--no-build Do not run cargo; requires --artifact.
--reproducible Add deterministic Rust path remaps for clean builds.
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
--builder NAME Redacted builder/provenance label. Default: local.
--signature-required Fail if verified signature metadata is not supplied.
--signature-file PATH Existing sidecar signature file to record.
--signature-format FORMAT Signature format: minisign, gpg, sigstore, external, or other.
--signature-verification-tool T Verification tool and version used by the release builder.
--signature-verification-command C
Verification command already run by the release builder.
--signature-key-fingerprint F Reviewed public-key fingerprint, when applicable.
--signature-certificate-identity ID
Reviewed certificate identity, when applicable.
--signature-certificate-issuer I
Reviewed certificate issuer, when applicable.
--signature-transparency-log-url URL
Transparency log entry, when applicable.
--signature-verified-sha256 SHA Artifact SHA-256 verified by the signature check.
-j, --jobs N Cargo parallel jobs.
--cargo-arg ARG Extra argument forwarded to cargo build.
-h, --help Show this help.
@@ -95,9 +91,13 @@ Outputs:
<out>/<platform>/lite-backend-symbols.txt
<out>/<platform>/lite-backend-artifact-manifest.json
The script captures symbols, checksums, and optional read-only signature
verification metadata only. It does not load the library, resolve function
pointers, call SDXL, sign, upload, or publish artifacts.
The lite backend is always built from the vendored in-tree source
(third_party/silentdragonxlite), which is the trust root. Prebuilt artifacts
and self-attested signature metadata are NOT accepted (F15-1) — the previous
scheme only recorded an unverified "verified" claim. The script captures the
freshly-built artifact's symbols and checksum, and records build provenance.
It does not load the library, resolve function pointers, call SDXL, sign,
upload, or publish artifacts.
EOF
}
@@ -166,15 +166,8 @@ while [[ $# -gt 0 ]]; do
OUT_DIR="$(absolute_path "$2")"
shift 2
;;
--artifact)
[[ $# -ge 2 ]] || die "--artifact requires a value"
ARTIFACT_PATH="$(absolute_path "$2")"
BUILD_ARTIFACT=false
shift 2
;;
--no-build)
BUILD_ARTIFACT=false
shift
--artifact|--no-build)
die "$1 was removed (F15-1): the lite backend must be built from the vendored in-tree source (third_party/silentdragonxlite); prebuilt artifacts are no longer accepted."
;;
--reproducible)
REPRODUCIBLE=true
@@ -191,54 +184,11 @@ while [[ $# -gt 0 ]]; do
BUILDER="$2"
shift 2
;;
--signature-required)
SIGNATURE_REQUIRED=true
shift
;;
--signature-file|--signature-path)
[[ $# -ge 2 ]] || die "$1 requires a value"
SIGNATURE_FILE="$(absolute_path "$2")"
shift 2
;;
--signature-format)
[[ $# -ge 2 ]] || die "--signature-format requires a value"
SIGNATURE_FORMAT="$2"
shift 2
;;
--signature-verification-tool|--signature-tool)
[[ $# -ge 2 ]] || die "$1 requires a value"
SIGNATURE_VERIFICATION_TOOL="$2"
shift 2
;;
--signature-verification-command)
[[ $# -ge 2 ]] || die "--signature-verification-command requires a value"
SIGNATURE_VERIFICATION_COMMAND="$2"
shift 2
;;
--signature-key-fingerprint)
[[ $# -ge 2 ]] || die "--signature-key-fingerprint requires a value"
SIGNATURE_KEY_FINGERPRINT="$2"
shift 2
;;
--signature-certificate-identity)
[[ $# -ge 2 ]] || die "--signature-certificate-identity requires a value"
SIGNATURE_CERTIFICATE_IDENTITY="$2"
shift 2
;;
--signature-certificate-issuer)
[[ $# -ge 2 ]] || die "--signature-certificate-issuer requires a value"
SIGNATURE_CERTIFICATE_ISSUER="$2"
shift 2
;;
--signature-transparency-log-url)
[[ $# -ge 2 ]] || die "--signature-transparency-log-url requires a value"
SIGNATURE_TRANSPARENCY_LOG_URL="$2"
shift 2
;;
--signature-verified-sha256)
[[ $# -ge 2 ]] || die "--signature-verified-sha256 requires a value"
SIGNATURE_VERIFIED_SHA256="$2"
shift 2
--signature-required|--signature-file|--signature-path|--signature-format|\
--signature-verification-tool|--signature-tool|--signature-verification-command|\
--signature-key-fingerprint|--signature-certificate-identity|--signature-certificate-issuer|\
--signature-transparency-log-url|--signature-verified-sha256)
die "signature-attestation flags were removed (F15-1): they recorded a self-attested \"verified\" claim without running any cryptographic verifier. The lite backend is built from the vendored in-tree source, which is the trust root."
;;
-j|--jobs)
[[ $# -ge 2 ]] || die "--jobs requires a value"
@@ -374,6 +324,9 @@ prepare_backend_source() {
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
# Honor the pinned Rust toolchain (rust-toolchain.toml) inside the prepared root too,
# so builds using --silentdragonxlitelib-dir still select rustc 1.63.
[[ -f "$BACKEND_SOURCE_DIR/rust-toolchain.toml" ]] && ln -s "$BACKEND_SOURCE_DIR/rust-toolchain.toml" "$prepared_root/rust-toolchain.toml"
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
# "vendor" relative to the build root, so expose it inside the prepared root too.
@@ -766,6 +719,7 @@ MANIFEST_FILE="$PLATFORM_OUT_DIR/lite-backend-artifact-manifest.json"
printf ' },\n'
printf ' "provenance": {\n'
printf ' "owner_ready": true,\n'
printf ' "built_from_source": true,\n'
printf ' "metadata_provided": true,\n'
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n'

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
Build a monochrome Noto Emoji subset for the chat/message UI.
Dear ImGui rasterizes fonts with stb_truetype, which handles only monochrome
(outline `glyf`) fonts — NOT color emoji (CBDT/sbix/COLR). So we use Google's
*monochrome* Noto Emoji (github.com/google/fonts, ofl/notoemoji, OFL-licensed)
and merge it into the text fonts (see Typography::loadFont, the block after the
CJK merge). ImGui renders one glyph per codepoint with no shaping, so ZWJ
sequences / regional-indicator flags won't compose — single-codepoint emoji
(😀 🎉 ❤ 🔥 👍 …) render fine, which covers the overwhelming majority of use.
Source is the variable font pinned to wght=400 → static, then subset to the
emoji planes plus the higher symbol/star ranges the base UI font doesn't cover.
The base Ubuntu font already owns U+260026FF etc.; ImGui's MergeMode gives the
first-loaded glyph precedence, so those stay text-styled and only the codepoints
the base lacks fall through to this font.
Get the source once (OFL, redistributable):
curl -fsSL -o /tmp/NotoEmoji-VF.ttf \
'https://github.com/google/fonts/raw/main/ofl/notoemoji/NotoEmoji%5Bwght%5D.ttf'
Then: python3 scripts/build_emoji_subset.py
Output: res/fonts/NotoEmoji-Subset.ttf (committed; embedded via INCBIN)
"""
import os
from fontTools import ttLib, subset
from fontTools.varLib.instancer import instantiateVariableFont
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SOURCE_VF = '/tmp/NotoEmoji-VF.ttf'
STATIC = '/tmp/NotoEmoji-Static.ttf'
OUTPUT = os.path.join(ROOT, 'res', 'fonts', 'NotoEmoji-Subset.ttf')
# Emoji codepoints to keep. Ranges are inclusive.
RANGES = [
(0x1F000, 0x1FAFF), # all the main emoji planes (emoticons, pictographs, transport, supplement, extended)
(0x2600, 0x27BF), # Miscellaneous Symbols + Dingbats
(0x2B00, 0x2BFF), # stars (⭐ 2B50) and misc arrows
(0xFE00, 0xFE0F), # variation selectors (VS16 emoji-style)
(0x2194, 0x21AA), # arrows used as emoji
(0x231A, 0x231B), # ⌚ ⌛
(0x23E9, 0x23FA), # media-control emoji
(0x25AA, 0x25FE), # small squares
]
SINGLES = [0x200D, 0x2934, 0x2935, 0x3030, 0x303D, 0x3297, 0x3299,
0x00A9, 0x00AE, 0x2122, 0x2139, 0x24C2]
def main():
if not os.path.exists(SOURCE_VF):
raise SystemExit(f"missing source font {SOURCE_VF} — see the header for the curl command")
# 1. Pin the weight axis so stb_truetype rasterizes a clean static instance.
f = ttLib.TTFont(SOURCE_VF)
if 'fvar' in f:
instantiateVariableFont(f, {'wght': 400}, inplace=True)
f.save(STATIC)
unicodes = list(SINGLES)
for lo, hi in RANGES:
unicodes.extend(range(lo, hi + 1))
opts = subset.Options()
opts.layout_features = [] # ImGui does no shaping — drop GSUB/GPOS
opts.name_IDs = []
opts.notdef_outline = True
opts.glyph_names = False
opts.drop_tables = ['GSUB', 'GPOS', 'GDEF', 'morx', 'kern']
font = subset.load_font(STATIC, opts)
ss = subset.Subsetter(options=opts)
ss.populate(unicodes=unicodes)
ss.subset(font)
subset.save_font(font, OUTPUT, opts)
out = ttLib.TTFont(OUTPUT)
cmap = out.getBestCmap()
color = any(t in out.reader.keys() for t in ('CBDT', 'sbix', 'COLR'))
print(f"Output: {OUTPUT}")
print(f"Size: {os.path.getsize(OUTPUT)//1024} KB | glyphs: {len(cmap)} | color tables: {color}")
if color:
raise SystemExit("ERROR: subset has color tables — stb_truetype cannot render it")
if __name__ == '__main__':
main()

View File

@@ -24,18 +24,27 @@ if [ ! -f "${BUILD_DIR}/bin/ObsidianDragon" ]; then
exit 1
fi
# Check for appimagetool
# Check for appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
# The old "continuous" tag is a moving, unverified network download that runs on the release
# builder; verify it or refuse to package.
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
APPIMAGETOOL=""
if command -v appimagetool &> /dev/null; then
APPIMAGETOOL="appimagetool"
elif [ -f "${BUILD_DIR}/appimagetool-x86_64.AppImage" ]; then
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
else
print_status "Downloading appimagetool..."
wget -q -O "${BUILD_DIR}/appimagetool-x86_64.AppImage" \
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x "${BUILD_DIR}/appimagetool-x86_64.AppImage"
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
AT="${BUILD_DIR}/appimagetool-x86_64.AppImage"
if [ ! -f "$AT" ] || ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
print_status "Downloading appimagetool 1.9.0 (pinned)..."
wget -q -O "$AT" "$APPIMAGETOOL_URL"
if ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
print_error "appimagetool SHA-256 verification failed — refusing to use it"
rm -f "$AT"
exit 1
fi
chmod +x "$AT"
fi
APPIMAGETOOL="$AT"
fi
print_status "Creating AppDir structure..."

View File

@@ -256,8 +256,8 @@ HEADER_START
echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"

View File

@@ -1,25 +1,31 @@
#!/usr/bin/env bash
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
# Package the prebuilt dragonx full-node binaries into per-platform release archives and sign them
# for the wallet's in-app daemon updater (ed25519 over the EXACT archive bytes).
#
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
# The wallet verifies a detached ed25519 signature over the archive bytes against a public key
# pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is MANDATORY
# (kDaemonRequireSignature = true): an in-app update is refused unless a valid "<archive>.sig" is
# published next to the archive. The wallet also checks each archive's SHA-256 against a markdown
# checksum table in the release body, so `release` prints that table for you to paste in.
#
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
# (the same flow the wallet's unit tests verify for the miner updater).
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl. OpenSSL's ed25519 is PureEdDSA (RFC 8032), the
# same primitive libsodium's crypto_sign_verify_detached checks, so the signatures are compatible.
#
# Usage:
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
# scripts/sign-daemon-release.sh sign <secret.key> <file>... # sign existing files -> <file>.sig
# scripts/sign-daemon-release.sh release <secret.key> <version> [--src DIR] [--out DIR]
# # zip prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,macos,
# # win64}.zip, sign each, and print the SHA-256 checksum table. Platforms with no dragonxd
# # binary staged are skipped.
#
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
# Keep the secret key (.ed25519.key) OFFLINE (mode 600). Paste the base64 public key into
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
die() { echo "error: $*" >&2; exit 1; }
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
@@ -27,6 +33,26 @@ command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25
# ed25519 is a fixed 12-byte prefix + the 32-byte key, so the trailing 32 bytes are the raw key.
pubkey_b64() { openssl pkey -in "$1" -pubout -outform DER | tail -c 32 | openssl base64 -A; }
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}';
else shasum -a 256 "$1" | awk '{print $1}'; fi
}
# Detached ed25519 signature over the raw file bytes -> <file>.sig (base64 of the 64-byte sig).
sign_file() {
local key="$1" f="$2" raw
raw="$(mktemp)"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
}
# platform -> (staging dir under prebuilt-binaries, release token, expected daemon binary name)
plat_dir() { case "$1" in linux) echo dragonxd-linux;; mac) echo dragonxd-mac;; win) echo dragonxd-win;; esac; }
plat_token() { case "$1" in linux) echo linux-amd64;; mac) echo macos;; win) echo win64;; esac; }
plat_daemon() { case "$1" in win) echo dragonxd.exe;; *) echo dragonxd;; esac; }
cmd="${1:-}"; shift || true
case "$cmd" in
keygen)
@@ -42,26 +68,90 @@ case "$cmd" in
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
echo " $pub"
;;
pubkey)
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
pubkey_b64 "$1"
;;
sign)
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
key="$1"; shift
[ -f "$key" ] || die "no such key: $key"
for f in "$@"; do
[ -f "$f" ] || die "no such file: $f"
raw="$(mktemp)"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
sign_file "$key" "$f"
echo "signed: $f -> $f.sig"
done
echo "Upload each .sig as a release asset next to its archive."
;;
release)
[ $# -ge 2 ] || die "usage: release <secret.key> <version> [--src DIR] [--out DIR]"
key="$1"; version="$2"; shift 2
src="$PROJECT_ROOT/prebuilt-binaries"
out="$PROJECT_ROOT/release/daemon"
while [ $# -gt 0 ]; do
case "$1" in
--src) [ $# -ge 2 ] || die "--src needs a value"; src="$2"; shift 2 ;;
--out) [ $# -ge 2 ] || die "--out needs a value"; out="$2"; shift 2 ;;
*) die "unknown option: $1" ;;
esac
done
[ -f "$key" ] || die "no such key: $key"
[ -d "$src" ] || die "no such source dir: $src"
command -v zip >/dev/null 2>&1 || die "zip not found (install 'zip')"
mkdir -p "$out"
# Sanity: warn if this key does not match the public key pinned in the wallet (the wallet would
# then reject every signature made with it — only expected when deliberately rotating the key).
pinned="$(grep -oE '"[A-Za-z0-9+/]{43}="' "$PROJECT_ROOT/src/util/daemon_updater.h" 2>/dev/null | head -1 | tr -d '"')"
mine="$(pubkey_b64 "$key")"
if [ -n "$pinned" ] && [ "$pinned" != "$mine" ]; then
echo "WARNING: this key's public key does not match the one pinned in daemon_updater.h:" >&2
echo " signing key -> $mine" >&2
echo " pinned key -> $pinned" >&2
echo " The wallet will REJECT these signatures unless you are rotating the pinned key." >&2
echo >&2
fi
made=0
table=""
for plat in linux mac win; do
d="$src/$(plat_dir "$plat")"
daemon="$d/$(plat_daemon "$plat")"
if [ ! -f "$daemon" ]; then
echo "skip $plat: no $(plat_daemon "$plat") staged in $d" >&2
continue
fi
archive="dragonx-$version-$(plat_token "$plat").zip"
apath="$out/$archive"
rm -f "$apath"
# Zip the staged files at the archive root (binaries + sapling params + asmap), excluding
# the .gitkeep placeholder. The updater flattens paths via baseName(), so a flat zip is fine.
files=()
while IFS= read -r fn; do files+=("$fn"); done < <(cd "$d" && ls -A | grep -vx '.gitkeep')
[ "${#files[@]}" -gt 0 ] || { echo "skip $plat: nothing to package in $d" >&2; continue; }
( cd "$d" && zip -q -X "$apath" "${files[@]}" )
sign_file "$key" "$apath"
sum="$(sha256_of "$apath")"
table+="| $archive | \`$sum\` |"$'\n'
echo "packaged + signed: $apath (+ .sig) sha256=$sum"
made=$((made + 1))
done
[ "$made" -gt 0 ] || die "no platform had a staged daemon binary under $src/dragonxd-{linux,mac,win}/"
echo
echo "Checksum table (paste into the release body so the wallet can verify SHA-256):"
echo "| Archive | SHA-256 |"
echo "|---|---|"
printf '%s' "$table"
echo
echo "Upload each .zip AND its .zip.sig as release assets. Wallet enforces the ed25519 signature"
echo "(kDaemonRequireSignature=true) and the SHA-256 from the table above."
;;
*)
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>... | release <secret.key> <version> [--src DIR] [--out DIR]}"
;;
esac

View File

@@ -133,7 +133,7 @@ pkgs_core_arch="base-devel cmake git pkg-config
libxkbcommon wayland libsodium curl
autoconf automake libtool wget python xxd"
pkgs_core_macos="cmake python xxd"
pkgs_core_macos="bash cmake python xxd"
# Windows cross-compile (from Linux)
pkgs_win_debian="mingw-w64 zip"
@@ -284,6 +284,14 @@ fi
header "Windows Cross-Compile"
if $SETUP_WIN; then
# Only touch apt / update-alternatives (which need sudo) when the toolchain is missing. If it is
# already installed, skip them so `./setup.sh --win` can run WITHOUT sudo — important because the
# daemon cross-compile that follows should run as the invoking user. Running the whole setup under
# sudo leaves root-owned build artifacts under external/dragonx, which then break `make clean` on
# a later non-sudo build (stale objects get relinked -> the mingw link failure recurs).
if has_cmd x86_64-w64-mingw32-g++-posix || has_cmd x86_64-w64-mingw32-g++; then
ok "Windows cross-compile toolchain already present — skipping apt install"
else
win_pkgs="$(get_pkgs win)"
if [[ -n "$win_pkgs" ]]; then
install_pkgs "$win_pkgs" "Windows cross-compile"
@@ -298,6 +306,7 @@ if $SETUP_WIN; then
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
fi
fi
fi
# Fetch libsodium for Windows
if [[ ! -f "$PROJECT_DIR/libs/libsodium-win/lib/libsodium.a" ]]; then
@@ -391,11 +400,26 @@ elif $SETUP_SAPLING; then
SPEND_URL="https://z.cash/downloads/sapling-spend.params"
OUTPUT_URL="https://z.cash/downloads/sapling-output.params"
# Consensus-critical MPC parameters with fixed, well-known SHA-256 (identical across every
# Zcash-family node; also pinned in scripts/build-lite-backend-artifact.sh). z.cash is
# plain HTTPS with no signature, so verify the digest and refuse a tampered/corrupt file.
SPEND_SHA256="8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"
OUTPUT_SHA256="2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"
curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" && \
ok "Downloaded sapling-spend.params"
curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" && \
ok "Downloaded sapling-output.params"
if curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" \
&& echo "${SPEND_SHA256} $PARAMS_DIR/sapling-spend.params" | sha256sum -c --status; then
ok "Downloaded + verified sapling-spend.params"
else
rm -f "$PARAMS_DIR/sapling-spend.params"
err "sapling-spend.params download or SHA-256 verification failed — not installed"
fi
if curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" \
&& echo "${OUTPUT_SHA256} $PARAMS_DIR/sapling-output.params" | sha256sum -c --status; then
ok "Downloaded + verified sapling-output.params"
else
rm -f "$PARAMS_DIR/sapling-output.params"
err "sapling-output.params download or SHA-256 verification failed — not installed"
fi
fi
else
skip "Sapling params not found (use --sapling to download, or they'll be extracted at runtime from embedded builds)"
@@ -684,11 +708,13 @@ if [[ "$STALE_DAEMON" -eq 1 ]]; then
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
fi
# ── 7. xmrig-hac (mining binary) ────────────────────────────────────────────
header "xmrig-hac Mining Binary"
# ── 7. drg-xmrig (mining binary) ────────────────────────────────────────────
header "drg-xmrig Mining Binary"
XMRIG_SRC="$PROJECT_DIR/external/xmrig-hac"
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/xmrig-hac"
XMRIG_SRC="$PROJECT_DIR/external/drg-xmrig"
# Output dir bundled by build.sh (Linux zip, AppImage, Windows embed, mac .app)
# and scripts/legacy/build-windows.sh — keep this path in sync with those.
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/drg-xmrig"
# Clean previous prebuilt xmrig binaries so we always rebuild
# Only clean the binary for the platform(s) we are actually building,
@@ -700,14 +726,14 @@ if ! $CHECK_ONLY; then
fi
fi
# Helper: clone xmrig-hac if not present
# Helper: clone drg-xmrig if not present
clone_xmrig_if_needed() {
if [[ ! -d "$XMRIG_SRC" ]]; then
info "Cloning xmrig-hac..."
git clone https://git.dragonx.is/dragonx/xmrig-hac.git "$XMRIG_SRC"
info "Cloning drg-xmrig..."
git clone https://git.dragonx.is/DragonX/drg-xmrig.git "$XMRIG_SRC"
else
ok "xmrig-hac source already present"
info "Pulling latest xmrig-hac..."
ok "drg-xmrig source already present"
info "Pulling latest drg-xmrig..."
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
fi
}
@@ -728,15 +754,15 @@ else
rm -rf "$XMRIG_SRC/build"
# Build dependencies (libuv, hwloc, openssl)
info "Building xmrig-hac dependencies (libuv, hwloc, openssl)..."
info "Building drg-xmrig dependencies (libuv, hwloc, openssl)..."
(
cd "$XMRIG_SRC/scripts"
sh build_deps.sh
)
ok "xmrig-hac dependencies built"
ok "drg-xmrig dependencies built"
# Build xmrig
info "Building xmrig-hac (Linux)..."
info "Building drg-xmrig (Linux)..."
mkdir -p "$XMRIG_SRC/build"
(
cd "$XMRIG_SRC/build"
@@ -753,7 +779,7 @@ else
mkdir -p "$XMRIG_PREBUILT"
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX"
ok "xmrig (Linux) built and installed to prebuilt-binaries/xmrig-hac/"
ok "xmrig (Linux) built and installed to prebuilt-binaries/drg-xmrig/"
else
err "xmrig (Linux) build failed — binary not found"
MISSING=$((MISSING + 1))
@@ -777,7 +803,7 @@ else
# Clean previous Windows build
rm -rf "$XMRIG_SRC/build-windows"
info "Building xmrig-hac (Windows cross-compile)..."
info "Building drg-xmrig (Windows cross-compile)..."
(
cd "$XMRIG_SRC/scripts"
bash build_windows.sh
@@ -787,7 +813,7 @@ else
mkdir -p "$XMRIG_PREBUILT"
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN"
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/xmrig-hac/"
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/drg-xmrig/"
else
err "xmrig.exe (Windows) build failed — binary not found"
MISSING=$((MISSING + 1))
@@ -797,7 +823,7 @@ fi
# ── 8. Binary directories ───────────────────────────────────────────────────
header "Binary Directories"
for platform in dragonxd-linux dragonxd-win dragonxd-mac xmrig; do
for platform in dragonxd-linux dragonxd-win dragonxd-mac drg-xmrig; do
dir="$PROJECT_DIR/prebuilt-binaries/$platform"
if [[ -d "$dir" ]]; then
# Count actual files (not .gitkeep)

File diff suppressed because it is too large Load Diff

386
src/app.h
View File

@@ -13,6 +13,8 @@
#include <chrono>
#include <unordered_map>
#include <unordered_set>
#include <deque>
#include <condition_variable>
#include <nlohmann/json_fwd.hpp>
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
@@ -70,6 +72,19 @@ enum class EncryptDialogPhase {
Done // Finished — close dialog
};
// A status string written by a background/worker thread and read every frame by the UI thread. Its
// operator= locks, so all the plain `x = "..."` assignment sites stay unchanged; readers call get()
// for a consistent per-frame snapshot instead of racing a non-atomic std::string. (M-05, L-06)
class GuardedStatus {
public:
GuardedStatus() = default;
GuardedStatus& operator=(std::string v) { std::lock_guard<std::mutex> lk(m_); v_ = std::move(v); return *this; }
std::string get() const { std::lock_guard<std::mutex> lk(m_); return v_; }
private:
mutable std::mutex m_;
std::string v_;
};
/**
* @brief Main application class
*
@@ -78,6 +93,7 @@ enum class EncryptDialogPhase {
class App {
public:
App();
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
~App();
// Non-copyable
@@ -139,10 +155,17 @@ public:
* @brief Whether we are in the shutdown phase
*/
bool isShuttingDown() const { return shutting_down_; }
// True while the wallet-switch progress modal is open — keeps the frame loop redrawing so its live
// phase/spinner update in real time even when the app is otherwise idle.
bool isWalletSwitchInProgress() const { return wallet_switch_dialog_open_.load(); }
wallet::WalletCapabilities walletCapabilities() const { return wallet::currentWalletCapabilities(); }
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
// W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state)
// for the "Copy diagnostics" action. Contains no secrets.
std::string buildDiagnosticsReport();
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
@@ -152,6 +175,21 @@ public:
*/
void renderShutdownScreen();
/**
* @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail).
* Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we
* attached to rather than spawned — so the user can still see the node flushing/exiting.
*/
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
// True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort
// heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch.
bool daemonWitnessRebuildActive() const;
// Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress).
bool shouldConfirmDaemonStop() const;
// The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
void renderDaemonStopConfirm();
/**
* @brief Render loading overlay in content area while daemon is starting/syncing
* @param contentH Height of the content area child window
@@ -168,6 +206,9 @@ public:
daemon::EmbeddedDaemon* consoleDaemon();
daemon::XmrigManager* consoleXmrig();
config::Settings* settings() { return settings_.get(); }
// Request a font-atlas rebuild before the next frame (e.g. after toggling color emoji). Handled in
// preFrame() via Typography::reload — safe to call from UI code mid-frame.
void requestFontRebuild() { font_rebuild_requested_ = true; }
// Lite wallet controller (non-null only in lite builds with a linked backend).
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
// HushChat service (identity + in-memory message store); the Chat tab reads its store.
@@ -176,6 +217,9 @@ public:
// message (to a conversation whose peer key we know) / a new-conversation contact request.
void sendChatMessage(const std::string& conversationId, const std::string& text);
void startChatConversation(const std::string& peerZaddr, const std::string& text);
// Send a contact request into an existing conversation (used to retry a failed request in place).
void sendContactRequestForCid(const std::string& cid, const std::string& peerZaddr,
const std::string& text);
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
// the chat feature is off.
@@ -198,10 +242,16 @@ public:
data::AddressBook& addressBook() { return address_book_; }
const data::AddressBook& addressBook() const { return address_book_; }
// Hash of the active wallet's identity (derived from its address list), used to scope
// per-wallet data (e.g. address-book contacts). Empty until addresses are known (pre-connect).
// Hash of the active wallet's identity (derived from its address list). This is the tx-history
// cache key — it changes when the address set changes, so DON'T scope persistent user data on it.
std::string activeWalletIdentityHash() const;
// Stable per-wallet id ("w:"+hex) for scoping persistent per-wallet data (address-book contacts).
// Generated once and persisted in the wallet index (keyed by wallet file), never recomputed from
// the mutable address set — so creating addresses / locking / disconnecting never changes it.
// Empty only when there is no active wallet file. Establishes + persists the id on first call.
std::string activeWalletScopeId();
data::WalletIndex& walletIndex() { return wallet_index_; }
const data::WalletIndex& walletIndex() const { return wallet_index_; }
@@ -300,7 +350,17 @@ public:
void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback);
// callback receives (keys, exportedCount, totalAddresses) so callers can detect a keyless/partial export.
void exportAllKeys(std::function<void(const std::string&, int, int)> callback);
void importPrivateKey(const std::string& key, std::function<void(bool, const std::string&)> callback);
// callback(success, errorOrEmpty, importedAddress). address is "" on failure or when the RPC
// returns none; the import routes to z_importviewingkey / z_importkey / importprivkey by key type.
// startHeight > 0 rescans from that block (shielded RPCs only; ignored for transparent WIF).
void importPrivateKey(const std::string& key, int startHeight,
std::function<void(bool, const std::string&, const std::string&)> callback);
// Sweep a spending key: import it (rescan) then z_sendmany ALL its funds (balance fee) to a
// destination you own — a freshly generated shielded address when destMode == 0, else destExisting.
// Drives the sweep_step_ / sweep_status_ / sweep_txid_ state; reuses the async-operation tracker.
void sweepPrivateKey(const std::string& key, int startHeight, int destMode,
const std::string& destExisting);
// Wallet backup
void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback);
@@ -335,6 +395,13 @@ public:
// Fetch historical USD price series (CoinGecko market_chart) that back the portfolio
// sparkline intervals; self-throttled to ~30 min. Safe to call every frame.
void refreshMarketChart();
// Fetch the SELECTED pair's candles from that exchange's own API (data/exchange_candles.h) so the
// Market chart shows the real per-exchange price. Re-fetches on pair change; falls back to the
// CoinGecko aggregate for unmapped venues / failed fetches. Safe to call every frame.
void refreshExchangeChart();
// True while a market price-history fetch is in flight (CoinGecko aggregate OR per-exchange). The
// Market chart shows a loading indicator instead of the empty state during pair switches.
bool isMarketChartLoading() const { return chart_fetch_in_flight_ || exchange_chart_fetch_in_flight_; }
/// @brief Per-category refresh intervals, adjusted by active tab
using RefreshIntervals = services::NetworkRefreshService::Intervals;
@@ -356,6 +423,9 @@ public:
// each under every skin. Output: <config>/screenshots-full/<surface>/<skin>.png + an index.
void startFullUiSweep();
std::string screenshotFullDir() const;
// Debug option: restrict either sweep to just the currently-active theme instead of cycling all.
bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; }
void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; }
bool isScreenshotSweeping() const { return screenshot_sweep_active_; }
bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; }
const std::string& screenshotSweepPath() const { return sweep_current_path_; }
@@ -363,7 +433,8 @@ public:
std::string screenshotDir() const; // <config>/screenshots (fixed; sweeps overwrite in place)
// Dialog triggers (used by settings page to open modal dialogs)
void showImportKeyDialog() { show_import_key_ = true; }
void showImportKeyDialog() { import_view_mode_ = false; show_import_key_ = true; } // spending
void showImportViewingKeyDialog() { import_view_mode_ = true; show_import_key_ = true; } // watch-only
void showExportKeyDialog() { show_export_key_ = true; }
void showBackupDialog() { show_backup_ = true; }
void showSeedBackupDialog() { show_seed_backup_ = true; }
@@ -371,6 +442,14 @@ public:
// 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; }
// Authoritative BIP39-mnemonic status of the ACTIVE wallet, decided at runtime by z_exportmnemonic
// (not the offline file probe, which can't tell an HD-but-no-mnemonic wallet from a seed-phrase one).
// 0 = unknown/undecidable, 1 = has a seed phrase, 2 = legacy (no mnemonic).
int activeWalletSeedBadge() const {
if (wallet_seed_status_ == WalletSeedStatus::HasMnemonic) return 1;
if (wallet_seed_status_ == WalletSeedStatus::NoMnemonic) return 2;
return 0;
}
void showAboutDialog() { show_about_ = true; }
// Legacy tab compat — maps int to NavPage
@@ -389,6 +468,12 @@ public:
// Embedded daemon control
bool startEmbeddedDaemon();
void stopEmbeddedDaemon();
// Stop the node specifically for a wallet switch, which MUST free the RPC port to relaunch on
// -wallet=<name>. Unlike stopEmbeddedDaemon() (whose DisconnectOnly policy leaves an adopted/external
// daemon running), this sends a graceful RPC "stop" — using our own connection creds, so only our
// daemon obeys it — even to an adopted daemon, then waits (bounded) for the port to actually release.
// Returns true once the RPC port is free (or we're shutting down).
bool stopDaemonForWalletSwitch();
bool isEmbeddedDaemonRunning() const;
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
@@ -431,6 +516,10 @@ public:
// Coin logo texture accessor (DragonX currency icon for balance tab)
ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; }
// DragonX custom chat emoji (the ":drgx:" shortcode) — the mark recolored to the theme accent (like
// the logo), re-rasterized on theme change. Used by the emoji picker tile + inline in chat bubbles.
ImTextureID getDrgxEmojiTexture() const { return drgx_emoji_tex_; }
/**
* @brief Reload theme images (background gradient + logo) from new paths
* @param bgPath Path to background image override (empty = use default)
@@ -438,6 +527,10 @@ public:
*/
void reloadThemeImages(const std::string& bgPath, const std::string& logoPath);
// Load / recolor-per-theme the DragonX header logo (SVG rasterized to the theme accent). Called at
// the top of render() so the wizard, lock screen, and main header all show it.
void ensureLogoTexture();
// Wizard / first-run
WizardPhase getWizardPhase() const { return wizard_phase_; }
bool isFirstRun() const;
@@ -456,7 +549,11 @@ public:
// Switch the active wallet: persist the new -wallet=<name>, stop the node, restart on it
// (rescan only if it was never synced in this datadir). Per-wallet data follows automatically
// via the identity-scoped caches (P1). No-op if that wallet is already active.
void switchToWallet(const std::string& walletFile);
// stopDaemonConfirmed: skip the "stop the running node?" confirmation (set true when re-entered from
// that dialog). salvage: start the target node with -salvagewallet (repair a corrupt wallet).
void switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed = false, bool salvage = false);
// Main-thread continuation: if a wallet switch's daemon failed to start, revert active_wallet_file.
void processWalletSwitchRevert();
// Wallet encryption helpers
void encryptWalletWithPassphrase(const std::string& passphrase);
@@ -513,6 +610,9 @@ public:
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
void copySecretToClipboard(const std::string& secret);
void pumpSecretClipboardClear();
// Immediately clear the clipboard if it still holds the armed secret (ignores the 45s timer).
// Called on app shutdown so a copied key/seed does not outlive the process in the OS clipboard.
void clearSecretClipboardIfArmed();
bool isTransactionRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
}
@@ -554,10 +654,13 @@ private:
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row).
// When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a
// retry of a retry is reported as a real error.
// background=true (autonomous chat sends / note-buffer splits): keep the single-flight + opid
// accounting but DON'T raise the global "transaction in progress" UI (status is on the chat message).
void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
const std::string& memo, const nlohmann::json& recipients,
const char* traceLabel, bool markFeeGapRetry,
std::function<void(bool, const std::string&)> callback);
std::function<void(bool, const std::string&)> callback,
bool background = false);
void markPendingSendTransactionSucceeded(const std::string& opid,
const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids,
@@ -635,29 +738,135 @@ private:
chat::ChatDatabase chat_db_; // persistent backing (seed-derived encryption at rest)
bool chat_identity_provisioned_ = false; // identity set on the service this session
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
// Bumped by resetChatSession() on every wallet change; an in-flight identity fetch captures the
// value at post time and its completion callback discards its (previous-wallet) secret if the id
// no longer matches — so wallet A's seed can't be provisioned under wallet B via a stale job.
int chat_session_generation_ = 0;
bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet)
// Per-conversation "last seen" watermark (message timestamp) for unread tracking (Q1). Updated when a
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
// wallets. In-memory only (resets on app restart).
std::map<std::string, std::int64_t> chat_seen_watermark_;
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
// rapid sends run out of verified funds. We keep a buffer of ~kChatBufferTarget small self-notes so a
// burst of messages each spends a separate verified note, refilled from change + background self-
// splits. Chat sends, self-splits and user sends share the wallet's single send channel; inflight_op_
// + (lite) lite_send_callback_ / (full node) send_submissions_in_flight_+pending_opids_ serialize them
// so exactly one send is ever outstanding. Implemented in app_network.cpp; pumped from update().
enum class LiteOpKind { None, ChatSend, ContactRequest, Split, UserSend };
struct LiteInflightOp {
LiteOpKind kind = LiteOpKind::None;
std::string echoLocalId; // ChatSend/ContactRequest: the echo to resolve when it completes
int sessionGen = 0; // chat_session_generation_ snapshot at submit (stale-guard)
double submittedAt = 0.0;
};
struct QueuedChatOp {
LiteOpKind kind = LiteOpKind::ChatSend;
chat::OutgoingChatMemos memos; // kept so a transient-funds retry re-broadcasts, never recomposes
std::string echoLocalId;
int sessionGen = 0;
int retries = 0;
};
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
int chat_verified_note_budget_ = 0;
int chat_pipeline_note_count_ = 0; // verified + maturing self-notes (drives shouldSplit)
std::uint64_t chat_verified_shielded_zat_ = 0; // verified shielded balance (split affordability)
bool chat_note_model_seen_ = false; // saw a refresh/scan carrying per-note visibility
// Single-split-in-flight guard: a self-split's OUTPUT notes are invisible until mined (~1 block), far
// longer than any wall-clock cooldown — so we permit only ONE outstanding split and clear the flag when
// the pipeline recovers (outputs mined) or a watchdog expires (a split that never mines mustn't wedge
// refill forever). Prevents runaway splitting that would drain balance into fees.
bool chat_split_outstanding_ = false;
double chat_split_submitted_at_ = 0.0; // ImGui time the outstanding split was submitted (watchdog)
// Full-node only: a coordinator-owned z_listunspent worker scan feeds the per-note counts (the shared
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
bool chat_note_scan_in_flight_ = false;
double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit)
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
std::int64_t chat_last_chain_height_ = 0;
int chat_fast_scan_last_seen_ = -1; // dedup: last memo-note count logged by the 0-conf scan
// Coordinator helpers (both variants unless noted; see app_network.cpp).
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
bool shouldSplitChatBuffer();
bool broadcastSelfSplitLite(int noteCount); // lite: self-send minting noteCount reply-address notes
bool broadcastSelfSplitNode(int noteCount); // full node: z_sendmany self-send minting noteCount notes
void enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId);
void onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error);
static bool isTransientVerifiedFundsError(const std::string& error);
int chatConfsRequired() const; // verified-note confs threshold: 5 (lite) / 1 (full node)
public:
// Status-bar summary of the chat note buffer (empty when not applicable). Shown while the Chat tab is
// active so the buffer's state (ready / building / sending) is visible.
std::string chatBufferStatusText();
private:
public:
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
// feature is off / no identity.
int chatUnreadCount() const;
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
private:
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
// feature is off, already provisioned, in flight, or unavailable.
void maybeProvisionChatIdentity();
// Drop the in-memory chat identity + decrypted message store, lock the chat DB, and re-arm
// provisioning. MUST be called whenever the loaded wallet changes (switch / seed-migration adopt)
// so wallet A's private chat can't surface — or be signed with A's keys — under wallet B.
void resetChatSession();
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
void maybeRemindSeedBackup();
void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once
void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert
void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files
static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02)
void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02)
// Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved).
void beginCreateSeedWallet(); // starts the isolated create on a background thread
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
// W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
// re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
// opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
void provisionChatIdentityFromSecret(std::string secret);
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat
std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr chat IDENTITY (reply-to)
// A spendable z-address that can actually PAY the fee (balance >= fee), preferring the identity
// reply address. The reply-to in the memo stays the identity address, so paying from a different
// funded note is transport-transparent. Empty if no z-address can cover the fee.
std::string chatPayFromZaddr(double fee) const;
std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos); // returns true if submitted
// Broadcast the memos and, when the async op resolves, flip the echo (echoLocalId) to Sent/Failed.
bool broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::string& echoLocalId); // true if submitted
bool broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send
void ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model); // lite chat receive harvest
// Full-node 0-conf fast path: re-scan just the chat reply address at minconf=0 every refresh cycle
// so incoming messages surface at mempool speed (before a block). Hidden conversations are skipped
// (they still un-hide via the normal confirmed harvest). Self-gated; no-op without a chat identity.
void fastScanChatMemos();
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
float chat_fast_scan_accum_ = 0.0f; // seconds since the last fast-scan (dedicated ~2.5s poll)
bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame()
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
bool lite_firstrun_dismissed_ = false;
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
@@ -666,6 +875,16 @@ private:
bool lite_startup_lock_checked_ = false;
std::unique_ptr<daemon::DaemonController> daemon_controller_;
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
// Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on
// this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start
// ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13)
std::thread mining_ctl_thread_;
std::mutex mining_ctl_mutex_;
std::condition_variable mining_ctl_cv_;
std::deque<std::function<void()>> mining_ctl_queue_;
bool mining_ctl_stop_ = false;
void postMiningControl(std::function<void()> job); // enqueue a blocking xmrig op onto the FIFO thread
void stopMiningControlThread(); // signal + join the control thread (shutdown)
// Auto-balance runtime state (pool mining, full-node only). The service fetches
// pool hashrates off-thread; the RNG drives the weighted-random pick.
util::PoolStatsService pool_stats_service_;
@@ -675,6 +894,19 @@ private:
bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied
bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch
bool chart_fetch_in_flight_ = false; // a market_chart history fetch is on the worker
// Per-exchange candle chart (refreshExchangeChart): which pair the loaded series is for, an
// in-flight guard, and a slow refresh timer (candles move slowly, like the aggregate chart).
std::string exchange_chart_key_; // "<identifier>:<BASE>/<QUOTE>" of the loaded series ("" = none)
bool exchange_chart_fetch_in_flight_ = false;
std::chrono::steady_clock::time_point exchange_chart_last_fetch_{};
// Per-pair candle cache: switching back to a recently-viewed venue loads instantly (no re-fetch)
// instead of overwriting the single active buffer. Keyed like exchange_chart_key_.
struct ExchangeChartCache {
std::vector<std::pair<std::time_t, double>> closeIntraday, closeDaily;
std::vector<data::Candle> ohlcIntraday, ohlcDaily;
std::chrono::steady_clock::time_point fetchedAt{};
};
std::unordered_map<std::string, ExchangeChartCache> exchange_chart_cache_;
util::AsyncTaskManager async_tasks_;
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
@@ -685,8 +917,13 @@ private:
std::atomic<bool> shutting_down_{false};
std::atomic<bool> shutdown_complete_{false};
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
std::string shutdown_status_;
GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
std::thread shutdown_thread_;
// Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm)
bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal
bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
float shutdown_timer_ = 0.0f;
bool force_quit_confirm_ = false;
std::chrono::steady_clock::time_point shutdown_start_time_;
@@ -694,6 +931,78 @@ private:
// Daemon restart (e.g. after changing debug log categories)
std::atomic<bool> daemon_restarting_{false};
// Wallet-switch failure recovery: the switch worker sets wallet_switch_failed_ when the new
// wallet's daemon won't start/stay up; the main loop then reverts active_wallet_file to
// wallet_switch_prev_file_ (settings writes stay on the main thread) so a broken wallet isn't
// persisted across restarts. daemon_restarting_ stays set until the revert re-arms the reconnect.
std::atomic<bool> wallet_switch_failed_{false};
// Distinguishes the failure reason for the revert message: true when the switch failed because the
// running node wouldn't release the RPC port in time (not because the target wallet is bad).
std::atomic<bool> switch_stop_failed_{false};
// True from a switch until the new wallet's daemon actually connects (onConnected). If the daemon
// instead crash-wedges (a wallet that fails LATE in init, past the fast start grace), the connect
// loop flags a revert so a broken wallet still can't stick.
std::atomic<bool> wallet_switch_pending_confirm_{false};
std::string wallet_switch_prev_file_;
// Confirm-before-stop for a switch that would stop an ADOPTED (externally-running) node. switchToWallet
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
bool show_switch_stop_daemon_confirm_ = false;
std::string pending_switch_wallet_file_;
// Block-database recovery: set when the embedded node aborts because its block DB is unreadable
// (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
// connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
// Wallet auto-recovery: the daemon moved wallet.dat to wallet.<ts>.bak and loaded a salvaged copy
// (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
// Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior
// run, or under an external daemon whose startup output we never captured): if the active wallet loads
// empty while a sibling wallet file in the datadir still holds keys, warn once so the user's funds
// (likely in a wallet.<ts>.bak) aren't mistaken for loss. See maybeWarnEmptyWalletWithFundedSiblings().
struct FundedSibling { std::string fileName; int transparentKeys = 0; int shieldedKeys = 0; };
bool show_empty_wallet_warning_ = false; // auto-shown warning modal
bool empty_wallet_warn_checked_ = false; // evaluated this wallet-open already (reset in onConnected)
bool empty_wallet_scan_in_flight_ = false; // a sibling scan is running (main-thread only)
bool empty_wallet_has_salvage_bak_ = false; // modal variant: a funded salvage .bak → offer Restore
std::vector<FundedSibling> empty_wallet_funded_siblings_; // scan result (main-thread only)
// The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/
// restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
// only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
enum class RecoveryPhase { Offer, Working, Done, Failed };
RecoveryPhase recovery_phase_ = RecoveryPhase::Offer;
int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed
std::string recovery_outcome_msg_; // honest result string for the Done/Failed body
bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option")
// After a successful repair the daemon restarts with a full rescan — minutes long, and it won't
// answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen
// (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the
// daemon-crash toast. Set on repair success; cleared on connect (onConnected).
bool post_recovery_rescan_ = false;
double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime)
// "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
// (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
std::mutex wallet_restore_mutex_;
bool wallet_restore_done_ = false;
int wallet_restore_severity_ = 0;
std::string wallet_restore_msg_;
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
// error_ is written only on the main thread (processWalletSwitchRevert) and read by the (main-thread) UI.
enum class WalletSwitchPhase : int { None = 0, Stopping, Starting, Reconnecting, Failed };
std::atomic<int> wallet_switch_phase_{0};
std::atomic<bool> wallet_switch_dialog_open_{false};
std::string wallet_switch_error_;
double wallet_switch_started_time_ = 0.0; // ImGui::GetTime() captured on first progress frame (elapsed)
// Set when a failed switch's node output indicates the target wallet is CORRUPT — the Failed modal then
// offers a one-click "-salvagewallet" repair, retrying the switch to wallet_switch_target_file_.
std::atomic<bool> switch_wallet_corrupt_{false};
std::string wallet_switch_target_file_; // the wallet we were switching TO (for a salvage retry)
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
std::atomic<int> pending_delete_result_{-1};
@@ -719,6 +1028,7 @@ private:
bool seed_backup_loading_ = false;
bool seed_backup_no_mnemonic_ = false;
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
// 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
@@ -777,11 +1087,28 @@ private:
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
// Export/Import state
std::string export_result_;
char export_result_[256] = {0}; // SECRET exported key — fixed buffer so it can be sodium_memzero'd
bool export_in_progress_ = false; // async key fetch running (spinner + disable Export)
bool export_error_ = false; // last export returned no key (locked wallet / failure)
char import_key_input_[512] = {0};
std::string export_address_;
std::string import_status_;
bool import_success_ = false;
bool import_key_reveal_ = false; // show the key in plaintext (default masked)
bool import_in_progress_ = false; // an import + rescan is running (disable/spinner)
std::string import_result_address_; // address imported on success (shown as a copy field)
bool import_view_mode_ = false; // dialog mode: false = spending key, true = viewing key
char import_key_scan_height_[16] = {0}; // optional rescan start height (shielded spend / viewing-key imports)
// --- Sweep: import a spending key then move all its funds to one of your own addresses, instead
// of keeping the key in the wallet (spending-key / non-view mode only). ---
bool import_sweep_mode_ = false; // sweep instead of a plain import
int sweep_dest_mode_ = 0; // destination: 0 = fresh shielded address, 1 = an existing one
char sweep_dest_pick_[128] = {0}; // chosen existing destination (sweep_dest_mode_ == 1)
enum class SweepStep { Idle, Running, Done, Error };
SweepStep sweep_step_ = SweepStep::Idle;
std::string sweep_status_; // progress / error text
std::string sweep_txid_; // sweep transaction id (on success)
std::string sweep_dest_shown_; // the destination address the funds were swept to
std::string backup_status_;
bool backup_success_ = false;
@@ -795,10 +1122,22 @@ private:
bool daemon_start_error_shown_ = false;
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
// off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan
bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
std::uint64_t clipboard_secret_hash_ = 0;
double clipboard_clear_deadline_ = 0.0;
float loading_timer_ = 0.0f; // spinner animation for loading overlay
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
// Current page (sidebar navigation)
ui::NavPage current_page_ = ui::NavPage::Overview;
@@ -808,6 +1147,7 @@ private:
// Debug screenshot sweep state.
bool screenshot_sweep_active_ = false;
bool sweep_current_theme_only_ = false; // Debug Options: sweep only the active theme
bool sweep_capture_this_frame_ = false;
int sweep_skin_idx_ = 0;
int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture
@@ -867,6 +1207,9 @@ private:
int logo_h_ = 0;
bool logo_loaded_ = false;
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded
ImU32 logo_accent_ = 0; // theme accent the SVG logo was last rasterized with (re-render on change)
ImTextureID drgx_emoji_tex_ = 0; // ":drgx:" custom chat emoji (themed to the accent, like the logo)
int drgx_emoji_w_ = 0, drgx_emoji_h_ = 0;
// Coin logo texture (DragonX currency icon, separate from wallet branding)
ImTextureID coin_logo_tex_ = 0;
@@ -994,8 +1337,8 @@ private:
services::WalletSecurityWorkflow wallet_security_workflow_;
// Wizard: stopping an external daemon before bootstrap
bool wizard_stopping_external_ = false;
std::string wizard_stop_status_;
std::atomic<bool> wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06)
GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06)
// PIN vault
std::unique_ptr<util::SecureVault> vault_;
@@ -1061,6 +1404,13 @@ private:
// Private methods - rendering
void renderStatusBar();
// Persistent node/RPC error strip at the top of the content column when the wallet can't
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
void renderNodeStatusBanner();
// Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
// newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
void renderAlertHistoryPanel();
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
void renderImportKeyDialog();
@@ -1077,6 +1427,16 @@ private:
void renderDecryptWalletDialog();
void renderPinDialogs();
void renderAntivirusHelpDialog();
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds
void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)
bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
void processDeferredEncryption();
// Private methods - connection
@@ -1107,6 +1467,8 @@ private:
void refreshPrice();
void refreshWalletEncryptionState();
void applyRefreshPolicy(ui::NavPage page);
bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost?
bool currentPageNeedsWalletDataRefresh() const;
bool shouldRunWalletTransactionRefresh() const;
bool shouldRefreshTransactions() const;

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,10 @@
#include <ctime>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <vector>
#include <utility>
#include <sodium.h>
#include <functional>
#include <memory>
#include <utility>
@@ -234,6 +238,41 @@ private:
// daemon off the main thread (to avoid stalling the UI), or ask the user to
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
// processDeferredEncryption(); must be called on the main thread.
// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is
// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
void App::scrubAndRemoveExport(const std::string& path)
{
if (path.empty()) return;
std::error_code ec;
const auto sz = std::filesystem::file_size(path, ec);
if (!ec && sz > 0) {
std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out);
if (scrub) {
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
scrub.flush();
}
}
std::filesystem::remove(path, ec);
}
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch.
void App::sweepStaleDecryptExports()
{
std::error_code ec;
const std::string dir = util::Platform::getDragonXDataDir();
std::filesystem::directory_iterator it(dir, ec), end;
for (; it != end; it.increment(ec)) {
if (ec) break;
const std::string name = it->path().filename().string();
if (name.rfind("obsidiandecryptexport", 0) == 0) {
scrubAndRemoveExport(it->path().string());
DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str());
}
}
}
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
if (isUsingEmbeddedDaemon()) {
if (announceRestartStatus) {
@@ -241,16 +280,23 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
// the daemon is restarting.
connection_status_ = TR("restarting_after_encryption");
}
// Gate the main-loop reconnect while the daemon is down (so tryConnect can't hit the stopped
// node or start a duplicate) and block a concurrent wallet switch/rescan, which check this flag.
daemon_restarting_ = true;
// Give daemon a moment to shut down, then restart
// (do this off the main thread to avoid stalling the UI)
async_tasks_.submit(taskName, [this](const util::AsyncTaskManager::Token& token) {
// daemon_restarting_ MUST be cleared on every exit (incl. an early-out or a throw), else it
// stays stuck true and wedges reconnect + all future switch/rescan/encryption operations.
try {
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if (token.cancelled() || shutting_down_) return;
if (!token.cancelled() && !shutting_down_) {
stopEmbeddedDaemon();
if (token.cancelled() || shutting_down_) return;
startEmbeddedDaemon();
// tryConnect will be called by the update loop
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
}
} catch (...) {}
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
});
} else {
ui::Notifications::instance().warning(
@@ -476,7 +522,17 @@ void App::lockWallet() {
state_.locked = true;
state_.unlocked_until = 0;
resetTransactionHistoryCacheSession();
lock_failure_warned_ = false;
DEBUG_LOGF("[App] Wallet locked\n");
} else {
// The walletlock RPC failed — the wallet is still UNLOCKED. Surface it (once) rather
// than silently leaving an auto-lock unfulfilled and the wallet exposed (W2-4).
DEBUG_LOGF("[App] walletlock failed — wallet remains unlocked\n");
if (!lock_failure_warned_) {
lock_failure_warned_ = true;
ui::Notifications::instance().warning(
"Couldn't lock the wallet — it is still unlocked. Check the daemon connection.", 12.0f);
}
}
};
});
@@ -553,6 +609,12 @@ void App::refreshWalletEncryptionState() {
state_.unlocked_until = until;
state_.locked = (until == 0);
state_.encryption_state_known = true;
// Wallet is encrypted — any pending deferred-encryption request has now been
// satisfied (however it completed). Clear the persisted flag (W2-2).
if (settings_ && settings_->getEncryptionPending()) {
settings_->setEncryptionPending(false);
settings_->save();
}
if (state_.locked) {
resetTransactionHistoryCacheSession();
} else if (state_.transactions.empty()) {
@@ -565,6 +627,19 @@ void App::refreshWalletEncryptionState() {
state_.locked = false;
state_.unlocked_until = 0;
state_.encryption_state_known = true;
// W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted,
// and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a
// failed connect before it applied. Warn (once/session) instead of silently leaving
// an unencrypted wallet the user believes is protected. The flag stays set until the
// wallet is actually encrypted, so the warning recurs each launch until resolved.
if (settings_ && settings_->getEncryptionPending() &&
!wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ &&
!encryption_incomplete_warned_) {
encryption_incomplete_warned_ = true;
ui::Notifications::instance().warning(
"Wallet encryption did not complete — your wallet is NOT encrypted. "
"Open Settings to finish encrypting it.", 30.0f);
}
if (state_.transactions.empty()) {
loadTransactionHistoryCacheIfAvailable();
} else {
@@ -684,6 +759,10 @@ void App::checkIdleMining() {
// Resolve auto values: active defaults to half, idle defaults to all
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
if (idleThreads <= 0) idleThreads = maxThreads;
// Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field
// could otherwise carry an arbitrary count straight past every bound. (M-06)
activeThreads = std::clamp(activeThreads, 1, maxThreads);
idleThreads = std::clamp(idleThreads, 1, maxThreads);
if (systemIdle) {
// System is idle — scale up to idle thread count
@@ -892,8 +971,8 @@ void App::renderLockScreen() {
cy += captionFont->LegacySize + 12.0f * dp;
}
// Check if PIN vault is available
bool hasPinVault = vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
// Check if PIN vault is available (per-wallet vault presence; not the global getPinEnabled flag).
bool hasPinVault = vault_ && vault_->hasVault();
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
if (hasPinVault) {
@@ -1125,35 +1204,33 @@ void App::renderEncryptWalletDialog() {
// Encrypt wallet dialog — multi-phase: passphrase → encrypting → PIN setup
if (show_encrypt_dialog_) {
const char* dlgTitle = (encrypt_dialog_phase_ == EncryptDialogPhase::PinSetup)
? "Quick-Unlock PIN" : "Encrypt Wallet";
? TR("wiz_pin_title") : TR("settings_encrypt_wallet");
// Prevent closing via X button while encrypting
bool canClose = (encrypt_dialog_phase_ != EncryptDialogPhase::Encrypting);
bool* pOpen = canClose ? &show_encrypt_dialog_ : nullptr;
if (BeginOverlayDialog(dlgTitle, pOpen, 460.0f, 0.94f)) {
OverlayDialogSpec ov;
ov.title = dlgTitle; ov.p_open = pOpen;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 480.0f; ov.idSuffix = "encrypt";
if (BeginOverlayDialog(ov)) {
// ---- Phase 1: Passphrase entry ----
if (encrypt_dialog_phase_ == EncryptDialogPhase::PassphraseEntry) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.7f, 0.3f, 1));
ImGui::TextWrapped(ICON_MD_WARNING
" If you lose your passphrase, you lose access to your funds.");
ImGui::PopStyleColor();
DialogWarningHeader(TR("wiz_encrypt_warning"));
ImGui::Spacing();
ImGui::TextWrapped("Encrypting your wallet protects your private keys "
"with a passphrase. After encryption, the daemon will restart.");
ImGui::Spacing();
ImGui::Separator();
ImGui::TextWrapped("%s", TR("enc_desc"));
ImGui::Spacing();
ImGui::Text("Passphrase:");
ImGui::TextUnformatted(TR("wiz_passphrase"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_pass", encrypt_pass_buf_, sizeof(encrypt_pass_buf_),
ImGuiInputTextFlags_Password);
ImGui::PopItemWidth();
ImGui::Text("Confirm:");
ImGui::TextUnformatted(TR("enc_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_confirm", encrypt_confirm_buf_, sizeof(encrypt_confirm_buf_),
ImGuiInputTextFlags_Password);
@@ -1174,7 +1251,7 @@ void App::renderEncryptWalletDialog() {
}
int classes = (int)hasDigit + (int)hasLower + (int)hasUpper + (int)hasSymbol;
const char* strengthLabel = "Weak";
const char* strengthLabel = TR("wiz_strength_weak");
ImVec4 strengthCol(0.9f, 0.2f, 0.2f, 1.0f);
float strengthPct = 0.25f;
int tier = 0; // 0=Weak, 1=Fair, 2=Good, 3=Strong
@@ -1183,12 +1260,12 @@ void App::renderEncryptWalletDialog() {
else if (len >= 8) tier = 1;
// Downgrade one tier when only a single character class is used.
if (classes <= 1 && tier > 0) tier -= 1;
if (tier == 3) { strengthLabel = "Strong"; strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 1.0f; }
else if (tier == 2) { strengthLabel = "Good"; strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 0.75f; }
else if (tier == 1) { strengthLabel = "Fair"; strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
if (tier == 3) { strengthLabel = TR("wiz_strength_strong"); strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 1.0f; }
else if (tier == 2) { strengthLabel = TR("wiz_strength_good"); strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 0.75f; }
else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
float barW = ImGui::GetContentRegionAvail().x;
float barH = 4.0f;
float barH = 4.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1197,7 +1274,7 @@ void App::renderEncryptWalletDialog() {
dl->AddRectFilled(p, ImVec2(p.x + barW * strengthPct, p.y + barH),
ImGui::ColorConvertFloat4ToU32(strengthCol), 2.0f);
ImGui::Dummy(ImVec2(barW, barH));
ImGui::Text("Strength: %s", strengthLabel);
ImGui::Text(TR("wiz_strength"), strengthLabel);
}
if (!encrypt_status_.empty()) {
@@ -1210,7 +1287,7 @@ void App::renderEncryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
if (ui::material::TactileButton("Encrypt Wallet", ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("settings_encrypt_wallet"), ImVec2(btnW, 40))) {
std::string pass(encrypt_pass_buf_);
enc_dlg_saved_passphrase_ = pass;
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
@@ -1222,7 +1299,7 @@ void App::renderEncryptWalletDialog() {
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton("Cancel", ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
show_encrypt_dialog_ = false;
@@ -1231,14 +1308,14 @@ void App::renderEncryptWalletDialog() {
// ---- Phase 2: Encrypting in progress ----
} else if (encrypt_dialog_phase_ == EncryptDialogPhase::Encrypting) {
const char* statusTitle = encrypt_in_progress_
? "Encrypting wallet..." : encrypt_status_.c_str();
? TR("enc_encrypting") : encrypt_status_.c_str();
ImGui::Text("%s", statusTitle);
ImGui::Spacing();
// Indeterminate progress bar
{
float barW = ImGui::GetContentRegionAvail().x;
float barH = 6.0f;
float barH = 6.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1258,7 +1335,7 @@ void App::renderEncryptWalletDialog() {
}
ImGui::Spacing();
ImGui::TextColored(ImVec4(1,1,1,0.4f), "Please wait, do not close the application.");
ImGui::TextColored(ImVec4(1,1,1,0.4f), "%s", TR("enc_wait"));
// Transition to PIN phase when encryption finishes successfully
if (!encrypt_in_progress_ && encrypt_dialog_phase_ == EncryptDialogPhase::Encrypting) {
@@ -1268,23 +1345,20 @@ void App::renderEncryptWalletDialog() {
// ---- Phase 3: PIN setup (after successful encryption) ----
} else if (encrypt_dialog_phase_ == EncryptDialogPhase::PinSetup) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 0.9f, 0.5f, 1));
ImGui::Text(ICON_MD_CHECK_CIRCLE " Wallet encrypted successfully!");
ImGui::Text(ICON_MD_CHECK_CIRCLE " %s", TR("enc_success"));
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::TextWrapped("A 4-8 digit PIN lets you unlock your wallet "
"without typing the full passphrase every time.");
ImGui::Spacing();
ImGui::Separator();
ImGui::TextWrapped("%s", TR("enc_pin_desc"));
ImGui::Spacing();
ImGui::Text("PIN (4-8 digits):");
ImGui::TextUnformatted(TR("wiz_pin_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_dlg_pin", enc_dlg_pin_buf_, sizeof(enc_dlg_pin_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
ImGui::Text("Confirm PIN:");
ImGui::TextUnformatted(TR("wiz_pin_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##enc_dlg_pin_confirm", enc_dlg_pin_confirm_buf_,
sizeof(enc_dlg_pin_confirm_buf_),
@@ -1304,20 +1378,24 @@ void App::renderEncryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!pinValid || !hasPassphrase || pin_in_progress_);
if (ui::material::TactileButton("Set PIN", ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
pin_in_progress_ = true;
enc_dlg_pin_status_.clear();
std::string savedPass = enc_dlg_saved_passphrase_;
if (worker_ && vault_) {
worker_->post([this, pinStr, savedPass]() -> rpc::RPCWorker::MainCb {
worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb {
// Argon2id runs here (worker thread)
bool ok = vault_->store(pinStr, savedPass);
// Scrub the captured PIN + passphrase copies (they live in the worker's task
// queue until this runs); the source member is scrubbed in the MainCb. (L-03)
if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size());
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
return [this, ok]() {
if (ok) {
settings_->setPinEnabled(true);
settings_->save();
pin_in_progress_ = false;
ui::Notifications::instance().info("PIN set successfully");
ui::Notifications::instance().info(TR("enc_pin_set_ok"));
// Clean up
if (!enc_dlg_saved_passphrase_.empty()) {
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
@@ -1328,20 +1406,20 @@ void App::renderEncryptWalletDialog() {
memset(enc_dlg_pin_confirm_buf_, 0, sizeof(enc_dlg_pin_confirm_buf_));
show_encrypt_dialog_ = false;
} else {
enc_dlg_pin_status_ = "Failed to create PIN vault";
enc_dlg_pin_status_ = TR("enc_pin_vault_fail");
pin_in_progress_ = false;
}
};
});
} else {
enc_dlg_pin_status_ = "Failed to create PIN vault";
enc_dlg_pin_status_ = TR("enc_pin_vault_fail");
pin_in_progress_ = false;
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton("Skip", ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW, 40))) {
if (!enc_dlg_saved_passphrase_.empty()) {
util::SecureVault::secureZero(&enc_dlg_saved_passphrase_[0],
enc_dlg_saved_passphrase_.size());
@@ -1350,8 +1428,7 @@ void App::renderEncryptWalletDialog() {
memset(enc_dlg_pin_buf_, 0, sizeof(enc_dlg_pin_buf_));
memset(enc_dlg_pin_confirm_buf_, 0, sizeof(enc_dlg_pin_confirm_buf_));
show_encrypt_dialog_ = false;
ui::Notifications::instance().info(
"PIN skipped. You can set one later in Settings.");
ui::Notifications::instance().info(TR("enc_pin_skipped"));
}
}
EndOverlayDialog();
@@ -1367,21 +1444,30 @@ void App::renderEncryptWalletDialog() {
// Change passphrase dialog
if (show_change_passphrase_) {
if (BeginOverlayDialog("Change Passphrase", &show_change_passphrase_, 440.0f, 0.94f)) {
OverlayDialogSpec ov;
ov.title = TR("change_pass_title"); ov.p_open = &show_change_passphrase_;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
if (BeginOverlayDialog(ov)) {
ImGui::Text("Current Passphrase:");
// Same fund-loss consequence as Encrypt/Remove Encryption if the new
// passphrase is lost — reuse their warning string/header for consistency.
DialogWarningHeader(TR("wiz_encrypt_warning"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("change_pass_current"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
ImGuiInputTextFlags_Password);
ImGui::PopItemWidth();
ImGui::Text("New Passphrase:");
ImGui::TextUnformatted(TR("change_pass_new"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_new", change_new_pass_buf_, sizeof(change_new_pass_buf_),
ImGuiInputTextFlags_Password);
ImGui::PopItemWidth();
ImGui::Text("Confirm New:");
ImGui::TextUnformatted(TR("change_pass_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_confirm", change_confirm_buf_, sizeof(change_confirm_buf_),
ImGuiInputTextFlags_Password);
@@ -1395,14 +1481,33 @@ void App::renderEncryptWalletDialog() {
bool valid = strlen(change_old_pass_buf_) > 0 &&
strlen(change_new_pass_buf_) >= 8 &&
strcmp(change_new_pass_buf_, change_confirm_buf_) == 0;
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
if (ui::material::TactileButton("Change Passphrase", ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
changePassphrase(std::string(change_old_pass_buf_),
std::string(change_new_pass_buf_));
}
ImGui::EndDisabled();
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_change_passphrase_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
show_change_passphrase_ = false;
}
EndOverlayDialog();
}
// Wipe the passphrase buffers if the dialog was dismissed (X / Esc /
// outside-click) without submitting. The success path already zeroes
// them in changePassphrase(); the failure path keeps them for retry.
if (!show_change_passphrase_) {
memset(change_old_pass_buf_, 0, sizeof(change_old_pass_buf_));
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
}
}
}
@@ -1427,26 +1532,21 @@ void App::renderDecryptWalletDialog() {
bool canClose = wallet_security_workflow_.canClose();
bool* pOpen = canClose ? &show_decrypt_dialog_ : nullptr;
if (BeginOverlayDialog("Remove Wallet Encryption", pOpen, 480.0f, 0.94f)) {
OverlayDialogSpec ov;
ov.title = TR("decrypt_title"); ov.p_open = pOpen;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 480.0f; ov.idSuffix = "decrypt";
if (BeginOverlayDialog(ov)) {
// ---- Phase 0: Passphrase entry ----
if (decryptState.phase == DecryptPhase::PassphraseEntry) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.7f, 0.3f, 1));
ImGui::TextWrapped(ICON_MD_WARNING
" This will remove encryption from your wallet. "
"Your private keys will be stored unprotected on disk.");
ImGui::PopStyleColor();
DialogWarningHeader(TR("decrypt_warning"));
ImGui::Spacing();
ImGui::TextWrapped(
"The wallet will be exported, the daemon restarted with a fresh "
"unencrypted wallet, and all keys re-imported. This may take "
"several minutes depending on wallet size.");
ImGui::Spacing();
ImGui::Separator();
ImGui::TextWrapped("%s", TR("decrypt_desc"));
ImGui::Spacing();
ImGui::Text("Current Passphrase:");
ImGui::TextUnformatted(TR("change_pass_current"));
ImGui::PushItemWidth(-1);
bool enterPressed = ImGui::InputText("##decrypt_pass", decrypt_pass_buf_,
sizeof(decrypt_pass_buf_), ImGuiInputTextFlags_Password |
@@ -1462,19 +1562,21 @@ void App::renderDecryptWalletDialog() {
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || decryptState.inProgress);
if (ui::material::TactileButton("Remove Encryption", ImVec2(btnW, 40)) || (enterPressed && valid)) {
if (ui::material::TactileButton(TR("settings_remove_encryption"), ImVec2(btnW, 40)) || (enterPressed && valid)) {
std::string passphrase(decrypt_pass_buf_);
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
wallet_security_workflow_.start(std::chrono::steady_clock::now());
// Run entire decrypt flow on worker thread
if (worker_) {
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
return sendStopCommandSafely(client, context);
});
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
// Scrub the passphrase — unlock is its only use in this flow.
if (!passphrase.empty()) sodium_memzero(&passphrase[0], passphrase.size());
if (!unlock.ok) {
return [this]() {
wallet_security_workflow_.failEntry("Incorrect passphrase");
@@ -1544,6 +1646,11 @@ void App::renderDecryptWalletDialog() {
std::chrono::steady_clock::now());
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
// Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path —
// success, a restart-failure early return, or an exception. A full cleartext dump of all
// private keys must never outlive this step. The startup sweep is a further net for a
// crash/kill mid-flight. (H-02)
struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath};
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
[this](rpc::RPCClient& client, const char* context) {
@@ -1597,6 +1704,8 @@ void App::renderDecryptWalletDialog() {
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
importAdapter, exportPath);
// (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
if (!importResult.ok) {
std::string err = importResult.error;
if (worker_) {
@@ -1653,7 +1762,7 @@ void App::renderDecryptWalletDialog() {
ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::TactileButton("Cancel", ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
show_decrypt_dialog_ = false;
}
@@ -1662,11 +1771,11 @@ void App::renderDecryptWalletDialog() {
} else if (decryptState.phase == DecryptPhase::Working) {
// Step checklist
const char* stepLabels[] = {
"Unlocking wallet",
"Exporting wallet keys",
"Stopping daemon",
"Backing up encrypted wallet",
"Restarting daemon"
TR("decrypt_step_unlock"),
TR("decrypt_step_export"),
TR("decrypt_step_stop"),
TR("decrypt_step_backup"),
TR("decrypt_step_restart")
};
const int numSteps = 5;
@@ -1721,7 +1830,7 @@ void App::renderDecryptWalletDialog() {
// Indeterminate progress bar
{
float barW = ImGui::GetContentRegionAvail().x;
float barH = 6.0f;
float barH = 6.0f * ui::Layout::dpiScale();
ImVec2 p = ImGui::GetCursorScreenPos();
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
@@ -1743,10 +1852,9 @@ void App::renderDecryptWalletDialog() {
// Step-specific hints
if (decryptState.step == DecryptStep::RestartDaemon) {
ImGui::TextWrapped("Waiting for the daemon to finish starting up...");
ImGui::TextWrapped("%s", TR("decrypt_wait_restart"));
} else {
ImGui::TextWrapped("Please wait. The daemon is exporting keys, restarting, "
"and re-importing. This may take several minutes.");
ImGui::TextWrapped("%s", TR("decrypt_wait_general"));
}
// Total elapsed
@@ -1763,15 +1871,13 @@ void App::renderDecryptWalletDialog() {
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.5f, 1.0f), ICON_MD_CHECK_CIRCLE);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.5f, 1.0f), "Wallet decrypted successfully!");
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.5f, 1.0f), "%s", TR("decrypt_success_title"));
ImGui::Spacing();
ImGui::TextWrapped(
"Your wallet is now unencrypted. A backup of the encrypted wallet "
"was saved as wallet.dat.encrypted.bak in your data directory.");
ImGui::TextWrapped("%s", TR("decrypt_success_desc"));
ImGui::Spacing();
if (ui::material::TactileButton("Close", ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("close"), ImVec2(-1, 40))) {
show_decrypt_dialog_ = false;
}
@@ -1781,23 +1887,31 @@ void App::renderDecryptWalletDialog() {
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), ICON_MD_ERROR);
ImGui::PopFont();
ImGui::SameLine();
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Decryption failed");
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "%s", TR("decrypt_error_title"));
ImGui::Spacing();
ImGui::TextWrapped("%s", decryptState.status.c_str());
ImGui::Spacing();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ui::material::TactileButton("Try Again", ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("try_again"), ImVec2(btnW, 40))) {
wallet_security_workflow_.reset();
}
ImGui::SameLine();
if (ui::material::TactileButton("Close", ImVec2(btnW, 40))) {
if (ui::material::TactileButton(TR("close"), ImVec2(btnW, 40))) {
show_decrypt_dialog_ = false;
}
}
EndOverlayDialog();
}
// Wipe the passphrase buffer if the dialog was dismissed (X / Esc / outside-
// click) without submitting. The submit and Cancel paths already memset it;
// this covers the BlurFloat dismiss paths. (canClose is false during Working,
// so an in-flight decrypt cannot be dismissed here.)
if (!show_decrypt_dialog_) {
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
}
}
// ===========================================================================
@@ -1809,29 +1923,28 @@ void App::renderPinDialogs() {
// ---- Set PIN dialog ----
if (show_pin_setup_) {
if (BeginOverlayDialog("Set PIN", &show_pin_setup_, 420.0f, 0.94f)) {
OverlayDialogSpec ov;
ov.title = TR("settings_set_pin"); ov.p_open = &show_pin_setup_;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 420.0f; ov.idSuffix = "pinsetup";
if (BeginOverlayDialog(ov)) {
ImGui::TextWrapped(
"Set a 4-8 digit PIN for quick wallet unlock. "
"Your wallet passphrase will be encrypted with this PIN "
"and stored locally.");
ImGui::Spacing();
ImGui::Separator();
ImGui::TextWrapped("%s", TR("pin_setup_desc"));
ImGui::Spacing();
ImGui::Text("Wallet Passphrase:");
ImGui::TextUnformatted(TR("pin_wallet_passphrase"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_passphrase", pin_passphrase_buf_, sizeof(pin_passphrase_buf_),
ImGuiInputTextFlags_Password);
ImGui::PopItemWidth();
ImGui::Text("New PIN (4-8 digits):");
ImGui::TextUnformatted(TR("pin_new_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_new", pin_buf_, sizeof(pin_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
ImGui::Text("Confirm PIN:");
ImGui::TextUnformatted(TR("wiz_pin_confirm"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_confirm", pin_confirm_buf_, sizeof(pin_confirm_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
@@ -1847,8 +1960,10 @@ void App::renderPinDialogs() {
util::SecureVault::isValidPin(pinStr) &&
strcmp(pin_buf_, pin_confirm_buf_) == 0;
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton("Set PIN", ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
pin_in_progress_ = true;
pin_status_ = "Verifying passphrase...";
@@ -1861,12 +1976,14 @@ void App::renderPinDialogs() {
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
if (rpc_ && rpc_->isConnected() && worker_) {
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
// Verify passphrase via RPC (worker thread)
try {
rpc::RPCClient::TraceScope trace("Security / PIN setup");
rpc_->call("walletpassphrase", {passphrase, 5});
} catch (const std::exception& e) {
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
return [this]() {
pin_status_ = "Incorrect passphrase";
pin_in_progress_ = false;
@@ -1875,6 +1992,9 @@ void App::renderPinDialogs() {
// Passphrase correct — store in vault (Argon2id, worker thread)
bool storeOk = vault_ && vault_->store(pin, passphrase);
// Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01)
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
// Lock wallet back
try {
@@ -1902,32 +2022,48 @@ void App::renderPinDialogs() {
}
}
ImGui::EndDisabled();
ImGui::SameLine();
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
// the !show_pin_setup_ cleanup block below.
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
show_pin_setup_ = false;
}
EndOverlayDialog();
}
// Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc /
// outside-click) without submitting. The submit path already memsets them.
if (!show_pin_setup_) {
memset(pin_passphrase_buf_, 0, sizeof(pin_passphrase_buf_));
memset(pin_buf_, 0, sizeof(pin_buf_));
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
}
}
// ---- Change PIN dialog ----
if (show_pin_change_) {
if (BeginOverlayDialog("Change PIN", &show_pin_change_, 420.0f, 0.94f)) {
OverlayDialogSpec ov;
ov.title = TR("settings_change_pin"); ov.p_open = &show_pin_change_;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 420.0f; ov.idSuffix = "pinchange";
if (BeginOverlayDialog(ov)) {
ImGui::TextWrapped("Change your unlock PIN. You need your current PIN and a new PIN.");
ImGui::Spacing();
ImGui::Separator();
ImGui::TextWrapped("%s", TR("pin_change_desc"));
ImGui::Spacing();
ImGui::Text("Current PIN:");
ImGui::TextUnformatted(TR("pin_current_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_old", pin_old_buf_, sizeof(pin_old_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
ImGui::Text("New PIN (4-8 digits):");
ImGui::TextUnformatted(TR("pin_new_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_change_new", pin_buf_, sizeof(pin_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
ImGui::PopItemWidth();
ImGui::Text("Confirm New PIN:");
ImGui::TextUnformatted(TR("pin_confirm_new_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_change_confirm", pin_confirm_buf_, sizeof(pin_confirm_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
@@ -1944,7 +2080,7 @@ void App::renderPinDialogs() {
strcmp(pin_buf_, pin_confirm_buf_) == 0;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton("Change PIN", ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
pin_in_progress_ = true;
pin_status_ = "Changing PIN...";
std::string oldPin(pin_old_buf_);
@@ -1977,20 +2113,26 @@ void App::renderPinDialogs() {
ImGui::EndDisabled();
EndOverlayDialog();
}
// Wipe the PIN buffers if the dialog was dismissed without submitting.
if (!show_pin_change_) {
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
memset(pin_buf_, 0, sizeof(pin_buf_));
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
}
}
// ---- Remove PIN dialog ----
if (show_pin_remove_) {
if (BeginOverlayDialog("Remove PIN", &show_pin_remove_, 400.0f, 0.94f)) {
OverlayDialogSpec ov;
ov.title = TR("settings_remove_pin"); ov.p_open = &show_pin_remove_;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 400.0f; ov.idSuffix = "pinremove";
if (BeginOverlayDialog(ov)) {
ImGui::TextWrapped(
"Enter your current PIN to confirm removal. "
"You will need to use your full passphrase to unlock.");
ImGui::Spacing();
ImGui::Separator();
ImGui::TextWrapped("%s", TR("pin_remove_desc"));
ImGui::Spacing();
ImGui::Text("Current PIN:");
ImGui::TextUnformatted(TR("pin_current_label"));
ImGui::PushItemWidth(-1);
ImGui::InputText("##pin_remove", pin_old_buf_, sizeof(pin_old_buf_),
ImGuiInputTextFlags_Password | ImGuiInputTextFlags_CharsDecimal);
@@ -2003,7 +2145,7 @@ void App::renderPinDialogs() {
ImGui::Spacing();
bool valid = strlen(pin_old_buf_) >= 4;
ImGui::BeginDisabled(!valid || pin_in_progress_);
if (ui::material::TactileButton("Remove PIN", ImVec2(-1, 40))) {
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
pin_in_progress_ = true;
pin_status_ = "Verifying PIN...";
std::string oldPin(pin_old_buf_);
@@ -2040,6 +2182,10 @@ void App::renderPinDialogs() {
ImGui::EndDisabled();
EndOverlayDialog();
}
// Wipe the PIN buffer if the dialog was dismissed without submitting.
if (!show_pin_remove_) {
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
}
}
}

View File

@@ -14,13 +14,25 @@
#include "app.h"
#include "config/settings.h"
#include "data/address_book.h"
#include "ui/schema/skin_manager.h"
#include "ui/notifications.h"
#include "ui/sidebar.h"
#include "ui/windows/send_tab.h"
#include "ui/windows/wallets_dialog.h"
#include "ui/windows/export_transactions_dialog.h"
#include "ui/windows/export_all_keys_dialog.h"
#include "ui/windows/bootstrap_download_dialog.h"
#include "ui/windows/daemon_download_dialog.h"
#include "ui/windows/xmrig_download_dialog.h"
#include "ui/windows/qr_popup_dialog.h"
#include "ui/windows/request_payment_dialog.h"
#include "ui/windows/validate_address_dialog.h"
#include "ui/windows/address_label_dialog.h"
#include "ui/windows/shield_dialog.h"
#include "ui/windows/address_transfer_dialog.h"
#include "ui/windows/key_export_dialog.h"
#include "ui/windows/contacts_tab.h"
#include "ui/pages/settings_page.h"
#include "util/platform.h"
#include "wallet/wallet_capabilities.h"
@@ -80,6 +92,30 @@ const char* kDemoMnemonic =
const char* kDemoZAddr =
"zs1demoseedwalletreceiveaddressforuisweepcaptures00000000000000000000000000";
const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f";
// Contacts-view sweep: seed a few demo contacts (Z + T types, some global) so the Cards/List/Table
// modes render with data, and restore the real book afterward. Uses sweepSetEntries (no disk write),
// so the user's persisted address book is never touched even if the sweep is interrupted.
std::vector<data::AddressBookEntry> s_contactsSweepBackup;
void seedSweepContacts(App& a)
{
s_contactsSweepBackup = a.addressBook().entries();
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path)
data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T
// Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet);
// non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix.
e2.scope = a.activeWalletIdentityHash();
data::AddressBookEntry e3("cold savings",
"zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000", "long-term storage"); // Z, global
a.addressBook().sweepSetEntries({ e1, e2, e3 });
}
void restoreSweepContacts(App& a)
{
a.addressBook().sweepSetEntries(s_contactsSweepBackup);
s_contactsSweepBackup.clear();
if (a.settings()) a.settings()->setContactsViewMode(0);
}
} // namespace
std::string App::screenshotDir() const
@@ -134,10 +170,10 @@ void App::installDemoWalletData()
}
auto zaddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "shielded"; i.label = label; return i;
};
auto taddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i;
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "transparent"; i.label = label; return i;
};
state_.z_addresses = {
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),
@@ -261,11 +297,139 @@ void App::buildSweepCatalog()
// Simple bool-flag modals.
add("modal-import-key", ui::NavPage::Overview,
[](App& a) { a.show_import_key_ = true; }, [](App& a) { a.show_import_key_ = false; });
[](App& a) { a.import_view_mode_ = false; a.show_import_key_ = true; }, [](App& a) { a.show_import_key_ = false; });
add("modal-import-viewkey", ui::NavPage::Overview,
[](App& a) { a.import_view_mode_ = true; a.show_import_key_ = true; }, [](App& a) { a.show_import_key_ = false; a.import_view_mode_ = false; });
add("modal-export-key", ui::NavPage::Overview,
[](App& a) { a.show_export_key_ = true; }, [](App& a) { a.show_export_key_ = false; });
add("modal-export-transactions", ui::NavPage::Settings,
[](App&) { ui::ExportTransactionsDialog::show(); }, [](App&) { ui::ExportTransactionsDialog::hide(); });
add("modal-export-all-keys", ui::NavPage::Settings,
[](App&) { ui::ExportAllKeysDialog::show(); }, [](App&) { ui::ExportAllKeysDialog::hide(); });
add("modal-bootstrap", ui::NavPage::Settings,
[](App& a) { ui::BootstrapDownloadDialog::show(&a); }, [](App&) { ui::BootstrapDownloadDialog::hide(); });
add("modal-backup", ui::NavPage::Overview,
[](App& a) { a.show_backup_ = true; }, [](App& a) { a.show_backup_ = false; a.backup_status_.clear(); });
// Encrypt-wallet dialog — the redesigned passphrase-entry phase (never fires the async encrypt).
add("modal-encrypt", ui::NavPage::Settings,
[](App& a) { a.encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry; a.show_encrypt_dialog_ = true; },
[](App& a) {
a.show_encrypt_dialog_ = false; a.encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry;
a.encrypt_status_.clear();
memset(a.encrypt_pass_buf_, 0, sizeof(a.encrypt_pass_buf_));
memset(a.encrypt_confirm_buf_, 0, sizeof(a.encrypt_confirm_buf_));
});
add("modal-change-passphrase", ui::NavPage::Settings,
[](App& a) { a.show_change_passphrase_ = true; },
[](App& a) {
a.show_change_passphrase_ = false; a.encrypt_status_.clear();
memset(a.change_old_pass_buf_, 0, sizeof(a.change_old_pass_buf_));
memset(a.change_new_pass_buf_, 0, sizeof(a.change_new_pass_buf_));
memset(a.change_confirm_buf_, 0, sizeof(a.change_confirm_buf_));
});
// Remove-encryption dialog — the redesigned passphrase-entry phase (reset() keeps the
// workflow in PassphraseEntry; nothing fires the async unlock/export/restart pyramid).
add("modal-decrypt", ui::NavPage::Settings,
[](App& a) { a.wallet_security_workflow_.reset(); a.show_decrypt_dialog_ = true; },
[](App& a) {
a.show_decrypt_dialog_ = false; a.wallet_security_workflow_.reset();
memset(a.decrypt_pass_buf_, 0, sizeof(a.decrypt_pass_buf_));
});
// PIN setup / change / remove dialogs (never fire the async vault store/verify).
add("modal-pin-setup", ui::NavPage::Settings,
[](App& a) { a.show_pin_setup_ = true; },
[](App& a) {
a.show_pin_setup_ = false; a.pin_status_.clear();
memset(a.pin_passphrase_buf_, 0, sizeof(a.pin_passphrase_buf_));
memset(a.pin_buf_, 0, sizeof(a.pin_buf_));
memset(a.pin_confirm_buf_, 0, sizeof(a.pin_confirm_buf_));
});
add("modal-pin-change", ui::NavPage::Settings,
[](App& a) { a.show_pin_change_ = true; },
[](App& a) {
a.show_pin_change_ = false; a.pin_status_.clear();
memset(a.pin_old_buf_, 0, sizeof(a.pin_old_buf_));
memset(a.pin_buf_, 0, sizeof(a.pin_buf_));
memset(a.pin_confirm_buf_, 0, sizeof(a.pin_confirm_buf_));
});
add("modal-pin-remove", ui::NavPage::Settings,
[](App& a) { a.show_pin_remove_ = true; },
[](App& a) {
a.show_pin_remove_ = false; a.pin_status_.clear();
memset(a.pin_old_buf_, 0, sizeof(a.pin_old_buf_));
});
// Wave-1 standalone dialogs (rendered globally from App::render, so any page works).
add("modal-qr-popup", ui::NavPage::Receive,
[](App&) { ui::QRPopupDialog::show(kDemoZAddr, "Savings"); },
[](App&) { ui::QRPopupDialog::close(); });
add("modal-request-payment", ui::NavPage::Receive,
[](App&) { ui::RequestPaymentDialog::show(kDemoZAddr); },
[](App&) { ui::RequestPaymentDialog::hide(); });
add("modal-validate-address", ui::NavPage::Receive,
[](App&) { ui::ValidateAddressDialog::show(); },
[](App&) { ui::ValidateAddressDialog::hide(); });
add("modal-address-label", ui::NavPage::Overview,
[](App& a) { ui::AddressLabelDialog::show(&a, kDemoZAddr, true); },
[](App&) { ui::AddressLabelDialog::hide(); });
// (No modal-daemon-prompt surface: renderDaemonUpdatePrompt is gated behind !capture_mode_,
// so it can't render during a sweep. Its migration is verified by build + the shared overlay pattern.)
add("modal-antivirus", ui::NavPage::Mining,
[](App& a) { a.pending_antivirus_dialog_ = true; },
[](App& a) { a.pending_antivirus_dialog_ = false; });
add("modal-switch-stopnode", ui::NavPage::Settings,
[](App& a) { a.pending_switch_wallet_file_ = "wallet-savings.dat"; a.show_switch_stop_daemon_confirm_ = true; },
[](App& a) { a.show_switch_stop_daemon_confirm_ = false; a.pending_switch_wallet_file_.clear(); });
add("modal-switch-progress", ui::NavPage::Settings,
[](App& a) { a.wallet_switch_phase_.store(static_cast<int>(App::WalletSwitchPhase::Stopping));
a.wallet_switch_dialog_open_.store(true); },
[](App& a) { a.wallet_switch_dialog_open_.store(false);
a.wallet_switch_phase_.store(static_cast<int>(App::WalletSwitchPhase::None)); });
// Wave-2 fund/secret dialogs (setup never fires the async RPC — no button is clicked).
add("modal-shield", ui::NavPage::Send,
[](App&) { ui::ShieldDialog::showShieldCoinbase(); },
[](App&) { ui::ShieldDialog::hide(); });
add("modal-merge", ui::NavPage::Send,
[](App&) { ui::ShieldDialog::showMerge(); },
[](App&) { ui::ShieldDialog::hide(); });
// z->t transfer so the (converted) deshielding DialogWarningHeader renders.
add("modal-transfer", ui::NavPage::Overview,
[](App& a) {
ui::AddressTransferInfo info;
info.fromAddr = kDemoZAddr;
info.toAddr = "t1DemoTransparentReceiveAddress00000";
info.fromBalance = 12.5; info.toBalance = 3.0;
info.fromIsZ = true; info.toIsZ = false;
ui::AddressTransferDialog::show(&a, info);
},
[](App&) { ui::AddressTransferDialog::close(); });
// Distinct from the settings "modal-export-key" (App::renderExportKeyDialog) — this is the
// per-address KeyExportDialog opened from Overview address rows.
add("modal-key-export", ui::NavPage::Overview,
[](App&) { ui::KeyExportDialog::show(kDemoZAddr, ui::KeyExportDialog::KeyType::Private); },
[](App&) { ui::KeyExportDialog::hide(); });
// Contacts address-list view modes, each seeded with demo contacts (restored after; no disk write).
add("contacts-cards", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(0); },
[](App& a) { restoreSweepContacts(a); });
add("contacts-list", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1); },
[](App& a) { restoreSweepContacts(a); });
add("contacts-table", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(2); },
[](App& a) { restoreSweepContacts(a); });
// Revamped edit dialog: live preview + avatar picker, one surface per avatar mode.
add("contacts-edit-icon", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1);
ui::ContactsSweepOpenEditDialog(1); },
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
add("contacts-edit-badge", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1);
ui::ContactsSweepOpenEditDialog(0); },
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
add("contacts-edit-image", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1);
ui::ContactsSweepOpenEditDialog(2); },
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
add("modal-about", ui::NavPage::Overview,
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
add("modal-settings", ui::NavPage::Settings,
@@ -540,6 +704,10 @@ void App::startSweepImpl(bool full)
if (sk.valid) sweep_skins_.push_back(sk.id);
if (sweep_skins_.empty()) return;
// Debug Options "Current theme only": sweep just the active skin instead of cycling every theme.
if (sweep_current_theme_only_)
sweep_skins_.assign(1, ui::schema::SkinManager::instance().activeSkinId());
sweep_full_ = full;
if (full) { capture_mode_ = true; installDemoWalletData(); }
buildSweepCatalog();

View File

@@ -121,16 +121,21 @@ void App::renderFirstRunWizard() {
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse;
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0, 0, 0, 0)); // reveal the skin backdrop behind
ImGui::Begin("##FirstRunWizard", nullptr, flags);
ImGui::PopStyleColor();
ImGui::PopStyleVar();
ImDrawList* dl = ImGui::GetWindowDrawList();
ImVec2 winPos = ImGui::GetWindowPos();
ImVec2 winSize = ImGui::GetWindowSize();
// Background fill
ImU32 bgCol = ui::material::Surface();
dl->AddRectFilled(winPos, ImVec2(winPos.x + winSize.x, winPos.y + winSize.y), bgCol);
// The app's skin backdrop (marble / gradient / acrylic) is already painted behind every window by
// drawWindowBackdrop(); reveal it here instead of a flat Surface() slab, under a gentle theme-tinted
// scrim so the wizard cards and text keep their contrast on busy skins.
ImU32 bgCol = ui::material::Surface(); // still used by the completed / not-reached card overlays
dl->AddRectFilled(winPos, ImVec2(winPos.x + winSize.x, winPos.y + winSize.y),
ui::material::WithAlpha(ui::material::Background(), 120));
// --- Determine which of the 3 masonry sections is focused ---
// 0 = Appearance, 1 = Bootstrap, 2 = Encrypt + PIN
@@ -172,8 +177,36 @@ void App::renderFirstRunWizard() {
// DPI scale factor — multiply all pixel constants by dp
const float dp = ui::Layout::dpiScale();
// Vertical scroll: the wizard cards are hand-drawn at absolute Y offsets and grow ~1.5x with the
// font-scale setting, so at high scale the focused card's primary button (Continue / Encrypt & Continue
// / Skip) can fall below the fixed window. Offset the whole layout by a wheel-driven scroll, clamped to
// last frame's measured content height, so every control stays reachable. The window keeps
// NoScrollWithMouse, so ImGui doesn't consume the wheel — we read the raw delta and apply our own offset.
static float s_wizScroll = 0.0f, s_wizContentH = 0.0f;
if (ImGui::IsWindowAppearing()) s_wizScroll = 0.0f;
const float wizMaxScroll = std::max(0.0f, s_wizContentH - winSize.y);
// Don't steal the wheel from an open combo popup (e.g. the 9-item Language dropdown, which is a
// scrollable popup): NoPopupHierarchy stops the popup counting as hovering the wizard, and the
// IsPopupOpen guard ensures no wheel is consumed for the whole wizard while any popup is showing.
const bool wizPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
if (wizMaxScroll > 0.0f && !wizPopupOpen &&
ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_NoPopupHierarchy)) {
float wheel = ImGui::GetIO().MouseWheel;
if (wheel != 0.0f) s_wizScroll -= wheel * 60.0f * dp;
}
s_wizScroll = std::max(0.0f, std::min(s_wizScroll, wizMaxScroll));
const float scrollY = s_wizScroll;
// --- Header: Logo + Welcome ---
float headerCy = winPos.y + 20.0f * dp;
// Vertically center the content when it fits (mirrors the horizontal centering below): on a tall
// monitor top-anchoring leaves a large void under the cards. Using last frame's measured block
// height, when the content fits inside the window (and we're NOT overflowing, so this doesn't
// fight the scroll), push everything down by half the leftover space. No-op once content
// fills/exceeds the window (s_wizContentH >= winSize.y ⇒ wizMaxScroll > 0 ⇒ vCenter skipped).
float vCenter = 0.0f;
if (wizMaxScroll == 0.0f && s_wizContentH > 0.0f && s_wizContentH < winSize.y)
vCenter = std::max(0.0f, (winSize.y - s_wizContentH) * 0.5f);
float headerCy = winPos.y - scrollY + 20.0f * dp + vCenter;
float logoSize = S.drawElement("screens.first-run", "logo").sizeOr(56.0f);
if (logo_tex_ != 0) {
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
@@ -184,11 +217,18 @@ void App::renderFirstRunWizard() {
headerCy += logoSize + 8.0f * dp;
{
const char* welcomeTitle = "Welcome to ObsidianDragon!";
const char* welcomeTitle = TR("wiz_welcome_title");
ImVec2 wts = titleFont->CalcTextSizeA(titleFont->LegacySize, FLT_MAX, 0, welcomeTitle);
dl->AddText(titleFont, titleFont->LegacySize,
ImVec2(winPos.x + (winSize.x - wts.x) * 0.5f, headerCy), textCol, welcomeTitle);
headerCy += wts.y + 16.0f * dp;
headerCy += wts.y + 6.0f * dp;
// Warmer, less-sparse header: a one-line subtitle under the welcome (dimmed body).
const char* welcomeSub = TR("wiz_welcome_sub");
ImVec2 sts = bodyFont->CalcTextSizeA(bodyFont->LegacySize, FLT_MAX, 0, welcomeSub);
dl->AddText(bodyFont, bodyFont->LegacySize,
ImVec2(winPos.x + (winSize.x - sts.x) * 0.5f, headerCy), dimCol, welcomeSub);
headerCy += sts.y + 16.0f * dp;
}
// --- Masonry: 2 columns ---
@@ -223,11 +263,8 @@ void App::renderFirstRunWizard() {
// Background (channel 0)
dl->ChannelsSetCurrent(0);
if (state == 1) {
// Focused card: subtle drop shadow
float shadowOff = 3.0f * dp;
dl->AddRectFilled(
ImVec2(cMin.x + shadowOff, cMin.y + shadowOff), ImVec2(cMax.x + shadowOff, cMax.y + shadowOff),
IM_COL32(0, 0, 0, 35), cardRound);
// Focused card lifts off the backdrop with the app's uniform card shadow (not a hard offset).
ui::material::DrawCardDropShadow(dl, cMin, cMax, cardRound);
}
// Use DrawGlassPanel for proper acrylic/opacity/noise/theme effects
ui::material::GlassPanelSpec glass;
@@ -237,14 +274,14 @@ void App::renderFirstRunWizard() {
// Overlays & borders (channel 2)
dl->ChannelsSetCurrent(2);
if (state == 1) {
// Focused: accent border
dl->AddRect(cMin, cMax, ui::material::Primary(), cardRound, 0, 2.0f * dp);
// Focused: soft accent ring
dl->AddRect(cMin, cMax, ui::material::Primary(), cardRound, 0, 1.5f * dp);
} else if (state == 2) {
// Completed: dim overlay (preserves color)
dl->AddRectFilled(cMin, cMax, (bgCol & 0x00FFFFFF) | IM_COL32(0, 0, 0, 110), cardRound);
// Completed: a light veil — reads as "done", not disabled.
dl->AddRectFilled(cMin, cMax, (bgCol & 0x00FFFFFF) | IM_COL32(0, 0, 0, 70), cardRound);
} else {
// Not reached: heavy overlay (creates greyscale look)
dl->AddRectFilled(cMin, cMax, (bgCol & 0x00FFFFFF) | IM_COL32(0, 0, 0, 165), cardRound);
// Upcoming: a gentle veil — reads as "waiting", not greyed-out.
dl->AddRectFilled(cMin, cMax, (bgCol & 0x00FFFFFF) | IM_COL32(0, 0, 0, 115), cardRound);
}
dl->ChannelsSetCurrent(1);
@@ -257,21 +294,34 @@ void App::renderFirstRunWizard() {
{
int state = cardState(0);
bool isFocused = (state == 1);
bool isCollapsed = (state == 2); // Completed: minimize to a compact pill (mirrors Card 1)
float cx = leftX + cardPad;
float cy = card0Top + cardPad;
float contentW = colW - 2 * cardPad;
// Step indicator + title (inline when collapsed)
if (isCollapsed) {
// Compact single-line: check icon + "Step 1" + "Appearance"
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
float labelX = cx + iconW + 4.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, TR("wiz_step1"));
float step1W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, TR("wiz_step1")).x;
float titleX = labelX + step1W + 12.0f * dp;
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, TR("wiz_appearance"));
cy += captionFont->LegacySize + 4.0f * dp;
} else {
// Step indicator
{
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, "Step 1");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
cy += captionFont->LegacySize + 6.0f * dp;
}
// Title
{
const char* t = "Appearance";
const char* t = TR("wiz_appearance");
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
cy += titleFont->LegacySize + 10.0f * dp;
}
@@ -280,6 +330,7 @@ void App::renderFirstRunWizard() {
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
cy += 14.0f * dp;
}
float& wiz_blur_amount = wizardUi.blur_amount;
bool& wiz_theme_effects = wizardUi.theme_effects;
@@ -315,6 +366,9 @@ void App::renderFirstRunWizard() {
wiz_appearance_init = true;
}
// Controls: rendered for the focused and upcoming states so content is visible under
// the dim overlay; skipped entirely once completed so the card shrinks to a compact pill.
if (!isCollapsed) {
// Render controls always so content is visible under the dim
// overlay when not focused; disable interaction when not active.
ImGui::BeginDisabled(!isFocused);
@@ -327,14 +381,14 @@ void App::renderFirstRunWizard() {
for (const auto& skin : skins) {
if (skin.id == skinMgr.activeSkinId()) { activePreview = skin.name; break; }
}
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy + 4.0f * dp), textCol, "Theme");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy + 4.0f * dp), textCol, TR("theme"));
float comboX = cx + 110.0f * dp;
float comboW = contentW - 110.0f * dp;
ImGui::SetCursorScreenPos(ImVec2(comboX, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f * dp);
ImGui::SetNextItemWidth(comboW);
if (ImGui::BeginCombo("##wiz_theme", activePreview.c_str())) {
ImGui::TextDisabled("Built-in");
ImGui::TextDisabled(TR("wiz_theme_builtin"));
ImGui::Separator();
for (const auto& skin : skins) {
if (!skin.bundled) continue;
@@ -350,7 +404,7 @@ void App::renderFirstRunWizard() {
for (const auto& skin : skins) { if (!skin.bundled) { hasCustom = true; break; } }
if (hasCustom) {
ImGui::Spacing();
ImGui::TextDisabled("Custom");
ImGui::TextDisabled(TR("wiz_theme_custom"));
ImGui::Separator();
for (const auto& skin : skins) {
if (skin.bundled) continue;
@@ -358,7 +412,7 @@ void App::renderFirstRunWizard() {
if (!skin.valid) {
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1,0.3f,0.3f,1));
ImGui::BeginDisabled(true);
ImGui::Selectable((skin.name + " (invalid)").c_str(), false);
ImGui::Selectable((skin.name + TR("wiz_theme_invalid")).c_str(), false);
ImGui::EndDisabled();
ImGui::PopStyleColor();
} else {
@@ -386,7 +440,7 @@ void App::renderFirstRunWizard() {
for (const auto& l : layouts) {
if (l.id == wiz_balance_layout) { balPreview = l.name; break; }
}
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy + 4.0f * dp), textCol, "Balance Layout");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy + 4.0f * dp), textCol, TR("balance_layout"));
float comboX = cx + 110.0f * dp;
float comboW = contentW - 110.0f * dp;
ImGui::SetCursorScreenPos(ImVec2(comboX, cy));
@@ -417,7 +471,7 @@ void App::renderFirstRunWizard() {
langNames.reserve(languages.size());
for (const auto& lang : languages) langNames.push_back(lang.second.c_str());
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy + 4.0f * dp), textCol, "Language");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy + 4.0f * dp), textCol, TR("language"));
float comboX = cx + 110.0f * dp;
float comboW = contentW - 110.0f * dp;
ImGui::SetCursorScreenPos(ImVec2(comboX, cy));
@@ -488,10 +542,10 @@ void App::renderFirstRunWizard() {
ImGui::SameLine();
dl->AddText(bodyFont, bodyFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, cy + 2.0f * dp), textCol,
"Low-spec mode");
TR("low_spec_mode"));
cy += bodyFont->LegacySize + 6.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx + 28.0f * dp, cy), dimCol, "Disable all heavy visual effects");
ImVec2(cx + 28.0f * dp, cy), dimCol, TR("wiz_lowspec_desc"));
cy += captionFont->LegacySize + 16.0f * dp;
ImGui::BeginDisabled(wiz_low_spec);
@@ -499,15 +553,15 @@ void App::renderFirstRunWizard() {
// Acrylic blur slider
dl->AddText(bodyFont, bodyFont->LegacySize,
ImVec2(cx, cy + 2.0f * dp), textCol,
"Acrylic glass effects");
TR("wiz_acrylic"));
cy += bodyFont->LegacySize + 4.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx, cy), dimCol, "Translucent blur on panels (Off disables)");
ImVec2(cx, cy), dimCol, TR("wiz_acrylic_desc"));
cy += captionFont->LegacySize + 10.0f * dp;
{
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx + 4.0f * dp, cy), textCol, "Level:");
ImVec2(cx + 4.0f * dp, cy), textCol, TR("wiz_level"));
ImGui::SetCursorScreenPos(ImVec2(cx + 72.0f * dp, cy - 2.0f * dp));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f * dp);
float sliderW = contentW - 72.0f * dp;
@@ -515,7 +569,7 @@ void App::renderFirstRunWizard() {
{
char blur_fmt[16];
if (wiz_blur_amount < 0.01f)
snprintf(blur_fmt, sizeof(blur_fmt), "Off");
snprintf(blur_fmt, sizeof(blur_fmt), TR("wiz_off"));
else
snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", wiz_blur_amount * 25.0f);
if (ImGui::SliderFloat("##wiz_blur", &wiz_blur_amount, 0.0f, 4.0f, blur_fmt,
@@ -549,19 +603,19 @@ void App::renderFirstRunWizard() {
ImGui::SameLine();
dl->AddText(bodyFont, bodyFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, cy + 2.0f * dp), textCol,
"Theme visual effects");
TR("wiz_theme_effects"));
cy += bodyFont->LegacySize + 6.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx + 28.0f * dp, cy), dimCol, "Animated borders, color wash");
ImVec2(cx + 28.0f * dp, cy), dimCol, TR("wiz_theme_effects_desc"));
cy += captionFont->LegacySize + 16.0f * dp;
// UI Opacity slider
dl->AddText(bodyFont, bodyFont->LegacySize,
ImVec2(cx, cy + 2.0f * dp), textCol,
"UI Opacity");
TR("ui_opacity"));
cy += bodyFont->LegacySize + 4.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx, cy), dimCol, "Card & sidebar transparency (1.0 = solid)");
ImVec2(cx, cy), dimCol, TR("wiz_ui_opacity_desc"));
cy += captionFont->LegacySize + 10.0f * dp;
{
ImGui::SetCursorScreenPos(ImVec2(cx, cy - 2.0f * dp));
@@ -590,10 +644,10 @@ void App::renderFirstRunWizard() {
ImGui::SameLine();
dl->AddText(bodyFont, bodyFont->LegacySize,
ImVec2(ImGui::GetCursorScreenPos().x, cy + 2.0f * dp), textCol,
"Console scanline");
TR("console_scanline"));
cy += bodyFont->LegacySize + 6.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx + 28.0f * dp, cy), dimCol, "CRT scanline effect in console");
ImVec2(cx + 28.0f * dp, cy), dimCol, TR("wiz_scanline_desc"));
cy += captionFont->LegacySize + 24.0f * dp;
ImGui::EndDisabled(); // low-spec
@@ -611,7 +665,7 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(ui::material::PrimaryVariant()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Continue##app", ImVec2(btnW, btnH))) {
if (ui::material::TactileButton(TR("wiz_continue"), ImVec2(btnW, btnH))) {
// Save appearance choices, advance to Bootstrap
settings_->setAcrylicEnabled(wiz_blur_amount > 0.001f);
settings_->setAcrylicQuality(wiz_blur_amount > 0.001f
@@ -631,14 +685,22 @@ void App::renderFirstRunWizard() {
cy += btnH;
}
} // if (!isCollapsed)
cy += cardPad;
// Lock card height to the tallest content ever seen
// Lock card height to the tallest content ever seen (but not when collapsed)
float& card0MaxH = wizardUi.card0_max_h;
if (isCollapsed) {
// Completed: finalize immediately as a compact pill (do not stretch to the
// right column height, and skip the deferred stretch below).
card0Bot = card0Top + (cy - card0Top);
finalizeCard(leftX, colW, card0Top, card0Bot, state);
} else {
card0MaxH = std::max(card0MaxH, cy - card0Top);
card0Bot = card0Top + card0MaxH;
// Card 0 finalization deferred until after cards 1+2 are sized
}
}
// ======================= CARD 1: Bootstrap =======================
@@ -658,23 +720,23 @@ void App::renderFirstRunWizard() {
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
float labelX = cx + iconW + 4.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, "Step 2");
float step2W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, "Step 2").x;
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, TR("wiz_step2"));
float step2W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, TR("wiz_step2")).x;
float titleX = labelX + step2W + 12.0f * dp;
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, "Bootstrap");
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, TR("wiz_bootstrap"));
cy += captionFont->LegacySize + 4.0f * dp;
} else {
// Step indicator
{
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, "Step 2");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step2"));
cy += captionFont->LegacySize + 4.0f * dp;
}
// Title
{
const char* t = "Bootstrap";
const char* t = TR("wiz_bootstrap");
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
cy += titleFont->LegacySize + 6.0f * dp;
}
@@ -697,11 +759,11 @@ void App::renderFirstRunWizard() {
const char* statusTitle;
if (prog.state == util::Bootstrap::State::Downloading)
statusTitle = "Downloading bootstrap...";
statusTitle = TR("bootstrap_downloading");
else if (prog.state == util::Bootstrap::State::Verifying)
statusTitle = "Verifying checksums...";
statusTitle = TR("bootstrap_verifying");
else
statusTitle = "Extracting blockchain data...";
statusTitle = TR("bootstrap_extracting");
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx, cy), textCol, statusTitle);
cy += bodyFont->LegacySize + 12.0f * dp;
@@ -730,7 +792,7 @@ void App::renderFirstRunWizard() {
if (prog.state == util::Bootstrap::State::Extracting) {
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
dimCol, "(wallet.dat is protected)");
dimCol, TR("bootstrap_wallet_protected"));
cy += captionFont->LegacySize + 6.0f * dp;
}
@@ -746,9 +808,9 @@ void App::renderFirstRunWizard() {
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
dotR, dotCol);
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
? "Daemon stopping..."
: "Daemon running")
: "Daemon stopped";
? TR("bootstrap_daemon_stopping")
: TR("bootstrap_daemon_running"))
: TR("bootstrap_daemon_stopped");
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,140), label);
@@ -763,7 +825,7 @@ void App::renderFirstRunWizard() {
float cancelBX = rightX + (colW - cancelW) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(cancelBX, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Cancel##bs", ImVec2(cancelW, cancelH))) {
if (ui::material::TactileButton(TR("cancel"), ImVec2(cancelW, cancelH))) {
bootstrap_->cancel();
}
ImGui::PopStyleVar();
@@ -790,10 +852,10 @@ void App::renderFirstRunWizard() {
errMsg = bootstrap_->getProgress().error;
bootstrap_.reset();
}
if (errMsg.empty()) errMsg = "Bootstrap failed";
if (errMsg.empty()) errMsg = TR("wiz_bootstrap_failed");
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx, cy),
ui::material::Error(), "Download Failed");
ui::material::Error(), TR("wiz_download_failed"));
cy += bodyFont->LegacySize + 8.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), textCol,
@@ -812,7 +874,7 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
if (ui::material::TactileButton("Retry##bs", ImVec2(btnW2, btnH2))) {
if (ui::material::TactileButton(TR("retry"), ImVec2(btnW2, btnH2))) {
// Stop embedded daemon before bootstrap to avoid chain data corruption
stopDaemonForBootstrap();
bootstrap_ = std::make_unique<util::Bootstrap>();
@@ -826,7 +888,7 @@ void App::renderFirstRunWizard() {
ImGui::SetCursorScreenPos(ImVec2(bx + btnW2 + 12.0f * dp, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Skip##bsfail", ImVec2(btnW2, btnH2))) {
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(btnW2, btnH2))) {
wizard_phase_ = WizardPhase::EncryptOffer;
}
ImGui::PopStyleVar();
@@ -869,19 +931,20 @@ void App::renderFirstRunWizard() {
{
float iw = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_WARNING).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), warnCol, ICON_MD_WARNING);
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx + iw + 4.0f * dp, cy), warnCol, "External daemon running");
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx + iw + 4.0f * dp, cy), warnCol, TR("wiz_ext_daemon_running"));
}
cy += bodyFont->LegacySize + 4.0f * dp;
{
const char* warnBody = "It must be stopped before downloading a bootstrap, otherwise chain data could be corrupted.";
const char* warnBody = TR("wiz_ext_daemon_warning");
ImVec2 ws = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, contentW, warnBody);
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), textCol, warnBody, nullptr, contentW);
cy += ws.y + 12.0f * dp;
}
if (wizard_stopping_external_) {
const std::string ws = wizard_stop_status_.get();
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol,
wizard_stop_status_.c_str());
ws.c_str());
cy += captionFont->LegacySize + 8.0f * dp;
} else {
float stopW = 150.0f * dp;
@@ -896,9 +959,9 @@ void App::renderFirstRunWizard() {
IM_COL32(220, 60, 60, 255)));
ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 255, 255, 255));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Stop Daemon##wiz", ImVec2(stopW, btnH2))) {
if (ui::material::TactileButton(TR("wiz_stop_daemon"), ImVec2(stopW, btnH2))) {
wizard_stopping_external_ = true;
wizard_stop_status_ = "Sending stop command...";
wizard_stop_status_ = TR("wiz_daemon_sending_stop");
async_tasks_.submit("wizard-stop-external-daemon", [this](const util::AsyncTaskManager::Token& token) {
auto config = rpc::Connection::autoDetectConfig();
if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
@@ -910,17 +973,17 @@ void App::renderFirstRunWizard() {
tmp_rpc->disconnect();
}
}
wizard_stop_status_ = "Waiting for daemon to shut down...";
wizard_stop_status_ = TR("wiz_daemon_waiting_stop");
for (int i = 0; i < 60 && !token.cancelled(); i++) {
std::this_thread::sleep_for(std::chrono::seconds(1));
if (!daemon::EmbeddedDaemon::isRpcPortInUse()) {
wizard_stop_status_ = "Daemon stopped.";
wizard_stop_status_ = TR("wiz_daemon_stopped_ok");
wizard_stopping_external_ = false;
return;
}
}
if (token.cancelled()) return;
wizard_stop_status_ = "Daemon did not stop — try manually.";
wizard_stop_status_ = TR("wiz_daemon_stop_failed");
wizard_stopping_external_ = false;
});
}
@@ -929,7 +992,7 @@ void App::renderFirstRunWizard() {
ImGui::SetCursorScreenPos(ImVec2(bx + stopW + 12.0f * dp, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Skip##extd", ImVec2(skipW2, btnH2))) {
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(skipW2, btnH2))) {
wizard_phase_ = WizardPhase::EncryptOffer;
}
ImGui::PopStyleVar();
@@ -938,7 +1001,7 @@ void App::renderFirstRunWizard() {
} else {
// --- Normal bootstrap offer ---
{
const char* bsText = "Download a blockchain bootstrap to dramatically speed up initial sync.\n\nYour existing wallet.dat will NOT be modified or replaced.";
const char* bsText = TR("wiz_bootstrap_desc");
ImVec2 bsSize = bodyFont->CalcTextSizeA(bodyFont->LegacySize, FLT_MAX, contentW, bsText);
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx, cy), textCol, bsText, nullptr, contentW);
cy += bsSize.y + 8.0f * dp;
@@ -951,7 +1014,7 @@ void App::renderFirstRunWizard() {
ImU32 warnCol = (textCol & 0x00FFFFFF) | ((ImU32)(255 * warnOpacity) << 24);
float iw = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_WARNING).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), warnCol, ICON_MD_WARNING);
const char* twText = "Only use bootstrap.dragonx.is or bootstrap2.dragonx.is. Using files from untrusted sources could compromise your node.";
const char* twText = TR("bootstrap_trust_warning");
float twWrap = contentW - iw - 4.0f * dp;
ImVec2 twSize = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, twWrap, twText);
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iw + 4.0f * dp, cy), warnCol, twText, nullptr, twWrap);
@@ -970,9 +1033,9 @@ void App::renderFirstRunWizard() {
dl->AddCircleFilled(ImVec2(cx + dotR, cy + captionFont->LegacySize * 0.5f),
dotR, dotCol);
const char* label = daemonUp ? (dStatus.find("Stopping") != std::string::npos
? "Daemon stopping..."
: "Daemon running")
: "Daemon stopped";
? TR("bootstrap_daemon_stopping")
: TR("bootstrap_daemon_running"))
: TR("bootstrap_daemon_stopped");
dl->AddText(captionFont, captionFont->LegacySize,
ImVec2(cx + dotR * 2.0f + 6.0f * dp, cy),
(dimCol & 0x00FFFFFF) | IM_COL32(0,0,0,140), label);
@@ -995,7 +1058,7 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
if (ui::material::TactileButton("Download##bs", ImVec2(dlBtnW, btnH2))) {
if (ui::material::TactileButton(TR("download"), ImVec2(dlBtnW, btnH2))) {
// Stop embedded daemon before bootstrap to avoid chain data corruption
stopDaemonForBootstrap();
bootstrap_ = std::make_unique<util::Bootstrap>();
@@ -1014,7 +1077,7 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
if (ui::material::TactileButton("Mirror##bs_mirror", ImVec2(mirrorW, btnH2))) {
if (ui::material::TactileButton(TR("bootstrap_mirror"), ImVec2(mirrorW, btnH2))) {
stopDaemonForBootstrap();
bootstrap_ = std::make_unique<util::Bootstrap>();
std::string dataDir = util::Platform::getDragonXDataDir();
@@ -1024,7 +1087,7 @@ void App::renderFirstRunWizard() {
}
ImGui::EndDisabled();
if (ImGui::IsItemHovered()) {
ui::material::Tooltip("Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.");
ui::material::Tooltip(TR("bootstrap_mirror_tooltip"));
}
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
@@ -1032,7 +1095,7 @@ void App::renderFirstRunWizard() {
// --- Skip button ---
ImGui::SetCursorScreenPos(ImVec2(bx + dlBtnW + 8.0f * dp + mirrorW + 8.0f * dp, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Skip##bs", ImVec2(skipW2, btnH2))) {
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(skipW2, btnH2))) {
wizard_phase_ = WizardPhase::EncryptOffer;
}
ImGui::PopStyleVar();
@@ -1084,14 +1147,14 @@ void App::renderFirstRunWizard() {
{
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, "Step 3");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step3"));
cy += captionFont->LegacySize + 4.0f * dp;
}
// Title (changes for PinSetup sub-state)
{
const char* t = (isFocused && wizard_phase_ == WizardPhase::PinSetup)
? "Quick-Unlock PIN" : "Encryption";
? TR("wiz_pin_title") : TR("wiz_encryption");
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
cy += titleFont->LegacySize + 6.0f * dp;
}
@@ -1108,11 +1171,11 @@ void App::renderFirstRunWizard() {
ImU32 okCol = ui::material::Secondary();
float iw = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_VERIFIED_USER).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), okCol, ICON_MD_VERIFIED_USER);
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx + iw + 6.0f * dp, cy), okCol, "Wallet is already encrypted");
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx + iw + 6.0f * dp, cy), okCol, TR("wiz_already_encrypted"));
cy += bodyFont->LegacySize + 12.0f * dp;
}
{
const char* desc = "Your wallet is protected with a passphrase. No further action is needed.";
const char* desc = TR("wiz_already_encrypted_desc");
ImVec2 ds = bodyFont->CalcTextSizeA(bodyFont->LegacySize, FLT_MAX, contentW, desc);
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx, cy), textCol, desc, nullptr, contentW);
cy += ds.y + 20.0f * dp;
@@ -1127,7 +1190,7 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(ui::material::PrimaryVariant()));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Continue##encok", ImVec2(btnW2, btnH2))) {
if (ui::material::TactileButton(TR("wiz_continue"), ImVec2(btnW2, btnH2))) {
wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true);
settings_->save();
@@ -1139,7 +1202,7 @@ void App::renderFirstRunWizard() {
} else if (isFocused) {
// ---- Encryption offer + optional PIN (combined) ----
{
const char* encDesc = "Encrypt your wallet to protect private keys with a passphrase.";
const char* encDesc = TR("wiz_encrypt_desc");
ImVec2 edSize = bodyFont->CalcTextSizeA(bodyFont->LegacySize, FLT_MAX, contentW, encDesc);
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx, cy), textCol, encDesc, nullptr, contentW);
cy += edSize.y + 6.0f * dp;
@@ -1148,7 +1211,7 @@ void App::renderFirstRunWizard() {
ImU32 warnCol2 = ui::material::Warning();
float iw = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_WARNING).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), warnCol2, ICON_MD_WARNING);
const char* warnLoss = "If you lose your passphrase, you lose access to your funds.";
const char* warnLoss = TR("wiz_encrypt_warning");
float wlWrap = contentW - iw - 4.0f * dp;
ImVec2 wlSize = bodyFont->CalcTextSizeA(bodyFont->LegacySize, FLT_MAX, wlWrap, warnLoss);
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx + iw + 4.0f * dp, cy), warnCol2, warnLoss, nullptr, wlWrap);
@@ -1156,7 +1219,7 @@ void App::renderFirstRunWizard() {
}
// Passphrase input
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, "Passphrase:");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, TR("wiz_passphrase"));
cy += captionFont->LegacySize + 4.0f * dp;
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
@@ -1168,7 +1231,7 @@ void App::renderFirstRunWizard() {
ImGui::PopItemWidth();
cy += 36.0f * dp + 6.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, "Confirm:");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, TR("wiz_confirm"));
cy += captionFont->LegacySize + 4.0f * dp;
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
@@ -1183,16 +1246,16 @@ void App::renderFirstRunWizard() {
// Strength meter
{
size_t len = strlen(encrypt_pass_buf_);
const char* strengthLabel = "Weak";
const char* strengthLabel = TR("wiz_strength_weak");
ImU32 strengthCol = ui::material::Error();
float strengthPct = 0.25f;
if (len >= 16) {
strengthLabel = "Strong"; strengthCol = ui::material::Secondary(); strengthPct = 1.0f;
strengthLabel = TR("wiz_strength_strong"); strengthCol = ui::material::Secondary(); strengthPct = 1.0f;
} else if (len >= 12) {
strengthLabel = "Good"; strengthCol = ui::material::Secondary(); strengthPct = 0.75f;
strengthLabel = TR("wiz_strength_good"); strengthCol = ui::material::Secondary(); strengthPct = 0.75f;
} else if (len >= 8) {
strengthLabel = "Fair"; strengthCol = ui::material::Warning(); strengthPct = 0.5f;
strengthLabel = TR("wiz_strength_fair"); strengthCol = ui::material::Warning(); strengthPct = 0.5f;
}
float sBarH = 4.0f * dp, sBarR = 2.0f * dp;
@@ -1205,7 +1268,7 @@ void App::renderFirstRunWizard() {
cy += sBarH + 4.0f * dp;
char slabel[64];
snprintf(slabel, sizeof(slabel), "Strength: %s", strengthLabel);
snprintf(slabel, sizeof(slabel), TR("wiz_strength"), strengthLabel);
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, slabel);
cy += captionFont->LegacySize + 10.0f * dp;
}
@@ -1215,14 +1278,14 @@ void App::renderFirstRunWizard() {
size_t pLen = strlen(encrypt_pass_buf_);
if (pLen > 0 && pLen < 8) {
char fb[80];
snprintf(fb, sizeof(fb), "Passphrase must be at least 8 characters (%zu/8)", pLen);
snprintf(fb, sizeof(fb), TR("wiz_pass_too_short"), pLen);
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
ui::material::Error(), fb);
cy += captionFont->LegacySize + 6.0f * dp;
} else if (pLen >= 8 && strlen(encrypt_confirm_buf_) > 0 &&
strcmp(encrypt_pass_buf_, encrypt_confirm_buf_) != 0) {
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
ui::material::Error(), "Passphrases do not match");
ui::material::Error(), TR("wiz_pass_mismatch"));
cy += captionFont->LegacySize + 6.0f * dp;
}
}
@@ -1234,12 +1297,12 @@ void App::renderFirstRunWizard() {
cy += 8.0f * dp;
{
const char* pinTitle = "Quick-Unlock PIN (optional)";
const char* pinTitle = TR("wiz_pin_optional");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), textCol, pinTitle);
cy += captionFont->LegacySize + 4.0f * dp;
}
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, "PIN (4-8 digits):");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, TR("wiz_pin_label"));
cy += captionFont->LegacySize + 4.0f * dp;
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
@@ -1251,7 +1314,7 @@ void App::renderFirstRunWizard() {
ImGui::PopItemWidth();
cy += 36.0f * dp + 6.0f * dp;
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, "Confirm PIN:");
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol, TR("wiz_pin_confirm"));
cy += captionFont->LegacySize + 4.0f * dp;
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
@@ -1268,12 +1331,12 @@ void App::renderFirstRunWizard() {
std::string pinStr(wizard_pin_buf_);
if (!pinStr.empty() && !util::SecureVault::isValidPin(pinStr)) {
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
ui::material::Error(), "PIN must be 4-8 digits");
ui::material::Error(), TR("wiz_pin_invalid"));
cy += captionFont->LegacySize + 6.0f * dp;
} else if (!pinStr.empty() && strlen(wizard_pin_confirm_buf_) > 0 &&
pinStr != std::string(wizard_pin_confirm_buf_)) {
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
ui::material::Error(), "PINs do not match");
ui::material::Error(), TR("wiz_pin_mismatch"));
cy += captionFont->LegacySize + 6.0f * dp;
}
}
@@ -1294,7 +1357,7 @@ void App::renderFirstRunWizard() {
}
if (passEdgeSpace) {
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy),
ui::material::Error(), "Passphrase has leading/trailing spaces — remove them");
ui::material::Error(), TR("wiz_pass_spaces"));
cy += captionFont->LegacySize + 6.0f * dp;
}
@@ -1324,11 +1387,15 @@ void App::renderFirstRunWizard() {
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
ImGui::BeginDisabled(!canEncrypt);
if (ui::material::TactileButton("Encrypt & Continue##wiz", ImVec2(encBtnW, btnH2))) {
if (ui::material::TactileButton(TR("wiz_encrypt_continue"), ImVec2(encBtnW, btnH2))) {
// Save passphrase + optional PIN for background processing
wallet_security_.beginDeferredEncryption(
std::string(encrypt_pass_buf_),
(pinEntered && pinOk) ? pinStr : std::string());
// Persist that encryption was requested (never the passphrase) so a quit/crash or
// failed daemon connect before it applies isn't silent — reconciled on the next
// connect in refreshWalletEncryptionState (W2-2). Saved with the wizard state below.
settings_->setEncryptionPending(true);
// Clear sensitive buffers
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
@@ -1347,7 +1414,7 @@ void App::renderFirstRunWizard() {
wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true);
settings_->save();
ui::Notifications::instance().info("Encryption will complete in the background");
ui::Notifications::instance().info(TR("wiz_encrypt_bg"));
}
ImGui::EndDisabled();
ImGui::PopStyleVar();
@@ -1355,14 +1422,21 @@ void App::renderFirstRunWizard() {
ImGui::SetCursorScreenPos(ImVec2(bx + encBtnW + 12.0f * dp, cy));
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
if (ui::material::TactileButton("Skip##enc", ImVec2(skipW2, btnH2))) {
if (ui::material::TactileButton(TR("wiz_skip"), ImVec2(skipW2, btnH2))) {
static bool s_skipEncConfirm = false;
if (!s_skipEncConfirm) {
// Skipping stores private keys UNENCRYPTED — require a confirming second click.
s_skipEncConfirm = true;
encrypt_status_ = "Continue WITHOUT encryption? Keys will be stored unencrypted — click Skip again to confirm.";
encrypt_status_ = TR("wiz_skip_confirm");
} else {
s_skipEncConfirm = false;
// Skipping leaves the wallet UNENCRYPTED — wipe the passphrase/PIN the user may have
// typed so it doesn't linger in these process-lifetime buffers (only the Encrypt
// path cleared them before). (L-07)
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
memset(wizard_pin_buf_, 0, sizeof(wizard_pin_buf_));
memset(wizard_pin_confirm_buf_, 0, sizeof(wizard_pin_confirm_buf_));
wizard_phase_ = WizardPhase::Done;
settings_->setWizardCompleted(true);
settings_->save();
@@ -1380,7 +1454,7 @@ void App::renderFirstRunWizard() {
}
} else {
// ---- Not focused: show static description ----
const char* encDesc = "Encrypt your wallet to protect private keys with a passphrase.";
const char* encDesc = TR("wiz_encrypt_desc");
ImVec2 edSize = bodyFont->CalcTextSizeA(bodyFont->LegacySize, FLT_MAX, contentW, encDesc);
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(cx, cy), dimCol, encDesc, nullptr, contentW);
cy += edSize.y + 6.0f * dp;
@@ -1398,7 +1472,9 @@ void App::renderFirstRunWizard() {
}
// --- Deferred Card 0 finalization: match right column total height ---
{
// Only for the focused/upcoming Appearance card; a completed one was already finalized
// above as a compact pill and must not be re-stretched.
if (cardState(0) != 2) {
float rightColBot = card2Bot;
if (rightColBot > card0Bot) card0Bot = rightColBot;
finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0));
@@ -1407,6 +1483,24 @@ void App::renderFirstRunWizard() {
// Merge channels: backgrounds → content → overlays
dl->ChannelsMerge();
// Measure this frame's content height (feeds next frame's scroll clamp) and, when it overflows the
// window, draw a slim scroll indicator so the off-screen content is discoverable.
{
float contentBottom = std::max(card0Bot, std::max(card1Bot, card2Bot));
// Subtract vCenter back out: everything below the header was shifted down by it, so the raw
// span includes it. We want s_wizContentH to be the true (un-centered) content height, or the
// vertical-centering above would feed on itself and oscillate frame-to-frame.
s_wizContentH = (contentBottom - winPos.y + scrollY - vCenter) + 24.0f * dp;
if (wizMaxScroll > 0.0f && s_wizContentH > 0.0f) {
float trackH = winSize.y - 8.0f * dp;
float thumbH = std::min(trackH, std::max(32.0f * dp, trackH * (winSize.y / s_wizContentH)));
float thumbY = winPos.y + 4.0f * dp + (trackH - thumbH) * (scrollY / wizMaxScroll);
float barX = winPos.x + winSize.x - 6.0f * dp;
dl->AddRectFilled(ImVec2(barX, thumbY), ImVec2(barX + 3.0f * dp, thumbY + thumbH),
ui::material::WithAlpha(ui::material::OnSurface(), 55), 1.5f * dp);
}
}
ImGui::End();
}

View File

@@ -129,6 +129,35 @@ bool ChatDatabase::append(const ChatMessage& message)
return sqlite3_changes(db_) > 0;
}
bool ChatDatabase::upsert(const ChatMessage& message)
{
if (!key_ready_ || !ensureOpen()) return false;
std::vector<unsigned char> nonce;
std::vector<unsigned char> cipher;
std::string plain = serialize(message);
const bool encrypted = encrypt(plain, nonce, cipher);
if (!plain.empty()) sodium_memzero(&plain[0], plain.size());
if (!encrypted) return false;
const std::string dedup = dedupHash(message.txid, message.payload_position);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_,
"INSERT INTO chat_messages (wallet_tag, dedup_hash, nonce, payload) VALUES (?, ?, ?, ?) "
"ON CONFLICT(wallet_tag, dedup_hash) DO UPDATE SET nonce=excluded.nonce, payload=excluded.payload",
-1, &stmt, nullptr) != SQLITE_OK) {
return false;
}
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_blob(stmt, 3, nonce.data(), static_cast<int>(nonce.size()), SQLITE_TRANSIENT);
sqlite3_bind_blob(stmt, 4, cipher.data(), static_cast<int>(cipher.size()), SQLITE_TRANSIENT);
const bool done = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
return done;
}
std::vector<ChatMessage> ChatDatabase::load()
{
std::vector<ChatMessage> out;
@@ -199,6 +228,17 @@ bool ChatDatabase::ensureOpen()
exec("PRAGMA journal_mode=WAL");
exec("PRAGMA synchronous=NORMAL");
// C3-1: restrict the chat DB and its WAL/SHM sidecars to owner-only. sqlite creates them with
// umask-derived permissions (often world/group-readable); they hold per-row nonces + AEAD
// ciphertext of the user's messages. Best-effort (errors swallowed; a no-op-ish on Windows).
{
std::error_code perr;
const auto ownerOnly = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write;
std::filesystem::permissions(database_path_, ownerOnly, std::filesystem::perm_options::replace, perr);
std::filesystem::permissions(database_path_ + "-wal", ownerOnly, std::filesystem::perm_options::replace, perr);
std::filesystem::permissions(database_path_ + "-shm", ownerOnly, std::filesystem::perm_options::replace, perr);
}
if (!createSchema()) {
close();
return false;
@@ -267,7 +307,10 @@ bool ChatDatabase::deserialize(const std::string& json, ChatMessage& out) const
out.body = parsed.value("b", std::string());
out.timestamp = parsed.value("ts", static_cast<std::int64_t>(0));
out.payload_position = static_cast<std::size_t>(parsed.value("pos", static_cast<std::uint64_t>(0)));
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent
out.delivery = static_cast<ChatDelivery>(parsed.value("dl", 0)); // old rows → Sent (0)
// A persisted "Sending" means we crashed mid-broadcast; the outcome is unknown. Resolve it
// optimistically to Sent on load so it can't show a stuck spinner forever.
if (out.delivery == ChatDelivery::Sending) out.delivery = ChatDelivery::Sent;
return true;
} catch (const std::exception&) {
return false;

View File

@@ -43,6 +43,11 @@ public:
// Returns true if newly inserted; false on duplicate or while locked.
bool append(const ChatMessage& message);
// Persist-or-overwrite one message by its (txid+position) dedup key. Unlike append(), this
// updates an existing row's payload — used for outgoing echoes whose delivery status changes
// (Sending → Sent/Failed). Returns true on success; false while locked / on error.
bool upsert(const ChatMessage& message);
// Decrypt and return every stored message for the unlocked wallet, in insertion order. Empty
// while locked or if none. Rows that fail to decrypt/parse are skipped.
std::vector<ChatMessage> load();

View File

@@ -10,9 +10,11 @@ namespace dragonx::chat {
enum class ChatDirection { Incoming, Outgoing };
enum class ChatMessageKind { Message, ContactRequest };
// Outgoing delivery: Sent = the broadcast was submitted; Failed = it wasn't (not connected, no
// spendable address, a send already in progress). Always Sent for incoming.
enum class ChatDelivery { Sent, Failed };
// Outgoing delivery status. Sending = broadcast in flight (async op not yet resolved); Sent = the
// daemon accepted + broadcast the tx; Failed = it didn't (not connected, no funded address, rejected).
// Always Sent for incoming. NB: the values are persisted (chat DB serializes the int), so Sent MUST
// stay 0 and new states are APPENDED — never reordered.
enum class ChatDelivery { Sent, Failed, Sending };
struct ChatMessage {
ChatDirection direction = ChatDirection::Incoming;

View File

@@ -16,7 +16,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
const std::string& conversationId,
const char* type,
const std::string& streamHeaderHex,
const std::string& publicKeyHex)
const std::string& publicKeyHex,
std::int64_t sentAt)
{
nlohmann::json header;
header["h"] = 1; // header number (>= 1)
@@ -26,6 +27,7 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
header["t"] = type; // "Memo" or "Cont"
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
return header.dump();
}
@@ -67,7 +69,8 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
OutgoingChatMemos memos;
memos.recipientZaddr = peerZaddr;
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex);
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
static_cast<std::int64_t>(std::time(nullptr)));
memos.payloadMemo = ciphertextHex;
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
@@ -95,7 +98,8 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
OutgoingChatMemos memos;
memos.recipientZaddr = peerZaddr;
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex);
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
static_cast<std::int64_t>(std::time(nullptr)));
memos.payloadMemo = requestText;
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {

View File

@@ -124,6 +124,10 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
if (!readRequiredString(object, "t", type, error)) return fail(error);
if (!readRequiredString(object, "e", header.secretstream_header_hex, error)) return fail(error);
if (!readRequiredString(object, "p", header.public_key_hex, error)) return fail(error);
// Optional sender compose time (Unix seconds). Absent on older senders — leave sent_at = 0 so the
// receiver falls back to the tx/receive time. Read leniently; never fail the header on a bad value.
if (auto it = object.find("ts"); it != object.end() && it->is_number_integer())
header.sent_at = it->get<std::int64_t>();
if (header.header_number < 1) return fail("header number must be positive");
if (header.version != kHushChatSupportedVersion) return fail("unsupported HushChat version");
@@ -303,6 +307,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
metadata.sender_public_key_hex = pair.header.public_key_hex;
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
metadata.payload_memo = pair.payload_memo;
metadata.sent_at = pair.header.sent_at; // carry the sender's compose time (0 if absent)
result.metadata.push_back(std::move(metadata));
}

View File

@@ -23,6 +23,9 @@ struct HushChatHeader {
HushChatHeaderType type = HushChatHeaderType::Message;
std::string secretstream_header_hex;
std::string public_key_hex;
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
std::int64_t sent_at = 0;
};
struct HushChatHeaderParseResult {
@@ -80,6 +83,7 @@ struct HushChatTransactionMetadata {
std::string sender_public_key_hex; // header "p": peer crypto_kx public key (hex)
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
};
struct HushChatTransactionExtractionResult {

View File

@@ -25,19 +25,41 @@ void ChatService::clearIdentity() {
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
std::int64_t fallbackTimestamp) {
std::int64_t fallbackTimestamp,
std::vector<std::string>* newIncomingCids) {
if (!has_identity_) return 0;
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
int added = 0;
for (const auto& meta : metadata) {
// A memo whose sender is our OWN identity is something we sent (only we hold our key). The local
// echo already records it as outgoing — ingesting it as incoming would duplicate it as a phantom
// "from peer" message. (This also collapses same-seed self-chat, where the "peer" wallet shares
// our identity, so every message would otherwise loop back.)
if (!myPubKey.empty() && meta.sender_public_key_hex == myPubKey) continue;
ChatMessage message;
message.direction = ChatDirection::Incoming;
message.txid = meta.txid;
message.conversation_id = meta.conversation_id;
message.peer_zaddr = meta.reply_zaddr;
message.peer_public_key_hex = meta.sender_public_key_hex;
// Reference time: the tx/receive time (block time if confirmed, else the receiver's wall clock for
// a mempool receive).
const auto timeIt = txTimestamps.find(meta.txid);
message.timestamp = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
const std::int64_t refTime = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
// Prefer the sender's stamped compose time (header "ts") — the true send time, shown identically on
// both ends. But REJECT a value implausibly in the FUTURE vs the reference: a wrong/ahead peer clock
// would otherwise pin their messages to the bottom of the thread forever. A compose time in the
// PAST is fine — the note buffer can broadcast a queued message long after it was composed, and a
// confirmed tx's block time is always >= the compose time.
constexpr std::int64_t kSenderTsFutureToleranceSec = 3600; // 1 hour of clock skew tolerated
if (meta.sent_at > 0 && (refTime <= 0 || meta.sent_at <= refTime + kSenderTsFutureToleranceSec)) {
message.timestamp = meta.sent_at;
} else {
message.timestamp = refTime;
}
message.payload_position = meta.payload_position;
if (meta.type == HushChatHeaderType::ContactRequest) {
@@ -59,6 +81,9 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
if (store_.append(message)) {
if (db_) db_->append(message);
++added;
// Every ingested message is incoming — report its cid so the caller can notify without
// relying on a seen-watermark delta (which block-time vs wall-clock skew can swallow).
if (newIncomingCids) newIncomingCids->push_back(message.conversation_id);
}
}
return added;
@@ -105,4 +130,18 @@ bool ChatService::recordOutgoing(const ChatMessage& message) {
return false;
}
bool ChatService::recordOutgoingPending(const ChatMessage& message) {
// Persist immediately (as Sending) so a send survives an app quit before the broadcast resolves;
// resolveOutgoing() then UPSERTS the row to the final status. A stray persisted Sending (crash mid-
// broadcast) loads as Sent (see ChatDatabase::deserialize).
const bool appended = store_.append(message);
if (appended && db_) db_->upsert(message);
return appended;
}
void ChatService::resolveOutgoing(const std::string& txid, ChatDelivery delivery) {
const ChatMessage* updated = store_.updateDelivery(txid, delivery);
if (updated && db_) db_->upsert(*updated); // overwrite the Sending row with the final status
}
} // namespace dragonx::chat

View File

@@ -40,9 +40,13 @@ public:
// when the txid isn't present. Newly-added messages are also persisted (if a database is
// attached). Returns the number of NEW messages added; 0 with no identity. Undecryptable
// Messages are dropped silently (no logging of memo/plaintext).
// When `newIncomingCids` is non-null it is filled with the conversation ids of the genuinely-new
// incoming (non-request) messages appended this call — a reliable "a new message arrived here"
// signal for notifications that doesn't depend on any timestamp/seen-watermark comparison.
int ingest(const std::vector<HushChatTransactionMetadata>& metadata,
const std::unordered_map<std::string, std::int64_t>& txTimestamps,
std::int64_t fallbackTimestamp = 0);
std::int64_t fallbackTimestamp = 0,
std::vector<std::string>* newIncomingCids = nullptr);
// Attach a persistent backing store (Phase 2). Not owned. Pass nullptr to detach. New messages
// from ingest() are written through; loadFromDatabase() rehydrates the in-memory store from it.
@@ -72,6 +76,12 @@ public:
// only local record of what we sent.)
bool recordOutgoing(const ChatMessage& message);
// Two-phase echo for delivery tracking: record + persist immediately as Sending (survives an app
// quit), then resolveOutgoing() upserts the final status once the broadcast completes. A stray
// persisted Sending (crash mid-broadcast) loads back as Sent.
bool recordOutgoingPending(const ChatMessage& message);
void resolveOutgoing(const std::string& txid, ChatDelivery delivery);
const ChatStore& store() const { return store_; }
ChatStore& store() { return store_; }

View File

@@ -2,6 +2,8 @@
#include "chat_store.h"
#include <algorithm>
namespace dragonx::chat {
std::string ChatStore::dedupKey(const ChatMessage& message) {
@@ -19,9 +21,29 @@ std::vector<ChatMessage> ChatStore::conversation(const std::string& conversation
for (const auto& message : messages_) {
if (message.conversation_id == conversationId) out.push_back(message);
}
// Return chronological (oldest→newest). messages_ is in scan/insertion order, which is NOT
// time-ordered (a full scan harvests txids in std::set/unordered_map order) — that would mis-order the
// rendered thread AND let the reply-target pin (first-seen peer address) latch onto a non-establishing
// message (B2). timestamp is the block/tx time (peer can't set it); tie-break txid + payload_position
// for determinism.
std::stable_sort(out.begin(), out.end(), [](const ChatMessage& a, const ChatMessage& b) {
if (a.timestamp != b.timestamp) return a.timestamp < b.timestamp;
if (a.txid != b.txid) return a.txid < b.txid;
return a.payload_position < b.payload_position;
});
return out;
}
const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelivery delivery) {
for (auto& message : messages_) {
if (message.txid == txid) {
message.delivery = delivery;
return &message;
}
}
return nullptr;
}
std::vector<std::string> ChatStore::conversationIds() const {
std::vector<std::string> ids;
std::unordered_set<std::string> seenIds;

View File

@@ -21,9 +21,23 @@ public:
// Messages in a conversation, in insertion order.
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
// Update an outgoing echo's delivery status by its local txid. Returns a pointer to the updated
// message (for the caller to persist), or nullptr if no message has that txid.
const ChatMessage* updateDelivery(const std::string& txid, ChatDelivery delivery);
// Distinct conversation ids, in first-seen order.
std::vector<std::string> conversationIds() const;
// The set of on-chain txids that carried a chat message (sent or received, messages + contact
// requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies.
std::unordered_set<std::string> chatTxids() const {
std::unordered_set<std::string> out;
out.reserve(messages_.size());
for (const auto& m : messages_)
if (!m.txid.empty()) out.insert(m.txid);
return out;
}
std::size_t size() const { return messages_.size(); }
bool empty() const { return messages_.empty(); }
void clear();

View File

@@ -147,6 +147,26 @@ bool Settings::load(const std::string& path)
loadScalar(j, "language", language_);
loadScalar(j, "skin_id", skin_id_);
loadScalar(j, "chat_reply_zaddr", chat_reply_zaddr_);
if (j.contains("muted_chat_cids") && j["muted_chat_cids"].is_array()) {
muted_chat_cids_.clear();
for (const auto& c : j["muted_chat_cids"])
if (c.is_string()) muted_chat_cids_.push_back(c.get<std::string>());
}
if (j.contains("hidden_chat_cids") && j["hidden_chat_cids"].is_array()) {
hidden_chat_cids_.clear();
for (const auto& c : j["hidden_chat_cids"])
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
}
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
loadScalar(j, "chat_bubble_style", chat_bubble_style_); setChatBubbleStyle(chat_bubble_style_);
loadScalar(j, "chat_bubble_accent", chat_bubble_accent_); setChatBubbleAccent(chat_bubble_accent_);
loadScalar(j, "chat_density", chat_density_); setChatDensity(chat_density_);
loadScalar(j, "chat_font_scale", chat_font_scale_); setChatFontScale(chat_font_scale_);
loadScalar(j, "chat_time_format", chat_time_format_); setChatTimeFormat(chat_time_format_);
loadScalar(j, "chat_enter_sends", chat_enter_sends_);
loadScalar(j, "time_format", time_format_); setTimeFormat(time_format_);
loadScalar(j, "acrylic_enabled", acrylic_enabled_);
loadScalar(j, "acrylic_quality", acrylic_quality_);
loadScalar(j, "blur_multiplier", blur_multiplier_);
@@ -174,7 +194,17 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "portfolio_style", portfolio_style_);
if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0;
loadScalar(j, "contacts_view_mode", contacts_view_mode_);
if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0;
loadScalar(j, "contacts_avatar_shape", contacts_avatar_shape_); setContactsAvatarShape(contacts_avatar_shape_);
loadScalar(j, "contacts_list_scale", contacts_list_scale_); setContactsListScale(contacts_list_scale_);
loadScalar(j, "animate_avatars", animate_avatars_);
loadScalar(j, "scanline_enabled", scanline_enabled_);
loadScalar(j, "console_line_accents", console_line_accents_);
loadScalar(j, "console_text_color", console_text_color_);
loadScalar(j, "console_zoom", console_zoom_);
if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN
loadScalar(j, "console_auto_focus", console_auto_focus_);
if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
hidden_addresses_.clear();
for (const auto& a : j["hidden_addresses"])
@@ -202,12 +232,20 @@ bool Settings::load(const std::string& path)
}
loadScalar(j, "wizard_completed", wizard_completed_);
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
empty_wallet_warning_acked_.clear();
for (const auto& w : j["empty_wallet_warning_acked"])
if (w.is_string()) empty_wallet_warning_acked_.insert(w.get<std::string>());
}
loadScalar(j, "encryption_pending", encryption_pending_);
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
loadScalar(j, "active_wallet_file", active_wallet_file_);
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
loadScalar(j, "unlock_duration", unlock_duration_);
loadScalar(j, "pin_enabled", pin_enabled_);
@@ -285,6 +323,8 @@ bool Settings::load(const std::string& path)
loadScalar(j, "reduce_motion", reduce_motion_);
loadScalar(j, "selected_exchange", selected_exchange_);
loadScalar(j, "selected_pair", selected_pair_);
loadScalar(j, "chart_interval", chart_interval_);
loadScalar(j, "chart_style", chart_style_);
loadScalar(j, "pool_url", pool_url_);
// Migrate old default pool URL that was missing the stratum port
if (pool_url_ == "pool.dragonx.is") pool_url_ = "pool.dragonx.is:3433";
@@ -413,6 +453,21 @@ bool Settings::save(const std::string& path)
j["language"] = language_;
j["skin_id"] = skin_id_;
j["chat_reply_zaddr"] = chat_reply_zaddr_;
j["muted_chat_cids"] = json::array();
for (const auto& c : muted_chat_cids_)
j["muted_chat_cids"].push_back(c);
j["hidden_chat_cids"] = json::array();
for (const auto& c : hidden_chat_cids_)
j["hidden_chat_cids"].push_back(c);
j["chat_emoji_color"] = chat_emoji_color_;
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
j["chat_bubble_style"] = chat_bubble_style_;
j["chat_bubble_accent"] = chat_bubble_accent_;
j["chat_density"] = chat_density_;
j["chat_font_scale"] = chat_font_scale_;
j["chat_time_format"] = chat_time_format_;
j["chat_enter_sends"] = chat_enter_sends_;
j["time_format"] = time_format_;
j["acrylic_enabled"] = acrylic_enabled_;
j["acrylic_quality"] = acrylic_quality_;
j["blur_multiplier"] = blur_multiplier_;
@@ -422,7 +477,15 @@ bool Settings::save(const std::string& path)
j["window_opacity"] = window_opacity_;
j["balance_layout"] = balance_layout_; // saved as string ID
j["portfolio_style"] = portfolio_style_;
j["contacts_view_mode"] = contacts_view_mode_;
j["contacts_avatar_shape"] = contacts_avatar_shape_;
j["contacts_list_scale"] = contacts_list_scale_;
j["animate_avatars"] = animate_avatars_;
j["scanline_enabled"] = scanline_enabled_;
j["console_line_accents"] = console_line_accents_;
j["console_text_color"] = console_text_color_;
j["console_zoom"] = console_zoom_;
j["console_auto_focus"] = console_auto_focus_;
j["hidden_addresses"] = json::array();
for (const auto& addr : hidden_addresses_)
j["hidden_addresses"].push_back(addr);
@@ -444,12 +507,18 @@ bool Settings::save(const std::string& path)
}
j["wizard_completed"] = wizard_completed_;
j["seed_backup_reminded"] = seed_backup_reminded_;
j["large_wallet_warned"] = large_wallet_warned_;
j["empty_wallet_warning_acked"] = json::array();
for (const auto& w : empty_wallet_warning_acked_)
j["empty_wallet_warning_acked"].push_back(w);
j["encryption_pending"] = encryption_pending_;
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
j["active_wallet_file"] = active_wallet_file_;
j["seed_migration_pending"] = seed_migration_pending_;
j["seed_migration_dest"] = seed_migration_dest_;
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
j["auto_lock_timeout"] = auto_lock_timeout_;
j["unlock_duration"] = unlock_duration_;
j["pin_enabled"] = pin_enabled_;
@@ -486,6 +555,8 @@ bool Settings::save(const std::string& path)
j["reduce_motion"] = reduce_motion_;
j["selected_exchange"] = selected_exchange_;
j["selected_pair"] = selected_pair_;
j["chart_interval"] = chart_interval_;
j["chart_style"] = chart_style_;
j["pool_url"] = pool_url_;
j["pool_algo"] = pool_algo_;
j["pool_worker"] = pool_worker_;

View File

@@ -91,7 +91,8 @@ public:
bool showValue = true; // show the converted/fiat value on the card
bool show24h = false; // show the 24h % change (live-market bases only)
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
int sparklineInterval = 0; // 0=minute 1=hour 2=day 3=week 4=month (resample of price history)
int sparklineInterval = 4; // 0=minute 1=hour 2=day 3=week 4=month (default month: a real curve
// from the daily series, vs the young in-session minute buffer)
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
// hash (shown only when that wallet is active). New entries are tagged with the current
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
@@ -118,6 +119,52 @@ public:
std::string getChatReplyZaddr() const { return chat_reply_zaddr_; }
void setChatReplyZaddr(const std::string& z) { chat_reply_zaddr_ = z; }
// Muted chat conversations (by cid) — muted conversations don't badge or raise a toast (Q10).
bool isChatMuted(const std::string& cid) const {
return std::find(muted_chat_cids_.begin(), muted_chat_cids_.end(), cid) != muted_chat_cids_.end();
}
void setChatMuted(const std::string& cid, bool muted) {
const bool already = isChatMuted(cid);
if (muted && !already) muted_chat_cids_.push_back(cid);
else if (!muted && already)
muted_chat_cids_.erase(std::remove(muted_chat_cids_.begin(), muted_chat_cids_.end(), cid),
muted_chat_cids_.end());
}
// Hidden chat conversations (by cid) — hidden ones are filtered out of the list; a new incoming
// message un-hides them (you can't un-receive) so nothing is silently lost.
bool isChatHidden(const std::string& cid) const {
return std::find(hidden_chat_cids_.begin(), hidden_chat_cids_.end(), cid) != hidden_chat_cids_.end();
}
void setChatHidden(const std::string& cid, bool hidden) {
const bool already = isChatHidden(cid);
if (hidden && !already) hidden_chat_cids_.push_back(cid);
else if (!hidden && already)
hidden_chat_cids_.erase(std::remove(hidden_chat_cids_.begin(), hidden_chat_cids_.end(), cid),
hidden_chat_cids_.end());
}
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
bool getChatEmojiColor() const { return chat_emoji_color_; }
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
float getChatPollRateSec() const { return chat_poll_rate_sec_; }
void setChatPollRateSec(float v) { chat_poll_rate_sec_ = std::max(0.5f, std::min(15.0f, v)); }
int getChatBubbleStyle() const { return chat_bubble_style_; }
void setChatBubbleStyle(int v) { chat_bubble_style_ = (v < 0 || v > 2) ? 0 : v; }
int getChatBubbleAccent() const { return chat_bubble_accent_; }
void setChatBubbleAccent(int v) { chat_bubble_accent_ = (v < 0 || v > 5) ? 0 : v; }
int getChatDensity() const { return chat_density_; }
void setChatDensity(int v) { chat_density_ = (v < 0 || v > 1) ? 0 : v; }
float getChatFontScale() const { return chat_font_scale_; }
void setChatFontScale(float v) { chat_font_scale_ = std::max(0.8f, std::min(1.5f, v)); }
int getChatTimeFormat() const { return chat_time_format_; } // 0=follow global, 1=24h, 2=12h
void setChatTimeFormat(int v) { chat_time_format_ = (v < 0 || v > 2) ? 0 : v; }
bool getChatEnterSends() const { return chat_enter_sends_; }
void setChatEnterSends(bool v) { chat_enter_sends_ = v; }
// Global clock format (0=24h, 1=12h) — chat can override it for the Chat tab only.
int getTimeFormat() const { return time_format_; }
void setTimeFormat(int v) { time_format_ = (v < 0 || v > 1) ? 0 : v; }
// Privacy
bool getSaveZtxs() const { return save_ztxs_; }
void setSaveZtxs(bool save) { save_ztxs_ = save; }
@@ -180,15 +227,39 @@ public:
std::string getBalanceLayout() const { return balance_layout_; }
void setBalanceLayout(const std::string& v) { balance_layout_ = v; }
// Market-tab portfolio row style: 0 = single-line, 1 = two-line, 2 = value-hero. Cycled with
// Left/Right arrows on the Market tab (like the Overview layouts).
// Market-tab portfolio row style: 0 = Table (borderless grid), 1 = Cards (glass card + Z/T bar),
// 2 = Spotlight (hero value). Cycled with Left/Right arrows or set in the Market settings modal.
int getPortfolioStyle() const { return portfolio_style_; }
void setPortfolioStyle(int v) { portfolio_style_ = (v < 0 || v > 2) ? 0 : v; }
// Contacts tab address-list view: 0 = cards, 1 = list, 2 = table.
int getContactsViewMode() const { return contacts_view_mode_; }
void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; }
// Contacts customization (gear modal): avatar shape + card/list row scale.
// Avatar shape: 0 = circle, 1 = rounded square, 2 = full-row-height left tab (rounded-left, flat right).
int getContactsAvatarShape() const { return contacts_avatar_shape_; }
void setContactsAvatarShape(int v) { contacts_avatar_shape_ = (v < 0 || v > 2) ? 0 : v; }
float getContactsListScale() const { return contacts_list_scale_; }
void setContactsListScale(float v) { contacts_list_scale_ = std::max(0.8f, std::min(1.5f, v)); }
// Play animated contact avatars (GIF/WebP). Off = show the first frame only.
bool getAnimateAvatars() const { return animate_avatars_; }
void setAnimateAvatars(bool v) { animate_avatars_ = v; }
// Console scanline effect
bool getScanlineEnabled() const { return scanline_enabled_; }
void setScanlineEnabled(bool v) { scanline_enabled_ = v; }
// Console output appearance: per-line left color accent bars, and per-channel text coloring.
// (Defaults match the ConsoleTab statics so an upgrade re-save doesn't flip visible behavior.)
bool getConsoleLineAccents() const { return console_line_accents_; }
void setConsoleLineAccents(bool v) { console_line_accents_ = v; }
bool getConsoleTextColor() const { return console_text_color_; }
void setConsoleTextColor(bool v) { console_text_color_ = v; }
float getConsoleZoom() const { return console_zoom_; }
void setConsoleZoom(float v) { console_zoom_ = v; }
// Auto-place the text cursor in the command box when the Console tab is opened.
bool getConsoleAutoFocus() const { return console_auto_focus_; }
void setConsoleAutoFocus(bool v) { console_auto_focus_ = v; }
// Hidden addresses (addresses hidden from the UI by the user)
const std::set<std::string>& getHiddenAddresses() const { return hidden_addresses_; }
bool isAddressHidden(const std::string& addr) const { return hidden_addresses_.count(addr) > 0; }
@@ -259,6 +330,24 @@ public:
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
bool getLargeWalletWarned() const { return large_wallet_warned_; }
void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
// Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds"
// warning has been dismissed. Keyed per active wallet file so switching to a different empty
// wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings).
bool isEmptyWalletWarnAcked(const std::string& walletFile) const {
return empty_wallet_warning_acked_.count(walletFile) > 0;
}
void ackEmptyWalletWarn(const std::string& walletFile) { empty_wallet_warning_acked_.insert(walletFile); }
// Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
bool getEncryptionPending() const { return encryption_pending_; }
void setEncryptionPending(bool v) { encryption_pending_ = v; }
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
@@ -282,6 +371,11 @@ public:
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
// Security — auto-lock timeout (seconds; 0 = disabled)
int getAutoLockTimeout() const { return auto_lock_timeout_; }
@@ -370,6 +464,10 @@ public:
void setSelectedExchange(const std::string& v) { selected_exchange_ = v; }
std::string getSelectedPair() const { return selected_pair_; }
void setSelectedPair(const std::string& v) { selected_pair_ = v; }
int getChartInterval() const { return chart_interval_; } // Market chart range 0=Live..4=1M
void setChartInterval(int v) { chart_interval_ = v; }
int getChartStyle() const { return chart_style_; } // 0 = line, 1 = candlestick
void setChartStyle(int v) { chart_style_ = v; }
// Pool mining
std::string getPoolUrl() const { return pool_url_; }
@@ -454,6 +552,18 @@ private:
std::string theme_ = "dragonx";
std::string skin_id_ = "dragonx";
std::string chat_reply_zaddr_;
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
int chat_bubble_style_ = 0; // 0 = rounded, 1 = square, 2 = minimal
int chat_bubble_accent_ = 0; // outgoing-bubble accent preset (0 = theme primary)
int chat_density_ = 0; // 0 = comfortable, 1 = compact
float chat_font_scale_ = 1.0f; // message text scale
int chat_time_format_ = 0; // 0 = follow global, 1 = 24h, 2 = 12h (Chat tab only)
bool chat_enter_sends_ = true; // Enter sends (vs. inserts newline; Ctrl+Enter sends)
int time_format_ = 0; // global clock: 0 = 24h, 1 = 12h
bool save_ztxs_ = true;
bool auto_shield_ = true;
bool use_tor_ = false;
@@ -470,25 +580,37 @@ private:
bool gradient_background_ = false;
#ifdef _WIN32
float ui_opacity_ = 0.50f; // Card/sidebar opacity (0.31.0, 1.0 = opaque)
float window_opacity_ = 0.75f; // Background alpha (0.31.0, <1 = desktop visible)
float window_opacity_ = 0.90f; // Background alpha (0.31.0, <1 = desktop visible)
#else
float ui_opacity_ = 1.0f; // Mac/Linux: default fully opaque
float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque
#endif
std::string balance_layout_ = "classic";
int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero)
int portfolio_style_ = 0; // Market portfolio row style (0 Table / 1 Cards / 2 Spotlight)
int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table)
int contacts_avatar_shape_ = 0; // 0 = circle, 1 = rounded square, 2 = full-height left tab
float contacts_list_scale_ = 1.0f; // card/list row scale (does not affect the table view)
bool animate_avatars_ = true; // play animated (GIF/WebP) contact avatars
bool scanline_enabled_ = true;
bool console_line_accents_ = true; // left color accent bars in console output
bool console_text_color_ = true; // per-channel text coloring in console output
float console_zoom_ = 1.0f; // console output font zoom factor
bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in)
std::set<std::string> hidden_addresses_;
std::set<std::string> favorite_addresses_;
std::map<std::string, AddressMeta> address_meta_;
bool wizard_completed_ = false;
bool seed_backup_reminded_ = false;
bool large_wallet_warned_ = false;
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
bool encryption_pending_ = false;
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
bool seed_migration_pending_ = false;
std::string seed_migration_dest_;
std::string seed_migration_temp_dir_;
std::string seed_migration_sweep_txid_;
std::string seed_migration_sweep_opid_;
int auto_lock_timeout_ = 900; // 15 minutes
int unlock_duration_ = 600; // 10 minutes
bool pin_enabled_ = false;
@@ -524,6 +646,8 @@ private:
bool reduce_motion_ = false;
std::string selected_exchange_ = "Nonkyc.io";
std::string selected_pair_ = "DRGX/USDT";
int chart_interval_ = 4; // Market chart range (0=Live 1=1H 2=1D 3=1W 4=1M)
int chart_style_ = 1; // Market chart style (0=line, 1=candlestick)
// Pool mining
std::string pool_url_ = "pool.dragonx.is:3433";

View File

@@ -61,14 +61,21 @@ bool DaemonController::externalDaemonDetected() const
return daemon_->externalDaemonDetected();
}
void DaemonController::clearExternalDaemonDetected()
{
daemon_->clearExternalDaemonDetected();
}
DaemonController::State DaemonController::state() const
{
return daemon_->getState();
}
const std::string& DaemonController::lastError() const
std::string DaemonController::lastError() const
{
return daemon_->getLastError();
// By value — getLastError() now returns a mutex-locked COPY, so forwarding it by reference would
// dangle (bind a reference to that temporary). (M-04 follow-through)
return daemon_ ? daemon_->getLastError() : std::string();
}
int DaemonController::crashCount() const
@@ -116,6 +123,16 @@ void DaemonController::setZapOnNextStart(bool enabled)
daemon_->setZapOnNextStart(enabled);
}
void DaemonController::setSalvageOnNextStart(bool enabled)
{
daemon_->setSalvageOnNextStart(enabled);
}
void DaemonController::setReindexOnNextStart(bool enabled)
{
daemon_->setReindexOnNextStart(enabled);
}
bool DaemonController::zapOnNextStart() const
{
return daemon_->zapOnNextStart();

View File

@@ -93,8 +93,9 @@ public:
bool isRunning() const;
bool externalDaemonDetected() const;
void clearExternalDaemonDetected();
State state() const;
const std::string& lastError() const;
std::string lastError() const; // by value: EmbeddedDaemon::getLastError() returns a locked copy (M-04)
int crashCount() const;
int lastBlockHeight() const;
double memoryUsageMB() const;
@@ -106,6 +107,8 @@ public:
bool rescanOnNextStart() const;
void setZapOnNextStart(bool enabled);
bool zapOnNextStart() const;
void setSalvageOnNextStart(bool enabled);
void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
bool externalDaemonDetected,

View File

@@ -0,0 +1,97 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// daemon_startup_diagnosis.h — pure classifiers over a crashed daemon's captured console output,
// so the app can offer a targeted one-click fix instead of a bare "daemon crashed" / silent no-funds.
#pragma once
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
namespace daemon {
// True when dragonxd aborted because its BLOCK DATABASE could not be loaded — either a
// daemon-vs-chaindata serialization-format mismatch after a daemon update (the deterministic
// "non-canonical optional discriminant" → "Error loading block database" → "Aborted block database
// rebuild. Exiting." sequence) or a genuinely corrupt/incomplete block index. In BOTH cases the fix
// is the same: `-reindex` rebuilds the index + chainstate from the intact raw blocks (blk*.dat).
// This is what otherwise silently presents as a wallet with zero balance — the node never starts.
inline bool blockDbOutputLooksBroken(const std::string& out)
{
return out.find("Error loading block database") != std::string::npos
|| out.find("non-canonical optional discriminant") != std::string::npos
|| out.find("Aborted block database rebuild") != std::string::npos
|| out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value"
}
// True when dragonxd AUTO-RECOVERED the wallet on startup: on any BDB-verify failure it moves the
// original wallet.dat to "wallet.{timestamp}.bak", salvages readable keys into a fresh wallet.dat, and
// keeps running — no flag required (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)).
// The salvage can be incomplete (or a false positive from stale/cross-platform BDB env state), so the
// node silently comes up on a possibly-empty wallet — which reads as fund loss unless we surface it.
inline bool walletAutoRecovered(const std::string& out)
{
// Cover BOTH salvage outcomes. A successful salvage prints the "data salvaged"/"saved as wallet.<ts>.bak"
// warning; a FAILED one (e.g. an inconsistent-but-readable file where aggressive salvage finds no
// records) prints "salvage failed"/"found no records". In every case CWalletDB::Recover first logs
// "Renamed <wallet> to wallet.<ts>.bak" and CDBEnv::Salvage logs its own banner — those two fire the
// instant a salvage begins, before the daemon may abort, so they're the earliest reliable signal.
return out.find("CDBEnv::Salvage") != std::string::npos // salvage is running
|| out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK
|| out.find("Original wallet.dat saved as wallet.") != std::string::npos
|| out.find("wallet.dat corrupt, salvage failed") != std::string::npos // RECOVER_FAIL
|| out.find("found no records in wallet") != std::string::npos // aggressive salvage empty
|| (out.find("Renamed ") != std::string::npos && out.find(" to wallet.") != std::string::npos
&& out.find(".bak") != std::string::npos); // Recover moved wallet.dat aside
}
// If `name` is a daemon salvage backup "wallet.<unixtime>.bak", return its timestamp; else -1.
inline long long parseWalletSalvageBakTs(const std::string& name)
{
if (name.rfind("wallet.", 0) != 0) return -1; // must start "wallet."
if (name.size() < 12 || name.compare(name.size() - 4, 4, ".bak") != 0) return -1; // ...and end ".bak"
const std::string mid = name.substr(7, name.size() - 7 - 4); // digits between the dots
if (mid.empty() || mid.size() > 18) return -1;
for (char c : mid) if (c < '0' || c > '9') return -1;
long long ts = 0;
for (char c : mid) ts = ts * 10 + (c - '0');
return ts;
}
// Most RECENT salvage backup (highest timestamp). Pure, testable.
inline std::string newestWalletSalvageBak(const std::vector<std::string>& filenames)
{
long long best = -1;
std::string bestName;
for (const auto& f : filenames) {
const long long ts = parseWalletSalvageBakTs(f);
if (ts > best) { best = ts; bestName = f; }
}
return bestName;
}
// LARGEST salvage backup, from (filename, fileSize) pairs — the least-salvaged one, i.e. the original.
// This is what "Restore original wallet" should use: a salvage CASCADE shrinks the wallet each round, so
// the newest .bak is the WORST and the largest is the pristine pre-salvage original (an emptied salvage
// is tiny; a real wallet is large). Ties break toward the newest timestamp. Returns "" if none present.
inline std::string largestWalletSalvageBak(const std::vector<std::pair<std::string, unsigned long long>>& files)
{
std::string bestName;
unsigned long long bestSize = 0;
long long bestTs = -1;
for (const auto& fp : files) {
const long long ts = parseWalletSalvageBakTs(fp.first);
if (ts < 0) continue;
if (fp.second > bestSize || (fp.second == bestSize && ts > bestTs)) {
bestSize = fp.second; bestTs = ts; bestName = fp.first;
}
}
return bestName;
}
} // namespace daemon
} // namespace dragonx

View File

@@ -224,11 +224,10 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
void EmbeddedDaemon::setState(State s, const std::string& message)
{
state_ = s;
if (!message.empty()) {
if (s == State::Error) {
if (!message.empty() && s == State::Error) {
std::lock_guard<std::mutex> lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
last_error_ = message;
}
}
if (state_callback_) {
state_callback_(s, message);
@@ -386,52 +385,80 @@ static std::string getPortOwnerInfo(int port)
#endif
}
// Check if a TCP port is already in use (something is LISTENING)
// Check if a TCP port is already in use (something is LISTENING). The daemon binds BOTH 127.0.0.1 (IPv4)
// and ::1 (IPv6); during shutdown one can linger after the other releases (and a "Binding RPC on ::1 …
// failed" is fatal to a fresh start), so we treat the port as in use if EITHER localhost family has it.
static bool isPortInUse(int port)
{
#ifdef _WIN32
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return false;
bool inUse = false;
{ // IPv4 127.0.0.1
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) { WSACleanup(); return false; }
struct sockaddr_in addr;
if (sock != INVALID_SOCKET) {
struct sockaddr_in addr; memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<u_short>(port));
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
int result = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0) inUse = true;
closesocket(sock);
}
}
if (!inUse) { // IPv6 ::1
SOCKET sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
if (sock != INVALID_SOCKET) {
struct sockaddr_in6 addr; memset(&addr, 0, sizeof(addr));
addr.sin6_family = AF_INET6;
addr.sin6_port = htons(static_cast<u_short>(port));
addr.sin6_addr = in6addr_loopback; // ::1 — avoids inet_pton's _WIN32_WINNT gating on mingw
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) == 0) inUse = true;
closesocket(sock);
}
}
WSACleanup();
return (result == 0);
return inUse;
#else
// On macOS /proc doesn't exist; on Linux prefer /proc/net/tcp to avoid
// creating sockets. Fall back to connect() if /proc is unavailable.
FILE* fp = fopen("/proc/net/tcp", "r");
if (fp) {
char line[256];
// On macOS /proc doesn't exist; on Linux prefer /proc/net/tcp{,6} to avoid creating sockets. The
// parse is family-agnostic: %*X skips the local IP (8 hex for v4, 32 for v6), %X grabs the port.
auto scanProc = [port](const char* path) -> bool {
FILE* fp = fopen(path, "r");
if (!fp) return false;
char line[512];
unsigned int localPort, state;
bool found = false;
while (fgets(line, sizeof(line), fp)) {
if (sscanf(line, " %*d: %*X:%X %*X:%*X %X", &localPort, &state) == 2) {
if (localPort == static_cast<unsigned int>(port) && state == 0x0A) {
found = true;
break;
}
if (localPort == static_cast<unsigned int>(port) && state == 0x0A) { found = true; break; }
}
}
fclose(fp);
return found;
};
if (FILE* probe = fopen("/proc/net/tcp", "r")) { // /proc available → authoritative LISTEN check
fclose(probe);
return scanProc("/proc/net/tcp") || scanProc("/proc/net/tcp6");
}
// Fallback (macOS): try to connect
int sock = socket(AF_INET, SOCK_STREAM, 0);
// Fallback (macOS): connect() probe on both loopback families.
auto connProbe = [port](int family, const char* addr) -> bool {
int sock = socket(family, SOCK_STREAM, 0);
if (sock < 0) return false;
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port));
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
int result = connect(sock, (struct sockaddr*)&addr, sizeof(addr));
bool ok = false;
if (family == AF_INET) {
struct sockaddr_in a; memset(&a, 0, sizeof(a));
a.sin_family = AF_INET; a.sin_port = htons(static_cast<uint16_t>(port));
a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
ok = (connect(sock, (struct sockaddr*)&a, sizeof(a)) == 0);
} else {
struct sockaddr_in6 a; memset(&a, 0, sizeof(a));
a.sin6_family = AF_INET6; a.sin6_port = htons(static_cast<uint16_t>(port));
inet_pton(AF_INET6, addr, &a.sin6_addr);
ok = (connect(sock, (struct sockaddr*)&a, sizeof(a)) == 0);
}
close(sock);
return (result == 0);
return ok;
};
return connProbe(AF_INET, "127.0.0.1") || connProbe(AF_INET6, "::1");
#endif
}
@@ -461,6 +488,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
}
external_daemon_detected_ = false;
// A previous dragonxd can release the RPC port well before it releases the datadir
// .lock — a graceful shutdown can take up to ~90s (see isDaemonProcessRunning). Starting
// into a still-held lock spawns a process that dies instantly with "Cannot obtain a lock
// on data directory"; the crash monitor reports that generically and, three times in
// ~12s, that is enough to trip the 3-strike restart cap before the lock's ~90s life
// elapses. Gate on the process actually still being alive, with a SHORT bounded wait
// (not the full ~90s — start() runs on the UI thread). Isolated starts (migrate-to-seed:
// skip_port_check_ / -datadir override) are exempt; they run their own datadir+port.
{
constexpr int kDatadirLockWaitPollMs = 100;
constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
bool stillRunning = false;
if (!skip_port_check_ && override_datadir_.empty()) {
stillRunning = isDaemonProcessRunning();
for (int i = 0; stillRunning && i < kDatadirLockWaitMaxPolls; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
stillRunning = isDaemonProcessRunning();
}
}
const StartLockGateDecision gate =
evaluateDatadirLockGate(skip_port_check_, !override_datadir_.empty(), stillRunning);
if (!gate.proceed) {
VERBOSE_LOGF("[INFO] %s\n", gate.errorMessage);
setState(State::Error, gate.errorMessage);
return false;
}
}
setState(State::Starting, "Looking for dragonxd binary...");
std::string daemon_path = binary_path;
@@ -496,9 +551,16 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-wallet=" + wallet_file_);
}
// Add wallet-repair flag if requested (one-shot). -zapwallettxes=2 wipes all wallet tx/note
// records and rebuilds them from the chain; it implies -rescan, so don't also pass -rescan.
if (zap_on_next_start_.exchange(false)) {
// Add wallet-repair flag if requested (one-shot). Precedence: salvage > zap > rescan; each implies a
// rescan in the daemon, so we don't stack them.
if (salvage_on_next_start_.exchange(false)) {
// -salvagewallet recovers readable keypairs from a corrupt wallet.dat; the daemon then implies -rescan.
DEBUG_LOGF("[INFO] Adding -salvagewallet flag to recover a corrupt wallet\n");
args.push_back("-salvagewallet");
zap_on_next_start_.store(false);
rescan_on_next_start_.store(false);
} else if (zap_on_next_start_.exchange(false)) {
// -zapwallettxes=2 wipes all wallet tx/note records and rebuilds them from the chain (implies -rescan).
DEBUG_LOGF("[INFO] Adding -zapwallettxes=2 flag for wallet repair (zap & rebuild)\n");
args.push_back("-zapwallettxes=2");
rescan_on_next_start_.store(false); // implied by zap; avoid redundant -rescan
@@ -508,6 +570,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
args.push_back("-rescan");
}
// -reindex rebuilds the block index + chainstate from the raw blocks (fixes an unreadable/format-
// mismatched block DB). It's about the CHAIN, not the wallet, so it's independent of the wallet-repair
// chain above (and implies its own wallet rescan). One-shot, consumed here.
if (reindex_on_next_start_.exchange(false)) {
DEBUG_LOGF("[INFO] Adding -reindex flag to rebuild the block database from raw blocks\n");
args.push_back("-reindex");
}
// One-shot isolated-datadir override (migrate-to-seed flow): run this start against a
// throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
@@ -522,8 +592,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
override_extra_args_.clear();
if (!startProcess(daemon_path, args)) {
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
setState(State::Error, "Failed to start dragonxd process");
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
// ... not executable or wrong architecture"). Surface THAT via setState — which also
// stores the Error message into last_error_ — instead of clobbering it with a generic
// string that would then be all getLastError()/the UI ever sees.
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
: last_error_;
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
setState(State::Error, detail);
return false;
}
@@ -544,12 +620,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
// Forward declaration — defined after startProcess
static DWORD findProcessByName(const char* name);
// Quote a single argument per the CommandLineToArgvW rules (MSDN) so a value containing a space or a
// quote is delivered as ONE argv token to the daemon instead of splitting/corrupting argv (L-02).
static std::string quoteWinArg(const std::string& arg) {
if (!arg.empty() && arg.find_first_of(" \t\n\v\"") == std::string::npos) return arg;
std::string out = "\"";
for (size_t i = 0; ; ++i) {
size_t nbs = 0;
while (i < arg.size() && arg[i] == '\\') { ++nbs; ++i; }
if (i == arg.size()) { out.append(nbs * 2, '\\'); break; }
if (arg[i] == '"') { out.append(nbs * 2 + 1, '\\'); out.push_back('"'); }
else { out.append(nbs, '\\'); out.push_back(arg[i]); }
}
out.push_back('"');
return out;
}
bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector<std::string>& args)
{
// Build command line
// Build command line (binary path always quoted; each arg quoted/escaped per Windows rules — L-02)
std::string cmd = "\"" + binary_path + "\"";
for (const auto& arg : args) {
cmd += " " + arg;
cmd += " " + quoteWinArg(arg);
}
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());
@@ -597,7 +689,10 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
debug_log_path_.c_str(), debug_log_offset_);
}
// Launch daemon with CREATE_NEW_CONSOLE (hidden via SW_HIDE).
// Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
// allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
// visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
// window at all (same approach as the xmrig launcher). The daemon logs to debug.log, not a console.
// The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX)
// — it must be in <exe_dir>/dragonx/ to avoid conflicts with lock files and data.
STARTUPINFOA si;
@@ -615,7 +710,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
NULL,
NULL,
FALSE,
CREATE_NEW_CONSOLE,
CREATE_NO_WINDOW,
NULL,
work_dir.c_str(),
&si,
@@ -656,17 +751,24 @@ static DWORD findProcessByName(const char* name)
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return 0;
PROCESSENTRY32 entry;
// Use the explicit WIDE Toolhelp API + a wide compare so this is correct regardless of the UNICODE
// macro. (The non-suffixed PROCESSENTRY32/Process32First map to the wide variants when UNICODE is
// defined, in which case szExeFile is WCHAR[] and an ANSI _stricmp would compare garbage and NEVER
// match — silently making findProcessByName a no-op that returns 0 for a running process.)
wchar_t wname[MAX_PATH];
if (MultiByteToWideChar(CP_ACP, 0, name, -1, wname, MAX_PATH) == 0) { CloseHandle(snap); return 0; }
PROCESSENTRY32W entry;
entry.dwSize = sizeof(entry);
DWORD pid = 0;
if (Process32First(snap, &entry)) {
if (Process32FirstW(snap, &entry)) {
do {
if (_stricmp(entry.szExeFile, name) == 0) {
if (lstrcmpiW(entry.szExeFile, wname) == 0) { // Win32 case-insensitive wide compare
pid = entry.th32ProcessID;
break;
}
} while (Process32Next(snap, &entry));
} while (Process32NextW(snap, &entry));
}
CloseHandle(snap);
return pid;
@@ -921,17 +1023,37 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
return false;
}
// Self-pipe used purely as an exec-success/failure handshake, separate from
// the stdout pipe above. Both ends are close-on-exec, so a successful execv()
// closes the write end for free (parent reads EOF); on execv() failure the
// child writes errno here, so the parent learns synchronously instead of
// reporting State::Running for a child that never became dragonxd. We use
// pipe()+FD_CLOEXEC (not pipe2) because this POSIX branch is shared with
// macOS, which has no pipe2().
int execpipe[2];
if (pipe(execpipe) == -1) {
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
return false;
}
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
pid_t pid = fork();
if (pid == -1) {
last_error_ = "Fork failed: " + std::string(strerror(errno));
close(pipefd[0]);
close(pipefd[1]);
close(execpipe[0]);
close(execpipe[1]);
return false;
}
if (pid == 0) {
// Child process
close(pipefd[0]); // Close read end
close(pipefd[0]); // Close read end of the stdout pipe
close(execpipe[0]); // Child only writes the exec-status pipe
// Put child in its own process group so we can kill the entire
// group later (including dragonxd spawned by a wrapper script).
@@ -998,17 +1120,56 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
execv(binary_path.c_str(), argv.data());
}
// If we get here, exec failed
fprintf(stderr, "execv failed: %s\n", strerror(errno));
// If we get here, execv() failed — the child never became dragonxd.
// Capture errno before fprintf/strerror can clobber it, report it to
// the parent over the exec-status pipe (EINTR-safe), then exit.
int exec_errno = errno;
fprintf(stderr, "execv failed: %s\n", strerror(exec_errno));
ssize_t w;
do {
w = write(execpipe[1], &exec_errno, sizeof(exec_errno));
} while (w < 0 && errno == EINTR);
_exit(127);
}
// Parent process
close(pipefd[1]); // Close write end
close(pipefd[1]); // Close our copy of the stdout write end
close(execpipe[1]); // Must close our copy, or the read() below never sees EOF
// Exec-status handshake: EOF => execv() succeeded (its write end was closed
// on exec); a full sizeof(int) => execv() failed and the child sent errno.
int child_errno = 0;
size_t got = 0;
char* ep = reinterpret_cast<char*>(&child_errno);
for (;;) {
ssize_t n = read(execpipe[0], ep + got, sizeof(child_errno) - got);
if (n == 0) break; // EOF: exec succeeded
if (n < 0) { if (errno == EINTR) continue; break; } // other error: assume success
got += static_cast<size_t>(n);
if (got >= sizeof(child_errno)) break; // full errno: exec failed
}
close(execpipe[0]);
if (got >= sizeof(child_errno)) {
// execv() never replaced the child; it fprintf'd and _exit(127)'d. Reap
// the already-dead zombie here — monitorProcess() is only started after
// this function returns true, so there is no competing reaper.
close(pipefd[0]);
int status;
waitpid(pid, &status, 0);
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
" — not executable or wrong architecture";
return false;
}
stdout_fd_ = pipefd[0];
// Also set process group from parent side (race with child's setpgid)
setpgid(pid, pid);
// Best-effort: the child already calls setpgid(0, 0); this parent-side call
// just closes the fork/exec race window. A failure here is not fatal to
// startup, so we log rather than abort.
if (setpgid(pid, pid) != 0) {
DEBUG_LOGF("[WARN] setpgid(%d) from parent failed: %s\n", (int)pid, strerror(errno));
}
// Set non-blocking
int flags = fcntl(stdout_fd_, F_GETFL, 0);
@@ -1093,17 +1254,21 @@ double EmbeddedDaemon::getMemoryUsageMB() const
bool EmbeddedDaemon::isRunning() const
{
// Read the atomic state_ instead of calling waitpid() here. monitorProcess()
// is the sole thread allowed to waitpid() process_pid_ during normal operation.
// Calling waitpid() from this method too (as it used to, and this is invoked
// from the UI thread nearly every frame) meant whichever thread reaped the
// child's exit first consumed the status; if isRunning() won that race,
// monitorProcess() never saw the exit, so crash_count_ / the decoded exit
// code / the State::Error transition were all silently lost. Mirrors the
// fix already in XmrigManager::isRunning().
if (process_pid_ <= 0) return false;
int status;
pid_t result = waitpid(process_pid_, &status, WNOHANG);
if (result == 0) {
// Still running
return true;
}
return false;
const State s = state_.load(std::memory_order_relaxed);
// State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
// isRunning() while state_ == Stopping — before the process has actually
// terminated — and must keep seeing "alive" to wait/escalate correctly.
return (s == State::Running || s == State::Stopping);
}
void EmbeddedDaemon::drainOutput()
@@ -1247,5 +1412,28 @@ bool EmbeddedDaemon::tcpPortInUse(int port)
return isPortInUse(port);
}
bool EmbeddedDaemon::isDaemonProcessRunning()
{
#ifdef _WIN32
return findProcessByName("dragonxd.exe") != 0;
#elif defined(__linux__)
// Scan /proc for a process whose comm is exactly "dragonxd". Iterate with an error_code so a proc
// entry vanishing mid-scan (a process exiting) can't throw.
std::error_code ec;
fs::directory_iterator it("/proc", ec), end;
for (; !ec && it != end; it.increment(ec)) {
const std::string pid = it->path().filename().string();
if (pid.empty() || pid[0] < '0' || pid[0] > '9') continue; // numeric pid dirs only
std::ifstream f((it->path() / "comm").string());
std::string comm;
if (f && std::getline(f, comm) && comm == "dragonxd") return true;
}
return false;
#else
// macOS has no /proc; fall back to the RPC-port probe (best-effort).
return isPortInUse(std::atoi(DRAGONX_DEFAULT_RPC_PORT));
#endif
}
} // namespace daemon
} // namespace dragonx

View File

@@ -79,7 +79,9 @@ public:
/**
* @brief Get last error message
*/
const std::string& getLastError() const { return last_error_; }
// Copy under lock: last_error_ is written from the monitor thread (setState on an unexpected exit)
// while the UI thread reads it — a reference would be a torn-read / use-after-free race (M-04).
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
/**
* @brief Get dragonxd process output (thread-safe copy)
@@ -142,6 +144,10 @@ public:
* When true the wallet should connect to it instead of showing an error.
*/
bool externalDaemonDetected() const { return external_daemon_detected_; }
// Clear the adopted-external latch before relaunching our OWN process (e.g. a wallet switch that
// stopped the adopted daemon), so the freshly spawned daemon is treated as owned. start() also
// clears it on the port-free fall-through, but only if not short-circuited by an early guard.
void clearExternalDaemonDetected() { external_daemon_detected_ = false; }
/**
* @brief Set callback for state changes
@@ -197,6 +203,18 @@ public:
void setZapOnNextStart(bool v) { zap_on_next_start_ = v; }
bool zapOnNextStart() const { return zap_on_next_start_.load(); }
// -salvagewallet: attempt to recover keys from a corrupt wallet.dat on startup (implies -rescan in
// the daemon). One-shot, consumed on the next start. Used to repair a wallet a switch flagged corrupt.
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
// -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot,
// consumed on the next start. Offered when the node aborts on an unreadable block database (a
// daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet
// rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags.
void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; }
bool reindexOnNextStart() const { return reindex_on_next_start_.load(); }
/**
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
@@ -217,6 +235,41 @@ public:
*/
void setSkipPortCheck(bool v) { skip_port_check_ = v; }
/**
* @brief True while ANY dragonxd process is running (by process name), regardless of who started it.
* Unlike isRpcPortInUse()/isRunning(), this reflects the actual PROCESS still being alive — a
* graceful shutdown stops accepting RPC (port reads "free") but keeps the datadir lock until the
* process exits, which can take up to ~90s. Use this to know a stopped node has FULLY released
* the datadir before starting a replacement. Matches the daemon binary name on all platforms.
*/
static bool isDaemonProcessRunning();
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
struct StartLockGateDecision {
bool proceed = true; // false => bail before spawning
const char* errorMessage = ""; // set (a string literal) when proceed == false
};
/**
* @brief Pure decision for start(): bail because a previous dragonxd still holds the
* shared datadir lock? Isolated instances (skip_port_check_ / an active -datadir
* override) are exempt — they run their own throwaway datadir+port and can coexist
* with the main daemon. Does no process/fs I/O itself (the caller does the probing),
* so it is directly unit-testable; defined inline so tests need only this header.
*/
static StartLockGateDecision evaluateDatadirLockGate(bool skipPortCheck,
bool isolatedOverride,
bool stillRunningAfterWait)
{
if (skipPortCheck || isolatedOverride) return {true, ""};
if (stillRunningAfterWait) {
return {false,
"A previous dragonxd is still shutting down and holding the data "
"directory lock. Retrying shortly…"};
}
return {true, ""};
}
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
static bool tcpPortInUse(int port);
@@ -235,6 +288,7 @@ private:
std::atomic<State> state_{State::Stopped};
std::atomic<bool> external_daemon_detected_{false};
std::string last_error_;
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_; // protects process_output_
std::string process_output_;
StateCallback state_callback_;
@@ -261,6 +315,8 @@ private:
std::atomic<int> crash_count_{0}; // consecutive crash counter
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
std::atomic<bool> reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB)
std::string override_datadir_; // one-shot: -datadir for the next start
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
bool skip_port_check_ = false; // isolated instance on a non-default port

View File

@@ -54,6 +54,16 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
// RPC port. So the wallet lives in <base>/DRAGONX; `base` is the migration root we clean up.
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
const std::string dataDir = base + "/DRAGONX";
// W3-2: never blindly wipe a pre-existing temp seed wallet. A prior migration that swept funds into
// it but was abandoned or crashed before adopting would otherwise have its (fund-bearing) wallet
// destroyed here. A completed migration removes this dir on adopt, so a leftover means an unfinished
// one — refuse and point the user at it rather than silently destroying it.
if (fs::exists(dataDir + "/wallet.dat")) {
r.error = "A previous seed migration looks unfinished — its temporary wallet is still at\n" + base +
"\nResume or cancel it first. If you are certain its funds are already in your main "
"wallet, delete that folder and try again.";
return r;
}
fs::remove_all(base, ec);
fs::create_directories(dataDir, ec);
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
@@ -110,9 +120,13 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
// 6. Export the new seed phrase + a fresh shielded receive address (the future sweep target).
try {
auto m = cli.call("z_exportmnemonic");
if (m.contains("mnemonic") && m["mnemonic"].is_string())
r.seedPhrase = m["mnemonic"].get<std::string>();
auto m = cli.callSecret("z_exportmnemonic"); // zero the raw body too (B7)
if (m.contains("mnemonic") && m["mnemonic"].is_string()) {
// Take our copy, then scrub the json node's own copy so it isn't freed in the clear (B7).
auto& mn = m["mnemonic"].get_ref<std::string&>();
r.seedPhrase = mn;
if (!mn.empty()) sodium_memzero(&mn[0], mn.size());
}
r.destAddress = cli.call("z_getnewaddress").get<std::string>();
r.ok = !r.seedPhrase.empty() && !r.destAddress.empty();
if (!r.ok) r.error = "The isolated node returned an empty seed or address.";
@@ -132,6 +146,14 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
}
}
// W1-2: never hand back a live seed on a failure path. If the mnemonic was exported but a
// later step failed (empty address, or z_getnewaddress threw), the caller discards this
// result without wiping it, which would leave the seed resident. Success keeps it deliberately.
if (!r.ok && !r.seedPhrase.empty()) {
sodium_memzero(&r.seedPhrase[0], r.seedPhrase.size());
r.seedPhrase.clear();
}
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
cli.disconnect();
temp.stop(20000);

View File

@@ -2,7 +2,7 @@
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// xmrig_manager.cpp — Pool mining process management via xmrig-hac.
// xmrig_manager.cpp — Pool mining process management via drg-xmrig.
// Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
#include "xmrig_manager.h"
@@ -23,6 +23,7 @@
#include <curl/curl.h>
#include "../util/logger.h"
#include "../util/platform.h"
#include "../util/pool_registry.h"
#ifdef _WIN32
@@ -89,8 +90,32 @@ static std::string getConfigDir() {
// libcurl write callback
static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
auto* s = static_cast<std::string*>(userdata);
s->append(static_cast<char*>(ptr), sz * n);
return sz * n;
const size_t add = sz * n;
// Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or
// MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02)
constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB
if (s->size() + add > kMaxStatsBytes) return 0;
s->append(static_cast<char*>(ptr), add);
return add;
}
// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target
// that a public mining pool would never be — used to refuse a background stats GET to it (M-09).
static bool hostLooksInternal(const std::string& host) {
if (host.empty() || host == "localhost") return true;
if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 ||
host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true;
if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255
const int second = std::atoi(host.c_str() + 4);
if (second >= 16 && second <= 31) return true;
}
if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local
if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 ||
host.rfind("fe80", 0) == 0) return true;
}
if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true;
if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool
return false;
}
// ============================================================================
@@ -100,9 +125,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
XmrigManager::XmrigManager() = default;
XmrigManager::~XmrigManager() {
should_stop_ = true;
if (isRunning()) {
stop(3000);
}
// Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is
// false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04)
if (monitor_thread_.joinable())
monitor_thread_.join();
}
// ============================================================================
@@ -116,32 +146,18 @@ std::string XmrigManager::findXmrigBinary() {
return path;
}
// Fallback: system PATH
// Fallback: system PATH — windowless so it never flashes a console.
#ifdef _WIN32
FILE* f = _popen("where xmrig.exe 2>nul", "r");
std::string out = util::Platform::runHiddenCapture("where xmrig.exe");
#else
FILE* f = popen("which xmrig 2>/dev/null", "r");
#endif
if (f) {
char line[512];
if (fgets(line, sizeof(line), f)) {
std::string s(line);
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
s.pop_back();
if (!s.empty() && fs::exists(s)) {
#ifdef _WIN32
_pclose(f);
#else
pclose(f);
#endif
return s;
}
}
#ifdef _WIN32
_pclose(f);
#else
pclose(f);
std::string out = util::Platform::runHiddenCapture("which xmrig");
#endif
{
std::string s = out;
const auto nl = s.find_first_of("\r\n"); // first line only
if (nl != std::string::npos) s.erase(nl);
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back();
if (!s.empty() && fs::exists(s)) return s;
}
return {};
@@ -208,22 +224,43 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
try {
fs::create_directories(fs::path(outPath).parent_path());
std::ofstream ofs(outPath);
if (!ofs.is_open()) {
last_error_ = "Cannot write xmrig config: " + outPath;
const std::string dumped = j.dump(4);
#ifndef _WIN32
// Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never
// in a world/group-readable file — even for a local attacker who opened it in the old
// create-then-chmod window and held the fd open across the chmod. (L-01)
int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) {
setLastError("Cannot write xmrig config: " + outPath);
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
ofs << j.dump(4);
ofs.close();
#ifndef _WIN32
// 0600 permissions — only owner can read/write
chmod(outPath.c_str(), 0600);
#endif
size_t off = 0;
bool wrote = true;
while (off < dumped.size()) {
ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off);
if (nw <= 0) { wrote = false; break; }
off += static_cast<size_t>(nw);
}
::close(fd);
if (!wrote) {
setLastError("Cannot write xmrig config: " + outPath);
return false;
}
return true;
#else
std::ofstream ofs(outPath, std::ios::trunc);
if (!ofs.is_open()) {
setLastError("Cannot write xmrig config: " + outPath);
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
ofs << dumped;
ofs.close();
return true;
#endif
} catch (const std::exception& e) {
last_error_ = std::string("Config write error: ") + e.what();
setLastError(std::string("Config write error: ") + e.what());
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -235,19 +272,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
bool XmrigManager::start(const Config& cfg) {
if (state_ == State::Running || state_ == State::Starting) {
last_error_ = "Already running";
setLastError("Already running");
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
return false;
}
state_ = State::Starting;
should_stop_ = false;
last_error_.clear();
setLastError(std::string());
{
std::lock_guard<std::mutex> lk(output_mutex_);
process_output_.clear();
}
{
std::lock_guard<std::mutex> lk(stats_mutex_);
stats_ = PoolStats{};
}
// Extract pool hostname for stats API queries
{
@@ -264,7 +304,7 @@ bool XmrigManager::start(const Config& cfg) {
// Find binary
std::string binary = findXmrigBinary();
if (binary.empty()) {
last_error_ = "xmrig binary not found";
setLastError("xmrig binary not found");
state_ = State::Error;
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
return false;
@@ -292,7 +332,11 @@ bool XmrigManager::start(const Config& cfg) {
return false;
}
// Start monitor thread
// Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set
// State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable
// std::thread calls std::terminate() and aborts the whole wallet. (M-04)
if (monitor_thread_.joinable())
monitor_thread_.join();
monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this);
state_ = State::Running;
DEBUG_LOGF("[INFO] XmrigManager: started\n");
@@ -367,7 +411,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
HANDLE hRead = nullptr, hWrite = nullptr;
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
last_error_ = "CreatePipe failed";
setLastError("CreatePipe failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
@@ -399,7 +443,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
char errBuf[256];
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
last_error_ = "CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf;
setLastError("CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf);
DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
return false;
}
@@ -450,14 +494,14 @@ void XmrigManager::drainOutput() {
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
int pipefd[2];
if (pipe(pipefd) != 0) {
last_error_ = "pipe() failed";
setLastError("pipe() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false;
}
pid_t pid = fork();
if (pid < 0) {
last_error_ = "fork() failed";
setLastError("fork() failed");
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
close(pipefd[0]);
close(pipefd[1]);
@@ -628,7 +672,7 @@ void XmrigManager::monitorProcess() {
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
state_ = State::Error;
last_error_ = "xmrig process exited unexpectedly";
setLastError("xmrig process exited unexpectedly");
break;
}
}
@@ -639,7 +683,7 @@ void XmrigManager::monitorProcess() {
if (ret == process_pid_ || ret < 0) {
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
state_ = State::Error;
last_error_ = "xmrig process exited unexpectedly";
setLastError("xmrig process exited unexpectedly");
break;
}
}
@@ -779,6 +823,11 @@ void XmrigManager::fetchPoolApiStats() {
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
// /api/pools); unknown/custom hosts fall back to the .is convention.
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
// SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a
// loopback/private/link-local/single-label target — those aren't public mining pools, and a
// paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted
// registry statsUrl and are exempt. (M-09)
if (!known && hostLooksInternal(pool_host_)) return;
const std::string url = known ? known->statsUrl
: ("https://" + pool_host_ + "/api/stats");
@@ -857,25 +906,18 @@ void XmrigManager::startVersionDetection()
std::thread([]() {
const std::string bin = findXmrigBinary();
std::string ver;
if (!bin.empty()) {
const std::string cmd = "\"" + bin + "\" --version 2>&1";
#ifdef _WIN32
FILE* fp = _popen(cmd.c_str(), "r");
#else
FILE* fp = popen(cmd.c_str(), "r");
#endif
if (fp) {
std::string out;
char buf[256];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
#ifdef _WIN32
_pclose(fp);
#else
pclose(fp);
#endif
ver = parseMinerVersion(out);
}
// Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an
// app-controlled path, but this closes command injection if it ever isn't. (M-10)
// Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"")
// on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows
// paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10)
const bool binShellSafe =
!bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos;
if (binShellSafe) {
// Windowless capture (mergeStderr: xmrig may print --version to stderr) — never flashes.
const std::string cmd = "\"" + bin + "\" --version";
const std::string out = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/true);
if (!out.empty()) ver = parseMinerVersion(out);
}
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
g_installed_ver = ver;

View File

@@ -86,8 +86,10 @@ public:
bool isRunning() const;
State getState() const { return state_.load(std::memory_order_relaxed); }
const PoolStats& getStats() const { return stats_; }
const std::string& getLastError() const { return last_error_; }
// Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI
// thread reads them, so handing out a reference is a torn-read / use-after-free race (M-03, M-04).
PoolStats getStats() const { std::lock_guard<std::mutex> lk(stats_mutex_); return stats_; }
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
/// Thread count requested at start() — available immediately, unlike
/// PoolStats::threads_active which requires an API response.
@@ -156,11 +158,14 @@ private:
void monitorProcess();
void drainOutput();
void appendOutput(const char* data, size_t len);
// Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
void setLastError(std::string e) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
std::atomic<State> state_{State::Stopped};
std::string last_error_;
mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads)
mutable std::mutex output_mutex_;
std::string process_output_;

View File

@@ -46,7 +46,12 @@ bool AddressBook::load()
entries_.clear();
if (j.contains("entries") && j["entries"].is_array()) {
size_t skipped = 0;
for (const auto& entry : j["entries"]) {
// W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
// abort the whole load — which would discard EVERY contact (entries_ was already cleared).
if (!entry.is_object()) { ++skipped; continue; }
try {
AddressBookEntry e;
e.label = entry.value("label", "");
e.address = entry.value("address", "");
@@ -54,11 +59,12 @@ bool AddressBook::load()
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
e.scope = entry.value("scope", "global");
if (!e.address.empty()) {
entries_.push_back(e);
}
e.avatar = entry.value("avatar", "");
if (!e.address.empty()) entries_.push_back(e);
} catch (const std::exception&) { ++skipped; }
}
if (skipped > 0)
DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies");
}
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
@@ -86,6 +92,7 @@ bool AddressBook::save()
e["address"] = entry.address;
e["notes"] = entry.notes;
e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope;
if (!entry.avatar.empty()) e["avatar"] = entry.avatar;
j["entries"].push_back(e);
}
@@ -142,6 +149,20 @@ bool AddressBook::removeEntry(size_t index)
return save();
}
int AddressBook::reattachLegacyScopes(const std::string& scopeId)
{
if (scopeId.empty()) return 0;
int rescoped = 0;
for (auto& e : entries_) {
if (e.isGlobal()) continue; // global stays global
if (e.scope.rfind("w:", 0) == 0) continue; // already a stable scope
e.scope = scopeId;
++rescoped;
}
if (rescoped > 0) save();
return rescoped;
}
int AddressBook::findByAddress(const std::string& address) const
{
for (size_t i = 0; i < entries_.size(); i++) {

View File

@@ -21,6 +21,9 @@ struct AddressBookEntry {
// (shown only when that wallet is the active one). Empty is treated as "global" so legacy
// entries — written before multi-wallet scoping — keep showing everywhere.
std::string scope = "global";
// Contact avatar: "" = default type badge (Z/T), "icon:<name>" = a Material wallet-icon,
// "img:<path>" = a custom image (copied into <config>/contact-avatars/).
std::string avatar;
AddressBookEntry() = default;
AddressBookEntry(const std::string& l, const std::string& a, const std::string& n = "",
@@ -83,6 +86,15 @@ public:
*/
bool removeEntry(size_t index);
/**
* @brief Re-attach contacts stuck on a legacy (drifting address-hash) scope to a stable wallet id.
* Rewrites every non-global entry whose scope is NOT already a stable "w:"-prefixed id to
* `scopeId`, and saves once if anything changed. Recovery for contacts orphaned when the
* old address-set-hash scope shifted (e.g. after creating a new address).
* @return number of entries re-scoped.
*/
int reattachLegacyScopes(const std::string& scopeId);
/**
* @brief Find entry by address (any scope). Used for contact-label lookups.
* @param address Address to search for
@@ -109,6 +121,12 @@ public:
*/
size_t size() const { return entries_.size(); }
/**
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
*/
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); }
/**
* @brief Check if empty
*/

44
src/data/candle.h Normal file
View File

@@ -0,0 +1,44 @@
#pragma once
// A single OHLC candle — the shared shape for per-exchange candlestick charts. Kept in its own tiny,
// dependency-free header so both the parser (data/exchange_candles.h, which pulls in nlohmann/json)
// and the model (data/wallet_state.h, included widely) can use it without dragging json everywhere.
#include <ctime>
#include <vector>
namespace dragonx {
namespace data {
struct Candle {
std::time_t time = 0; // epoch SECONDS (the candle's open/bucket time)
double open = 0.0;
double high = 0.0;
double low = 0.0;
double close = 0.0;
};
// Aggregate fine candles into fixed time buckets (e.g. 5-min -> hourly for the 1D view): open = first
// open, high = max high, low = min low, close = last close. Input must be ascending by time.
inline std::vector<Candle> bucketOHLC(const std::vector<Candle>& src, long windowSec) {
std::vector<Candle> out;
if (src.empty() || windowSec <= 0) return out;
long curBucket = -1;
Candle cur;
for (const auto& c : src) {
const long b = (long)(c.time / windowSec);
if (b != curBucket) {
if (curBucket >= 0) out.push_back(cur);
curBucket = b;
cur = c;
cur.time = (std::time_t)(b * windowSec);
} else {
if (c.high > cur.high) cur.high = c.high;
if (c.low < cur.low) cur.low = c.low;
cur.close = c.close;
}
}
if (curBucket >= 0) out.push_back(cur);
return out;
}
} // namespace data
} // namespace dragonx

117
src/data/exchange_candles.h Normal file
View File

@@ -0,0 +1,117 @@
#pragma once
// Per-exchange OHLC candle adapters. CoinGecko tells us WHICH exchanges list DRGX (via the ticker
// `market.identifier`, e.g. "ourbit" / "nonkyc_io") but NOT their API — this is the hand-maintained
// mapping from that identifier to each venue's public candle endpoint, so the market chart can show the
// SELECTED exchange's real price history instead of CoinGecko's cross-exchange aggregate.
//
// Header-only + pure (no I/O — the actual HTTP fetch happens in app_network.cpp via util::httpGetString)
// so the URL builder + parsers are unit-tested against real captured responses. An unmapped exchange
// returns an empty URL, so the caller falls back to the CoinGecko aggregate and nothing regresses.
#include <algorithm>
#include <ctime>
#include <string>
#include <utility>
#include <vector>
#include <nlohmann/json.hpp>
#include "candle.h"
namespace dragonx {
namespace data {
using PricePoint = std::pair<std::time_t, double>; // (epoch SECONDS, close price)
enum class CandleRange {
Intraday, // ~2 days of 5-minute candles — backs the Live / 1H / 1D views
Daily, // ~1 year of daily candles — backs the 1W / 1M views
};
// True when we have a candle adapter for this CoinGecko exchange identifier.
inline bool hasExchangeCandleAdapter(const std::string& identifier) {
return identifier == "ourbit" || identifier == "nonkyc_io";
}
// Build the venue's candle URL for a pair + range. Returns "" when the exchange isn't mapped.
// `now` is epoch seconds, passed in (no hidden clock reads) so URLs are deterministic in tests.
inline std::string buildExchangeCandleUrl(const std::string& identifier, const std::string& base,
const std::string& quote, CandleRange range, std::time_t now) {
if (identifier == "ourbit") {
// Ourbit = MEXC-style /api/v3/klines: symbol=BASEQUOTE, interval=5m|1d, limit.
const char* iv = (range == CandleRange::Intraday) ? "5m" : "1d";
const int limit = (range == CandleRange::Intraday) ? 576 : 365;
return "https://api.ourbit.com/api/v3/klines?symbol=" + base + quote +
"&interval=" + iv + "&limit=" + std::to_string(limit);
}
if (identifier == "nonkyc_io") {
// NonKYC = TradingView-UDF candles: symbol=BASE_QUOTE, resolution in minutes, from/to seconds.
const char* res = (range == CandleRange::Intraday) ? "5" : "1440";
const std::time_t span = (range == CandleRange::Intraday) ? (std::time_t)2 * 24 * 3600
: (std::time_t)365 * 24 * 3600;
return "https://api.nonkyc.io/api/v2/market/candles?symbol=" + base + "_" + quote +
"&resolution=" + res + "&from=" + std::to_string(now - span) + "&to=" + std::to_string(now);
}
return "";
}
namespace detail {
// Read a JSON value that may be a number or a numeric string (Ourbit sends prices as strings).
inline double jnum(const nlohmann::json& v) {
if (v.is_number()) return v.get<double>();
if (v.is_string()) { try { return std::stod(v.get<std::string>()); } catch (...) { return 0.0; } }
return 0.0;
}
} // namespace detail
// Parse the venue's candle response into ascending OHLC candles. Empty on failure.
inline std::vector<Candle> parseExchangeOHLC(const std::string& identifier, const std::string& body) {
std::vector<Candle> out;
if (body.empty()) return out;
try {
const nlohmann::json j = nlohmann::json::parse(body);
if (identifier == "ourbit") {
// [[openTime_ms, open, high, low, close, volume, closeTime, quoteVol], ...]
if (!j.is_array()) return out;
out.reserve(j.size());
for (const auto& k : j) {
if (!k.is_array() || k.size() < 5) continue;
Candle c;
c.time = (std::time_t)(detail::jnum(k[0]) / 1000.0);
c.open = detail::jnum(k[1]);
c.high = detail::jnum(k[2]);
c.low = detail::jnum(k[3]);
c.close = detail::jnum(k[4]);
if (c.time > 0 && c.close > 0) out.push_back(c);
}
} else if (identifier == "nonkyc_io") {
// {"bars":[{"time":ms,"open":..,"high":..,"low":..,"close":..,"volume":..}, ...]}
if (!j.contains("bars") || !j["bars"].is_array()) return out;
out.reserve(j["bars"].size());
for (const auto& b : j["bars"]) {
if (!b.is_object() || !b.contains("time") || !b.contains("close")) continue;
Candle c;
c.time = (std::time_t)(detail::jnum(b["time"]) / 1000.0);
c.close = detail::jnum(b["close"]);
c.open = b.contains("open") ? detail::jnum(b["open"]) : c.close;
c.high = b.contains("high") ? detail::jnum(b["high"]) : c.close;
c.low = b.contains("low") ? detail::jnum(b["low"]) : c.close;
if (c.time > 0 && c.close > 0) out.push_back(c);
}
}
} catch (...) {
return {};
}
std::sort(out.begin(), out.end(), [](const Candle& a, const Candle& b) { return a.time < b.time; });
return out;
}
// Close-only convenience over parseExchangeOHLC (backs the line chart + change-% fallback). bucketOHLC
// for candlestick resampling lives in candle.h (dependency-free, reused by market_series.h).
inline std::vector<PricePoint> parseExchangeCandles(const std::string& identifier, const std::string& body) {
std::vector<PricePoint> out;
for (const auto& c : parseExchangeOHLC(identifier, body)) out.emplace_back(c.time, c.close);
return out;
}
} // namespace data
} // namespace dragonx

View File

@@ -22,14 +22,14 @@ const std::vector<ExchangeInfo>& getExchangeRegistry()
"Nonkyc.io",
"https://nonkyc.io",
{
{"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT"},
{"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT", "nonkyc_io"},
}
},
{
"OurBit",
"https://www.ourbit.com",
{
{"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT"},
{"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT", "ourbit"},
}
},
};
@@ -59,11 +59,19 @@ std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
if (!t.is_object()) continue;
const std::string base = t.value("base", std::string{});
const std::string target = t.value("target", std::string{});
std::string market;
if (t.contains("market") && t["market"].is_object())
std::string market, identifier;
if (t.contains("market") && t["market"].is_object()) {
market = t["market"].value("name", std::string{});
identifier = t["market"].value("identifier", std::string{}); // keys the per-exchange candle adapter
}
const std::string tradeUrl = t.value("trade_url", std::string{});
if (base.empty() || target.empty() || market.empty()) continue;
double lastUsd = 0.0; // this venue's current USD price + 24h volume — differ per exchange
if (t.contains("converted_last") && t["converted_last"].is_object())
lastUsd = t["converted_last"].value("usd", 0.0);
double volumeUsd = 0.0;
if (t.contains("converted_volume") && t["converted_volume"].is_object())
volumeUsd = t["converted_volume"].value("usd", 0.0);
auto it = byName.find(market);
if (it == byName.end()) {
@@ -71,7 +79,7 @@ std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
exchanges.push_back(ExchangeInfo{market, originOf(tradeUrl), {}});
it = byName.find(market);
}
ExchangePair pair{base, target, base + "/" + target, tradeUrl};
ExchangePair pair{base, target, base + "/" + target, tradeUrl, identifier, lastUsd, volumeUsd};
// Skip duplicate pairs on the same exchange.
bool dup = false;
for (const auto& p : exchanges[it->second].pairs)

View File

@@ -18,6 +18,9 @@ struct ExchangePair {
std::string quote; ///< e.g. "BTC"
std::string displayName; ///< e.g. "DRGX/BTC"
std::string tradeUrl; ///< Link to the exchange pair page
std::string identifier; ///< CoinGecko exchange id (e.g. "ourbit", "nonkyc_io") — keys the candle adapter
double lastUsd = 0.0; ///< This venue's current price in USD (CoinGecko converted_last.usd); 0 if unknown
double volumeUsd = 0.0; ///< This venue's 24h volume in USD (CoinGecko converted_volume.usd); 0 if unknown
};
/**

View File

@@ -81,11 +81,15 @@ inline std::vector<std::pair<std::time_t, double>> chartSeries(const MarketInfo&
for (const auto& s : src) if (s.first >= cutoff) out.push_back(s);
return out;
};
// Draw the SELECTED exchange's own candles when active (data/exchange_candles.h), else the CoinGecko
// cross-exchange aggregate. Only the main chart switches source; portfolio sparklines stay aggregate.
const auto& intraday = m.exchange_chart_active ? m.exchange_chart_intraday : m.price_chart_intraday;
const auto& daily = m.exchange_chart_active ? m.exchange_chart_daily : m.price_chart_daily;
switch (interval) {
case 1: { auto v = lastWindow(m.price_chart_intraday, 3600); if (v.size() >= 2) return v; break; } // 1H
case 2: { auto v = lastWindow(m.price_chart_intraday, kDay); if (v.size() >= 2) return v; break; } // 1D
case 3: { auto v = lastWindow(m.price_chart_daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W
case 4: { auto v = lastWindow(m.price_chart_daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M
case 1: { auto v = lastWindow(intraday, 3600); if (v.size() >= 2) return v; break; } // 1H
case 2: { auto v = lastWindow(intraday, kDay); if (v.size() >= 2) return v; break; } // 1D
case 3: { auto v = lastWindow(daily, 7 * kDay); if (v.size() >= 2) return v; break; } // 1W
case 4: { auto v = lastWindow(daily, 30 * kDay); if (v.size() >= 2) return v; break; } // 1M
default: break;
}
std::vector<std::pair<std::time_t, double>> out;
@@ -95,5 +99,28 @@ inline std::vector<std::pair<std::time_t, double>> chartSeries(const MarketInfo&
return out;
}
// OHLC candles for the main chart at the selected RANGE — only when the per-exchange series is active
// (the CoinGecko aggregate is close-only, so this returns empty and the chart draws a line). The 1D
// view buckets the 5-minute intraday to hourly so it isn't ~288 hair-thin candles; other ranges use
// the raw candles. Empty for the Live range (uses the in-session line).
inline std::vector<Candle> chartCandles(const MarketInfo& m, int interval, std::time_t now)
{
if (!m.exchange_chart_active) return {};
const long kDay = 86400;
auto window = [now](const std::vector<Candle>& src, long rangeSec) {
std::vector<Candle> out;
const std::time_t cutoff = now - (std::time_t)rangeSec;
for (const auto& c : src) if (c.time >= cutoff) out.push_back(c);
return out;
};
switch (interval) {
case 1: return window(m.exchange_ohlc_intraday, 3600); // 1H: 5-min candles (~12)
case 2: return bucketOHLC(window(m.exchange_ohlc_intraday, kDay), 3600); // 1D: hourly candles (~24)
case 3: return window(m.exchange_ohlc_daily, 7 * kDay); // 1W: daily (~7)
case 4: return window(m.exchange_ohlc_daily, 30 * kDay); // 1M: daily (~30)
default: return {}; // Live -> line
}
}
} // namespace data
} // namespace dragonx

View File

@@ -0,0 +1,37 @@
#pragma once
#include <string>
// Pure routing decision for resuming a pending migrate-to-seed flow (finding W3-3). Kept free of
// App/UI/RPC state so the highest-risk branch — where a reopened migration lands — is unit-testable
// and reviewable in isolation. App::showSeedMigrationDialog feeds it the persisted state + live
// connectivity and switches on the result. See src/app_network.cpp.
namespace dragonx {
enum class MigrationResume {
Intro, // no pending migration → start fresh at the intro
Confirming, // a sweep txid is persisted → resume at the confirm/adopt gate (re-derived from chain)
RetrackOpid, // a sweep opid (but no txid yet) is persisted AND we're connected → re-poll it
SweepGate, // otherwise → the dismissable Sweep step (reload balance, offer re-sweep)
};
// Decide where reopening the migration dialog lands.
//
// Invariant: the txid outranks the opid — once a sweep resolves to a txid the opid is cleared in the
// same settings write, so a persisted txid always means "past the sweep". A persisted opid is only
// re-tracked when connected, because the buttonless "Sweeping" spinner relies on the opid poller
// (which needs an RPC connection) to ever exit; disconnected, we fall back to the dismissable Sweep
// gate (which reloads the balance and, if the earlier sweep already drained it, short-circuits to
// adopt) — never trapping the user.
inline MigrationResume decideSeedMigrationResume(bool pending,
bool haveDest,
const std::string& sweepTxid,
const std::string& sweepOpid,
bool connected) {
if (!pending || !haveDest) return MigrationResume::Intro;
if (!sweepTxid.empty()) return MigrationResume::Confirming;
if (!sweepOpid.empty() && connected) return MigrationResume::RetrackOpid;
return MigrationResume::SweepGate;
}
} // namespace dragonx

View File

@@ -25,6 +25,7 @@ bool sameEntry(const WalletIndexEntry& a, const WalletIndexEntry& b)
return a.fileName == b.fileName
&& a.displayName == b.displayName
&& a.walletIdentityHash == b.walletIdentityHash
&& a.scopeId == b.scopeId
&& a.cachedBalance == b.cachedBalance
&& a.cachedAddressCount == b.cachedAddressCount
&& a.lastOpenedEpoch == b.lastOpenedEpoch
@@ -61,6 +62,7 @@ bool WalletIndex::load()
if (w.fileName.empty()) continue;
w.displayName = e.value("name", w.fileName);
w.walletIdentityHash = e.value("identity", "");
w.scopeId = e.value("scopeId", "");
w.cachedBalance = e.value("balance", -1.0);
w.cachedAddressCount = e.value("addresses", (long long)-1);
w.lastOpenedEpoch = e.value("lastOpened", (long long)0);
@@ -96,6 +98,7 @@ bool WalletIndex::save()
e["file"] = w.fileName;
e["name"] = w.displayName;
e["identity"] = w.walletIdentityHash;
e["scopeId"] = w.scopeId;
e["balance"] = w.cachedBalance;
e["addresses"] = w.cachedAddressCount;
e["lastOpened"] = w.lastOpenedEpoch;

View File

@@ -22,6 +22,8 @@ struct WalletIndexEntry {
std::string fileName; // plain wallet filename in the datadir (e.g. "wallet.dat")
std::string displayName; // user-facing name (defaults to fileName)
std::string walletIdentityHash; // address-derived identity of the last load; "" = unknown
std::string scopeId; // stable per-wallet id ("w:"+hex) for scoping contacts etc.;
// generated once, never recomputed from the (mutable) address set
double cachedBalance = -1.0; // last-known total balance; < 0 = unknown (never opened)
long long cachedAddressCount = -1;// < 0 = unknown
long long lastOpenedEpoch = 0; // unix seconds of last open; 0 = never opened

View File

@@ -3,6 +3,7 @@
// Released under the GPLv3
#include "wallet_state.h"
#include "../util/text_format.h" // util::formatClockDateTime (app-wide 24h/12h clock)
#include <algorithm>
#include <ctime>
#include <sstream>
@@ -18,12 +19,13 @@ std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>
for (size_t i = 0; i < addresses.size(); ++i) {
const auto& address = addresses[i];
if (!address.isSpendable()) continue;
if (requirePositiveBalance && address.balance <= 0.0) continue;
// Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
indices.push_back(i);
}
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
return addresses[lhs].balance > addresses[rhs].balance;
return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
});
return indices;
}
@@ -33,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
int bestIndex = -1;
double bestBalance = 0.0;
for (size_t i = 0; i < addresses.size(); ++i) {
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
bestBalance = addresses[i].balance;
if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
bestBalance = addresses[i].spendableBalance;
bestIndex = static_cast<int>(i);
}
}
@@ -44,13 +46,7 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
std::string TransactionInfo::getTimeString() const
{
if (timestamp == 0) return "Unknown";
std::time_t t = static_cast<std::time_t>(timestamp);
std::tm* tm = std::localtime(&t);
std::stringstream ss;
ss << std::put_time(tm, "%Y-%m-%d %H:%M");
return ss.str();
return util::formatClockDateTime(timestamp);
}
std::string TransactionInfo::getTypeDisplay() const
@@ -77,13 +73,7 @@ std::string PeerInfo::getConnectionTime() const
std::string BannedPeer::getBannedUntilString() const
{
if (banned_until == 0) return "Never";
std::time_t t = static_cast<std::time_t>(banned_until);
std::tm* tm = std::localtime(&t);
std::stringstream ss;
ss << std::put_time(tm, "%Y-%m-%d %H:%M");
return ss.str();
return util::formatClockDateTime(banned_until);
}
} // namespace dragonx

View File

@@ -12,6 +12,7 @@
#include <utility>
#include "exchange_info.h"
#include "candle.h"
namespace dragonx {
@@ -20,13 +21,17 @@ namespace dragonx {
*/
struct AddressInfo {
std::string address;
double balance = 0.0;
double balance = 0.0; // DISPLAY total incl. pending 0-conf change (minconf=0)
std::string type; // "shielded" or "transparent"
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
// For display
std::string label;
// CONFIRMED balance (minconf>=1) — what z_sendmany can actually spend now. Kept last so positional
// brace-init of the leading fields (used in tests) still compiles.
double spendableBalance = 0.0;
// Derived
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
bool isShielded() const { return type == "shielded"; }
@@ -175,6 +180,18 @@ struct MarketInfo {
std::chrono::steady_clock::time_point chart_last_fetch_time{};
bool chart_loaded = false;
// Per-EXCHANGE candle series for the SELECTED pair, fetched from that venue's own API (see
// data/exchange_candles.h). When exchange_chart_active is true the Market chart draws these instead
// of the CoinGecko cross-exchange aggregate above; it's set false while switching pairs or when the
// selected venue has no adapter / its fetch failed, so the chart gracefully falls back to aggregate.
std::vector<std::pair<std::time_t, double>> exchange_chart_intraday;
std::vector<std::pair<std::time_t, double>> exchange_chart_daily;
bool exchange_chart_active = false;
// Full OHLC for the same per-exchange candles, backing the candlestick rendering (the aggregate
// CoinGecko series is close-only, so it stays a line). Parallel to exchange_chart_* above.
std::vector<data::Candle> exchange_ohlc_intraday;
std::vector<data::Candle> exchange_ohlc_daily;
// Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab
// falls back to data::getExchangeRegistry() while empty).
std::vector<data::ExchangeInfo> exchanges;
@@ -239,11 +256,17 @@ struct WalletState {
// Sync status
SyncInfo sync;
// Balances (named to match UI usage)
double privateBalance = 0.0; // shielded balance
// Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the
// user's own pending change and don't crater during an unconfirmed send.
double privateBalance = 0.0; // shielded balance (display, incl. pending change)
double transparentBalance = 0.0;
double totalBalance = 0.0;
double unconfirmedBalance = 0.0;
double unconfirmedBalance = 0.0; // = totalBalance - spendableTotalBalance (the pending portion)
// CONFIRMED / spendable totals (minconf>=1) — what can actually be sent right now. z_sendmany runs at
// minconf=1, so the Send form / Max / spend validation must size off these, never the display totals.
double spendablePrivateBalance = 0.0;
double spendableTransparentBalance = 0.0;
double spendableTotalBalance = 0.0;
// Aliases for backward compatibility
double& shielded_balance = privateBalance;
@@ -289,6 +312,7 @@ struct WalletState {
// Timestamps for refresh logic
int64_t last_balance_update = 0;
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
int64_t last_tx_update = 0;
int64_t last_peer_update = 0;
int64_t last_mining_update = 0;
@@ -312,6 +336,7 @@ struct WalletState {
sync = SyncInfo{};
privateBalance = transparentBalance = totalBalance = 0.0;
unconfirmedBalance = 0.0;
spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
encrypted = false;
locked = false;
unlocked_until = 0;
@@ -322,6 +347,15 @@ struct WalletState {
transactions.clear();
peers.clear();
bannedPeers.clear();
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
// stops), so leaving the previous wallet's hashrate/blocks would show stale mining stats.
mining = MiningInfo{};
pool_mining = PoolMiningState{};
// After a disconnect / wallet switch nothing is freshly known, so drop the "last successful
// refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness
// badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
// re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
}
// Rebuild combined addresses list from z/t lists

View File

@@ -15,3 +15,5 @@ INCBIN(ubuntu_mono, "@CMAKE_SOURCE_DIR@/res/fonts/UbuntuMono-R.ttf");
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");
INCBIN(noto_emoji_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoEmoji-Subset.ttf");
INCBIN(twemoji_color, "@CMAKE_SOURCE_DIR@/res/fonts/TwemojiMozilla-Color.ttf");

View File

@@ -35,4 +35,11 @@ extern "C" {
extern const unsigned char g_noto_cjk_subset_data[];
extern const unsigned int g_noto_cjk_subset_size;
extern const unsigned char g_noto_emoji_subset_data[];
extern const unsigned int g_noto_emoji_subset_size;
// Twemoji COLR/CPAL color-emoji font (used only when color emoji is enabled + FreeType is available).
extern const unsigned char g_twemoji_color_data[];
extern const unsigned int g_twemoji_color_size;
}

View File

@@ -0,0 +1,39 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Embedded source of the DragonX mark (res/img/logos/logo_dragonx.svg). Kept as a string so the logo
// is available in every build (dev + portable single-file) with no resource-pipeline or file dependency.
// It is rasterized + recolored per theme at runtime (see util/svg_texture.*). Two fills: the crimson
// body (.cls-2 #d82652) becomes the theme accent; the white detail (.cls-1 #fff) becomes the light tone.
#pragma once
// Global `embedded` namespace to match the generated embedded resources (embedded::ui_toml_data, etc.).
namespace embedded {
inline constexpr const char* kLogoDragonXSvg = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: #d82652;
}
</style>
</defs>
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
<g>
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
<g>
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
</g>
</g>
</svg>)SVG";
} // namespace embedded

View File

@@ -9,6 +9,7 @@
#include "ui/schema/ui_schema.h"
#include "ui/effects/low_spec.h"
#include "ui/notifications.h"
#include "ui/windows/contacts_tab.h"
#include "ui/theme.h"
#include "ui/material/color_theme.h"
#include "ui/material/typography.h"
@@ -438,8 +439,16 @@ static bool InitImGui(SDL_Window* window, SDL_GLContext gl_context);
static void Shutdown(SDL_Window* window, SDL_GLContext gl_context);
#endif
// Global single instance lock
// Global single instance lock. Keyed PER VARIANT so the full node and Lite can run side by side —
// their config dirs are already separate (DRAGONX_APP_NAME), so only this lock kept them apart.
// NB: DRAGONX_LITE_BUILD is ALWAYS defined (0 for the full node, 1 for Lite) via $<BOOL:...>, so this
// must be #if (value), not #ifdef (existence) — #ifdef is true for both and made the full node grab
// the Lite lock.
#if DRAGONX_LITE_BUILD
static dragonx::util::SingleInstance g_single_instance("obsidiandragonlite");
#else
static dragonx::util::SingleInstance g_single_instance("obsidiandragon");
#endif
// Check for payment URI in command line args
static std::string findPaymentURI(int argc, char* argv[])
@@ -712,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
}
}
#if !defined(_WIN32)
#include <csignal>
#include <cstring>
#include <unistd.h>
#include <fcntl.h>
#if defined(__has_include)
# if __has_include(<execinfo.h>)
# include <execinfo.h>
# define DRAGONX_HAVE_BACKTRACE 1
# endif
#endif
// Absolute path to the crash log, filled at install time so the async-signal handler needs no
// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.)
static char g_crashLogPath[1024] = {0};
// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used —
// no stdio, std::filesystem or malloc (all unsafe inside a signal handler).
static void PosixCrashHandler(int sig)
{
int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1;
if (fd >= 0) {
auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; };
put("\n=== CRASH: signal ");
char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio
if (v == 0) { num[i++] = '0'; }
else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; }
while (t > 0) num[i++] = tmp[--t]; }
num[i] = '\n';
ssize_t nn = write(fd, num, i + 1); (void)nn;
#ifdef DRAGONX_HAVE_BACKTRACE
void* frames[64];
int nframes = backtrace(frames, 64);
backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe
#endif
put("=== END CRASH ===\n");
close(fd);
}
// Restore the default disposition and re-raise so we still get a core dump / normal termination.
signal(sig, SIG_DFL);
raise(sig);
}
static void installPosixCrashHandler(const std::string& crashLogPath)
{
std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str());
struct sigaction sa;
std::memset(&sa, 0, sizeof(sa));
sa.sa_handler = PosixCrashHandler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) {
sigaction(sig, &sa, nullptr);
}
}
#endif // !_WIN32
int main(int argc, char* argv[])
{
// Ensure ObsidianDragon config directory exists early (before any file I/O)
{
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
std::error_code ec;
std::filesystem::create_directories(odDir, ec);
std::string odErr;
if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
// Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
// Windows log redirect below isn't set up yet — report loudly before any setup.
std::fprintf(stderr, "%s\n", odErr.c_str());
#ifdef _WIN32
MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
#endif
return 1;
}
}
#ifdef _WIN32
// Redirect stdout/stderr to a log file so diagnostic output is visible
// even when built as a GUI app (WIN32_EXECUTABLE hides the console).
// W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
// actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
// file never existed at all (the Windows-only stdout freopen below is a separate mechanism).
{
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
const std::string logPath =
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
dragonx::util::Logger::instance().init(logPath);
}
#if !defined(_WIN32)
// W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or
// abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS.
{
const std::string crashPath =
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string();
installPosixCrashHandler(crashPath);
}
#endif
#ifdef _WIN32
// Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when
// built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger
// above so the two writers don't interleave/contend on one file.
{
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
freopen(logPath.c_str(), "w", stdout);
freopen(logPath.c_str(), "a", stderr);
}
@@ -763,11 +857,12 @@ int main(int argc, char* argv[])
// Check for existing instance
if (!g_single_instance.tryLock()) {
fprintf(stderr, "Another instance of ObsidianDragon is already running.\n");
fprintf(stderr, "Another instance of %s is already running.\n", DRAGONX_APP_NAME);
DEBUG_LOGF("Please close the existing instance first.\n");
#ifdef _WIN32
MessageBoxW(nullptr, L"Another instance of ObsidianDragon is already running.\nPlease close it first.",
L"ObsidianDragon", MB_OK | MB_ICONINFORMATION);
const std::string msg = std::string("Another instance of ") + DRAGONX_APP_NAME +
" is already running.\nPlease close it first.";
MessageBoxA(nullptr, msg.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONINFORMATION);
#endif
return 1;
}
@@ -1965,12 +2060,14 @@ int main(int argc, char* argv[])
bool backdropNeedsFrames = (backdrop_active || app.getGradientTexture() != 0)
&& !opaqueBackground;
bool animating = app.isShuttingDown()
|| app.isWalletSwitchInProgress()
|| backdropNeedsFrames
|| app.hasTransactionSendProgress()
|| app.isTransactionRefreshInProgress()
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|| dragonx::ui::Notifications::instance().hasActive()
|| dragonx::ui::material::SmoothScrollAnimating();
|| dragonx::ui::material::SmoothScrollAnimating()
|| dragonx::ui::ConsumeContactsAvatarAnimation();
// If nothing is happening, allow the next iteration to idle
needsRedraw = uiActive || animating;
}
@@ -2030,6 +2127,7 @@ int main(int argc, char* argv[])
// deadlocks waiting for detached pthreads. On Linux, static
// destructors and atexit handlers can also block. _Exit() bypasses
// all of that.
app.wipeSecrets(); // _Exit() below bypasses ~App(), so scrub secret buffers here (L-05)
fflush(stdout);
fflush(stderr);
_Exit(0);

View File

@@ -40,6 +40,9 @@ static const EmbeddedResource s_resources[] = {
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
{ g_dragonx_wallet_rebuild_exe_data, g_dragonx_wallet_rebuild_exe_size, RESOURCE_DRAGONX_WALLET_REBUILD },
#endif
#ifdef HAS_EMBEDDED_XMRIG
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
#endif
@@ -436,6 +439,24 @@ bool extractEmbeddedResources()
}
#endif
#ifdef HAS_EMBEDDED_WALLET_REBUILD
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
if (rebuildRes) {
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
if (!std::filesystem::exists(dest)) {
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
if (!extractResource(rebuildRes, dest)) {
success = false;
}
#ifndef _WIN32
else { chmod(dest.c_str(), 0755); }
#endif
}
}
#endif
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
// if it's still running, the remove fails harmlessly and we retry on the next startup.
@@ -450,6 +471,32 @@ bool extractEmbeddedResources()
return success;
}
std::string ensureWalletRebuildHelperExtracted()
{
#ifdef HAS_EMBEDDED_WALLET_REBUILD
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
if (!res || res->size == 0) return {};
#ifdef _WIN32
const char sep = '\\';
#else
const char sep = '/';
#endif
const std::string dir = getDaemonDirectory();
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
std::error_code ec;
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
std::filesystem::create_directories(dir, ec);
if (!extractResource(res, dest)) return {};
#ifndef _WIN32
chmod(dest.c_str(), 0755);
#endif
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
return dest;
#else
return {};
#endif
}
std::string getDaemonDirectory()
{
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or

View File

@@ -55,6 +55,12 @@ BundledDaemonInfo getBundledDaemonInfo();
// caller should stop the daemon first. Returns true if all present resources were written.
bool reextractBundledDaemon();
// Ensure the embedded offline wallet-rebuild recovery helper is extracted to the daemon dir, and
// return its path ("" if not embedded in this build or extraction failed). Idempotent — extracts only
// when missing. Unlike the first-run extractEmbeddedResources() (gated on needsParamsExtraction()),
// this runs on demand so recovery works from a self-contained exe on ANY run, not just the first.
std::string ensureWalletRebuildHelperExtracted();
// Resource names
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
@@ -62,6 +68,7 @@ constexpr const char* RESOURCE_ASMAP = "asmap.dat";
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";

View File

@@ -14,8 +14,12 @@
#include <filesystem>
#include <algorithm>
#include <cctype>
#include <vector>
#include <chrono>
#include "../util/logger.h"
#include "../util/platform.h"
#include "../util/xmrig_updater.h" // util::sha256Hex
#ifdef _WIN32
#include <shlobj.h>
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
return resources::getDaemonDirectory();
}
bool Connection::verifySaplingParams()
namespace {
std::string joinParamPath(const std::string& dir, const std::string& file) {
#ifdef _WIN32
return dir + "\\" + file;
#else
return dir + "/" + file;
#endif
}
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
std::string paramStatLine(const std::string& path) {
std::error_code ec;
auto sz = fs::file_size(path, ec);
if (ec) return {};
auto mtime = fs::last_write_time(path, ec);
long long ticks = ec ? 0 :
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
}
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
std::ifstream f(path, std::ios::binary | std::ios::ate);
if (!f) return false;
std::streamsize sz = f.tellg();
if (sz <= 0) return false;
f.seekg(0, std::ios::beg);
std::vector<char> buf(static_cast<size_t>(sz));
if (!f.read(buf.data(), sz)) return false;
std::string got = util::sha256Hex(buf.data(), buf.size());
return !got.empty() && got == expectedHex;
}
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
// in list order, from the last successful hash check.
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
std::ifstream f(markerPath);
if (!f) return false;
std::vector<std::string> lines;
std::string l;
while (std::getline(f, l)) lines.push_back(l);
return lines == expected;
}
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
std::ofstream f(markerPath, std::ios::trunc);
if (!f) return;
for (const auto& l : lines) f << l << "\n";
}
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
// Keep in sync if the params are ever rotated.
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
};
} // namespace
bool Connection::verifySaplingParamsIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests)
{
std::string params_dir = getSaplingParamsDir();
if (params_dir.empty()) {
if (dir.empty()) {
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
return false;
}
if (digests.empty()) return false;
#ifdef _WIN32
std::string spend_path = params_dir + "\\sapling-spend.params";
std::string output_path = params_dir + "\\sapling-output.params";
#else
std::string spend_path = params_dir + "/sapling-spend.params";
std::string output_path = params_dir + "/sapling-output.params";
#endif
// 1) Every param must exist.
std::vector<std::string> paths;
paths.reserve(digests.size());
for (const auto& d : digests) {
std::string p = joinParamPath(dir, d.first);
if (!fs::exists(p)) {
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
return false;
}
paths.push_back(std::move(p));
}
bool spend_exists = fs::exists(spend_path);
bool output_exists = fs::exists(output_path);
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
// the previous successful hash instead of re-hashing ~48MB on every startup.
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
std::vector<std::string> current;
current.reserve(paths.size());
for (const auto& p : paths) current.push_back(paramStatLine(p));
if (saplingMarkerMatches(markerPath, current)) {
return true;
}
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
// here instead of being handed to the daemon and failing later on a shielded operation.
for (size_t i = 0; i < paths.size(); ++i) {
if (!paramHashMatches(paths[i], digests[i].second)) {
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
paths[i].c_str());
return false;
}
}
return spend_exists && output_exists;
// 4) Record the verified state so later startups take the fast path.
writeSaplingMarker(markerPath, current);
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
return true;
}
bool Connection::verifySaplingParams()
{
std::vector<std::pair<std::string, std::string>> digests;
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
}
ConnectionConfig Connection::parseConfFile(const std::string& path)
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
config.proxy = value;
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
config.use_tls = parseBoolValue(value);
} else if (key == "rpcallowplaintext") {
config.allow_plaintext_remote = parseBoolValue(value);
}
}
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
{
ConnectionConfig config;
// Ensure data directory exists
// Ensure the data directory exists. Use the non-throwing helper and report any failure
// via config.dir_error so callers can surface it — the old throwing create_directories()
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
// callers (read-only home, permission denied, etc.).
std::string data_dir = getDefaultDataDir();
if (!fs::exists(data_dir)) {
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
fs::create_directories(data_dir);
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
return config; // data dir unusable — bail early with dir_error set
}
// Try to find DRAGONX.conf
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
return true;
}
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
static bool isExactIPv4Loopback(const std::string& host)
{
int octets = 0, value = 0, digits = 0;
bool firstIs127 = false;
for (size_t i = 0; i <= host.size(); ++i) {
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
if (c == '.') {
if (digits == 0 || digits > 3 || value > 255) return false;
if (octets == 0) firstIs127 = (value == 127);
++octets;
value = 0;
digits = 0;
} else if (c >= '0' && c <= '9') {
value = value * 10 + (c - '0');
++digits;
} else {
return false;
}
}
return octets == 4 && firstIs127;
}
bool Connection::isLocalHost(const std::string& host)
{
std::string lowered = lowercase(host);
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
return lowered == "localhost" || lowered == "localhost." ||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
isExactIPv4Loopback(lowered);
}
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
return !config.use_tls && !isLocalHost(config.host);
}
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
{
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
// link to a remote host. Off by default — see usesPlaintextRemote().
return config.allow_plaintext_remote;
}
const char* Connection::authSourceName(AuthSource source)
{
switch (source) {

View File

@@ -5,6 +5,8 @@
#pragma once
#include <string>
#include <vector>
#include <utility>
namespace dragonx {
namespace rpc {
@@ -27,7 +29,11 @@ struct ConnectionConfig {
std::string proxy; // SOCKS5 proxy for Tor
bool use_embedded = true;
bool use_tls = false;
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
AuthSource auth_source = AuthSource::Missing;
// Non-empty when autoDetectConfig() could not create the data directory; callers
// should surface it and abort the connect rather than proceeding blindly.
std::string dir_error;
};
/**
@@ -69,6 +75,14 @@ public:
*/
static bool verifySaplingParams();
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
// pinned production digests and getSaplingParamsDir().
static bool verifySaplingParamsIn(
const std::string& dir,
const std::vector<std::pair<std::string, std::string>>& digests);
/**
* @brief Get the Sapling params directory
*/
@@ -119,6 +133,11 @@ public:
*/
static bool usesPlaintextRemote(const ConnectionConfig& config);
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
// means the connect is refused.
static bool allowsPlaintextRemote(const ConnectionConfig& config);
static const char* authSourceName(AuthSource source);
private:

View File

@@ -23,6 +23,19 @@ namespace rpc {
namespace {
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree
// that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
// Templated so it works on both nlohmann::json and nlohmann::ordered_json (callRaw uses the latter).
template <typename J>
void scrubJsonSecrets(J& j) {
if (j.is_string()) {
auto& s = j.template get_ref<std::string&>();
if (!s.empty()) sodium_memzero(&s[0], s.size());
} else if (j.is_object() || j.is_array()) {
for (auto& el : j) scrubJsonSecrets(el);
}
}
std::mutex g_trace_mutex;
RPCClient::TraceCallback g_trace_callback;
std::atomic_bool g_trace_enabled{false};
@@ -85,6 +98,10 @@ void RPCClient::setTraceSource(std::string source)
// Callback for libcurl to write response data
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t totalSize = size * nmemb;
// Bound accumulation so a hostile/compromised daemon cannot OOM the client with an unbounded
// response body. 256 MiB is far above any legitimate JSON-RPC response yet prevents exhaustion.
static constexpr size_t kMaxRpcResponseBytes = 256u * 1024 * 1024;
if (userp->size() + totalSize > kMaxRpcResponseBytes) return 0; // short count aborts the transfer
userp->append((char*)contents, totalSize);
return totalSize;
}
@@ -129,7 +146,11 @@ RPCClient::RPCClient() : impl_(std::make_unique<Impl>())
{
}
RPCClient::~RPCClient() = default;
RPCClient::~RPCClient() {
// Scrub the persistent Basic-auth secret on destruction (disconnect() may not have run). impl_ is
// still destroyed normally afterward (curl cleanup unchanged). (L-04)
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size());
}
bool RPCClient::connect(const std::string& host, const std::string& port,
const std::string& user, const std::string& password)
@@ -149,6 +170,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// Create Basic auth header with proper base64 encoding, then wipe the plaintext
// "user:password" temporary (std::string does not zero its buffer on destruction).
std::string credentials = user + ":" + password;
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size()); // wipe any prior secret before overwrite (L-04)
auth_ = util::base64_encode(credentials);
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size());
@@ -176,6 +198,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
impl_->headers = curl_slist_append(nullptr, "Content-Type: text/plain");
std::string auth_header = "Authorization: Basic " + auth_;
impl_->headers = curl_slist_append(impl_->headers, auth_header.c_str());
if (!auth_header.empty()) sodium_memzero(auth_header.data(), auth_header.size()); // curl copied it (L-04)
// Configure curl
curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str());
@@ -191,6 +214,10 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
@@ -278,6 +305,7 @@ void RPCClient::disconnect()
curl_slist_free_all(impl_->headers);
impl_->headers = nullptr;
}
if (!auth_.empty()) { sodium_memzero(auth_.data(), auth_.size()); auth_.clear(); } // scrub Basic-auth secret (L-04)
}
json RPCClient::makePayload(const std::string& method, const json& params)
@@ -317,7 +345,7 @@ std::string RPCClient::performCall(const std::string& method, const json& params
return response_data;
}
json RPCClient::parseRpcResult(long httpCode, const std::string& body)
json RPCClient::parseRpcResult(long httpCode, const std::string& body, bool scrubSource)
{
// Bitcoin/Hush RPC returns HTTP 500 for application-level errors
// (insufficient funds, bad params, etc.) with a valid JSON body.
@@ -355,7 +383,9 @@ json RPCClient::parseRpcResult(long httpCode, const std::string& body)
throw RpcError(errCode, "RPC error: " + err_msg);
}
return response["result"];
json result = response["result"]; // a COPY of the subobject (operator[] yields an lvalue ref)
if (scrubSource) scrubJsonSecrets(response); // zero the discarded tree's secret before it frees (B7)
return result;
}
json RPCClient::call(const std::string& method, const json& params)
@@ -370,6 +400,56 @@ json RPCClient::call(const std::string& method, const json& params)
return parseRpcResult(http_code, response_data);
}
json RPCClient::callSecret(const std::string& method, const json& params)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
if (!impl_->curl) {
throw std::runtime_error("Not connected");
}
// Zero the raw response body whether parsing succeeds or throws — it holds the secret in the
// clear (the curl write buffer performCall returns), and would otherwise be freed un-wiped (B7).
long http_code = 0;
std::string response_data = performCall(method, params, http_code);
try {
json result = parseRpcResult(http_code, response_data, /*scrubSource=*/true);
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
return result;
} catch (...) {
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
throw;
}
}
std::string RPCClient::callSecretString(const std::string& method, const json& params)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
if (!impl_->curl) {
throw std::runtime_error("Not connected");
}
long http_code = 0;
std::string response_data = performCall(method, params, http_code);
try {
json result = parseRpcResult(http_code, response_data, /*scrubSource=*/true);
std::string out;
if (result.is_string()) {
// Copy the secret out, then zero the json node's OWN heap buffer — parseRpcResult's
// json::parse allocates this independently of response_data, so scrubbing the raw body
// alone would leave it behind (B7). `out` is now the only live copy; the caller wipes it.
auto& s = result.get_ref<std::string&>();
out = s;
if (!s.empty()) sodium_memzero(&s[0], s.size());
}
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
if (!result.is_string()) throw std::runtime_error("RPC result is not a string");
return out;
} catch (...) {
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
throw;
}
}
json RPCClient::call(const std::string& method, const json& params, long timeoutSec)
{
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
@@ -445,14 +525,22 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
}
auto& result = oj["result"];
std::string out;
if (result.is_null()) {
return "null";
out = "null";
} else if (result.is_string()) {
// Return the raw string (not JSON-encoded) — caller wraps as needed
return result.get<std::string>();
out = result.get<std::string>();
} else {
return result.dump(4);
out = result.dump(4);
}
// B7: this raw path serves arbitrary console commands including dumpprivkey / z_exportkey,
// whose response carries plaintext key material. Zero the raw buffer and the parsed tree so
// the secret does not linger in freed heap (matching callSecret). The single returned copy is
// the caller's to manage.
scrubJsonSecrets(oj);
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
return out;
}
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)

View File

@@ -133,6 +133,26 @@ public:
*/
json call(const std::string& method, const json& params = json::array());
/**
* @brief Like call(), but zeroes the raw HTTP response body after parsing (B7).
*
* For RPCs whose response carries a secret (z_exportmnemonic / z_exportkey), the parsed
* value is scrubbed by the caller — but the raw response string this method builds also
* holds that secret and is otherwise freed without zeroing. Use this variant so the largest,
* longest-lived plaintext copy doesn't linger in freed heap. Slightly slower (an extra wipe);
* only worth it for secret-bearing calls.
*/
json callSecret(const std::string& method, const json& params = json::array());
/**
* @brief callSecret() for RPCs whose result is a bare secret string (z_exportkey / dumpprivkey /
* z_exportviewingkey). Returns the result string with BOTH the raw response body AND the
* parsed json's own copy of the secret zeroed — so no un-scrubbed heap copy survives the
* call. The returned std::string is the only remaining copy; the caller owns it and must
* wipe it (sodium_memzero) when done. Throws if the result is not a JSON string.
*/
std::string callSecretString(const std::string& method, const json& params = json::array());
/**
* @brief Make a raw RPC call with a custom timeout
* @param method RPC method name
@@ -250,8 +270,10 @@ private:
// hold curl_mutex_ and have verified impl_->curl.
std::string performCall(const std::string& method, const json& params, long& httpCodeOut);
// Centralizes the HTTP-code check and JSON error->RpcError extraction, returning
// response["result"] on success.
static json parseRpcResult(long httpCode, const std::string& body);
// response["result"] on success. When scrubSource is true (secret-bearing calls), the intermediate
// parse tree's string values are zeroed before it is discarded — parseRpcResult returns a COPY of
// the result node, so its own tree would otherwise free the secret un-wiped (B7).
static json parseRpcResult(long httpCode, const std::string& body, bool scrubSource = false);
// Splits a UnifiedCallback into the (Callback, ErrorCallback) pair used by doRPC.
static std::pair<Callback, ErrorCallback> splitUnified(UnifiedCallback cb);

View File

@@ -3,6 +3,7 @@
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cmath>
#include <cstdlib>
#include <map>
@@ -37,16 +38,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
{
if (!unspent.is_array()) return;
std::map<std::string, double> balances;
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
std::map<std::string, double> total;
std::map<std::string, double> spendable;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (address && amount) balances[*address] += *amount;
if (!address || !amount) continue;
total[*address] += *amount;
auto conf = readOptional<int>(output, "confirmations");
if (conf && *conf >= 1) spendable[*address] += *amount;
}
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
for (auto& info : addresses) {
auto balance = balances.find(info.address);
if (balance != balances.end()) info.balance = balance->second;
auto t = total.find(info.address);
auto s = spendable.find(info.address);
info.balance = (t != total.end()) ? t->second : 0.0;
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
}
}
@@ -168,20 +180,6 @@ void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetada
}
}
void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
const std::string& txid,
const NetworkRefreshService::TransactionViewCacheEntry& entry)
{
if (!chat::hushChatFeatureEnabledAtBuild()) return;
std::vector<chat::HushChatMemoOutput> outputs;
outputs.reserve(entry.outgoing_outputs.size());
for (const auto& output : entry.outgoing_outputs) {
if (!output.memo.empty()) outputs.push_back(chat::HushChatMemoOutput{output.position, output.memo});
}
appendExtractedHushChatMetadata(destination, txid, outputs);
}
void appendExtractedHushChatMetadata(std::vector<chat::HushChatTransactionMetadata>& destination,
const HushChatMemoOutputMap& outputsByTxid)
{
@@ -263,7 +261,7 @@ NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnec
}
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
const json& totalBalance, bool balanceOk, const json& blockInfo, bool blockOk)
const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
{
CoreRefreshResult result;
result.balanceOk = balanceOk && totalBalance.is_object();
@@ -272,6 +270,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
result.transparentBalance = readBalanceString(totalBalance, "transparent");
result.totalBalance = readBalanceString(totalBalance, "total");
}
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
}
result.blockchainOk = blockOk && blockInfo.is_object();
if (result.blockchainOk) {
@@ -288,17 +291,28 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
{
json totalBalance;
json spendableBalance;
json blockInfo;
bool balanceOk = false;
bool blockOk = false;
double balanceScanMs = 0.0;
if (includeBalance) {
try {
totalBalance = rpc.call("z_gettotalbalance", json::array());
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
// (balanceRefreshDue()), keeping balance scans from starving block connection.
const auto balanceStart = std::chrono::steady_clock::now();
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
balanceOk = true;
} catch (const std::exception& e) {
DEBUG_LOGF("Balance error: %s\n", e.what());
}
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
} catch (...) {}
balanceScanMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - balanceStart).count();
}
try {
@@ -308,7 +322,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
}
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
result.balanceScanMs = balanceScanMs;
return result;
}
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
@@ -440,16 +456,32 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
if (!parsed.contains("dragonx-2")) return std::nullopt;
const auto& data = parsed["dragonx-2"];
// CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
// commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
// spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
// which the outer catch turns into "no price update at all", so read null-tolerantly and
// keep the valid usd/btc rather than discarding the whole refresh.
auto num = [&data](const char* key, double def) {
auto it = data.find(key);
return (it != data.end() && it->is_number()) ? it->get<double>() : def;
};
PriceRefreshResult result;
result.market.price_usd = data.value("usd", 0.0);
result.market.price_btc = data.value("btc", 0.0);
result.market.change_24h = data.value("usd_24h_change", 0.0);
result.market.volume_24h = data.value("usd_24h_vol", 0.0);
result.market.market_cap = data.value("usd_market_cap", 0.0);
result.market.price_usd = num("usd", 0.0);
result.market.price_btc = num("btc", 0.0);
result.market.change_24h = num("usd_24h_change", 0.0);
result.market.volume_24h = num("usd_24h_vol", 0.0);
result.market.market_cap = num("usd_market_cap", 0.0);
char buf[64];
std::tm* tm = std::localtime(&fetchedAt);
if (tm && std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm) > 0) {
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
// reentrant variant into a local tm (matches the rest of the codebase).
std::tm tmv{};
#ifdef _WIN32
localtime_s(&tmv, &fetchedAt);
#else
localtime_r(&fetchedAt, &tmv);
#endif
if (std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tmv) > 0) {
result.market.last_updated = buf;
}
return result;
@@ -606,18 +638,23 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
}
} catch (const std::exception& e) {
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
}
try {
json unspent = rpc.call("z_listunspent", json::array());
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
for (auto& info : result.shieldedAddresses) {
try {
json balance = rpc.call("z_getbalance", json::array({info.address}));
if (!balance.is_null()) info.balance = balance.get<double>();
try { // display total (minconf=0, includes pending change)
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
if (!total.is_null()) info.balance = total.get<double>();
} catch (...) {}
try { // spendable (minconf=1); degrade to the display value on old daemons
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
} catch (...) { info.spendableBalance = info.balance; }
}
}
@@ -626,10 +663,11 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
result.transparentAddresses = parseTransparentAddressList(tList);
} catch (const std::exception& e) {
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
}
try {
json unspent = rpc.call("listunspent", json::array());
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
} catch (const std::exception& e) {
DEBUG_LOGF("listunspent error: %s\n", e.what());
@@ -920,7 +958,10 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
if (cached != snapshot.viewTxCache.end()) {
if (!trackedSend || !cached->second.outgoing_outputs.empty()) {
appendViewTransactionOutputs(result.transactions, txid, cached->second);
appendExtractedHushChatMetadata(result.hushChatMetadata, txid, cached->second);
// NB: do NOT extract chat metadata from our OWN outgoing memos here — ingest marks
// everything Incoming, so that duplicates every sent message as a phantom "from peer"
// entry. Genuine incoming comes from the z_listreceivedbyaddress path; our sends are
// recorded by the local echo. (The recent-refresh variant already omits this.)
continue;
}
}
@@ -945,7 +986,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
auto entry = parseViewTransactionCacheEntry(viewTransaction);
appendViewTransactionOutputs(result.transactions, txid, entry);
appendExtractedHushChatMetadata(result.hushChatMetadata, txid, entry);
// (Chat metadata is harvested only from RECEIVED notes, not our own outgoing memos — see
// the cached-view branch above.)
json rawTransaction;
bool hasRawTransaction = false;
@@ -1104,12 +1146,16 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
std::set<std::string> reported;
for (const auto& op : result) {
if (!op.is_object()) continue;
std::string opid = op.value("id", std::string());
// Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
// type, which would abort the whole poll (and wedge it for the session — see the call site).
if (!op.contains("id") || !op["id"].is_string()) continue;
std::string opid = op["id"].get<std::string>();
if (opid.empty()) continue;
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
reported.insert(opid);
std::string status = op.value("status", std::string());
std::string status = (op.contains("status") && op["status"].is_string())
? op["status"].get<std::string>() : std::string();
if (status == "success") {
parsed.doneOpids.push_back(opid);
parsed.anySuccess = true;
@@ -1187,6 +1233,12 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
if (result.totalBalance) state.total_balance = *result.totalBalance;
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
state.last_balance_update = updatedAt;
}

View File

@@ -98,9 +98,12 @@ public:
struct CoreRefreshResult {
bool balanceOk = false;
std::optional<double> shieldedBalance;
std::optional<double> shieldedBalance; // display (minconf=0, incl. pending change)
std::optional<double> transparentBalance;
std::optional<double> totalBalance;
std::optional<double> spendableShieldedBalance; // confirmed (minconf=1)
std::optional<double> spendableTransparentBalance;
std::optional<double> spendableTotalBalance;
bool blockchainOk = false;
std::optional<int> blocks;
std::optional<int> headers;
@@ -108,6 +111,7 @@ public:
std::optional<double> verificationProgress;
std::optional<int> longestChain;
std::optional<int> notarized;
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
};
struct MiningRefreshResult {
@@ -146,6 +150,10 @@ public:
struct AddressRefreshResult {
std::vector<AddressInfo> shieldedAddresses;
std::vector<AddressInfo> transparentAddresses;
// False if either address-enumeration RPC (z_listaddresses / getaddressesbyaccount) threw, so the
// lists may be falsely short. Consumers that treat an empty list as authoritative (e.g. the
// empty-wallet warning) must not trust a 0 count unless this is true.
bool addressListOk = true;
};
struct AddressRefreshSnapshot {
@@ -227,6 +235,7 @@ public:
RefreshRpcGateway& rpc,
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
const nlohmann::json& spendableBalance,
bool balanceOk,
const nlohmann::json& blockInfo,
bool blockOk);

View File

@@ -1,9 +1,12 @@
#include "wallet_security_controller.h"
#include "../util/secure_vault.h"
#include "../util/address_validation.h"
#include <cctype>
#include <cstdint>
#include <cstdio>
#include <utility>
#include <vector>
namespace dragonx {
namespace services {
@@ -101,20 +104,50 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(c
// (The old `key[0]=='s'` test misrouted an uppercase "SK..." shielded key to the transparent RPC.)
if (key.rfind("secret-extended-key-", 0) == 0) return KeyKind::Shielded;
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return KeyKind::Shielded;
if (!key.empty() && key[0] == 's') return KeyKind::Shielded;
// A "z"-prefixed key is a shielded viewing key (zxview…); "s"-prefixed is a legacy Sprout key.
if (!key.empty() && (key[0] == 's' || key[0] == 'z')) return KeyKind::Shielded;
return KeyKind::Transparent;
}
bool WalletSecurityController::isViewingKey(const std::string& key)
{
// DragonX's z_exportviewingkey returns a Sapling *incoming* viewing key (mainnet HRP "zivks");
// z_importviewingkey only decodes that form. Recognize it structurally — a valid Bech32 checksum
// plus a known HRP — instead of a bare prefix, and cover testnet/regtest too. (The old check
// looked for Zcash's "zxview" extended-FVK HRP, which DragonX never emits, so every real viewing
// key was rejected client-side.) Watch-only: reveals the address's funds but cannot spend them.
const std::string hrp = util::bech32Hrp(key);
return hrp == "zivks" // mainnet
|| hrp == "zivktestsapling" // testnet
|| hrp == "zivkregtestsapling"; // regtest
}
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
{
// Sapling z spending key (HRP "secret-extended-key-{main,test,regtest}"). These run ~300 chars,
// past the Bech32 length cap, so match by HRP prefix and let the daemon vet the payload.
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
// Transparent WIF: base58, ~51-52 chars, common version prefixes.
if (key.size() >= 51 && key.size() <= 52 &&
(key[0] == '5' || key[0] == 'K' || key[0] == 'L' || key[0] == 'U')) return true;
// Transparent WIF: decode Base58Check and confirm it is actually a secret key — version byte plus
// a 32-byte key, optionally a compression flag (payload 33 or 34 bytes). This accepts BOTH the
// compressed ("U…") and uncompressed ("7…") mainnet forms and the testnet form, and rejects
// addresses / typos via the real checksum — the old length+first-char heuristic dropped the
// uncompressed mainnet key (which starts with '7', not one of 5/K/L/U).
std::vector<std::uint8_t> payload;
if (util::decodeBase58Check(key, payload) &&
(payload.size() == 33 || payload.size() == 34) &&
(payload[0] == 188 /* DragonX main/regtest SECRET_KEY */ ||
payload[0] == 128 /* DragonX testnet SECRET_KEY */)) {
return true;
}
return false;
}
bool WalletSecurityController::isRecognizedImportKey(const std::string& key)
{
return isRecognizedPrivateKey(key) || isViewingKey(key);
}
const char* WalletSecurityController::importSuccessMessage(KeyKind kind)
{
return kind == KeyKind::Shielded

View File

@@ -74,9 +74,13 @@ public:
std::size_t minLength = 4);
static KeyKind classifyAddress(const std::string& address);
static KeyKind classifyPrivateKey(const std::string& key);
// True if `key` is a shielded viewing key (Sapling incoming viewing key, "zivks…" — watch-only).
static bool isViewingKey(const std::string& key);
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
// Single source of truth for the import dialog's indicator AND its submit guard.
static bool isRecognizedPrivateKey(const std::string& key);
// As above, but also accepts a recognized shielded viewing key — the import dialog auto-detects
// both, so this is the single source of truth for its indicator AND its submit guard.
static bool isRecognizedImportKey(const std::string& key);
static const char* importSuccessMessage(KeyKind kind);
static std::string decryptExportFileName(std::uint64_t timestampSeconds);
static void secureClear(std::string& value);

View File

@@ -388,14 +388,14 @@ void AcrylicMaterial::setQuality(AcrylicQuality quality)
}
}
void AcrylicMaterial::applyBlur(float radius)
void AcrylicMaterial::applyBlur(float radius, bool ignoreMultiplier)
{
if (!blurBuffers_.isValid() || !blurShader_.isValid()) {
return;
}
// Apply blur radius multiplier from settings
float scaledRadius = radius * settings_.blurRadiusMultiplier;
// Apply the user's blur-strength multiplier — unless the caller wants an absolute radius.
float scaledRadius = ignoreMultiplier ? radius : radius * settings_.blurRadiusMultiplier;
// Skip blur entirely when multiplier is at or near zero — show sharp background
if (scaledRadius < 0.5f) {
@@ -566,7 +566,7 @@ void AcrylicMaterial::drawRect(ImDrawList* drawList, const ImVec2& pMin, const I
}
// Apply blur to captured background
applyBlur(params.blurRadius);
applyBlur(params.blurRadius, params.absoluteBlurRadius);
// Calculate UV coordinates for sampling the blur FBO texture.
// With multi-viewport enabled, ImGui draw coordinates are in
@@ -1274,11 +1274,11 @@ void AcrylicMaterial::captureLiveFramebuffer()
blurCacheValid_ = false;
}
void AcrylicMaterial::applyBlur(float radius)
void AcrylicMaterial::applyBlur(float radius, bool ignoreMultiplier)
{
if (!dx_blurSRV_[0] || !dx_blurPS_ || !dx_context_) return;
float scaledRadius = radius * settings_.blurRadiusMultiplier;
float scaledRadius = ignoreMultiplier ? radius : radius * settings_.blurRadiusMultiplier;
if (blurCacheValid_ && std::abs(scaledRadius - lastBlurRadius_) < 0.1f) return;
int passes = 1;
@@ -1540,7 +1540,7 @@ void AcrylicMaterial::drawRect(ImDrawList* drawList, const ImVec2& pMin, const I
}
// Run DX11 blur pipeline
applyBlur(params.blurRadius);
applyBlur(params.blurRadius, params.absoluteBlurRadius);
// DX11 UV: top-left = (0,0), bottom-right = (1,1) — same as ImGui
// With multi-viewport, pMin/pMax are in OS screen space; subtract
@@ -1704,7 +1704,7 @@ void AcrylicMaterial::resize(int, int) {}
void AcrylicMaterial::captureBackground() {}
void AcrylicMaterial::captureBackgroundDirect() {}
void AcrylicMaterial::captureLiveFramebuffer() {}
void AcrylicMaterial::applyBlur(float) {}
void AcrylicMaterial::applyBlur(float, bool) {}
ImTextureID AcrylicMaterial::getBlurredTexture() const { return 0; }
ImTextureID AcrylicMaterial::getNoiseTexture() const { return 0; }
void AcrylicMaterial::refreshCapabilities() {}

View File

@@ -40,6 +40,11 @@ struct AcrylicParams {
// Blur radius in pixels (typical: 20-60)
float blurRadius = 30.0f;
// When true, blurRadius is used AS-IS — NOT scaled by the user's blur-strength slider
// (blurRadiusMultiplier). The modal backdrop sets this so its blur is a fixed value, independent
// of the global acrylic slider.
bool absoluteBlurRadius = false;
// Noise texture opacity (typical: 0.02-0.04)
float noiseOpacity = 0.02f;
@@ -341,7 +346,7 @@ private:
/**
* @brief Apply blur passes to captured content
*/
void applyBlur(float radius);
void applyBlur(float radius, bool ignoreMultiplier = false);
/**
* @brief Composite final acrylic appearance

View File

@@ -160,6 +160,10 @@ void ThemeEffects::loadFromTheme() {
// ---- Gradient Border Shift ----
gradient_border_.enabled = eff("gradient-border-enabled").sizeOr(0.0f) > 0.5f;
// Opt-in: also draw the shifting border on every glass panel (not just the
// active nav button). Off by default so themes that only want the button
// accent (e.g. Obsidian) are unaffected; Jade turns it on as its hero.
gradient_border_.panels = eff("gradient-border-panels").sizeOr(0.0f) > 0.5f;
gradient_border_.speed = eff("gradient-border-speed").sizeOr(0.15f);
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f);
gradient_border_.alpha = eff("gradient-border-alpha").sizeOr(0.6f);
@@ -512,11 +516,15 @@ void ThemeEffects::drawGlowPulse(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
// ============================================================================
void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
float rounding, float phaseOffset,
float alphaMul) const {
if (!enabled_ || !gradient_border_.enabled) return;
// Smooth sinusoidal oscillation between color A and color B
float phase = std::sin(time_ * gradient_border_.speed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
// Smooth sinusoidal oscillation between color A and color B.
// phaseOffset shifts where in the cycle this element sits so a wall of
// panels reads like veins at different depths rather than one pulse.
float phase = std::sin((time_ * gradient_border_.speed + phaseOffset)
* 2.0f * 3.14159265f) * 0.5f + 0.5f;
// Extract RGBA from both colors and lerp
RGB ca = unpackRGB(gradient_border_.colorA);
@@ -525,7 +533,7 @@ void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 p
int r = ca.r + (int)((cb.r - ca.r) * phase);
int g = ca.g + (int)((cb.g - ca.g) * phase);
int b = ca.b + (int)((cb.b - ca.b) * phase);
int a = scaledAlpha(gradient_border_.alpha, bgOpacity_);
int a = scaledAlpha(gradient_border_.alpha * alphaMul, bgOpacity_);
// Draw the shifting border
dl->AddRect(pMin, pMax, IM_COL32(r, g, b, a),
@@ -896,6 +904,22 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const {
if (!enabled_ || effects::isLowSpecMode()) return;
// Gradient border on panels — a slow color-shifting outline that hugs the
// panel's rounded corners (drawn via AddRect, so it follows the rounding
// exactly — no polygonal corners). Position-based phase offset makes each
// panel drift like a vein at a different depth; softer than the active
// nav button (alphaMul 0.6) so a screenful of panels stays calm.
if (gradient_border_.enabled && gradient_border_.panels) {
float w = pMax.x - pMin.x;
float h = pMax.y - pMin.y;
if (w > 80 && h > 40) { // skip small panels
float posKey = (pMin.x * 0.0073f + pMin.y * 0.0137f);
posKey = posKey - (int)posKey; // fractional 0..1
if (posKey < 0) posKey += 1.0f;
drawGradientBorderShift(dl, pMin, pMax, rounding, posKey, 0.6f);
}
}
// Edge trace on panels — use position-based phase offset so each
// panel's tracer is at a different position around the border
if (edge_trace_.enabled) {

View File

@@ -69,9 +69,12 @@ public:
void drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const;
/// Draw a border that shifts between two colors over time (gem-like)
/// Draw a border that shifts between two colors over time (gem-like).
/// phaseOffset (0..1) shifts this element's point in the color cycle so
/// many panels don't pulse in unison; alphaMul scales the whole effect.
void drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const;
float rounding, float phaseOffset = 0.0f,
float alphaMul = 1.0f) const;
/// Draw ember particles that rise from an element (fire theme)
void drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const;
@@ -186,6 +189,7 @@ private:
struct GradientBorderConfig {
bool enabled = false;
bool panels = false; ///< also draw on glass panels, not just the active nav button
float speed = 0.15f; ///< full color shift cycles per second
float thickness = 1.5f; ///< border line thickness in pixels
float alpha = 0.6f; ///< peak alpha

View File

@@ -173,6 +173,24 @@ inline float kSidePanelMinWidth() { return schema::UI().drawElement("panels",
inline float kSidePanelMaxWidth() { return schema::UI().drawElement("panels", "side-panel").getFloat("max-width", 450.0f) * dpiScale(); }
inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels", "side-panel").getFloat("width-ratio", 0.4f); }
// Overall content-column cap: the max width a tab's content occupies before it is centered in wider
// windows. <= 0 disables the cap so tab content fills ALL available horizontal width (the default —
// requested so large windows don't leave a big empty gutter on the right). Set a positive
// ui.toml [layout] content-max-width to re-enable a centered readable column.
inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(0.0f) * dpiScale(); }
// Shared compose-card envelope for the Send + Receive tabs (and any tab wanting the same box): fill the
// available column up to the content-max-width cap, then center the leftover as margin. Both tabs MUST
// derive their card width/offset from this so the two envelopes stay byte-for-byte identical — they
// previously drifted (Send capped at 760dp, Receive at 860dp), so the Send card rendered narrower than
// Receive on any window wider than ~860dp. Returns {width, offsetX} in the same units as availW.
struct CardBox { float width; float offsetX; };
inline CardBox mainComposeCardBox(float availW) {
float cap = kContentMaxWidth();
float w = (cap > 0.0f) ? std::min(availW, cap) : availW; // cap <= 0 -> fill full width
return CardBox{ w, std::max(0.0f, (availW - w) * 0.5f) };
}
inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); }
inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); }

View File

@@ -116,6 +116,26 @@ inline ImVec4 WarningVec4() { return ImGui::ColorConvertU32ToFloat4(Warni
// Convenience Functions for Common Patterns
// ============================================================================
/**
* @brief Theme-aware translucent overlay for tracks / hover fills / dividers.
*
* A raw white overlay (IM_COL32(255,255,255,a)) reads on dark skins but vanishes
* on light/pastel skins (white-on-white). This picks a dark overlay on light
* themes and a white overlay on dark themes so the alpha reads either way.
* (Self-contained luminance check so colors.h stays free of draw_helpers.h.)
*
* @param alpha 0-255 opacity of the overlay
*/
inline ImU32 SurfaceOverlay(int alpha)
{
ImU32 bg = Background();
float r = ((bg >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
float g = ((bg >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
float b = ((bg >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
bool light = (0.299f * r + 0.587f * g + 0.114f * b) > 0.5f;
return light ? IM_COL32(0, 0, 0, alpha) : IM_COL32(255, 255, 255, alpha);
}
/**
* @brief Get color with applied state overlay
*

View File

@@ -44,6 +44,34 @@ inline bool IsLightTheme() {
return (0.299f * r + 0.587f * g + 0.114f * b) > 0.5f;
}
// Theme error color tuned for use as TEXT. The Material --error reds are muted, so on a dark modal
// backdrop they read low-contrast; lift them toward a legible light red on dark themes. Light themes
// keep the theme error (already a dark red readable on a light background).
inline ImU32 ReadableError() {
ImU32 e = Error();
if (IsLightTheme()) return e;
const float m = 0.35f; // blend toward white
int r = (int)(((e >> IM_COL32_R_SHIFT) & 0xFF) * (1 - m) + 255 * m);
int g = (int)(((e >> IM_COL32_G_SHIFT) & 0xFF) * (1 - m) + 255 * m);
int b = (int)(((e >> IM_COL32_B_SHIFT) & 0xFF) * (1 - m) + 255 * m);
return IM_COL32(r, g, b, (e >> IM_COL32_A_SHIFT) & 0xFF);
}
// Middle-ellipsis truncation ("front...back", roughly equal halves) so `text` fits within
// maxWidth pixels when drawn with `font` at `fontSize`. Returns `text` unchanged if it already
// fits (or maxWidth is non-positive). Display-only — never mutate the underlying value with this.
inline std::string TruncateToWidth(const std::string& text, ImFont* font, float fontSize, float maxWidth) {
if (text.empty() || !font || maxWidth <= 0.0f) return text;
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text.c_str()).x <= maxWidth) return text;
const int n = static_cast<int>(text.size());
for (int f = n / 2; f >= 3; --f) {
const int b = (f - 2 > 3) ? (f - 2) : 3; // keep the two halves roughly equal
std::string t = text.substr(0, f) + "..." + text.substr(n - b);
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, t.c_str()).x <= maxWidth) return t;
}
return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text;
}
// Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
inline const char* LoadingDots() {
int n = ((int)(ImGui::GetTime() * 3.0f)) % 4;
@@ -51,6 +79,63 @@ inline const char* LoadingDots() {
return kDots[n];
}
// ── Centered empty state ─────────────────────────────────────────────────
// A big muted icon + title + optional wrapped hint, centered on BOTH axes within
// GetContentRegionAvail(). Mirrors chat_tab's centeredEmptyState so list-empty states
// read the same across tabs. Call at the start of the region you want it centered in
// (e.g. right after a BeginChild / a leading Dummy). Font metrics use the live font
// scale (LegacySize * FontScaleMain) and PushFont draws at that same scale, so this is
// crisp at HiDPI / font_scale 1.5 without any manual dpiScale multiply on the metrics.
inline void DrawEmptyState(const char* iconGlyph, const char* title, const char* hint = nullptr)
{
auto scaled = [](ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; };
const ImVec2 avail = ImGui::GetContentRegionAvail();
const ImVec2 origin = ImGui::GetCursorPos();
ImFont* iconF = Type().iconXL();
ImFont* titleF = Type().subtitle1();
ImFont* hintF = Type().body2();
const float dp = Layout::dpiScale();
const float gap = 8.0f * dp;
const float wrap = std::min(avail.x - 40.0f * dp, 360.0f * dp);
const float iconSz = iconF ? scaled(iconF) : 40.0f;
const float iconH = (iconF && iconGlyph) ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).y : 0.0f;
const float titleH = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).y;
const float hintH = hint ? hintF->CalcTextSizeA(scaled(hintF), wrap, wrap, hint).y : 0.0f;
const float totalH = iconH + (iconH > 0.0f ? gap : 0.0f) + titleH + (hint ? gap + hintH : 0.0f);
float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);
if (iconF && iconGlyph && iconGlyph[0]) {
const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).x;
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
ImGui::PushFont(iconF);
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 70));
ImGui::TextUnformatted(iconGlyph);
ImGui::PopStyleColor();
ImGui::PopFont();
y += iconH + gap;
}
{
const float tw = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).x;
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
ImGui::PushFont(titleF);
ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceMedium());
ImGui::TextUnformatted(title);
ImGui::PopStyleColor();
ImGui::PopFont();
y += titleH + gap;
}
if (hint) {
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
ImGui::PushFont(hintF);
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 120));
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
ImGui::TextUnformatted(hint);
ImGui::PopTextWrapPos();
ImGui::PopStyleColor();
ImGui::PopFont();
}
}
// ============================================================================
// Text Drop Shadow
// ============================================================================
@@ -459,11 +544,16 @@ inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0),
ImVec2 bMin = ImGui::GetItemRectMin();
ImVec2 bMax = ImGui::GetItemRectMax();
// For icon fonts, manually draw centered icon after getting button rect
// For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the
// VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way
// ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and
// shove the glyph left off-center (and try to draw the notdef id chars).
if (isIconFont && size.x > 0 && size.y > 0) {
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label);
const char* labelEnd = label;
while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd;
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd);
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label);
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd);
}
float rounding = ImGui::GetStyle().FrameRounding;
@@ -729,10 +819,19 @@ inline void DrawFullWindowBlurBackdrop(ImDrawList* dl, const ImVec2& pMin, const
// panel skips acrylic (IsFullWindowBlurOverlayActive), so the backdrop is the SOLE applyBlur
// caller — no other radius contends for the shared, radius-keyed blur cache.
auto params = GetCurrentAcrylicTheme().card;
params.blurRadius = 64.0f; // strong full-window blur
params.blurRadius = 120.0f; // strong full-window blur (deeper than panels)
params.absoluteBlurRadius = true; // fixed modal blur — independent of the user's acrylic slider
params.fallbackColor.w = 1.0f; // full-strength blur so the live content reads
effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax, params, 0.0f);
dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 70)); // subtle theme-tinted frost so the card reads as foreground
// Veil over the blur so the floating (card-less) modal reads as foreground. On DARK themes a
// theme-tinted frost darkens the backdrop and the light content pops. On LIGHT themes the modal's
// own controls carry a dropshadow for figure/ground, so the backdrop stays LIGHT — a theme-tinted
// light frost — rather than a dark veil (which made a "light" theme read dark and sank the
// dark-on-light chrome into it).
if (IsLightTheme())
dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 205)); // light frost — dropshadows give shape
else
dl->AddRectFilled(pMin, pMax, WithAlpha(Background(), 120)); // theme-tinted darken, stronger
}
}
@@ -1325,10 +1424,17 @@ inline int SegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float h
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
WithAlpha(Primary(), 210), (height - 4.0f * dp) * 0.5f);
ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, labels[i]);
// Center when the label fits; otherwise left-align with a small pad (so a long translation clips
// on the right, not on BOTH sides). Clip to the cell so no label can bleed into a neighbouring
// segment or past the rounded track — English fits, but de/es/fr/pt/ru labels can overrun.
const float lpad = 4.0f * dp;
const float tx = (ts.x <= cellW - 2.0f * lpad) ? (cellW - ts.x) * 0.5f : lpad;
dl->PushClipRect(cMin, cMax, true);
dl->AddText(font, font->LegacySize,
ImVec2(cMin.x + (cellW - ts.x) * 0.5f, cMin.y + (height - ts.y) * 0.5f),
ImVec2(cMin.x + tx, cMin.y + (height - ts.y) * 0.5f),
active ? IM_COL32(255, 255, 255, 255) : (hov ? OnSurface() : OnSurfaceMedium()),
labels[i]);
dl->PopClipRect();
ImGui::PushID(i);
ImGui::SetCursorScreenPos(cMin);
if (ImGui::InvisibleButton(idBase, ImVec2(cellW, height))) clicked = i;
@@ -1359,6 +1465,7 @@ struct OverlayCardState {
int stableCount = 0; // consecutive frames the height held steady (within 1px)
int appearFrames = 0; // frames since (re)appearing while still hidden — a safety cap
bool shown = false; // revealed (centered) at least once this open; don't re-hide after
bool overflow = false; // content once exceeded the viewport → clamp to viewport + scroll (sticky/open)
};
inline std::unordered_map<std::string, OverlayCardState> g_overlayCardHeights;
inline std::string g_overlayCurrentKey;
@@ -1484,6 +1591,7 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
float cardY, cardBottomY;
bool hideForMeasure = false; // true on an auto-height dialog's first (unmeasured) frame
bool autoOverflow = false; // auto-height content taller than the viewport → clamp + scroll
const bool fixedHeight = (spec.cardHeight > 0.0f);
if (fixedHeight) {
float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f);
@@ -1493,9 +1601,16 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
} else {
g_overlayCurrentKey = childId;
OverlayCardState& cs = g_overlayCardHeights[childId];
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; }
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; cs.overflow = false; }
if (!cs.shown) cs.appearFrames++;
const float measuredH = cs.height;
const float maxCardH = vp_size.y - 32.0f;
// Once the measured content is taller than the viewport, lock the card to the viewport height and
// let its content child scroll (autoOverflow) so the footer/actions stay reachable. Sticky for this
// open: clamping makes next frame's measured height the clamped value, so re-deciding from it would
// oscillate — decide once and hold until the dialog re-opens.
if (measuredH > maxCardH) cs.overflow = true;
autoOverflow = cs.overflow;
// Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's
// already been shown this open (don't re-hide on a mid-dialog content change); a frame cap
// guarantees a pathological ever-changing height can't hide the dialog forever.
@@ -1503,11 +1618,18 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
(cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8);
if (ready) {
cs.shown = true;
if (autoOverflow) {
// Taller than the screen: top-anchor at the 16px margin, clamp to the viewport; the
// content child (below) becomes the scroll region so the footer/actions stay reachable.
cardY = vp_pos.y + 16.0f;
cardBottomY = cardY + maxCardH;
} else {
// Center the measured content; if it's taller than the window, anchor at the top margin.
cardY = (measuredH < vp_size.y - 32.0f)
cardY = (measuredH < maxCardH)
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
: vp_pos.y + 16.0f;
cardBottomY = cardY + measuredH;
}
} else {
// Still settling: lay the content out (so the auto-height child gets measured) but keep
// the card hidden (hideForMeasure below) so it never flashes off-center — it appears,
@@ -1523,7 +1645,10 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
// the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
if (!floating && !hideForMeasure) {
GlassPanelSpec cardGlass;
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f;
// Fill/border alpha govern every overlay dialog's card boundary — kept well above the default
// glass panel so the card reads as a distinct surface over busy backdrops (tx lists, mining
// tiles, chat) while staying translucent rather than opaque.
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 60; cardGlass.borderAlpha = 90; cardGlass.borderWidth = 1.0f;
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
}
@@ -1540,14 +1665,21 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f : 16.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28, 20) : ImVec2(28, 24));
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // transparent (glass/blur behind)
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (fixedHeight ? 0 : ImGuiChildFlags_AutoResizeY);
// A card with a known height is a fixed frame (fixed-height dialogs, and auto-height dialogs whose
// content overflowed the viewport); otherwise the child auto-resizes to its content.
const bool clampedCard = fixedHeight || autoOverflow;
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (clampedCard ? 0 : ImGuiChildFlags_AutoResizeY);
// NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift
// the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll
// the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on
// their own; auto-height cards resize to content so they never overflow anyway.
// their own; auto-height cards resize to content so they normally never overflow — EXCEPT when the
// content is taller than the viewport (autoOverflow), where the card itself IS the scroll region.
ImGuiWindowFlags childScroll = autoOverflow
? ImGuiWindowFlags_None
: (ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
bool childVisible = ImGui::BeginChild(childId.c_str(),
ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f),
cflags, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImVec2(cardWidth, clampedCard ? (cardBottomY - cardY) : 0.0f),
cflags, childScroll);
// Floating (portfolio-style) cards: the padding applies to this content child only, so pop it
// now (nested children mustn't inherit it), and center button labels. Net style-var count stays
// at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged.
@@ -1644,11 +1776,21 @@ inline void BeginOverlayDialogFooter(float totalActionWidth, bool drawSeparator
// TextColored(label). The label text is passed in (already translated).
inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = WarningVec4())
{
ImGui::PushFont(Type().iconLarge());
ImFont* iconF = Type().iconLarge();
const float rowTop = ImGui::GetCursorPosY();
const float iconH = iconF->LegacySize; // already dp-scaled
const float textH = ImGui::GetFontSize();
ImGui::PushFont(iconF);
ImGui::TextColored(col, ICON_MD_WARNING);
ImGui::PopFont();
ImGui::SameLine();
// Vertically center the label with the taller warning glyph (stays within the icon's row height).
if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f);
// Wrap so a long warning flows to a second line instead of clipping at the card edge; short
// warnings (which fit) are unaffected.
ImGui::PushTextWrapPos(0.0f);
ImGui::TextColored(col, "%s", warningLabel);
ImGui::PopTextWrapPos();
}
// 50/50 Cancel / confirm footer for overlay dialogs. Sets `outCancel` /
@@ -1660,7 +1802,7 @@ inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = Wa
inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
bool danger, bool& outCancel, bool& outConfirm)
{
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f);
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f) * Layout::dpiScale();
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) {
outCancel = true;
@@ -1678,6 +1820,70 @@ inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
}
}
// Reference dialog footer: a centered primary + Close (or Cancel) TactileButton pair, fixed width,
// NO divider above (the reference dropped the separator). Sets outPrimary/outClose when the respective
// button is clicked — the caller runs the bodies (so the primary's action can be arbitrary). The
// primary is BeginDisabled'd when !primaryEnabled. btnW<=0 → 130·dp default.
inline void DialogActionFooter(const char* primaryLabel, bool primaryEnabled,
const char* closeLabel, bool& outPrimary, bool& outClose,
float btnW = 0.0f)
{
const float dp = Layout::dpiScale();
if (btnW <= 0.0f) btnW = 130.0f * dp;
const float total = btnW * 2.0f + ImGui::GetStyle().ItemSpacing.x;
const float off = (ImGui::GetContentRegionAvail().x - total) * 0.5f;
if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off);
ImGui::BeginDisabled(!primaryEnabled);
if (TactileButton(primaryLabel, ImVec2(btnW, 0))) outPrimary = true;
ImGui::EndDisabled();
ImGui::SameLine();
if (TactileButton(closeLabel, ImVec2(btnW, 0))) outClose = true;
}
// RAII glass sub-section for grouping an optional/advanced block inside a dialog (the reference's
// scan-height panel). Auto-sizes to its content and paints a subtle glass panel behind it. Uses a
// LOCAL ImDrawListSplitter — safe with BeginCombo popups, unlike the shared ChannelsSplit that
// GlassCardScope uses. padX/padY are the interior insets (in logical px, dp-scaled here). Render the
// section body between construction and destruction; use interiorWidth() for full-width children.
struct GlassSectionScope {
ImDrawListSplitter splitter;
ImDrawList* dl;
ImVec2 cardMin;
float panelW, padX, padY;
GlassPanelSpec spec;
explicit GlassSectionScope(float padXLogical = 12.0f, float padYLogical = 10.0f)
{
const float dp = Layout::dpiScale();
padX = padXLogical * dp;
padY = padYLogical * dp;
spec.rounding = 8.0f * dp;
spec.fillAlpha = 20;
spec.borderAlpha = 30;
dl = ImGui::GetWindowDrawList();
panelW = ImGui::GetContentRegionAvail().x;
cardMin = ImGui::GetCursorScreenPos();
splitter.Split(dl, 2);
splitter.SetCurrentChannel(dl, 1); // content above the panel background
ImGui::Dummy(ImVec2(0, padY));
ImGui::Indent(padX);
}
~GlassSectionScope()
{
ImGui::Unindent(padX);
ImGui::Dummy(ImVec2(0, padY));
const ImVec2 cardMax(cardMin.x + panelW, ImGui::GetCursorScreenPos().y);
splitter.SetCurrentChannel(dl, 0);
DrawGlassPanel(dl, cardMin, cardMax, spec);
splitter.Merge(dl);
}
// Interior content width (panel width minus both side insets) for full-width children.
float interiorWidth() const { return panelW - 2.0f * padX; }
GlassSectionScope(const GlassSectionScope&) = delete;
GlassSectionScope& operator=(const GlassSectionScope&) = delete;
};
} // namespace material
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,195 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Settings design-system controls — the polished chat-settings look (accent subsection headers,
// labeled rows with right-aligned controls, iOS-style segmented controls) promoted to reusable
// components, plus a tiered ActionButton (Primary/Secondary/Tertiary/Destructive) with optional
// leading Material icon and a ButtonFlow that wraps rows of buttons instead of shrinking them.
#pragma once
#include "draw_helpers.h" // colors, type, layout, icons, WithAlpha, ScaleAlpha, DrawButtonGlassOverlay
#include "imgui.h"
#include <algorithm>
namespace dragonx {
namespace ui {
namespace material {
// Accent small-caps subsection header ("KEYS & BACKUP", "APPEARANCE"…) — the chat-settings section() look.
inline void SettingsSubheader(const char* text) {
const float dp = Layout::dpiScale();
ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
ImGui::PushFont(Type().caption());
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(Primary(), 235));
ImGui::TextUnformatted(text);
ImGui::PopStyleColor();
ImGui::PopFont();
ImGui::Dummy(ImVec2(0.0f, 2.0f * dp));
}
// A labeled settings row: label left, control right-aligned in a fixed column. Construct once per card
// section with the content width (0 = auto), then call .label(text) before drawing each control (leaves
// the cursor at the control origin and sets the next item width to the control column).
struct SettingsRow {
float leftX, rowW, ctrlW, rowGap;
explicit SettingsRow(float contentWidth, float controlWidth = 250.0f) {
const float dp = Layout::dpiScale();
leftX = ImGui::GetCursorPosX();
rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
ctrlW = controlWidth * dp;
rowGap = 5.0f * dp;
}
void label(const char* text) {
ImGui::Dummy(ImVec2(0.0f, rowGap));
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted(text);
ImGui::SameLine();
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), leftX + std::max(0.0f, rowW - ctrlW)));
ImGui::SetNextItemWidth(ctrlW);
}
};
// iOS-style segmented control (rounded track + inset pill on the selection). Draws its own labeled row
// and returns the (possibly changed) index.
inline int SegmentedControl(SettingsRow& row, const char* label, const char* const* items, int count, int value) {
const float dp = Layout::dpiScale();
row.label(label);
const ImVec2 origin = ImGui::GetCursorScreenPos();
const float h = ImGui::GetFrameHeight();
const float seg = row.ctrlW / static_cast<float>(count);
const float round = 7.0f * dp;
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(origin, ImVec2(origin.x + row.ctrlW, origin.y + h), WithAlpha(OnSurface(), 20), round);
int result = value;
ImGui::PushID(label);
for (int i = 0; i < count; ++i) {
ImGui::PushID(i);
const ImVec2 mn(origin.x + i * seg, origin.y), mx(origin.x + (i + 1) * seg, origin.y + h);
ImGui::SetCursorScreenPos(mn);
if (ImGui::InvisibleButton("##s", ImVec2(seg, h))) result = i;
const bool hov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
const bool sel = (value == i);
if (sel) {
const float in = 2.0f * dp;
dl->AddRectFilled(ImVec2(mn.x + in, mn.y + in), ImVec2(mx.x - in, mx.y - in),
WithAlpha(Primary(), 210), std::max(1.0f, round - in));
} else if (hov) {
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 26), round);
}
const ImVec2 ts = ImGui::CalcTextSize(items[i]);
const float lpad = 4.0f * dp;
const float tx = (ts.x <= seg - 2.0f * lpad) ? (seg - ts.x) * 0.5f : lpad;
ImGui::PushClipRect(mn, mx, true);
dl->AddText(ImVec2(mn.x + tx, mn.y + (h - ts.y) * 0.5f),
sel ? IM_COL32(255, 255, 255, 236) : OnSurfaceMedium(), items[i]);
ImGui::PopClipRect();
ImGui::PopID();
}
ImGui::PopID();
ImGui::SetCursorScreenPos(origin);
ImGui::Dummy(ImVec2(row.ctrlW, h)); // reserve the control's rect for layout flow
return result;
}
// ── Tiered action buttons ───────────────────────────────────────────────────
// Primary = filled accent (the one main action of a group)
// Secondary = glass (default — common actions)
// Tertiary = ghost / low-emphasis (rarely used)
// Destructive = error-tinted outline (delete / reset)
enum class ActionTier { Primary, Secondary, Tertiary, Destructive };
// The width an ActionButton will occupy (for ButtonFlow / manual layout).
inline float ActionButtonWidth(const char* label, const char* icon, float minWidth = 0.0f) {
ImFont* lf = Type().button();
ImFont* icf = Type().iconSmall();
const float dp = Layout::dpiScale();
const float padX = 9.0f * dp, gap = 6.0f * dp; // mockup .btn padding: 9px horizontal
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
const float iconW = (icon && icon[0] && icf) ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
const float w = padX * 2.0f + iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
return std::max(w, minWidth);
}
// A tiered action button with an optional leading Material icon (ICON_MD_* or nullptr). Auto-sizes to
// its content (>= minWidth). Respects BeginDisabled() (dims via the style alpha, not clickable).
inline bool ActionButton(const char* id, const char* label, const char* icon, ActionTier tier, float minWidth = 0.0f) {
ImFont* lf = Type().button();
ImFont* icf = Type().iconSmall();
const float dp = Layout::dpiScale();
const float gap = 6.0f * dp;
const float h = ImGui::GetFrameHeight();
const bool hasIcon = icon && icon[0] && icf;
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
const float iconW = hasIcon ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
const float w = ActionButtonWidth(label, icon, minWidth);
const ImVec2 pos = ImGui::GetCursorScreenPos();
const bool pressed = ImGui::InvisibleButton(id, ImVec2(w, h));
const bool hov = ImGui::IsItemHovered(), act = ImGui::IsItemActive();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 pMax(pos.x + w, pos.y + h);
const float round = 7.0f * dp; // mockup .btn radius: 7px (softer than the global 4px frame)
const float a = ImGui::GetStyle().Alpha; // BeginDisabled() lowers this
ImU32 bg = 0, border = 0, fg = OnSurface();
bool glass = false;
switch (tier) {
case ActionTier::Primary:
// Mockup .btn.acc: a dark accent-tinted chip with accent TEXT — not a bright filled button.
bg = WithAlpha(Primary(), hov ? 52 : 38);
border = WithAlpha(Primary(), hov ? 150 : 110);
fg = Primary();
break;
case ActionTier::Secondary:
bg = WithAlpha(OnSurface(), hov ? 30 : 20);
border = WithAlpha(OnSurface(), 48);
glass = true;
fg = OnSurface();
break;
case ActionTier::Tertiary:
bg = hov ? WithAlpha(OnSurface(), 18) : 0;
fg = OnSurfaceMedium();
break;
case ActionTier::Destructive:
bg = hov ? WithAlpha(Error(), 32) : WithAlpha(Error(), 12);
border = WithAlpha(Error(), 90);
fg = Error();
break;
}
if (bg) dl->AddRectFilled(pos, pMax, ScaleAlpha(bg, a), round);
if (border) dl->AddRect(pos, pMax, ScaleAlpha(border, a), round, 0, 1.0f);
if (glass) DrawButtonGlassOverlay(dl, pos, pMax, round, act, hov);
fg = ScaleAlpha(fg, a);
const float contentW = iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
float cx = pos.x + (w - contentW) * 0.5f;
if (hasIcon) {
dl->AddText(icf, icf->LegacySize, ImVec2(cx, pos.y + (h - icf->LegacySize) * 0.5f), fg, icon);
cx += iconW + gap;
}
dl->AddText(lf, lf->LegacySize, ImVec2(cx, pos.y + (h - lf->LegacySize) * 0.5f), fg, label);
return pressed;
}
// Places ActionButtons left→right, wrapping to a new row when the next one won't fit (instead of the
// old font-scale-to-fit). Call next(width) before each ActionButton.
struct ButtonFlow {
float availW, gap; float x = 0.0f; bool firstOnRow = true;
explicit ButtonFlow(float availWidth, float gapPx = 8.0f) : availW(availWidth) {
gap = gapPx * Layout::dpiScale();
}
void next(float w) {
if (firstOnRow) { firstOnRow = false; x = w; return; }
if (x + gap + w <= availW) { ImGui::SameLine(0, gap); x += gap + w; }
else { x = w; } // natural newline wraps to the next row
}
};
} // namespace material
} // namespace ui
} // namespace dragonx

View File

@@ -15,6 +15,11 @@
#include "../embedded/IconsMaterialDesign.h" // Icon codepoint defines
#include "../../util/logger.h"
#ifdef DRAGONX_HAVE_FREETYPE
#include "imgui_internal.h" // ImFontAtlasGetFontLoaderForStbTruetype
#include "misc/freetype/imgui_freetype.h" // ImGuiFreeType::GetFontLoader + LoadColor flag
#endif
namespace dragonx {
namespace ui {
namespace material {
@@ -126,6 +131,15 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
DEBUG_LOGF("Typography: Loading Material Design type scale (DPI: %.2f, fontScale: %.2f, userFontScale: %.2f, combined: %.2f)\n",
dpiScale, Layout::kFontScale(), Layout::userFontScale(), scale);
#ifdef DRAGONX_HAVE_FREETYPE
// Choose the atlas font loader BEFORE any font is added: FreeType (required to rasterize COLR
// color-emoji glyphs) when color emoji is enabled, else the default stb_truetype loader. Toggling
// the setting + reload() flips this cleanly.
io.Fonts->SetFontLoader(color_emoji_ ? ImGuiFreeType::GetFontLoader()
: ImFontAtlasGetFontLoaderForStbTruetype());
DEBUG_LOGF("Typography: font loader = %s\n", color_emoji_ ? "FreeType (color emoji)" : "stb_truetype");
#endif
// For ImGui, we need to load fonts at specific pixel sizes.
// Font sizes come from Layout:: accessors (backed by UISchema JSON)
@@ -325,6 +339,63 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
name, g_noto_cjk_subset_size);
}
}
// Merge monochrome emoji for chat + user text (Q12). Only into the small text fonts — baking
// ~1400 emoji at heading sizes would bloat the atlas for glyphs no heading needs. ImGui does no
// shaping, so single-codepoint emoji render but ZWJ sequences / flags won't compose. The
// >0xFFFF ranges require IMGUI_USE_WCHAR32 (imconfig.h).
static const char* const kEmojiFonts[] = {
"Body1", "Body2", "Subtitle1", "Subtitle2", "Caption", "Overline", "Button", "ButtonSm"
};
bool wantEmoji = false;
for (const char* n : kEmojiFonts) if (strcmp(name, n) == 0) { wantEmoji = true; break; }
// Emoji blob: the COLR/CPAL color font (FreeType-rendered) when color emoji is enabled and this
// is a FreeType build, else the monochrome subset (default / non-FreeType path).
const unsigned char* emojiData = g_noto_emoji_subset_data;
unsigned int emojiSize = g_noto_emoji_subset_size;
bool colorGlyphs = false;
#ifdef DRAGONX_HAVE_FREETYPE
if (color_emoji_ && g_twemoji_color_size > 0) {
emojiData = g_twemoji_color_data;
emojiSize = g_twemoji_color_size;
colorGlyphs = true;
}
#endif
if (wantEmoji && emojiSize > 0) {
void* emojiCopy = IM_ALLOC(emojiSize);
memcpy(emojiCopy, emojiData, emojiSize);
ImFontConfig emojiCfg;
emojiCfg.FontDataOwnedByAtlas = true;
emojiCfg.MergeMode = true; // merge into the text font just loaded
emojiCfg.OversampleH = 1;
emojiCfg.OversampleV = 1;
emojiCfg.PixelSnapH = true;
emojiCfg.GlyphMinAdvanceX = 0;
#ifdef DRAGONX_HAVE_FREETYPE
if (colorGlyphs) emojiCfg.FontLoaderFlags |= ImGuiFreeTypeLoaderFlags_LoadColor; // render COLR in color
#endif
// The base Ubuntu font already owns U+260026FF etc.; MergeMode keeps the first-loaded glyph,
// so its text-style symbols win and only the codepoints it lacks fall through to emoji.
static const ImWchar emojiRanges[] = {
0x2600, 0x27BF, // Misc Symbols + Dingbats
0x2B00, 0x2BFF, // stars (⭐) + arrows
0x1F000, 0x1FAFF, // emoji planes (emoticons, pictographs, transport, supplement, extended)
0,
};
emojiCfg.GlyphRanges = emojiRanges;
snprintf(emojiCfg.Name, sizeof(emojiCfg.Name), "%s %.0fpx (merge)",
colorGlyphs ? "Twemoji" : "NotoEmoji", size);
ImFont* emojiMerge = io.Fonts->AddFontFromMemoryTTF(emojiCopy, emojiSize, size, &emojiCfg);
if (emojiMerge) {
DEBUG_LOGF("Typography: Merged %s emoji (%u bytes) into %s OK\n",
colorGlyphs ? "color" : "mono", emojiSize, name);
} else {
DEBUG_LOGF("Typography: WARNING — emoji merge FAILED for %s (size=%u)\n", name, size);
}
}
} else {
DEBUG_LOGF("Typography: Failed to load %s\n", name);
IM_FREE(fontDataCopy);

View File

@@ -113,6 +113,14 @@ public:
*/
float getDpiScale() const { return dpiScale_; }
/**
* @brief Select color vs monochrome emoji for the next (re)load. Color needs a FreeType-enabled
* build (DRAGONX_HAVE_FREETYPE); otherwise this is inert and monochrome is always used.
* Set before load()/reload() (App does this from the chat_emoji_color setting).
*/
void setColorEmoji(bool enabled) { color_emoji_ = enabled; }
bool colorEmoji() const { return color_emoji_; }
/**
* @brief Get font for a type style
*
@@ -261,6 +269,7 @@ private:
bool loaded_ = false;
float dpiScale_ = 1.0f;
bool color_emoji_ = false; // when true + FreeType present, merge the COLR color-emoji font
// Fonts for each type style
ImFont* fonts_[15] = {};

Some files were not shown because too many files have changed in this diff Show More