139 Commits

Author SHA1 Message Date
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
118 changed files with 16137 additions and 1462 deletions

10
.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,7 @@ imgui.ini
*.bak*
*.params
asmap.dat
/external/xmrig-hac
/external/drg-xmrig
/memory
/todo.md
/.github/
@@ -54,3 +54,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/

View File

@@ -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}")
@@ -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 v1.4.0
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)
# -----------------------------------------------------------------------------
@@ -503,6 +551,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 +756,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 +786,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 +796,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 +891,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
@@ -1137,5 +1200,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

105
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)
@@ -386,7 +386,7 @@ build_release_linux() {
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
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
@@ -419,7 +419,7 @@ build_release_linux() {
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
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 +478,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 +638,8 @@ 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"
# ── 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 +735,27 @@ 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 ────────────────────────────────────────────────────────
info "Configuring (cross-compile) ..."
cmake "$SCRIPT_DIR" \
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
-DCMAKE_BUILD_TYPE=Release \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
"${FT_CMAKE_ARG[@]}" \
"${CMAKE_LITE_ARGS[@]}"
info "Building with $JOBS jobs ..."
@@ -766,7 +791,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 +901,26 @@ build_release_mac() {
fi
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
else
# Native macOS: build universal binary (arm64 + x86_64)
MAC_ARCH="universal"
# 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 +1008,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 +1019,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
@@ -1012,8 +1055,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"
@@ -1063,8 +1110,8 @@ TOOLCHAIN
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 +1260,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 +1345,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

@@ -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

View File

@@ -30,6 +30,7 @@
"address_book_added": "Adresse zum Buch hinzugefügt",
"address_book_confirm_delete": "Löschen bestätigen?",
"address_book_count": "%zu Adressen gespeichert",
"address_book_count_one": "%zu Adresse gespeichert",
"address_book_deleted": "Eintrag gelöscht",
"address_book_edit": "Adresse bearbeiten",
"address_book_empty": "Keine gespeicherten Adressen. Klicken Sie auf 'Neue hinzufügen', um eine hinzuzufügen.",
@@ -53,6 +54,7 @@
"amount_details": "BETRAGSDETAILS",
"amount_exceeds_balance": "Betrag übersteigt Guthaben",
"amount_label": "Betrag:",
"animate_avatars": "Avatare animieren",
"appearance": "ERSCHEINUNGSBILD",
"auto_shield": "Mining automatisch abschirmen",
"av_intro": "Mining-Software wird oft als potenziell unerwünscht eingestuft. Führen Sie diese Schritte aus, um das Pool-Mining zu aktivieren:",
@@ -133,26 +135,99 @@
"change_pass_title": "Passphrase ändern",
"characters": "Zeichen",
"chat": "Chat",
"chat_accent_amber": "Bernstein",
"chat_accent_blue": "Blau",
"chat_accent_green": "Grün",
"chat_accent_pink": "Rosa",
"chat_accent_purple": "Lila",
"chat_accent_theme": "Design",
"chat_add_contact": "Kontakt hinzufügen",
"chat_awaiting_key": "Warten auf Antwort",
"chat_bubble_minimal": "Minimal",
"chat_bubble_rounded": "Abgerundet",
"chat_bubble_square": "Eckig",
"chat_buffer_loading": "Chat-Puffer: …",
"chat_buffer_preparing": "Chat-Puffer: bereite %d/%d vor…",
"chat_buffer_ready": "Chat-Puffer: %d/%d bereit",
"chat_buffer_sending": "Chat: sende %d Nachrichten…",
"chat_buffer_sending_one": "Chat: sende %d Nachricht…",
"chat_cancel": "Abbrechen",
"chat_contact_added": "Kontakt hinzugefügt benenne ihn in Kontakte um",
"chat_contact_request": "kontaktanfrage",
"chat_copy_address_tip": "Zum Kopieren der Adresse klicken",
"chat_density_comfortable": "Komfortabel",
"chat_density_compact": "Kompakt",
"chat_emoji_color": "Farbig",
"chat_emoji_mono": "Monochrom",
"chat_emoji_search": "Emoji suchen",
"chat_empty_hint": "Noch keine Unterhaltungen. Nachrichten, die du erhältst, erscheinen hier.",
"chat_empty_start": "Starte eine mit \"Neue Unterhaltung\".",
"chat_empty_title": "Noch keine Unterhaltungen",
"chat_export": "Chat exportieren…",
"chat_export_done": "Unterhaltung exportiert",
"chat_export_failed": "Exportdatei konnte nicht geschrieben werden.",
"chat_export_warn": "Speichert die entschlüsselten Nachrichten als Klartext. Bewahre die Datei sicher auf.",
"chat_filter": "Chat",
"chat_hidden_toast": "Unterhaltung ausgeblendet eine neue Nachricht holt sie zurück",
"chat_hide": "Ausblenden",
"chat_hide_hidden": "Ausgeblendete verbergen",
"chat_jump_latest": "Neueste",
"chat_len_over": "Nachricht zu lang",
"chat_locked_hint": "Entsperre deine Wallet, um deine Chats zu laden.",
"chat_new_button": "Neue Unterhaltung",
"chat_mute": "Stummschalten",
"chat_new_button": "Neuer Chat",
"chat_new_message": "Nachricht",
"chat_new_message_toast": "Neue verschlüsselte Chat-Nachricht",
"chat_new_send": "Anfrage senden",
"chat_new_title": "Neue Unterhaltung",
"chat_new_title": "Neuer Chat",
"chat_new_zaddr": "z-Adresse des Empfängers",
"chat_no_matches": "Keine Unterhaltungen entsprechen deiner Suche.",
"chat_no_z_contacts": "Noch keine Kontakte mit geschützter Adresse",
"chat_opt_bubble_accent": "Blasenfarbe",
"chat_opt_bubble_style": "Blasenstil",
"chat_opt_density": "Nachrichtendichte",
"chat_opt_emoji": "Emoji-Stil",
"chat_opt_enter_sends": "Eingabetaste sendet",
"chat_opt_font_size": "Textgröße",
"chat_opt_global_clock": "Globales Uhrzeitformat",
"chat_opt_poll": "Abrufrate",
"chat_opt_timestamp": "Zeitstempel",
"chat_pick_contact": "Aus Kontakten wählen…",
"chat_rename": "Kontakt umbenennen",
"chat_rename_hint": "Kontaktname",
"chat_renamed": "Kontakt umbenannt",
"chat_retry": "Wiederholen",
"chat_search": "Unterhaltungen durchsuchen",
"chat_sec_appearance": "DARSTELLUNG",
"chat_sec_messaging": "NACHRICHTEN",
"chat_select_hint": "Wähle eine Unterhaltung aus, um sie anzuzeigen.",
"chat_send": "Senden",
"chat_send_failed": "nicht gesendet",
"chat_sending": "senden…",
"chat_settings_done": "Fertig",
"chat_settings_section": "CHAT & KONTAKTE",
"chat_settings_tip": "Chat anpassen",
"chat_settings_title": "Chat-Einstellungen",
"chat_show_hidden": "Ausgeblendete anzeigen",
"chat_time_now": "jetzt",
"chat_toast_compose_failed": "Nachricht konnte nicht erstellt werden (zu lang?).",
"chat_toast_lite_busy": "Es wird bereits gesendet, oder es ist keine Wallet geöffnet.",
"chat_toast_need_funds": "Ein kleines geschütztes Guthaben ist nötig, um Chats zu senden (zur Deckung der Gebühr).",
"chat_toast_no_zaddr": "Keine z-Adresse verfügbar, um den Chat zu senden.",
"chat_toast_not_connected": "Nicht verbunden Nachricht nicht gesendet.",
"chat_toast_request_compose_failed": "Kontaktanfrage konnte nicht erstellt werden (ungültige Adresse / ungültiger Text?).",
"chat_toast_request_queued": "Kontaktanfrage in Warteschlange.",
"chat_toast_waiting_reply": "Warte auf die Antwort des Kontakts, bevor du ihm schreiben kannst.",
"chat_today": "Heute",
"chat_ts_12h": "12-Stunden",
"chat_ts_24h": "24-Stunden",
"chat_ts_global": "Global folgen",
"chat_ts_global_short": "Global",
"chat_unhide": "Einblenden",
"chat_unmute": "Stummschaltung aufheben",
"chat_verify_key": "Identitätsschlüssel zum Verifizieren vergleichen",
"chat_waiting_reply": "Warte auf die Antwort dieses Kontakts sobald er antwortet, kannst du ihm schreiben.",
"chat_yesterday": "Gestern",
"chat_you": "Du",
"choose_icon": "Symbol wählen",
"clear": "Leeren",
@@ -164,6 +239,7 @@
"click_copy_address": "Klicken zum Kopieren der Adresse",
"click_copy_uri": "Klicken zum Kopieren der URI",
"click_to_copy": "Klicken zum Kopieren",
"clock_format": "Uhrzeitformat",
"close": "Schließen",
"conf_count": "%d Best.",
"confirm_and_send": "Bestätigen & Senden",
@@ -201,12 +277,18 @@
"console_app": "App",
"console_auto_scroll": "Automatisch scrollen",
"console_available_commands": "Verfügbare Befehle:",
"console_backend_reference": "Backend-Befehlsreferenz",
"console_backend_unavailable": "Kein Backend",
"console_capturing_output": "Erfasse Daemon-Ausgabe...",
"console_cat_advanced": "Erweitert",
"console_cat_blockchain": "Blockchain",
"console_cat_control": "Steuerung",
"console_cat_keys": "Schlüssel & Sicherheit",
"console_cat_mining": "Mining",
"console_cat_network": "Netzwerk",
"console_cat_raw_transactions": "Rohtransaktionen",
"console_cat_send": "Senden",
"console_cat_sync": "Synchronisierung",
"console_cat_utility": "Dienstprogramme",
"console_cat_wallet": "Wallet",
"console_clear": "Leeren",
@@ -240,11 +322,14 @@
"console_help_help": " help - Diese Hilfe anzeigen",
"console_help_setgenerate": " setgenerate - Mining steuern",
"console_help_stop": " stop - Daemon stoppen",
"console_last_error": "Letzter Fehler:",
"console_line_count": "%zu Zeilen",
"console_matches": "Treffer",
"console_new_lines": "%d neue Zeilen",
"console_no_daemon": "Kein Daemon",
"console_no_output": "(keine Ausgabe)",
"console_not_connected": "Fehler: Nicht mit Daemon verbunden",
"console_not_connected_lite": "Fehler: Keine Wallet geöffnet",
"console_quit_note": "'quit'/'exit' werden hier nicht benötigt — schließen Sie einfach das Fenster.",
"console_ref_builds": "Ergibt",
"console_ref_cancel": "Abbrechen",
@@ -260,12 +345,14 @@
"console_ref_run_confirm": "%s jetzt ausführen? Dies ist ein folgenreicher Befehl.",
"console_ref_search_hint": "Nach Name oder Aufgabe suchen…",
"console_ref_select_hint": "Wählen Sie einen Befehl, um zu sehen, was er tut.",
"console_ref_value": "Wert",
"console_rpc_reference": "RPC-Befehlsreferenz",
"console_rpc_trace": "RPC",
"console_scanline": "Konsolen-Scanline",
"console_search_commands": "Befehle suchen...",
"console_select_all": "Alles auswählen",
"console_show_app_output": "[App]-Wallet-Protokollzeilen anzeigen",
"console_show_backend_ref": "Backend-Befehlsreferenz anzeigen",
"console_show_daemon_output": "Daemon-Ausgabe anzeigen",
"console_show_errors_only": "Nur Fehler anzeigen",
"console_show_rpc_ref": "RPC-Befehlsreferenz anzeigen",
@@ -278,6 +365,7 @@
"console_status_stopped": "Gestoppt",
"console_status_stopping": "Stoppt",
"console_status_unknown": "Unbekannt",
"console_stop_confirm_node": "'stop' fährt den Node herunter und trennt die Wallet. Geben Sie zur Bestätigung erneut 'stop' ein.",
"console_tab_completion": "Tab zur Vervollständigung",
"console_text_colors": "Textfarben",
"console_toggle_accents": "Farbakzente der Zeilen umschalten",
@@ -286,12 +374,34 @@
"console_welcome": "Willkommen bei ObsidianDragon Konsole",
"console_zoom_in": "Vergrößern",
"console_zoom_out": "Verkleinern",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Dieses Bild konnte nicht geladen werden.",
"contact_avatar_badge": "Abzeichen",
"contact_avatar_badge_hint": "Das Abzeichen wird automatisch anhand des Adresstyps gewählt.",
"contact_avatar_choose": "Bild auswählen…",
"contact_avatar_copy_failed": "Dieses Bild konnte nicht kopiert werden.",
"contact_avatar_icon": "Symbol",
"contact_avatar_image": "Bild",
"contact_avatar_image_hint": "Das Bild wird in die App kopiert, damit es verfügbar bleibt, wenn das Original verschoben wird.",
"contact_avatar_remove": "Entfernen",
"contact_avatar_shielded": "Abgeschirmt",
"contact_avatar_transparent": "Transparent",
"contact_global": "In jeder Wallet anzeigen (globaler Kontakt)",
"contact_global_badge_tt": "Globaler Kontakt — in jeder Wallet sichtbar",
"contact_global_tt": "Ein: Dieser Kontakt bleibt sichtbar, egal welche Wallet Sie laden. Aus: Er gehört nur zur aktuellen Wallet.",
"contact_preview_addr": "Adresse erscheint hier",
"contact_preview_name": "Kontaktname",
"contact_wallet_loading": "Die Wallet lädt noch — aktiviere „In jeder Wallet anzeigen“ oder versuche es gleich erneut.",
"contacts": "Kontakte",
"contacts_avatar_shape": "Avatarform",
"contacts_list_scale": "Listengröße",
"contacts_search_no_match": "Keine passenden Kontakte",
"contacts_search_placeholder": "Kontakte durchsuchen...",
"contacts_settings_tip": "Kontakte anpassen",
"contacts_settings_title": "Kontakteinstellungen",
"contacts_shape_circle": "Kreis",
"contacts_shape_square": "Quadrat",
"contacts_shape_tab": "Reiter",
"copied": "Kopiert!",
"copy": "Kopieren",
"copy_address": "Vollständige Adresse kopieren",
@@ -464,6 +574,12 @@
"hide_qr": "QR ausblenden",
"hide_zero_balances": "Nullsalden ausblenden",
"history": "Verlauf",
"img_picker_count": "%d Bild(er) in diesem Ordner",
"img_picker_empty": "Dieser Ordner enthält keine Unterordner oder Bilder.",
"img_picker_none": "Keine Bilder in diesem Ordner",
"img_picker_pictures": "Bilderordner",
"img_picker_title": "Bild auswählen",
"img_picker_use": "Bild verwenden",
"immature_type": "Unreif",
"import": "Importieren",
"import_key_address": "Adresse:",
@@ -528,6 +644,7 @@
"lite_birthday_backup": "Geburtstag: %llu (auch diesen sichern)",
"lite_birthday_hint": "Blockhöhe, ab der gescannt werden soll. Bei 0 belassen, falls unbekannt (langsamerer vollständiger Scan).",
"lite_birthday_label": "Geburtsblock",
"lite_console_backend_commands": "Backend-Befehle:",
"lite_console_help_passthrough": "Jede andere Eingabe wird als Lite-Wallet-Konsolenbefehl ausgeführt.",
"lite_copy": "Kopieren",
"lite_could_not_write": "Konnte nicht schreiben ",
@@ -545,6 +662,7 @@
"lite_net_add_url_hint": "https://ihr-lite-server",
"lite_net_checking": "wird geprüft…",
"lite_net_connected": "Verbunden",
"lite_net_connecting": "Verbinde…",
"lite_net_custom": "Benutzerdefiniert",
"lite_net_disconnected": "Nicht verbunden",
"lite_net_hidden_section": "Ausgeblendete Server",
@@ -632,6 +750,9 @@
"market_cap": "Marktkapitalisierung",
"market_cap_short": "Kap.",
"market_chart_loading": "Preisverlauf wird geladen",
"market_col_name": "Name",
"market_col_trend": "Trend",
"market_col_value": "Wert",
"market_iv_1d": "1T",
"market_iv_1h": "1S",
"market_iv_1m": "1M",
@@ -640,13 +761,18 @@
"market_no_history": "Kein Preisverlauf verfügbar",
"market_no_price": "Keine Preisdaten",
"market_now": "Jetzt",
"market_opt_chart_style": "Diagrammstil",
"market_pct_shielded": "%.0f%% Abgeschirmt",
"market_portfolio": "PORTFOLIO",
"market_price_loading": "Preisdaten werden geladen...",
"market_price_unavailable": "Preisdaten nicht verfügbar",
"market_refresh_price": "Preisdaten aktualisieren",
"market_settings_tip": "Marktoptionen",
"market_settings_title": "Markteinstellungen",
"market_style_candle": "Zu Kerzenchart wechseln",
"market_style_candle_label": "Kerzen",
"market_style_line": "Zum Liniendiagramm wechseln",
"market_style_line_label": "Linie",
"market_trade_on": "Handeln auf %s",
"market_updated": "\\xc2\\xb7 Aktualisiert %s",
"market_vol_short": "Vol.",
@@ -950,11 +1076,12 @@
"portfolio_spark_min": "Minute",
"portfolio_spark_month": "Monat",
"portfolio_spark_week": "Woche",
"portfolio_style_compact": "Kompakte Zeilen",
"portfolio_style_detailed": "Detaillierte Zeilen",
"portfolio_style_featured": "Hervorgehobene Zeilen",
"portfolio_style_compact": "Tabelle",
"portfolio_style_detailed": "Karten",
"portfolio_style_featured": "Hervorgehoben",
"portfolio_style_label": "Portfolio-Stil",
"portfolio_untitled": "Ohne Titel",
"portfolio_wallet_loading": "Warte, bis die Wallet fertig geladen ist, um eine Gruppe hinzuzufügen.",
"price_chart": "Preisdiagramm",
"privacy_great": "Großartige Privatsphäre!",
"privacy_low": "Geringe Privatsphäre — Gelder abschirmen",
@@ -1270,6 +1397,23 @@
"sweep_to": "Gefegt an:",
"sweep_toggle": "In meine Wallet fegen (Schlüssel nicht behalten)",
"sweep_tx": "Transaktion:",
"switch_corrupt_body": "Diese Wallet scheint beschädigt zu sein der Knoten konnte sie nicht öffnen. Aus einem Backup wiederherstellen, neu erstellen oder eine Reparatur versuchen.",
"switch_corrupt_repair": "Reparatur versuchen (Salvage)",
"switch_progress_background": "Im Hintergrund fortsetzen",
"switch_progress_default_wallet": "Standard-Wallet",
"switch_progress_elapsed": "Vergangen",
"switch_progress_external_wallet": "Externe Wallet",
"switch_progress_failed_title": "Wallet-Wechsel fehlgeschlagen",
"switch_progress_from_label": "von",
"switch_progress_hint": "Ein sauberes Herunterfahren kann bis zu einer Minute dauern.",
"switch_progress_reconnecting": "Neu verbinden",
"switch_progress_starting": "Knoten wird mit der neuen Wallet gestartet",
"switch_progress_stopping": "Aktueller Knoten wird gestoppt",
"switch_progress_title": "Wallet wird gewechselt",
"switch_stopnode_body": "Beim Wallet-Wechsel wird der Knoten mit der ausgewählten Wallet neu gestartet. Der laufende Knoten wird gestoppt und mit der neuen Wallet neu gestartet wenn Sie ihn absichtlich laufen ließen, startet er automatisch wieder.",
"switch_stopnode_confirm": "Knoten stoppen & wechseln",
"switch_stopnode_title": "Laufenden Knoten stoppen?",
"switch_stopnode_warn": "Es läuft bereits ein Knoten, den diese Wallet nicht gestartet hat.",
"syncing": "Synchronisiere...",
"t_address": "T-Adresse",
"t_addresses": "T-Adressen",
@@ -1308,6 +1452,7 @@
"try_again": "Erneut versuchen",
"tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer",
"tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten",
"tt_animate_avatars": "Animierte Kontakt-Avatare (GIF / WebP) abspielen; aus zeigt nur das erste Bild",
"tt_auto_lock": "Wallet nach dieser Inaktivitätszeit sperren",
"tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben",
"tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen",
@@ -1315,10 +1460,20 @@
"tt_blur": "Unschärfe-Stärke (0%% = aus, 100%% = maximum)",
"tt_change_pass": "Die Wallet-Verschlüsselungspassphrase ändern",
"tt_change_pin": "Ihre Entsperr-PIN ändern",
"tt_chat_bubble_accent": "Akzentfarbe für deine ausgehenden Nachrichtenblasen (oder dem aktuellen Theme folgen)",
"tt_chat_bubble_style": "Form der Nachrichtenblase: abgerundet, eckig oder minimal (flach, randlos)",
"tt_chat_density": "Abstand zwischen Nachrichten: Komfortabel fügt mehr Abstand hinzu; Kompakt zeigt mehr auf dem Bildschirm",
"tt_chat_emoji_style": "Emoji als einfarbige Umrisse oder in voller Farbe darstellen",
"tt_chat_enter_sends": "Wenn aktiviert, sendet Enter die Nachricht und Shift+Enter fügt einen Zeilenumbruch ein; wenn deaktiviert, fügt Enter einen Zeilenumbruch ein",
"tt_chat_font_size": "Skaliere den Chat-Nachrichtentext von 0.8x bis 1.5x. Betrifft nur den Chat-Tab, nicht den Rest der App",
"tt_chat_poll_rate": "Wie oft auf neue und 0-conf-Nachrichten geprüft wird (0.5-15 s). Schneller ist reaktionsfreudiger, verbraucht aber mehr CPU",
"tt_chat_timestamp": "Zeitstempelformat nur für diesen Tab: der app-weiten Uhr folgen oder 24-hour bzw. 12-hour erzwingen",
"tt_clear_ztx": "Lokal zwischengespeicherten Z-Transaktionsverlauf löschen",
"tt_clock_format": "24- oder 12-Stunden-Uhr, app-weit. Der Chat-Tab kann sie überschreiben.",
"tt_custom_fees": "Manuelle Gebühreneingabe beim Senden von Transaktionen aktivieren",
"tt_custom_theme": "Benutzerdefiniertes Theme aktiv",
"tt_daemon_install_bundled": "Node stoppen, den installierten dragonxd mit der in diesem Wallet-Build enthaltenen Version überschreiben und dann neu starten",
"tt_daemon_refresh": "Version, Größe und Datum des installierten und mitgelieferten dragonxd (oben angezeigt) erneut einlesen",
"tt_daemon_update_check": "Den neuesten dragonxd-Full-Node vom Projekt-Gitea herunterladen und verifizieren, dann zum Anwenden neu starten",
"tt_debug_collapse": "Debug-Protokollierungsoptionen einklappen",
"tt_debug_expand": "Debug-Protokollierungsoptionen ausklappen",
@@ -1336,7 +1491,30 @@
"tt_keep_daemon": "Der Daemon wird beim Ausführen des Einrichtungsassistenten gestoppt",
"tt_language": "Schnittstellensprache der Wallet-UI",
"tt_layout_hotkey": "Hotkey: Links-/Rechts-Pfeiltasten zum Wechseln der Balance-Layouts",
"tt_lite_copy": "Das angezeigte Geheimnis in die Zwischenablage kopieren",
"tt_lite_decrypt_pass": "Gib deine Passphrase ein, um die Verschlüsselung von der Wallet zu entfernen",
"tt_lite_encrypt": "Die Wallet mit der obigen Passphrase verschlüsseln; sie wird sofort gesperrt und benötigt die Passphrase zum Entsperren",
"tt_lite_encrypt_pass": "Passphrase, mit der die Wallet verschlüsselt wird. Geht sie verloren, kann die Wallet nicht mehr entsperrt oder wiederhergestellt werden",
"tt_lite_hide_wipe": "Das angezeigte Geheimnis ausblenden und sicher aus dem Speicher löschen",
"tt_lite_import_key": "Einen privaten Ausgabe- oder Ansichtsschlüssel zum Importieren einfügen; dessen Verlauf erscheint nach der nächsten Synchronisierung",
"tt_lite_import_key_btn": "Den eingegebenen privaten Schlüssel in diese Wallet importieren; Guthaben und Verlauf erscheinen nach der nächsten Synchronisierung",
"tt_lite_lifecycle_op": "Wähle, ob eine neue Wallet erstellt, eine vorhandene geöffnet oder eine aus einer Seed-Phrase wiederhergestellt werden soll",
"tt_lite_lifecycle_pass": "Passphrase, um die Wallet bei diesem Erstellen- / Öffnen- / Wiederherstellen-Vorgang zu entsperren oder zu setzen",
"tt_lite_lifecycle_run": "Den ausgewählten Erstellen- / Öffnen- / Wiederherstellen-Vorgang mit den obigen Werten ausführen",
"tt_lite_lifecycle_toggle": "Die Bedienelemente zum Erstellen / Öffnen / Wiederherstellen zur Verwaltung deiner Lite-Wallet-Datei ein- oder ausblenden",
"tt_lite_lock": "Die Wallet jetzt sperren; zum Entsperren ist eine Passphrase erforderlich und jede Chat-Sitzung wird beendet",
"tt_lite_redownload": "Alle Blöcke erneut vom Lite-Server herunterladen und neu scannen",
"tt_lite_remove_encrypt": "Verschlüsselung entfernen und die Wallet ungeschützt speichern; zum Öffnen ist dann keine Passphrase mehr erforderlich",
"tt_lite_restore_account": "HD-Konto-Index zum Wiederherstellen; belasse 0, sofern du nicht mehrere Konten unter diesem Seed verwendet hast",
"tt_lite_restore_birthday": "Blockhöhe, bei der die Wallet erstellt wurde; das Scannen beginnt hier. Verwende 0 oder die früheste Höhe, falls unsicher",
"tt_lite_restore_overwrite": "Eine vorhandene Wallet-Datei durch diese Wiederherstellung ersetzen. Warnung: überschreibt die aktuellen Wallet-Daten",
"tt_lite_restore_seed": "Die 24-word-Wiederherstellungs-Seed-Phrase, aus der diese Wallet wiederhergestellt wird; bei der Eingabe ausgeblendet",
"tt_lite_save_seed_file": "Seed und Erstellungsdatum in eine nur für den Eigentümer lesbare Datei (lite-seed-backup.txt) im Konfigurationsordner schreiben",
"tt_lite_show_keys": "Die privaten Ausgabeschlüssel dieser Wallet anzeigen. Wer einen Schlüssel besitzt, kann das von ihm kontrollierte Guthaben ausgeben",
"tt_lite_show_seed": "Die Wiederherstellungs-Seed-Phrase und das Erstellungsdatum dieser Wallet anzeigen. Wer den Seed besitzt, kann dein Guthaben ausgeben",
"tt_lite_unlock": "Die verschlüsselte Wallet mit der obigen Passphrase entsperren",
"tt_lite_unlock_pass": "Gib deine Passphrase ein, um die verschlüsselte Wallet zu entsperren",
"tt_lite_wallet_path": "Pfad oder Name der Wallet-Datei, die geöffnet oder in die wiederhergestellt werden soll",
"tt_lock": "Die Wallet sofort sperren",
"tt_low_spec": "Alle aufwendigen visuellen Effekte deaktivieren\\nHotkey: Ctrl+Shift+Down",
"tt_merge": "Mehrere UTXOs einer Adresse zusammenführen",
@@ -1357,12 +1535,17 @@
"tt_rpc_host": "Hostname des DragonX-Daemons",
"tt_rpc_pass": "RPC-Authentifizierungspasswort",
"tt_rpc_port": "Port für RPC-Verbindungen des Daemons",
"tt_rpc_toggle": "Die schreibgeschützten RPC-Verbindungsdaten (Host, Port, Benutzer, Passwort) für den Daemon ein- oder ausblenden",
"tt_rpc_user": "RPC-Authentifizierungsbenutzername",
"tt_save_settings": "Alle Einstellungen auf der Festplatte speichern",
"tt_save_ztx": "Z-Adresse-Transaktionsverlauf lokal für schnelleres Laden speichern",
"tt_scan_themes": "Nach neuen Themes suchen.\\nTheme-Ordner ablegen in:\\n%s",
"tt_scanline": "CRT-Scanlinieneffekt in der Konsole",
"tt_screenshot_open_dir": "Den Screenshots-Ordner (unter dem Konfigurationsverzeichnis) im Dateimanager öffnen",
"tt_screenshot_sweep": "Jedes Theme über jeden Tab durchlaufen und von jedem einen Screenshot in den Screenshots-Ordner der Konfiguration speichern (überschreibt den letzten Durchlauf)",
"tt_screenshot_sweep_full": "Wie der Theme-Durchlauf, erfasst aber auch jedes Modal / jeden Dialog / jeden Ablauf mit temporären Offline-Demo-Wallet-Daten",
"tt_seed_backup": "Die 24-Wort-Wiederherstellungsphrase Ihrer Wallet anzeigen und sichern",
"tt_seed_demo_chat": "Beispielunterhaltungen in den Chat-Tab einfügen, damit ein Durchlauf dessen UI erfasst; nur im Speicher, beim Neustart verschwunden",
"tt_seed_migrate": "Eine neue Wallet mit Wiederherstellungsphrase erstellen und Ihre Gelder dorthin übertragen",
"tt_set_pin": "Eine 4-8-stellige PIN für schnelles Entsperren festlegen",
"tt_shield_mining": "Transparente Mining-Belohnungen an eine geschirmte Adresse verschieben",
@@ -1381,6 +1564,7 @@
"tt_website": "Die DragonX-Website öffnen",
"tt_window_opacity": "Hintergrund-Deckkraft (niedriger = Desktop durch Fenster sichtbar)",
"tt_wizard": "Den Ersteinrichtungsassistenten erneut ausführen\\nDer Daemon wird neu gestartet",
"tx_chat_badge": "Nachricht",
"tx_confirmations": "%d Bestätigungen",
"tx_details_title": "Transaktionsdetails",
"tx_from_address": "Von Adresse:",
@@ -1449,6 +1633,8 @@
"wallets_add_folder_toggle": "+ Weiteren Ordner nach Wallets durchsuchen…",
"wallets_badge_encrypted": "Verschlüsselt (passphrasengeschützt)",
"wallets_badge_encrypted_short": "Verschlüsselt",
"wallets_badge_hd": "HD-Wallet Seed-Phrase ohne Öffnen nicht bestätigbar",
"wallets_badge_hd_short": "HD-Wallet",
"wallets_badge_legacy": "Legacy-Wallet (keine Seed-Phrase)",
"wallets_badge_legacy_short": "Legacy",
"wallets_badge_seed": "Seed-Phrase-Wallet (HD)",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Dirección agregada a la libreta",
"address_book_confirm_delete": "¿Confirmar eliminación?",
"address_book_count": "%zu direcciones guardadas",
"address_book_count_one": "%zu dirección guardada",
"address_book_deleted": "Entrada eliminada",
"address_book_edit": "Editar Dirección",
"address_book_empty": "No hay direcciones guardadas. Haz clic en 'Agregar Nueva' para añadir una.",
@@ -53,6 +54,7 @@
"amount_details": "DETALLES DE CANTIDAD",
"amount_exceeds_balance": "La cantidad excede el saldo",
"amount_label": "Cantidad:",
"animate_avatars": "Animar avatares",
"appearance": "APARIENCIA",
"auto_shield": "Auto-proteger minería",
"av_intro": "El software de minería suele marcarse como potencialmente no deseado. Sigue estos pasos para habilitar la minería en pool:",
@@ -133,26 +135,99 @@
"change_pass_title": "Cambiar frase de contraseña",
"characters": "caracteres",
"chat": "Chat",
"chat_accent_amber": "Ámbar",
"chat_accent_blue": "Azul",
"chat_accent_green": "Verde",
"chat_accent_pink": "Rosa",
"chat_accent_purple": "Morado",
"chat_accent_theme": "Tema",
"chat_add_contact": "Añadir contacto",
"chat_awaiting_key": "Esperando respuesta",
"chat_bubble_minimal": "Mínima",
"chat_bubble_rounded": "Redondeada",
"chat_bubble_square": "Cuadrada",
"chat_buffer_loading": "Búfer de chat: …",
"chat_buffer_preparing": "Búfer de chat: preparando %d/%d…",
"chat_buffer_ready": "Búfer de chat: %d/%d listos",
"chat_buffer_sending": "Chat: enviando %d mensajes…",
"chat_buffer_sending_one": "Chat: enviando %d mensaje…",
"chat_cancel": "Cancelar",
"chat_contact_added": "Contacto añadido: renómbralo en Contactos",
"chat_contact_request": "solicitud de contacto",
"chat_copy_address_tip": "Clic para copiar la dirección",
"chat_density_comfortable": "Cómoda",
"chat_density_compact": "Compacta",
"chat_emoji_color": "Color",
"chat_emoji_mono": "Monocromo",
"chat_emoji_search": "Buscar emoji",
"chat_empty_hint": "Aún no hay conversaciones. Los mensajes que recibas aparecerán aquí.",
"chat_empty_start": "Inicia una con \"Nueva conversación\".",
"chat_empty_title": "Aún no hay conversaciones",
"chat_export": "Exportar chat…",
"chat_export_done": "Conversación exportada",
"chat_export_failed": "No se pudo escribir el archivo de exportación.",
"chat_export_warn": "Guarda los mensajes descifrados como texto sin cifrar. Guarda el archivo de forma segura.",
"chat_filter": "Chat",
"chat_hidden_toast": "Conversación oculta: un mensaje nuevo la recupera",
"chat_hide": "Ocultar",
"chat_hide_hidden": "Ocultar ocultos",
"chat_jump_latest": "Recientes",
"chat_len_over": "Mensaje demasiado largo",
"chat_locked_hint": "Desbloquea tu monedero para cargar tus chats.",
"chat_new_button": "Nueva conversación",
"chat_mute": "Silenciar",
"chat_new_button": "Nuevo chat",
"chat_new_message": "Mensaje",
"chat_new_message_toast": "Nuevo mensaje de chat cifrado",
"chat_new_send": "Enviar solicitud",
"chat_new_title": "Nueva conversación",
"chat_new_title": "Nuevo chat",
"chat_new_zaddr": "Dirección z del destinatario",
"chat_no_matches": "Ninguna conversación coincide con tu búsqueda.",
"chat_no_z_contacts": "Aún no hay contactos con dirección blindada",
"chat_opt_bubble_accent": "Color de burbuja",
"chat_opt_bubble_style": "Estilo de burbuja",
"chat_opt_density": "Densidad de mensajes",
"chat_opt_emoji": "Estilo de emoji",
"chat_opt_enter_sends": "Enter envía el mensaje",
"chat_opt_font_size": "Tamaño del texto",
"chat_opt_global_clock": "Formato de reloj global",
"chat_opt_poll": "Frecuencia de sondeo",
"chat_opt_timestamp": "Marcas de tiempo",
"chat_pick_contact": "Elegir de contactos…",
"chat_rename": "Renombrar contacto",
"chat_rename_hint": "Nombre del contacto",
"chat_renamed": "Contacto renombrado",
"chat_retry": "Reintentar",
"chat_search": "Buscar conversaciones",
"chat_sec_appearance": "APARIENCIA",
"chat_sec_messaging": "MENSAJES",
"chat_select_hint": "Selecciona una conversación para verla.",
"chat_send": "Enviar",
"chat_send_failed": "no enviado",
"chat_sending": "enviando…",
"chat_settings_done": "Listo",
"chat_settings_section": "CHAT Y CONTACTOS",
"chat_settings_tip": "Personalizar chat",
"chat_settings_title": "Ajustes de chat",
"chat_show_hidden": "Ver ocultos",
"chat_time_now": "ahora",
"chat_toast_compose_failed": "No se pudo componer el mensaje (¿demasiado largo?).",
"chat_toast_lite_busy": "Ya hay un envío en curso, o no hay ningún monedero abierto.",
"chat_toast_need_funds": "Necesitas un pequeño saldo blindado para enviar chats (para cubrir la comisión).",
"chat_toast_no_zaddr": "No hay ninguna dirección z disponible desde la que enviar el chat.",
"chat_toast_not_connected": "Sin conexión: mensaje de chat no enviado.",
"chat_toast_request_compose_failed": "No se pudo componer la solicitud de contacto (¿dirección o texto no válidos?).",
"chat_toast_request_queued": "Solicitud de contacto en cola.",
"chat_toast_waiting_reply": "Espera a que este contacto responda antes de poder escribirle.",
"chat_today": "Hoy",
"chat_ts_12h": "12 horas",
"chat_ts_24h": "24 horas",
"chat_ts_global": "Seguir global",
"chat_ts_global_short": "Global",
"chat_unhide": "Mostrar",
"chat_unmute": "Reactivar",
"chat_verify_key": "Clave de identidad: compárala para verificar",
"chat_waiting_reply": "Esperando a que este contacto responda: podrás escribirle una vez lo haga.",
"chat_yesterday": "Ayer",
"chat_you": "Tú",
"choose_icon": "Elegir Icono",
"clear": "Limpiar",
@@ -164,6 +239,7 @@
"click_copy_address": "Clic para copiar dirección",
"click_copy_uri": "Clic para copiar URI",
"click_to_copy": "Clic para copiar",
"clock_format": "Formato de hora",
"close": "Cerrar",
"conf_count": "%d conf",
"confirm_and_send": "Confirmar y Enviar",
@@ -201,12 +277,18 @@
"console_app": "App",
"console_auto_scroll": "Auto-desplazamiento",
"console_available_commands": "Comandos disponibles:",
"console_backend_reference": "Referencia de Comandos del Backend",
"console_backend_unavailable": "Sin backend",
"console_capturing_output": "Capturando salida del daemon...",
"console_cat_advanced": "Avanzado",
"console_cat_blockchain": "Blockchain",
"console_cat_control": "Control",
"console_cat_keys": "Claves y seguridad",
"console_cat_mining": "Minería",
"console_cat_network": "Red",
"console_cat_raw_transactions": "Transacciones sin procesar",
"console_cat_send": "Enviar",
"console_cat_sync": "Sincronización",
"console_cat_utility": "Utilidades",
"console_cat_wallet": "Cartera",
"console_clear": "Limpiar",
@@ -240,11 +322,14 @@
"console_help_help": " help - Mostrar este mensaje de ayuda",
"console_help_setgenerate": " setgenerate - Controlar minería",
"console_help_stop": " stop - Detener el daemon",
"console_last_error": "Último error:",
"console_line_count": "%zu líneas",
"console_matches": "coincidencias",
"console_new_lines": "%d nuevas líneas",
"console_no_daemon": "Sin daemon",
"console_no_output": "(sin salida)",
"console_not_connected": "Error: No conectado al daemon",
"console_not_connected_lite": "Error: No hay ninguna cartera abierta",
"console_quit_note": "'quit'/'exit' no son necesarios aquí — simplemente cierra la ventana.",
"console_ref_builds": "Genera",
"console_ref_cancel": "Cancelar",
@@ -260,12 +345,14 @@
"console_ref_run_confirm": "¿Ejecutar %s ahora? Es un comando con consecuencias.",
"console_ref_search_hint": "Buscar por nombre o tarea…",
"console_ref_select_hint": "Selecciona un comando para ver qué hace.",
"console_ref_value": "valor",
"console_rpc_reference": "Referencia de Comandos RPC",
"console_rpc_trace": "RPC",
"console_scanline": "Líneas de consola",
"console_search_commands": "Buscar comandos...",
"console_select_all": "Seleccionar Todo",
"console_show_app_output": "Mostrar las líneas de registro de la cartera [app]",
"console_show_backend_ref": "Mostrar referencia de comandos del backend",
"console_show_daemon_output": "Mostrar salida del daemon",
"console_show_errors_only": "Mostrar solo errores",
"console_show_rpc_ref": "Mostrar referencia de comandos RPC",
@@ -278,6 +365,7 @@
"console_status_stopped": "Detenido",
"console_status_stopping": "Deteniendo",
"console_status_unknown": "Desconocido",
"console_stop_confirm_node": "'stop' apagará el nodo y desconectará la cartera. Escribe 'stop' de nuevo para confirmar.",
"console_tab_completion": "Tab para completar",
"console_text_colors": "Colores de texto",
"console_toggle_accents": "Alternar acentos de color de línea",
@@ -286,12 +374,34 @@
"console_welcome": "Bienvenido a la Consola de ObsidianDragon",
"console_zoom_in": "Acercar",
"console_zoom_out": "Alejar",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "No se pudo cargar esa imagen.",
"contact_avatar_badge": "Insignia",
"contact_avatar_badge_hint": "La insignia se elige automáticamente según el tipo de dirección.",
"contact_avatar_choose": "Elegir imagen…",
"contact_avatar_copy_failed": "No se pudo copiar esa imagen.",
"contact_avatar_icon": "Icono",
"contact_avatar_image": "Imagen",
"contact_avatar_image_hint": "La imagen se copia en la app para que siga disponible si el original se mueve.",
"contact_avatar_remove": "Quitar",
"contact_avatar_shielded": "Blindada",
"contact_avatar_transparent": "Transparente",
"contact_global": "Mostrar en todas las carteras (contacto global)",
"contact_global_badge_tt": "Contacto global — visible en todas las carteras",
"contact_global_tt": "Activado: este contacto permanece visible sin importar qué cartera cargues. Desactivado: pertenece solo a la cartera actual.",
"contact_preview_addr": "La dirección aparecerá aquí",
"contact_preview_name": "Nombre del contacto",
"contact_wallet_loading": "La cartera aún se está cargando: marca «Mostrar en todas las carteras» o inténtalo de nuevo en un momento.",
"contacts": "Contactos",
"contacts_avatar_shape": "Forma del avatar",
"contacts_list_scale": "Escala de la lista",
"contacts_search_no_match": "No hay contactos coincidentes",
"contacts_search_placeholder": "Buscar contactos...",
"contacts_settings_tip": "Personalizar contactos",
"contacts_settings_title": "Ajustes de contactos",
"contacts_shape_circle": "Círculo",
"contacts_shape_square": "Cuadrado",
"contacts_shape_tab": "Pestaña",
"copied": "¡Copiado!",
"copy": "Copiar",
"copy_address": "Copiar Dirección Completa",
@@ -464,6 +574,12 @@
"hide_qr": "Ocultar QR",
"hide_zero_balances": "Ocultar saldos 0",
"history": "Historial",
"img_picker_count": "%d imagen(es) en esta carpeta",
"img_picker_empty": "Esta carpeta no tiene subcarpetas ni imágenes.",
"img_picker_none": "No hay imágenes en esta carpeta",
"img_picker_pictures": "Carpeta de imágenes",
"img_picker_title": "Elegir una imagen",
"img_picker_use": "Usar imagen",
"immature_type": "Inmaduro",
"import": "Importar",
"import_key_address": "Dirección:",
@@ -528,6 +644,7 @@
"lite_birthday_backup": "Cumpleaños: %llu (respalda esto también)",
"lite_birthday_hint": "Altura de bloque desde la que empezar a escanear. Deja 0 si se desconoce (escaneo completo más lento).",
"lite_birthday_label": "Fecha de creación",
"lite_console_backend_commands": "Comandos del backend:",
"lite_console_help_passthrough": "Cualquier otra entrada se ejecuta como un comando de consola de la cartera lite.",
"lite_copy": "Copiar",
"lite_could_not_write": "No se pudo escribir ",
@@ -545,6 +662,7 @@
"lite_net_add_url_hint": "https://tu-servidor-lite",
"lite_net_checking": "comprobando…",
"lite_net_connected": "Conectado",
"lite_net_connecting": "Conectando…",
"lite_net_custom": "Personalizado",
"lite_net_disconnected": "No conectado",
"lite_net_hidden_section": "Servidores ocultos",
@@ -632,6 +750,9 @@
"market_cap": "Cap. de Mercado",
"market_cap_short": "Cap.",
"market_chart_loading": "Cargando historial de precios",
"market_col_name": "Nombre",
"market_col_trend": "Tendencia",
"market_col_value": "Valor",
"market_iv_1d": "1D",
"market_iv_1h": "1H",
"market_iv_1m": "1M",
@@ -640,13 +761,18 @@
"market_no_history": "No hay historial de precios disponible",
"market_no_price": "Sin datos de precio",
"market_now": "Ahora",
"market_opt_chart_style": "Estilo de gráfico",
"market_pct_shielded": "%.0f%% Protegido",
"market_portfolio": "PORTAFOLIO",
"market_price_loading": "Cargando datos de precio...",
"market_price_unavailable": "Datos de precio no disponibles",
"market_refresh_price": "Actualizar datos de precio",
"market_settings_tip": "Opciones de mercado",
"market_settings_title": "Ajustes de mercado",
"market_style_candle": "Cambiar a velas",
"market_style_candle_label": "Velas",
"market_style_line": "Cambiar a gráfico de líneas",
"market_style_line_label": "Línea",
"market_trade_on": "Operar en %s",
"market_updated": "\\xc2\\xb7 Actualizado %s",
"market_vol_short": "Vol",
@@ -950,11 +1076,12 @@
"portfolio_spark_min": "Minuto",
"portfolio_spark_month": "Mes",
"portfolio_spark_week": "Semana",
"portfolio_style_compact": "Filas compactas",
"portfolio_style_detailed": "Filas detalladas",
"portfolio_style_featured": "Filas destacadas",
"portfolio_style_compact": "Tabla",
"portfolio_style_detailed": "Tarjetas",
"portfolio_style_featured": "Destacado",
"portfolio_style_label": "Estilo de cartera",
"portfolio_untitled": "Sin título",
"portfolio_wallet_loading": "Espera a que la cartera termine de cargar para añadir un grupo.",
"price_chart": "Gráfico de Precios",
"privacy_great": "¡Excelente privacidad!",
"privacy_low": "Privacidad baja: protege los fondos",
@@ -1270,6 +1397,23 @@
"sweep_to": "Barrido a:",
"sweep_toggle": "Barrer a mi monedero (no conservar la clave)",
"sweep_tx": "Transacción:",
"switch_corrupt_body": "Esta cartera parece dañada: el nodo no pudo abrirla. Restáurala desde una copia de seguridad, vuelve a crearla o intenta repararla.",
"switch_corrupt_repair": "Intentar reparar (salvage)",
"switch_progress_background": "Continuar en segundo plano",
"switch_progress_default_wallet": "Cartera predeterminada",
"switch_progress_elapsed": "Transcurrido",
"switch_progress_external_wallet": "Cartera externa",
"switch_progress_failed_title": "Error al cambiar de cartera",
"switch_progress_from_label": "desde",
"switch_progress_hint": "Un apagado ordenado puede tardar hasta un minuto.",
"switch_progress_reconnecting": "Reconectando",
"switch_progress_starting": "Iniciando el nodo con la nueva cartera",
"switch_progress_stopping": "Deteniendo el nodo actual",
"switch_progress_title": "Cambiando de cartera",
"switch_stopnode_body": "Cambiar de cartera reinicia el nodo con la cartera seleccionada. El nodo en ejecución se detendrá y se reiniciará con la nueva cartera; si lo dejaste en marcha a propósito, volverá a iniciarse automáticamente.",
"switch_stopnode_confirm": "Detener nodo y cambiar",
"switch_stopnode_title": "¿Detener el nodo en ejecución?",
"switch_stopnode_warn": "Ya hay un nodo en ejecución que esta cartera no inició.",
"syncing": "Sincronizando...",
"t_address": "Dirección T",
"t_addresses": "Direcciones T",
@@ -1308,6 +1452,7 @@
"try_again": "Reintentar",
"tt_addr_url": "URL base para ver direcciones en un explorador de bloques",
"tt_address_book": "Administrar direcciones guardadas para envío rápido",
"tt_animate_avatars": "Reproduce avatares de contacto animados (GIF / WebP); desactivado muestra solo el primer fotograma",
"tt_auto_lock": "Bloquear billetera después de este tiempo de inactividad",
"tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad",
"tt_backup": "Crear una copia de seguridad de su wallet.dat",
@@ -1315,10 +1460,20 @@
"tt_blur": "Cantidad de desenfoque (0%% = apagado, 100%% = máximo)",
"tt_change_pass": "Cambiar la contraseña de cifrado de la billetera",
"tt_change_pin": "Cambiar su PIN de desbloqueo",
"tt_chat_bubble_accent": "Color de acento para tus burbujas de mensaje salientes (o sigue el tema actual)",
"tt_chat_bubble_style": "Forma de la burbuja de mensaje: redondeada, cuadrada o mínima (plana, sin borde)",
"tt_chat_density": "Espaciado entre mensajes: Cómodo añade más relleno; Compacto muestra más en pantalla",
"tt_chat_emoji_style": "Muestra los emoji con contorno monocromo o a todo color",
"tt_chat_enter_sends": "Si está activado, Enter envía el mensaje y Shift+Enter añade un salto de línea; si está desactivado, Enter añade un salto de línea",
"tt_chat_font_size": "Escala el texto de los mensajes de chat de 0.8x a 1.5x. Solo afecta a la pestaña de Chat, no al resto de la app",
"tt_chat_poll_rate": "Con qué frecuencia se comprueban mensajes nuevos y de 0-conf (0.5-15 s). Más rápido responde mejor pero usa más CPU",
"tt_chat_timestamp": "Formato de marca de tiempo solo para esta pestaña: seguir el reloj de toda la app, o forzar 24-hour o 12-hour",
"tt_clear_ztx": "Eliminar historial de z-transacciones en caché local",
"tt_clock_format": "Reloj de 24 o 12 horas, en toda la app. El chat puede anularlo.",
"tt_custom_fees": "Habilitar entrada manual de comisiones al enviar transacciones",
"tt_custom_theme": "Tema personalizado activo",
"tt_daemon_install_bundled": "Detiene el nodo, sobrescribe el dragonxd instalado con la versión incluida en esta compilación de la cartera y luego lo reinicia",
"tt_daemon_refresh": "Vuelve a leer la versión, el tamaño y la fecha de dragonxd instalado y del incluido que se muestran arriba",
"tt_daemon_update_check": "Descarga y verifica el nodo completo dragonxd más reciente desde el Gitea del proyecto, y luego reinicia para aplicarlo",
"tt_debug_collapse": "Colapsar opciones de registro de depuración",
"tt_debug_expand": "Expandir opciones de registro de depuración",
@@ -1336,7 +1491,30 @@
"tt_keep_daemon": "El daemon se detendrá cuando ejecute el asistente de configuración",
"tt_language": "Idioma de la interfaz de la billetera",
"tt_layout_hotkey": "Atajo: teclas de flecha izquierda/derecha para cambiar diseños de Balance",
"tt_lite_copy": "Copia el secreto revelado al portapapeles",
"tt_lite_decrypt_pass": "Introduce tu frase de contraseña para quitar el cifrado de la cartera",
"tt_lite_encrypt": "Cifra la cartera con la frase de contraseña de arriba; se bloquea de inmediato y requiere la frase para desbloquearse",
"tt_lite_encrypt_pass": "Frase de contraseña con la que cifrar la cartera. Si se pierde, la cartera no se puede desbloquear ni recuperar",
"tt_lite_hide_wipe": "Oculta el secreto revelado y lo borra de la memoria de forma segura",
"tt_lite_import_key": "Pega una clave privada de gasto o de visualización para importar; su historial aparece tras la próxima sincronización",
"tt_lite_import_key_btn": "Importa la clave privada introducida en esta cartera; los fondos y el historial aparecen tras la próxima sincronización",
"tt_lite_lifecycle_op": "Elige si crear una cartera nueva, abrir una existente o restaurar una desde una frase de recuperación",
"tt_lite_lifecycle_pass": "Frase de contraseña para desbloquear o establecer en la cartera durante esta operación de crear / abrir / restaurar",
"tt_lite_lifecycle_run": "Ejecuta la operación de crear / abrir / restaurar seleccionada con los valores de arriba",
"tt_lite_lifecycle_toggle": "Muestra u oculta los controles de crear / abrir / restaurar para gestionar tu archivo de cartera lite",
"tt_lite_lock": "Bloquea la cartera ahora; se necesita una frase de contraseña para desbloquearla y se cierra cualquier sesión de chat",
"tt_lite_redownload": "Volver a descargar y re-escanear todos los bloques del servidor lite",
"tt_lite_remove_encrypt": "Quita el cifrado y guarda la cartera sin protección; no se requerirá ninguna frase de contraseña para abrirla",
"tt_lite_restore_account": "Índice de cuenta HD a restaurar; deja 0 salvo que hayas usado varias cuentas con esta semilla",
"tt_lite_restore_birthday": "Altura de bloque en la que se creó la cartera; el escaneo empieza aquí. Usa 0 o la altura más temprana si no estás seguro",
"tt_lite_restore_overwrite": "Reemplaza un archivo de cartera existente con esta restauración. Advertencia: sobrescribe los datos de la cartera actual",
"tt_lite_restore_seed": "La frase de recuperación de 24-word para restaurar esta cartera; se oculta mientras escribes",
"tt_lite_save_seed_file": "Escribe la semilla y la fecha de creación en un archivo solo para el propietario (lite-seed-backup.txt) en la carpeta de configuración",
"tt_lite_show_keys": "Revela las claves privadas de gasto de esta cartera. Cualquiera con una clave puede gastar los fondos que controla",
"tt_lite_show_seed": "Revela la frase de recuperación y la fecha de creación de esta cartera. Cualquiera con la semilla puede gastar tus fondos",
"tt_lite_unlock": "Desbloquea la cartera cifrada con la frase de contraseña de arriba",
"tt_lite_unlock_pass": "Introduce tu frase de contraseña para desbloquear la cartera cifrada",
"tt_lite_wallet_path": "Ruta o nombre del archivo de cartera que se abrirá o en el que se restaurará",
"tt_lock": "Bloquear la billetera inmediatamente",
"tt_low_spec": "Desactivar todos los efectos visuales pesados\\nAtajo: Ctrl+Shift+Down",
"tt_merge": "Consolidar múltiples UTXOs en una dirección",
@@ -1357,12 +1535,17 @@
"tt_rpc_host": "Nombre de host del daemon DragonX",
"tt_rpc_pass": "Contraseña de autenticación RPC",
"tt_rpc_port": "Puerto para conexiones RPC del daemon",
"tt_rpc_toggle": "Muestra u oculta los datos de conexión RPC de solo lectura (host, puerto, usuario, contraseña) del daemon",
"tt_rpc_user": "Nombre de usuario de autenticación RPC",
"tt_save_settings": "Guardar todas las configuraciones en disco",
"tt_save_ztx": "Almacenar historial de transacciones de z-address localmente para carga más rápida",
"tt_scan_themes": "Buscar nuevos temas.\\nColoque carpetas de temas en:\\n%s",
"tt_scanline": "Efecto de líneas de escaneo CRT en la consola",
"tt_screenshot_open_dir": "Abre la carpeta de capturas (dentro del directorio de configuración) en tu explorador de archivos",
"tt_screenshot_sweep": "Recorre cada tema en cada pestaña y guarda una captura de cada uno en la carpeta de capturas de la configuración (sobrescribe el último recorrido)",
"tt_screenshot_sweep_full": "Como el recorrido de temas, pero además captura cada modal / diálogo / flujo usando datos de cartera de demostración temporales y sin conexión",
"tt_seed_backup": "Muestra y respalda la frase de recuperación de 24 palabras de tu cartera",
"tt_seed_demo_chat": "Inserta conversaciones de ejemplo en la pestaña de Chat para que un recorrido capture su interfaz; solo en memoria, se pierde al reiniciar",
"tt_seed_migrate": "Crea una nueva cartera con frase de recuperación y traslada tus fondos a ella",
"tt_set_pin": "Establecer un PIN de 4-8 dígitos para desbloqueo rápido",
"tt_shield_mining": "Mover recompensas de minería transparentes a una dirección blindada",
@@ -1381,6 +1564,7 @@
"tt_website": "Abrir el sitio web de DragonX",
"tt_window_opacity": "Opacidad del fondo (menor = escritorio visible a través de la ventana)",
"tt_wizard": "Volver a ejecutar el asistente de configuración inicial\\nEl daemon será reiniciado",
"tx_chat_badge": "Mensaje",
"tx_confirmations": "%d confirmaciones",
"tx_details_title": "Detalles de Transacción",
"tx_from_address": "Dirección Origen:",
@@ -1449,6 +1633,8 @@
"wallets_add_folder_toggle": "+ Buscar wallets en otra carpeta…",
"wallets_badge_encrypted": "Cifrada (protegida con contraseña)",
"wallets_badge_encrypted_short": "Cifrada",
"wallets_badge_hd": "Cartera HD: no se puede confirmar la frase semilla sin abrirla",
"wallets_badge_hd_short": "Cartera HD",
"wallets_badge_legacy": "Billetera heredada (sin frase semilla)",
"wallets_badge_legacy_short": "Heredada",
"wallets_badge_seed": "Billetera con frase semilla (HD)",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Adresse ajoutée au carnet",
"address_book_confirm_delete": "Confirmer la suppression ?",
"address_book_count": "%zu adresses enregistrées",
"address_book_count_one": "%zu adresse enregistrée",
"address_book_deleted": "Entrée supprimée",
"address_book_edit": "Modifier l'adresse",
"address_book_empty": "Aucune adresse enregistrée. Cliquez sur 'Ajouter' pour en créer une.",
@@ -53,6 +54,7 @@
"amount_details": "DÉTAILS DU MONTANT",
"amount_exceeds_balance": "Le montant dépasse le solde",
"amount_label": "Montant :",
"animate_avatars": "Animer les avatars",
"appearance": "APPARENCE",
"auto_shield": "Auto-blindage du minage",
"av_intro": "Les logiciels de minage sont souvent signalés comme potentiellement indésirables. Suivez ces étapes pour activer le minage en pool :",
@@ -133,26 +135,99 @@
"change_pass_title": "Changer la phrase secrète",
"characters": "caractères",
"chat": "Discussion",
"chat_accent_amber": "Ambre",
"chat_accent_blue": "Bleu",
"chat_accent_green": "Vert",
"chat_accent_pink": "Rose",
"chat_accent_purple": "Violet",
"chat_accent_theme": "Thème",
"chat_add_contact": "Ajouter un contact",
"chat_awaiting_key": "En attente de réponse",
"chat_bubble_minimal": "Minimale",
"chat_bubble_rounded": "Arrondie",
"chat_bubble_square": "Carrée",
"chat_buffer_loading": "Tampon de chat: …",
"chat_buffer_preparing": "Tampon de chat: préparation %d/%d…",
"chat_buffer_ready": "Tampon de chat: %d/%d prêts",
"chat_buffer_sending": "Chat: envoi de %d messages…",
"chat_buffer_sending_one": "Chat: envoi de %d message…",
"chat_cancel": "Annuler",
"chat_contact_added": "Contact ajouté — renommez-le dans Contacts",
"chat_contact_request": "demande de contact",
"chat_copy_address_tip": "Cliquer pour copier l'adresse",
"chat_density_comfortable": "Confortable",
"chat_density_compact": "Compacte",
"chat_emoji_color": "Couleur",
"chat_emoji_mono": "Monochrome",
"chat_emoji_search": "Rechercher un emoji",
"chat_empty_hint": "Aucune conversation pour l'instant. Les messages que vous recevez apparaîtront ici.",
"chat_empty_start": "Commencez-en une avec « Nouvelle conversation ».",
"chat_empty_title": "Aucune conversation pour l'instant",
"chat_export": "Exporter le chat…",
"chat_export_done": "Conversation exportée",
"chat_export_failed": "Impossible d'écrire le fichier d'exportation.",
"chat_export_warn": "Enregistre les messages déchiffrés en texte clair. Conservez le fichier en lieu sûr.",
"chat_filter": "Chat",
"chat_hidden_toast": "Conversation masquée — un nouveau message la fait réapparaître",
"chat_hide": "Masquer",
"chat_hide_hidden": "Masquer masqués",
"chat_jump_latest": "Récents",
"chat_len_over": "Message trop long",
"chat_locked_hint": "Déverrouillez votre portefeuille pour charger vos discussions.",
"chat_new_button": "Nouvelle conversation",
"chat_mute": "Muet",
"chat_new_button": "Nouvelle discussion",
"chat_new_message": "Message",
"chat_new_message_toast": "Nouveau message chiffré",
"chat_new_send": "Envoyer la demande",
"chat_new_title": "Nouvelle conversation",
"chat_new_title": "Nouvelle discussion",
"chat_new_zaddr": "Adresse Z du destinataire",
"chat_no_matches": "Aucune conversation ne correspond à votre recherche.",
"chat_no_z_contacts": "Aucun contact avec adresse blindée pour l'instant",
"chat_opt_bubble_accent": "Couleur de bulle",
"chat_opt_bubble_style": "Style de bulle",
"chat_opt_density": "Densité des messages",
"chat_opt_emoji": "Style d'emoji",
"chat_opt_enter_sends": "Entrée envoie le message",
"chat_opt_font_size": "Taille du texte",
"chat_opt_global_clock": "Format d'horloge global",
"chat_opt_poll": "Fréquence d'actualisation",
"chat_opt_timestamp": "Horodatage",
"chat_pick_contact": "Choisir dans les contacts…",
"chat_rename": "Renommer le contact",
"chat_rename_hint": "Nom du contact",
"chat_renamed": "Contact renommé",
"chat_retry": "Réessayer",
"chat_search": "Rechercher des conversations",
"chat_sec_appearance": "APPARENCE",
"chat_sec_messaging": "MESSAGERIE",
"chat_select_hint": "Sélectionnez une conversation pour l'afficher.",
"chat_send": "Envoyer",
"chat_send_failed": "non envoyé",
"chat_sending": "envoi…",
"chat_settings_done": "Terminé",
"chat_settings_section": "CHAT ET CONTACTS",
"chat_settings_tip": "Personnaliser le chat",
"chat_settings_title": "Paramètres du chat",
"chat_show_hidden": "Afficher masqués",
"chat_time_now": "à l'instant",
"chat_toast_compose_failed": "Impossible de composer le message (trop long ?).",
"chat_toast_lite_busy": "Un envoi est déjà en cours, ou aucun portefeuille n'est ouvert.",
"chat_toast_need_funds": "Un petit solde blindé est nécessaire pour envoyer des messages (pour couvrir les frais).",
"chat_toast_no_zaddr": "Aucune adresse Z disponible pour envoyer le message.",
"chat_toast_not_connected": "Non connecté — message non envoyé.",
"chat_toast_request_compose_failed": "Impossible de composer la demande de contact (adresse / texte invalide ?).",
"chat_toast_request_queued": "Demande de contact mise en file d'attente.",
"chat_toast_waiting_reply": "En attente de la réponse de ce contact avant de pouvoir lui écrire.",
"chat_today": "Aujourd'hui",
"chat_ts_12h": "12 heures",
"chat_ts_24h": "24 heures",
"chat_ts_global": "Suivre global",
"chat_ts_global_short": "Global",
"chat_unhide": "Afficher",
"chat_unmute": "Réactiver",
"chat_verify_key": "Clé d'identité — comparez pour vérifier",
"chat_waiting_reply": "En attente de la réponse de ce contact — vous pourrez lui écrire dès qu'il aura répondu.",
"chat_yesterday": "Hier",
"chat_you": "Vous",
"choose_icon": "Choisir une icône",
"clear": "Effacer",
@@ -164,6 +239,7 @@
"click_copy_address": "Cliquez pour copier l'adresse",
"click_copy_uri": "Cliquez pour copier l'URI",
"click_to_copy": "Cliquez pour copier",
"clock_format": "Format d'horloge",
"close": "Fermer",
"conf_count": "%d conf.",
"confirm_and_send": "Confirmer & Envoyer",
@@ -201,12 +277,18 @@
"console_app": "App",
"console_auto_scroll": "Défilement auto",
"console_available_commands": "Commandes disponibles :",
"console_backend_reference": "Référence des commandes du backend",
"console_backend_unavailable": "Aucun backend",
"console_capturing_output": "Capture de la sortie du daemon...",
"console_cat_advanced": "Avancé",
"console_cat_blockchain": "Blockchain",
"console_cat_control": "Contrôle",
"console_cat_keys": "Clés et sécurité",
"console_cat_mining": "Minage",
"console_cat_network": "Réseau",
"console_cat_raw_transactions": "Transactions brutes",
"console_cat_send": "Envoyer",
"console_cat_sync": "Synchronisation",
"console_cat_utility": "Utilitaires",
"console_cat_wallet": "Portefeuille",
"console_clear": "Effacer",
@@ -240,11 +322,14 @@
"console_help_help": " help - Afficher ce message d'aide",
"console_help_setgenerate": " setgenerate - Contrôler le minage",
"console_help_stop": " stop - Arrêter le daemon",
"console_last_error": "Dernière erreur :",
"console_line_count": "%zu lignes",
"console_matches": "correspondances",
"console_new_lines": "%d nouvelles lignes",
"console_no_daemon": "Pas de daemon",
"console_no_output": "(aucune sortie)",
"console_not_connected": "Erreur : Non connecté au daemon",
"console_not_connected_lite": "Erreur : Aucun portefeuille ouvert",
"console_quit_note": "'quit'/'exit' ne sont pas nécessaires ici — fermez simplement la fenêtre.",
"console_ref_builds": "Génère",
"console_ref_cancel": "Annuler",
@@ -260,12 +345,14 @@
"console_ref_run_confirm": "Exécuter %s maintenant ? C'est une commande à conséquences.",
"console_ref_search_hint": "Rechercher par nom ou tâche…",
"console_ref_select_hint": "Sélectionnez une commande pour voir ce qu'elle fait.",
"console_ref_value": "valeur",
"console_rpc_reference": "Référence des commandes RPC",
"console_rpc_trace": "RPC",
"console_scanline": "Scanline de la console",
"console_search_commands": "Rechercher des commandes...",
"console_select_all": "Tout sélectionner",
"console_show_app_output": "Afficher les lignes du journal du portefeuille [app]",
"console_show_backend_ref": "Afficher la référence des commandes du backend",
"console_show_daemon_output": "Afficher la sortie du daemon",
"console_show_errors_only": "Afficher uniquement les erreurs",
"console_show_rpc_ref": "Afficher la référence des commandes RPC",
@@ -278,6 +365,7 @@
"console_status_stopped": "Arrêté",
"console_status_stopping": "Arrêt",
"console_status_unknown": "Inconnu",
"console_stop_confirm_node": "'stop' arrêtera le nœud et déconnectera le portefeuille. Tapez à nouveau 'stop' pour confirmer.",
"console_tab_completion": "Tab pour compléter",
"console_text_colors": "Couleurs du texte",
"console_toggle_accents": "Basculer les accents de couleur des lignes",
@@ -286,12 +374,34 @@
"console_welcome": "Bienvenue dans la console ObsidianDragon",
"console_zoom_in": "Agrandir",
"console_zoom_out": "Réduire",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Cette image n'a pas pu être chargée.",
"contact_avatar_badge": "Badge",
"contact_avatar_badge_hint": "Le badge est choisi automatiquement selon le type d'adresse.",
"contact_avatar_choose": "Choisir une image…",
"contact_avatar_copy_failed": "Impossible de copier cette image.",
"contact_avatar_icon": "Icône",
"contact_avatar_image": "Image",
"contact_avatar_image_hint": "L'image est copiée dans l'application pour rester disponible si l'original est déplacé.",
"contact_avatar_remove": "Retirer",
"contact_avatar_shielded": "Blindée",
"contact_avatar_transparent": "Transparente",
"contact_global": "Afficher dans tous les portefeuilles (contact global)",
"contact_global_badge_tt": "Contact global — visible dans tous les portefeuilles",
"contact_global_tt": "Activé : ce contact reste visible quel que soit le portefeuille chargé. Désactivé : il appartient au portefeuille actuel uniquement.",
"contact_preview_addr": "L'adresse apparaîtra ici",
"contact_preview_name": "Nom du contact",
"contact_wallet_loading": "Le portefeuille se charge encore — cochez « Afficher dans chaque portefeuille » ou réessayez dans un instant.",
"contacts": "Contacts",
"contacts_avatar_shape": "Forme de l'avatar",
"contacts_list_scale": "Échelle de la liste",
"contacts_search_no_match": "Aucun contact correspondant",
"contacts_search_placeholder": "Rechercher des contacts...",
"contacts_settings_tip": "Personnaliser les contacts",
"contacts_settings_title": "Paramètres des contacts",
"contacts_shape_circle": "Cercle",
"contacts_shape_square": "Carré",
"contacts_shape_tab": "Onglet",
"copied": "Copié !",
"copy": "Copier",
"copy_address": "Copier l'adresse complète",
@@ -464,6 +574,12 @@
"hide_qr": "Masquer le QR",
"hide_zero_balances": "Masquer les soldes à 0",
"history": "Historique",
"img_picker_count": "%d image(s) dans ce dossier",
"img_picker_empty": "Ce dossier ne contient ni sous-dossiers ni images.",
"img_picker_none": "Aucune image dans ce dossier",
"img_picker_pictures": "Dossier Images",
"img_picker_title": "Choisir une image",
"img_picker_use": "Utiliser l'image",
"immature_type": "Immature",
"import": "Importer",
"import_key_address": "Adresse :",
@@ -528,6 +644,7 @@
"lite_birthday_backup": "Date de création : %llu (à sauvegarder également)",
"lite_birthday_hint": "Hauteur de bloc à partir de laquelle commencer l'analyse. Laissez 0 si inconnue (analyse complète plus lente).",
"lite_birthday_label": "Bloc de création",
"lite_console_backend_commands": "Commandes du backend :",
"lite_console_help_passthrough": "Toute autre entrée est exécutée comme une commande de la console du portefeuille lite.",
"lite_copy": "Copier",
"lite_could_not_write": "Impossible d'écrire ",
@@ -545,6 +662,7 @@
"lite_net_add_url_hint": "https://votre-serveur-lite",
"lite_net_checking": "vérification…",
"lite_net_connected": "Connecté",
"lite_net_connecting": "Connexion…",
"lite_net_custom": "Personnalisé",
"lite_net_disconnected": "Non connecté",
"lite_net_hidden_section": "Serveurs masqués",
@@ -632,6 +750,9 @@
"market_cap": "Capitalisation",
"market_cap_short": "Cap.",
"market_chart_loading": "Chargement de l'historique des prix",
"market_col_name": "Nom",
"market_col_trend": "Tendance",
"market_col_value": "Valeur",
"market_iv_1d": "1J",
"market_iv_1h": "1H",
"market_iv_1m": "1M",
@@ -640,13 +761,18 @@
"market_no_history": "Aucun historique de prix disponible",
"market_no_price": "Pas de données de prix",
"market_now": "Maintenant",
"market_opt_chart_style": "Style du graphique",
"market_pct_shielded": "%.0f%% Blindé",
"market_portfolio": "PORTEFEUILLE",
"market_price_loading": "Chargement des données de prix...",
"market_price_unavailable": "Données de prix indisponibles",
"market_refresh_price": "Actualiser les données de prix",
"market_settings_tip": "Options du marché",
"market_settings_title": "Paramètres du marché",
"market_style_candle": "Passer aux chandeliers",
"market_style_candle_label": "Chandelier",
"market_style_line": "Passer au graphique en ligne",
"market_style_line_label": "Ligne",
"market_trade_on": "Échanger sur %s",
"market_updated": "\\xc2\\xb7 Mis à jour %s",
"market_vol_short": "Vol",
@@ -950,11 +1076,12 @@
"portfolio_spark_min": "Minute",
"portfolio_spark_month": "Mois",
"portfolio_spark_week": "Semaine",
"portfolio_style_compact": "Lignes compactes",
"portfolio_style_detailed": "Lignes détaillées",
"portfolio_style_featured": "Lignes en vedette",
"portfolio_style_compact": "Tableau",
"portfolio_style_detailed": "Cartes",
"portfolio_style_featured": "En vedette",
"portfolio_style_label": "Style du portefeuille",
"portfolio_untitled": "Sans titre",
"portfolio_wallet_loading": "Attendez la fin du chargement du portefeuille pour ajouter un groupe.",
"price_chart": "Graphique des prix",
"privacy_great": "Excellente confidentialité !",
"privacy_low": "Faible confidentialité — blindez vos fonds",
@@ -1270,6 +1397,23 @@
"sweep_to": "Balayé vers :",
"sweep_toggle": "Balayer vers mon portefeuille (ne pas conserver la clé)",
"sweep_tx": "Transaction :",
"switch_corrupt_body": "Ce portefeuille semble corrompu — le nœud n'a pas pu l'ouvrir. Restaurez-le depuis une sauvegarde, recréez-le ou tentez de le réparer.",
"switch_corrupt_repair": "Tenter une réparation (salvage)",
"switch_progress_background": "Continuer en arrière-plan",
"switch_progress_default_wallet": "Portefeuille par défaut",
"switch_progress_elapsed": "Écoulé",
"switch_progress_external_wallet": "Portefeuille externe",
"switch_progress_failed_title": "Échec du changement de portefeuille",
"switch_progress_from_label": "depuis",
"switch_progress_hint": "Un arrêt propre peut prendre jusqu'à une minute.",
"switch_progress_reconnecting": "Reconnexion",
"switch_progress_starting": "Démarrage du nœud sur le nouveau portefeuille",
"switch_progress_stopping": "Arrêt du nœud actuel",
"switch_progress_title": "Changement de portefeuille",
"switch_stopnode_body": "Changer de portefeuille redémarre le nœud sur le portefeuille sélectionné. Le nœud en cours sera arrêté puis relancé sur le nouveau portefeuille — si vous l'avez laissé tourner exprès, il redémarre automatiquement.",
"switch_stopnode_confirm": "Arrêter le nœud et changer",
"switch_stopnode_title": "Arrêter le nœud en cours d'exécution ?",
"switch_stopnode_warn": "Un nœud que ce portefeuille n'a pas démarré est déjà en cours d'exécution.",
"syncing": "Synchronisation...",
"t_address": "Adresse T",
"t_addresses": "Adresses T",
@@ -1308,6 +1452,7 @@
"try_again": "Réessayer",
"tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs",
"tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide",
"tt_animate_avatars": "Lit les avatars de contact animés (GIF / WebP) ; désactivé n'affiche que la première image",
"tt_auto_lock": "Verrouiller le portefeuille après cette durée d'inactivité",
"tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité",
"tt_backup": "Créer une sauvegarde de votre wallet.dat",
@@ -1315,10 +1460,20 @@
"tt_blur": "Quantité de flou (0%% = désactivé, 100%% = maximum)",
"tt_change_pass": "Changer la phrase secrète de chiffrement du portefeuille",
"tt_change_pin": "Changer votre PIN de déverrouillage",
"tt_chat_bubble_accent": "Couleur d'accent de vos bulles de message sortantes (ou suivre le thème actuel)",
"tt_chat_bubble_style": "Forme de la bulle de message : arrondie, carrée ou minimale (plate, sans bordure)",
"tt_chat_density": "Espacement entre les messages : Confortable ajoute plus de marge ; Compact en affiche davantage à l'écran",
"tt_chat_emoji_style": "Affiche les emoji en contour monochrome ou en couleur",
"tt_chat_enter_sends": "Si activé, Enter envoie le message et Shift+Enter ajoute un saut de ligne ; si désactivé, Enter ajoute un saut de ligne",
"tt_chat_font_size": "Met à l'échelle le texte des messages de chat de 0.8x à 1.5x. N'affecte que l'onglet Chat, pas le reste de l'application",
"tt_chat_poll_rate": "Fréquence de vérification des messages nouveaux et 0-conf (0.5-15 s). Plus rapide est plus réactif mais utilise plus de CPU",
"tt_chat_timestamp": "Format d'horodatage pour cet onglet uniquement : suivre l'horloge de l'application, ou forcer 24-hour ou 12-hour",
"tt_clear_ztx": "Supprimer l'historique des z-transactions mis en cache localement",
"tt_clock_format": "Horloge 24 h ou 12 h, dans toute l'app. Le chat peut la remplacer.",
"tt_custom_fees": "Activer la saisie manuelle des frais lors de l'envoi de transactions",
"tt_custom_theme": "Thème personnalisé actif",
"tt_daemon_install_bundled": "Arrêter le nœud, remplacer le dragonxd installé par la version intégrée dans cette version du portefeuille, puis redémarrer",
"tt_daemon_refresh": "Relit la version, la taille et la date de dragonxd installé et fourni affichées ci-dessus",
"tt_daemon_update_check": "Télécharger et vérifier le dernier nœud complet dragonxd depuis le Gitea du projet, puis redémarrer pour l'appliquer",
"tt_debug_collapse": "Réduire les options de journalisation de débogage",
"tt_debug_expand": "Développer les options de journalisation de débogage",
@@ -1336,7 +1491,30 @@
"tt_keep_daemon": "Le daemon s'arrêtera lors de l'exécution de l'assistant de configuration",
"tt_language": "Langue de l'interface du portefeuille",
"tt_layout_hotkey": "Raccourci : touches fléchées gauche/droite pour changer les dispositions de Balance",
"tt_lite_copy": "Copie le secret révélé dans le presse-papiers",
"tt_lite_decrypt_pass": "Saisissez votre phrase de passe pour retirer le chiffrement du portefeuille",
"tt_lite_encrypt": "Chiffre le portefeuille avec la phrase de passe ci-dessus ; il se verrouille immédiatement et requiert la phrase pour se déverrouiller",
"tt_lite_encrypt_pass": "Phrase de passe pour chiffrer le portefeuille. En cas de perte, le portefeuille ne peut être ni déverrouillé ni récupéré",
"tt_lite_hide_wipe": "Masque le secret révélé et l'efface de la mémoire de façon sécurisée",
"tt_lite_import_key": "Collez une clé privée de dépense ou de lecture à importer ; son historique apparaît après la prochaine synchronisation",
"tt_lite_import_key_btn": "Importe la clé privée saisie dans ce portefeuille ; les fonds et l'historique apparaissent après la prochaine synchronisation",
"tt_lite_lifecycle_op": "Choisissez de créer un nouveau portefeuille, d'en ouvrir un existant ou d'en restaurer un à partir d'une phrase de récupération",
"tt_lite_lifecycle_pass": "Phrase de passe pour déverrouiller ou définir sur le portefeuille lors de cette opération de création / ouverture / restauration",
"tt_lite_lifecycle_run": "Exécute l'opération de création / ouverture / restauration sélectionnée avec les valeurs ci-dessus",
"tt_lite_lifecycle_toggle": "Affiche ou masque les commandes de création / ouverture / restauration pour gérer votre fichier de portefeuille lite",
"tt_lite_lock": "Verrouille le portefeuille maintenant ; une phrase de passe est requise pour le déverrouiller et toute session de chat est fermée",
"tt_lite_redownload": "Re-télécharger et re-scanner tous les blocs depuis le serveur lite",
"tt_lite_remove_encrypt": "Retire le chiffrement et stocke le portefeuille sans protection ; aucune phrase de passe ne sera requise pour l'ouvrir",
"tt_lite_restore_account": "Index de compte HD à restaurer ; laissez 0 sauf si vous avez utilisé plusieurs comptes avec cette graine",
"tt_lite_restore_birthday": "Hauteur de bloc à laquelle le portefeuille a été créé ; l'analyse commence ici. Utilisez 0 ou la hauteur la plus ancienne en cas de doute",
"tt_lite_restore_overwrite": "Remplace un fichier de portefeuille existant par cette restauration. Attention : écrase les données du portefeuille actuel",
"tt_lite_restore_seed": "La phrase de récupération de 24-word pour restaurer ce portefeuille ; masquée pendant la saisie",
"tt_lite_save_seed_file": "Écrit la graine et la date de création dans un fichier réservé au propriétaire (lite-seed-backup.txt) dans le dossier de configuration",
"tt_lite_show_keys": "Révèle les clés privées de dépense de ce portefeuille. Quiconque possède une clé peut dépenser les fonds qu'elle contrôle",
"tt_lite_show_seed": "Révèle la phrase de récupération et la date de création de ce portefeuille. Quiconque possède la graine peut dépenser vos fonds",
"tt_lite_unlock": "Déverrouille le portefeuille chiffré à l'aide de la phrase de passe ci-dessus",
"tt_lite_unlock_pass": "Saisissez votre phrase de passe pour déverrouiller le portefeuille chiffré",
"tt_lite_wallet_path": "Chemin ou nom du fichier de portefeuille à ouvrir ou dans lequel restaurer",
"tt_lock": "Verrouiller le portefeuille immédiatement",
"tt_low_spec": "Désactiver tous les effets visuels lourds\\nRaccourci : Ctrl+Shift+Down",
"tt_merge": "Consolider plusieurs UTXOs vers une adresse",
@@ -1357,12 +1535,17 @@
"tt_rpc_host": "Nom d'hôte du daemon DragonX",
"tt_rpc_pass": "Mot de passe d'authentification RPC",
"tt_rpc_port": "Port pour les connexions RPC du daemon",
"tt_rpc_toggle": "Affiche ou masque les informations de connexion RPC en lecture seule (hôte, port, utilisateur, mot de passe) du daemon",
"tt_rpc_user": "Nom d'utilisateur d'authentification RPC",
"tt_save_settings": "Enregistrer tous les paramètres sur le disque",
"tt_save_ztx": "Stocker l'historique des transactions z-address localement pour un chargement plus rapide",
"tt_scan_themes": "Rechercher de nouveaux thèmes.\\nPlacez les dossiers de thèmes dans :\\n%s",
"tt_scanline": "Effet de lignes de balayage CRT dans la console",
"tt_screenshot_open_dir": "Ouvre le dossier de captures (sous le répertoire de configuration) dans votre gestionnaire de fichiers",
"tt_screenshot_sweep": "Parcourt chaque thème sur chaque onglet et enregistre une capture de chacun dans le dossier de captures de la configuration (écrase le dernier parcours)",
"tt_screenshot_sweep_full": "Comme le parcours des thèmes, mais capture aussi chaque modale / boîte de dialogue / flux à l'aide de données de portefeuille de démonstration temporaires et hors ligne",
"tt_seed_backup": "Afficher et sauvegarder la phrase de récupération de 24 mots de votre portefeuille",
"tt_seed_demo_chat": "Injecte des conversations d'exemple dans l'onglet Chat pour qu'un parcours capture son interface ; en mémoire uniquement, perdu au redémarrage",
"tt_seed_migrate": "Créer un nouveau portefeuille à phrase de récupération et y transférer vos fonds",
"tt_set_pin": "Définir un PIN de 4-8 chiffres pour un déverrouillage rapide",
"tt_shield_mining": "Déplacer les récompenses de minage transparentes vers une adresse blindée",
@@ -1381,6 +1564,7 @@
"tt_website": "Ouvrir le site web DragonX",
"tt_window_opacity": "Opacité de l'arrière-plan (plus bas = bureau visible à travers la fenêtre)",
"tt_wizard": "Relancer l'assistant de configuration initiale\\nLe daemon sera redémarré",
"tx_chat_badge": "Message",
"tx_confirmations": "%d confirmations",
"tx_details_title": "Détails de la transaction",
"tx_from_address": "Adresse d'origine :",
@@ -1449,6 +1633,8 @@
"wallets_add_folder_toggle": "+ Analyser un autre dossier pour les portefeuilles…",
"wallets_badge_encrypted": "Chiffré (protégé par phrase secrète)",
"wallets_badge_encrypted_short": "Chiffré",
"wallets_badge_hd": "Portefeuille HD — impossible de confirmer une phrase de récupération sans l'ouvrir",
"wallets_badge_hd_short": "Portefeuille HD",
"wallets_badge_legacy": "Portefeuille hérité (sans phrase de récupération)",
"wallets_badge_legacy_short": "Hérité",
"wallets_badge_seed": "Portefeuille à phrase de récupération (HD)",

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": "保存されたアドレスがありません。「新規追加」をクリックして追加してください。",
@@ -53,6 +54,7 @@
"amount_details": "金額の詳細",
"amount_exceeds_balance": "金額が残高を超えています",
"amount_label": "金額:",
"animate_avatars": "アバターをアニメーション",
"appearance": "外観",
"auto_shield": "マイニング自動シールド",
"av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。",
@@ -133,26 +135,99 @@
"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": "クリア",
@@ -164,6 +239,7 @@
"click_copy_address": "クリックしてアドレスをコピー",
"click_copy_uri": "クリックしてURIをコピー",
"click_to_copy": "クリックしてコピー",
"clock_format": "時刻形式",
"close": "閉じる",
"conf_count": "%d 確認",
"confirm_and_send": "確認して送金",
@@ -201,12 +277,18 @@
"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": "クリア",
@@ -240,11 +322,14 @@
"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": "キャンセル",
@@ -260,12 +345,14 @@
"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コマンドリファレンスを表示",
@@ -278,6 +365,7 @@
"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": "行のカラーアクセントを切り替え",
@@ -286,12 +374,34 @@
"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": "完全なアドレスをコピー",
@@ -464,6 +574,12 @@
"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": "アドレス:",
@@ -528,6 +644,7 @@
"lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)",
"lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください完全スキャンが遅くなります。",
"lite_birthday_label": "バースデー",
"lite_console_backend_commands": "バックエンドコマンド:",
"lite_console_help_passthrough": "その他の入力はライトウォレットのコンソールコマンドとして実行されます。",
"lite_copy": "コピー",
"lite_could_not_write": "書き込めませんでした ",
@@ -545,6 +662,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": "非表示のサーバー",
@@ -632,6 +750,9 @@
"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ヶ月",
@@ -640,13 +761,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": "出来高",
@@ -950,11 +1076,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": "プライバシーが低い — 資金をシールドしてください",
@@ -1270,6 +1397,23 @@
"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アドレス",
@@ -1308,6 +1452,7 @@
"try_again": "再試行",
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
"tt_address_book": "クイック送信用の保存済みアドレスを管理",
"tt_animate_avatars": "アニメーション連絡先アバターGIF / WebPを再生。オフでは最初のフレームのみ表示",
"tt_auto_lock": "この無操作時間後にウォレットをロック",
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
"tt_backup": "wallet.dat のバックアップを作成",
@@ -1315,10 +1460,20 @@
"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_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": "デバッグログオプションを展開",
@@ -1336,7 +1491,30 @@
"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 を一つのアドレスに統合",
@@ -1357,12 +1535,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": "透明マイニング報酬をシールドアドレスに移動",
@@ -1381,6 +1564,7 @@
"tt_website": "DragonX ウェブサイトを開く",
"tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)",
"tt_wizard": "初期セットアップウィザードを再実行\\nデーモンは再起動されます",
"tx_chat_badge": "メッセージ",
"tx_confirmations": "%d 確認",
"tx_details_title": "取引の詳細",
"tx_from_address": "送信元アドレス:",
@@ -1449,6 +1633,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)",

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": "저장된 주소가 없습니다. '새로 추가'를 클릭하여 추가하세요.",
@@ -53,6 +54,7 @@
"amount_details": "금액 상세",
"amount_exceeds_balance": "금액이 잔액을 초과합니다",
"amount_label": "금액:",
"animate_avatars": "아바타 애니메이션",
"appearance": "외관",
"auto_shield": "채굴 자동 차폐",
"av_intro": "채굴 소프트웨어는 종종 잠재적으로 원치 않는 항목으로 표시됩니다. 풀 채굴을 활성화하려면 다음 단계를 따르세요:",
@@ -133,26 +135,99 @@
"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": "지우기",
@@ -164,6 +239,7 @@
"click_copy_address": "클릭하여 주소 복사",
"click_copy_uri": "클릭하여 URI 복사",
"click_to_copy": "복사하려면 클릭",
"clock_format": "시간 형식",
"close": "닫기",
"conf_count": "%d 확인",
"confirm_and_send": "확인 후 전송",
@@ -201,12 +277,18 @@
"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": "지우기",
@@ -240,11 +322,14 @@
"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": "취소",
@@ -260,12 +345,14 @@
"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 명령어 참조 표시",
@@ -278,6 +365,7 @@
"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": "줄 색상 강조 전환",
@@ -286,12 +374,34 @@
"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": "전체 주소 복사",
@@ -464,6 +574,12 @@
"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": "주소:",
@@ -528,6 +644,7 @@
"lite_birthday_backup": "생성 블록: %llu (이 값도 백업하세요)",
"lite_birthday_hint": "스캔을 시작할 블록 높이입니다. 모르면 0으로 두세요(전체 스캔이 느려짐).",
"lite_birthday_label": "생일 블록",
"lite_console_backend_commands": "백엔드 명령:",
"lite_console_help_passthrough": "그 외 입력은 라이트 지갑 콘솔 명령으로 실행됩니다.",
"lite_copy": "복사",
"lite_could_not_write": "쓸 수 없습니다: ",
@@ -545,6 +662,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": "숨겨진 서버",
@@ -632,6 +750,9 @@
"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개월",
@@ -640,13 +761,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": "거래량",
@@ -950,11 +1076,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": "낮은 프라이버시 — 자금을 차폐하세요",
@@ -1270,6 +1397,23 @@
"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 주소",
@@ -1308,6 +1452,7 @@
"try_again": "다시 시도",
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
"tt_animate_avatars": "애니메이션 연락처 아바타(GIF / WebP) 재생, 끄면 첫 프레임만 표시",
"tt_auto_lock": "이 비활성 시간 후 지갑 잠금",
"tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동",
"tt_backup": "wallet.dat 백업 만들기",
@@ -1315,10 +1460,20 @@
"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_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": "디버그 로깅 옵션 펼치기",
@@ -1336,7 +1491,30 @@
"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를 하나의 주소로 통합",
@@ -1357,12 +1535,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": "파일 관리자에서 screenshots 폴더(설정 디렉터리 아래)를 엽니다",
"tt_screenshot_sweep": "모든 탭에서 모든 테마를 순환하며 각각의 스크린샷을 설정 screenshots 폴더에 저장합니다(마지막 스윕을 덮어씀)",
"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": "투명 채굴 보상을 차폐 주소로 이동",
@@ -1381,6 +1564,7 @@
"tt_website": "DragonX 웹사이트 열기",
"tt_window_opacity": "배경 불투명도 (낮을수록 = 창을 통해 바탕 화면이 보임)",
"tt_wizard": "초기 설정 마법사 다시 실행\\n데몬이 재시작됩니다",
"tx_chat_badge": "메시지",
"tx_confirmations": "%d 확인",
"tx_details_title": "거래 상세",
"tx_from_address": "보낸 주소:",
@@ -1449,6 +1633,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)",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Endereço adicionado ao livro",
"address_book_confirm_delete": "Confirmar exclusão?",
"address_book_count": "%zu endereços salvos",
"address_book_count_one": "%zu endereço salvo",
"address_book_deleted": "Entrada excluída",
"address_book_edit": "Editar Endereço",
"address_book_empty": "Nenhum endereço salvo. Clique em 'Adicionar Novo' para criar um.",
@@ -53,6 +54,7 @@
"amount_details": "DETALHES DO VALOR",
"amount_exceeds_balance": "Valor excede o saldo",
"amount_label": "Valor:",
"animate_avatars": "Animar avatares",
"appearance": "APARÊNCIA",
"auto_shield": "Auto-blindar mineração",
"av_intro": "Softwares de mineração costumam ser sinalizados como potencialmente indesejados. Siga estes passos para habilitar a mineração em pool:",
@@ -133,26 +135,99 @@
"change_pass_title": "Alterar senha",
"characters": "caracteres",
"chat": "Chat",
"chat_accent_amber": "Âmbar",
"chat_accent_blue": "Azul",
"chat_accent_green": "Verde",
"chat_accent_pink": "Rosa",
"chat_accent_purple": "Roxo",
"chat_accent_theme": "Tema",
"chat_add_contact": "Adicionar contato",
"chat_awaiting_key": "Aguardando resposta",
"chat_bubble_minimal": "Mínimo",
"chat_bubble_rounded": "Arredondado",
"chat_bubble_square": "Quadrado",
"chat_buffer_loading": "Buffer de chat: …",
"chat_buffer_preparing": "Buffer de chat: preparando %d/%d…",
"chat_buffer_ready": "Buffer de chat: %d/%d prontos",
"chat_buffer_sending": "Chat: enviando %d mensagens…",
"chat_buffer_sending_one": "Chat: enviando %d mensagem…",
"chat_cancel": "Cancelar",
"chat_contact_added": "Contato adicionado — renomeie em Contatos",
"chat_contact_request": "solicitação de contato",
"chat_copy_address_tip": "Clique para copiar o endereço",
"chat_density_comfortable": "Confortável",
"chat_density_compact": "Compacta",
"chat_emoji_color": "Colorido",
"chat_emoji_mono": "Monocromático",
"chat_emoji_search": "Pesquisar emoji",
"chat_empty_hint": "Nenhuma conversa ainda. As mensagens que você receber aparecerão aqui.",
"chat_empty_start": "Inicie uma com \"Nova conversa\".",
"chat_empty_title": "Ainda não há conversas",
"chat_export": "Exportar conversa…",
"chat_export_done": "Conversa exportada",
"chat_export_failed": "Não foi possível gravar o arquivo de exportação.",
"chat_export_warn": "Salva as mensagens descriptografadas como texto simples. Guarde o arquivo com segurança.",
"chat_filter": "Chat",
"chat_hidden_toast": "Conversa ocultada — uma nova mensagem a traz de volta",
"chat_hide": "Ocultar",
"chat_hide_hidden": "Ocultar ocultas",
"chat_jump_latest": "Recentes",
"chat_len_over": "Mensagem muito longa",
"chat_locked_hint": "Desbloqueie sua carteira para carregar suas conversas.",
"chat_new_button": "Nova conversa",
"chat_mute": "Silenciar",
"chat_new_button": "Novo chat",
"chat_new_message": "Mensagem",
"chat_new_message_toast": "Nova mensagem de chat criptografada",
"chat_new_send": "Enviar solicitação",
"chat_new_title": "Nova conversa",
"chat_new_title": "Novo chat",
"chat_new_zaddr": "Endereço-z do destinatário",
"chat_no_matches": "Nenhuma conversa corresponde à sua pesquisa.",
"chat_no_z_contacts": "Ainda não há contatos com endereço blindado",
"chat_opt_bubble_accent": "Cor do balão",
"chat_opt_bubble_style": "Estilo do balão",
"chat_opt_density": "Densidade das mensagens",
"chat_opt_emoji": "Estilo de emoji",
"chat_opt_enter_sends": "Enter envia a mensagem",
"chat_opt_font_size": "Tamanho do texto",
"chat_opt_global_clock": "Formato de relógio global",
"chat_opt_poll": "Taxa de atualização",
"chat_opt_timestamp": "Carimbos de data/hora",
"chat_pick_contact": "Escolher dos contatos…",
"chat_rename": "Renomear contato",
"chat_rename_hint": "Nome do contato",
"chat_renamed": "Contato renomeado",
"chat_retry": "Tentar novamente",
"chat_search": "Pesquisar conversas",
"chat_sec_appearance": "APARÊNCIA",
"chat_sec_messaging": "MENSAGENS",
"chat_select_hint": "Selecione uma conversa para visualizá-la.",
"chat_send": "Enviar",
"chat_send_failed": "não enviada",
"chat_sending": "enviando…",
"chat_settings_done": "Concluído",
"chat_settings_section": "CHAT E CONTATOS",
"chat_settings_tip": "Personalizar chat",
"chat_settings_title": "Configurações de chat",
"chat_show_hidden": "Ver ocultas",
"chat_time_now": "agora",
"chat_toast_compose_failed": "Não foi possível compor a mensagem (muito longa?).",
"chat_toast_lite_busy": "Já há um envio em andamento, ou nenhuma carteira está aberta.",
"chat_toast_need_funds": "É necessário um pequeno saldo blindado para enviar chats (para cobrir a taxa).",
"chat_toast_no_zaddr": "Nenhum endereço-z disponível para enviar o chat.",
"chat_toast_not_connected": "Não conectado — mensagem de chat não enviada.",
"chat_toast_request_compose_failed": "Não foi possível compor a solicitação de contato (endereço / texto inválido?).",
"chat_toast_request_queued": "Solicitação de contato na fila.",
"chat_toast_waiting_reply": "Aguardando a resposta do contato antes que você possa enviar mensagens a ele.",
"chat_today": "Hoje",
"chat_ts_12h": "12 horas",
"chat_ts_24h": "24 horas",
"chat_ts_global": "Seguir global",
"chat_ts_global_short": "Global",
"chat_unhide": "Mostrar",
"chat_unmute": "Reativar som",
"chat_verify_key": "Chave de identidade — compare para verificar",
"chat_waiting_reply": "Aguardando a resposta deste contato — você poderá enviar mensagens assim que ele responder.",
"chat_yesterday": "Ontem",
"chat_you": "Você",
"choose_icon": "Escolher Ícone",
"clear": "Limpar",
@@ -164,6 +239,7 @@
"click_copy_address": "Clique para copiar o endereço",
"click_copy_uri": "Clique para copiar a URI",
"click_to_copy": "Clique para copiar",
"clock_format": "Formato de hora",
"close": "Fechar",
"conf_count": "%d conf.",
"confirm_and_send": "Confirmar & Enviar",
@@ -201,12 +277,18 @@
"console_app": "App",
"console_auto_scroll": "Rolagem automática",
"console_available_commands": "Comandos disponíveis:",
"console_backend_reference": "Referência de Comandos do Backend",
"console_backend_unavailable": "Sem backend",
"console_capturing_output": "Capturando saída do daemon...",
"console_cat_advanced": "Avançado",
"console_cat_blockchain": "Blockchain",
"console_cat_control": "Controle",
"console_cat_keys": "Chaves e segurança",
"console_cat_mining": "Mineração",
"console_cat_network": "Rede",
"console_cat_raw_transactions": "Transações brutas",
"console_cat_send": "Enviar",
"console_cat_sync": "Sincronização",
"console_cat_utility": "Utilitários",
"console_cat_wallet": "Carteira",
"console_clear": "Limpar",
@@ -240,11 +322,14 @@
"console_help_help": " help - Mostrar esta mensagem de ajuda",
"console_help_setgenerate": " setgenerate - Controlar mineração",
"console_help_stop": " stop - Parar o daemon",
"console_last_error": "Último erro:",
"console_line_count": "%zu linhas",
"console_matches": "correspondências",
"console_new_lines": "%d novas linhas",
"console_no_daemon": "Sem daemon",
"console_no_output": "(sem saída)",
"console_not_connected": "Erro: Não conectado ao daemon",
"console_not_connected_lite": "Erro: Nenhuma carteira aberta",
"console_quit_note": "'quit'/'exit' não são necessários aqui — basta fechar a janela.",
"console_ref_builds": "Gera",
"console_ref_cancel": "Cancelar",
@@ -260,12 +345,14 @@
"console_ref_run_confirm": "Executar %s agora? Este é um comando com consequências.",
"console_ref_search_hint": "Pesquisar por nome ou tarefa…",
"console_ref_select_hint": "Selecione um comando para ver o que ele faz.",
"console_ref_value": "valor",
"console_rpc_reference": "Referência de Comandos RPC",
"console_rpc_trace": "RPC",
"console_scanline": "Scanline do console",
"console_search_commands": "Pesquisar comandos...",
"console_select_all": "Selecionar Tudo",
"console_show_app_output": "Mostrar linhas do log da carteira [app]",
"console_show_backend_ref": "Mostrar referência de comandos do backend",
"console_show_daemon_output": "Mostrar saída do daemon",
"console_show_errors_only": "Mostrar apenas erros",
"console_show_rpc_ref": "Mostrar referência de comandos RPC",
@@ -278,6 +365,7 @@
"console_status_stopped": "Parado",
"console_status_stopping": "Parando",
"console_status_unknown": "Desconhecido",
"console_stop_confirm_node": "'stop' irá desligar o nó e desconectar a carteira. Digite 'stop' novamente para confirmar.",
"console_tab_completion": "Tab para completar",
"console_text_colors": "Cores do texto",
"console_toggle_accents": "Alternar destaques de cor das linhas",
@@ -286,12 +374,34 @@
"console_welcome": "Bem-vindo ao Console ObsidianDragon",
"console_zoom_in": "Aumentar zoom",
"console_zoom_out": "Diminuir zoom",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Não foi possível carregar essa imagem.",
"contact_avatar_badge": "Selo",
"contact_avatar_badge_hint": "O selo é escolhido automaticamente conforme o tipo de endereço.",
"contact_avatar_choose": "Escolher imagem…",
"contact_avatar_copy_failed": "Não foi possível copiar essa imagem.",
"contact_avatar_icon": "Ícone",
"contact_avatar_image": "Imagem",
"contact_avatar_image_hint": "A imagem é copiada para o app para continuar disponível se o original for movido.",
"contact_avatar_remove": "Remover",
"contact_avatar_shielded": "Blindado",
"contact_avatar_transparent": "Transparente",
"contact_global": "Mostrar em todas as carteiras (contato global)",
"contact_global_badge_tt": "Contato global — visível em todas as carteiras",
"contact_global_tt": "Ativado: este contato permanece visível em qualquer carteira que você carregar. Desativado: ele pertence apenas à carteira atual.",
"contact_preview_addr": "O endereço aparecerá aqui",
"contact_preview_name": "Nome do contato",
"contact_wallet_loading": "A carteira ainda está carregando — marque “Mostrar em todas as carteiras” ou tente novamente em um momento.",
"contacts": "Contatos",
"contacts_avatar_shape": "Forma do avatar",
"contacts_list_scale": "Escala da lista",
"contacts_search_no_match": "Nenhum contato correspondente",
"contacts_search_placeholder": "Pesquisar contatos...",
"contacts_settings_tip": "Personalizar contatos",
"contacts_settings_title": "Configurações de contatos",
"contacts_shape_circle": "Círculo",
"contacts_shape_square": "Quadrado",
"contacts_shape_tab": "Aba",
"copied": "Copiado!",
"copy": "Copiar",
"copy_address": "Copiar Endereço Completo",
@@ -464,6 +574,12 @@
"hide_qr": "Ocultar QR",
"hide_zero_balances": "Ocultar saldos zero",
"history": "Histórico",
"img_picker_count": "%d imagem(ns) nesta pasta",
"img_picker_empty": "Esta pasta não tem subpastas nem imagens.",
"img_picker_none": "Nenhuma imagem nesta pasta",
"img_picker_pictures": "Pasta de imagens",
"img_picker_title": "Escolher uma imagem",
"img_picker_use": "Usar imagem",
"immature_type": "Imaturo",
"import": "Importar",
"import_key_address": "Endereço:",
@@ -528,6 +644,7 @@
"lite_birthday_backup": "Aniversário: %llu (faça o backup disto também)",
"lite_birthday_hint": "Altura do bloco a partir da qual começar a escanear. Deixe 0 se desconhecida (escaneamento completo mais lento).",
"lite_birthday_label": "Data de nascimento",
"lite_console_backend_commands": "Comandos do backend:",
"lite_console_help_passthrough": "Qualquer outra entrada é executada como um comando de console da carteira leve.",
"lite_copy": "Copiar",
"lite_could_not_write": "Não foi possível gravar ",
@@ -545,6 +662,7 @@
"lite_net_add_url_hint": "https://seu-servidor-lite",
"lite_net_checking": "verificando…",
"lite_net_connected": "Conectado",
"lite_net_connecting": "Conectando…",
"lite_net_custom": "Personalizado",
"lite_net_disconnected": "Não conectado",
"lite_net_hidden_section": "Servidores ocultos",
@@ -632,6 +750,9 @@
"market_cap": "Capitalização",
"market_cap_short": "Cap.",
"market_chart_loading": "Carregando histórico de preços",
"market_col_name": "Nome",
"market_col_trend": "Tendência",
"market_col_value": "Valor",
"market_iv_1d": "1D",
"market_iv_1h": "1H",
"market_iv_1m": "1M",
@@ -640,13 +761,18 @@
"market_no_history": "Nenhum histórico de preços disponível",
"market_no_price": "Sem dados de preço",
"market_now": "Agora",
"market_opt_chart_style": "Estilo do gráfico",
"market_pct_shielded": "%.0f%% Blindado",
"market_portfolio": "PORTFÓLIO",
"market_price_loading": "Carregando dados de preço...",
"market_price_unavailable": "Dados de preço indisponíveis",
"market_refresh_price": "Atualizar dados de preço",
"market_settings_tip": "Opções de mercado",
"market_settings_title": "Configurações de mercado",
"market_style_candle": "Mudar para velas",
"market_style_candle_label": "Velas",
"market_style_line": "Mudar para gráfico de linhas",
"market_style_line_label": "Linha",
"market_trade_on": "Negociar no %s",
"market_updated": "\\xc2\\xb7 Atualizado %s",
"market_vol_short": "Vol",
@@ -950,11 +1076,12 @@
"portfolio_spark_min": "Minuto",
"portfolio_spark_month": "Mês",
"portfolio_spark_week": "Semana",
"portfolio_style_compact": "Linhas compactas",
"portfolio_style_detailed": "Linhas detalhadas",
"portfolio_style_featured": "Linhas em destaque",
"portfolio_style_compact": "Tabela",
"portfolio_style_detailed": "Cartões",
"portfolio_style_featured": "Destaque",
"portfolio_style_label": "Estilo do portfólio",
"portfolio_untitled": "Sem título",
"portfolio_wallet_loading": "Aguarde a carteira terminar de carregar para adicionar um grupo.",
"price_chart": "Gráfico de Preços",
"privacy_great": "Ótima privacidade!",
"privacy_low": "Baixa privacidade — blinde os fundos",
@@ -1270,6 +1397,23 @@
"sweep_to": "Varrido para:",
"sweep_toggle": "Varrer para minha carteira (não manter a chave)",
"sweep_tx": "Transação:",
"switch_corrupt_body": "Esta carteira parece corrompida — o nó não conseguiu abri-la. Restaure de um backup, recrie-a ou tente repará-la.",
"switch_corrupt_repair": "Tentar reparar (salvage)",
"switch_progress_background": "Continuar em segundo plano",
"switch_progress_default_wallet": "Carteira padrão",
"switch_progress_elapsed": "Decorrido",
"switch_progress_external_wallet": "Carteira externa",
"switch_progress_failed_title": "Falha ao trocar de carteira",
"switch_progress_from_label": "de",
"switch_progress_hint": "Um desligamento normal pode levar até um minuto.",
"switch_progress_reconnecting": "Reconectando",
"switch_progress_starting": "Iniciando o nó com a nova carteira",
"switch_progress_stopping": "Parando o nó atual",
"switch_progress_title": "Trocando de carteira",
"switch_stopnode_body": "Trocar de carteira reinicia o nó com a carteira selecionada. O nó em execução será parado e reiniciado com a nova carteira — se você o deixou em execução de propósito, ele volta automaticamente.",
"switch_stopnode_confirm": "Parar nó e trocar",
"switch_stopnode_title": "Parar o nó em execução?",
"switch_stopnode_warn": "Já há um nó em execução que esta carteira não iniciou.",
"syncing": "Sincronizando...",
"t_address": "Endereço T",
"t_addresses": "Endereços T",
@@ -1308,6 +1452,7 @@
"try_again": "Tentar novamente",
"tt_addr_url": "URL base para visualizar endereços em um explorador de blocos",
"tt_address_book": "Gerenciar endereços salvos para envio rápido",
"tt_animate_avatars": "Reproduz avatares de contato animados (GIF / WebP); desativado mostra só o primeiro quadro",
"tt_auto_lock": "Bloquear carteira após este tempo de inatividade",
"tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade",
"tt_backup": "Criar um backup do seu wallet.dat",
@@ -1315,10 +1460,20 @@
"tt_blur": "Quantidade de desfoque (0%% = desligado, 100%% = máximo)",
"tt_change_pass": "Alterar a frase secreta de encriptação da carteira",
"tt_change_pin": "Alterar seu PIN de desbloqueio",
"tt_chat_bubble_accent": "Cor de destaque para seus balões de mensagem enviados (ou seguir o tema atual)",
"tt_chat_bubble_style": "Formato do balão de mensagem: arredondado, quadrado ou minimalista (plano, sem borda)",
"tt_chat_density": "Espaçamento entre mensagens: Confortável adiciona mais espaçamento; Compacto exibe mais na tela",
"tt_chat_emoji_style": "Renderizar emojis em contorno monocromático ou colorido",
"tt_chat_enter_sends": "Quando ativado, Enter envia a mensagem e Shift+Enter adiciona uma nova linha; quando desativado, Enter adiciona uma nova linha",
"tt_chat_font_size": "Dimensionar o texto das mensagens de chat de 0.8x a 1.5x. Afeta apenas a aba Chat, não o restante do aplicativo",
"tt_chat_poll_rate": "Com que frequência verificar mensagens novas e 0-conf (0.5-15 s). Mais rápido é mais responsivo, mas usa mais CPU",
"tt_chat_timestamp": "Formato de horário apenas para esta aba: seguir o relógio geral do aplicativo, ou forçar 24-hour ou 12-hour",
"tt_clear_ztx": "Excluir histórico de z-transações em cache local",
"tt_clock_format": "Relógio de 24 ou 12 horas, em todo o app. O chat pode substituí-lo.",
"tt_custom_fees": "Ativar entrada manual de taxas ao enviar transações",
"tt_custom_theme": "Tema personalizado ativo",
"tt_daemon_install_bundled": "Parar o nó, sobrescrever o dragonxd instalado com a versão incluída nesta compilação da carteira e reiniciar",
"tt_daemon_refresh": "Reler a versão, o tamanho e a data do dragonxd instalado e do incorporado, mostrados acima",
"tt_daemon_update_check": "Baixe e verifique o nó completo dragonxd mais recente do Gitea do projeto e, em seguida, reinicie para aplicar",
"tt_debug_collapse": "Recolher opções de registro de depuração",
"tt_debug_expand": "Expandir opções de registro de depuração",
@@ -1336,7 +1491,30 @@
"tt_keep_daemon": "O daemon será parado ao executar o assistente de configuração",
"tt_language": "Idioma da interface da carteira",
"tt_layout_hotkey": "Atalho: teclas de seta esquerda/direita para alternar layouts de Saldo",
"tt_lite_copy": "Copiar o segredo revelado para a área de transferência",
"tt_lite_decrypt_pass": "Digite sua frase-senha para remover a criptografia da carteira",
"tt_lite_encrypt": "Criptografar a carteira com a frase-senha acima; ela é bloqueada imediatamente e exige a frase-senha para desbloquear",
"tt_lite_encrypt_pass": "Frase-senha com a qual criptografar a carteira. Se perdida, a carteira não pode ser desbloqueada nem recuperada",
"tt_lite_hide_wipe": "Ocultar o segredo revelado e apagá-lo com segurança da memória",
"tt_lite_import_key": "Cole uma chave privada de gasto ou de visualização para importar; seu histórico aparece após a próxima sincronização",
"tt_lite_import_key_btn": "Importar a chave privada informada para esta carteira; os fundos e o histórico aparecem após a próxima sincronização",
"tt_lite_lifecycle_op": "Escolha entre criar uma nova carteira, abrir uma existente ou restaurar uma a partir de uma frase de recuperação",
"tt_lite_lifecycle_pass": "Frase-senha para desbloquear ou definir na carteira durante esta operação de criar / abrir / restaurar",
"tt_lite_lifecycle_run": "Executar a operação selecionada de criar / abrir / restaurar com os valores acima",
"tt_lite_lifecycle_toggle": "Mostrar ou ocultar os controles de criar / abrir / restaurar para gerenciar o arquivo da sua carteira lite",
"tt_lite_lock": "Bloquear a carteira agora; uma frase-senha é necessária para desbloquear e qualquer sessão de chat é encerrada",
"tt_lite_redownload": "Rebaixar e reescanear todos os blocos do servidor lite",
"tt_lite_remove_encrypt": "Remover a criptografia e armazenar a carteira desprotegida; nenhuma frase-senha será necessária para abri-la",
"tt_lite_restore_account": "Índice da conta HD a restaurar; deixe 0 a menos que você tenha usado várias contas sob esta frase de recuperação",
"tt_lite_restore_birthday": "Altura de bloco em que a carteira foi criada; a varredura começa aqui. Use 0 ou a altura mais antiga se não tiver certeza",
"tt_lite_restore_overwrite": "Substituir um arquivo de carteira existente por esta restauração. Aviso: sobrescreve os dados da carteira atual",
"tt_lite_restore_seed": "A frase de recuperação de 24-word para restaurar esta carteira; oculta enquanto você digita",
"tt_lite_save_seed_file": "Gravar a frase de recuperação e a data de criação em um arquivo restrito ao dono (lite-seed-backup.txt) na pasta de configuração",
"tt_lite_show_keys": "Revelar as chaves privadas de gasto desta carteira. Qualquer pessoa com uma chave pode gastar os fundos que ela controla",
"tt_lite_show_seed": "Revelar a frase de recuperação e a data de criação desta carteira. Qualquer pessoa com a frase de recuperação pode gastar seus fundos",
"tt_lite_unlock": "Desbloquear a carteira criptografada usando a frase-senha acima",
"tt_lite_unlock_pass": "Digite sua frase-senha para desbloquear a carteira criptografada",
"tt_lite_wallet_path": "Caminho ou nome do arquivo da carteira a abrir ou para o qual restaurar",
"tt_lock": "Bloquear a carteira imediatamente",
"tt_low_spec": "Desativar todos os efeitos visuais pesados\\nAtalho: Ctrl+Shift+Down",
"tt_merge": "Consolidar múltiplos UTXOs em um endereço",
@@ -1357,12 +1535,17 @@
"tt_rpc_host": "Nome do host do daemon DragonX",
"tt_rpc_pass": "Senha de autenticação RPC",
"tt_rpc_port": "Porta para conexões RPC do daemon",
"tt_rpc_toggle": "Mostrar ou ocultar os detalhes de conexão RPC somente leitura (host, porta, usuário, senha) do daemon",
"tt_rpc_user": "Nome de usuário de autenticação RPC",
"tt_save_settings": "Salvar todas as configurações no disco",
"tt_save_ztx": "Armazenar histórico de transações z-address localmente para carregamento mais rápido",
"tt_scan_themes": "Procurar novos temas.\\nColoque pastas de temas em:\\n%s",
"tt_scanline": "Efeito de linhas de varredura CRT no console",
"tt_screenshot_open_dir": "Abrir a pasta screenshots (dentro do diretório de configuração) no seu gerenciador de arquivos",
"tt_screenshot_sweep": "Percorrer cada tema em cada aba, salvando uma captura de tela de cada uma na pasta screenshots de configuração (sobrescreve a última varredura)",
"tt_screenshot_sweep_full": "Como a varredura de temas, mas também captura cada modal / caixa de diálogo / fluxo usando dados temporários de carteira de demonstração offline",
"tt_seed_backup": "Mostrar e fazer backup da frase de recuperação de 24 palavras da sua carteira",
"tt_seed_demo_chat": "Injetar conversas de exemplo na aba Chat para que uma varredura capture sua interface; apenas na memória, some ao reiniciar",
"tt_seed_migrate": "Criar uma nova carteira com frase de recuperação e mover seus fundos para ela",
"tt_set_pin": "Definir um PIN de 4-8 dígitos para desbloqueio rápido",
"tt_shield_mining": "Mover recompensas de mineração transparentes para um endereço blindado",
@@ -1381,6 +1564,7 @@
"tt_website": "Abrir o site do DragonX",
"tt_window_opacity": "Opacidade do fundo (menor = área de trabalho visível através da janela)",
"tt_wizard": "Executar novamente o assistente de configuração inicial\\nO daemon será reiniciado",
"tx_chat_badge": "Mensagem",
"tx_confirmations": "%d confirmações",
"tx_details_title": "Detalhes da Transação",
"tx_from_address": "Endereço de Origem:",
@@ -1449,6 +1633,8 @@
"wallets_add_folder_toggle": "+ Procurar carteiras noutra pasta…",
"wallets_badge_encrypted": "Encriptada (protegida por senha)",
"wallets_badge_encrypted_short": "Encriptada",
"wallets_badge_hd": "Carteira HD — não é possível confirmar a frase-semente sem abri-la",
"wallets_badge_hd_short": "Carteira HD",
"wallets_badge_legacy": "Carteira legada (sem frase semente)",
"wallets_badge_legacy_short": "Legada",
"wallets_badge_seed": "Carteira com frase semente (HD)",

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": "Нет сохранённых адресов. Нажмите 'Добавить новый', чтобы создать.",
@@ -53,6 +54,7 @@
"amount_details": "ДЕТАЛИ СУММЫ",
"amount_exceeds_balance": "Сумма превышает баланс",
"amount_label": "Сумма:",
"animate_avatars": "Анимировать аватары",
"appearance": "ВНЕШНИЙ ВИД",
"auto_shield": "Авто-экранирование майнинга",
"av_intro": "Программы для майнинга часто помечаются как потенциально нежелательные. Выполните эти шаги, чтобы включить пул-майнинг:",
@@ -133,26 +135,99 @@
"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": "Очистить",
@@ -164,6 +239,7 @@
"click_copy_address": "Нажмите, чтобы скопировать адрес",
"click_copy_uri": "Нажмите, чтобы скопировать URI",
"click_to_copy": "Нажмите для копирования",
"clock_format": "Формат времени",
"close": "Закрыть",
"conf_count": "%d подтв.",
"confirm_and_send": "Подтвердить и отправить",
@@ -201,12 +277,18 @@
"console_app": "Прил.",
"console_auto_scroll": "Авто-прокрутка",
"console_available_commands": "Доступные команды:",
"console_backend_reference": "Справочник команд бэкенда",
"console_backend_unavailable": "Нет бэкенда",
"console_capturing_output": "Захват вывода daemon...",
"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": "Очистить",
@@ -240,11 +322,14 @@
"console_help_help": " help - Показать эту справку",
"console_help_setgenerate": " setgenerate - Управление майнингом",
"console_help_stop": " stop - Остановить daemon",
"console_last_error": "Последняя ошибка:",
"console_line_count": "%zu строк",
"console_matches": "совпадений",
"console_new_lines": "%d новых строк",
"console_no_daemon": "Нет daemon",
"console_no_output": "(нет вывода)",
"console_not_connected": "Ошибка: Не подключено к daemon",
"console_not_connected_lite": "Ошибка: Нет открытого кошелька",
"console_quit_note": "Здесь не нужны 'quit'/'exit' — просто закройте окно.",
"console_ref_builds": "Формирует",
"console_ref_cancel": "Отмена",
@@ -260,12 +345,14 @@
"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": "Показать вывод daemon",
"console_show_errors_only": "Показать только ошибки",
"console_show_rpc_ref": "Показать справочник RPC-команд",
@@ -278,6 +365,7 @@
"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": "Переключить цветовые акценты строк",
@@ -286,12 +374,34 @@
"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": "Копировать полный адрес",
@@ -464,6 +574,12 @@
"hide_qr": "Скрыть 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": "Адрес:",
@@ -528,6 +644,7 @@
"lite_birthday_backup": "Дата рождения: %llu (сохраните её тоже)",
"lite_birthday_hint": "Высота блока, с которой начинать сканирование. Оставьте 0, если неизвестно (медленное полное сканирование).",
"lite_birthday_label": "Дата рождения",
"lite_console_backend_commands": "Команды бэкенда:",
"lite_console_help_passthrough": "Любой другой ввод выполняется как команда консоли лайт-кошелька.",
"lite_copy": "Копировать",
"lite_could_not_write": "Не удалось записать ",
@@ -545,6 +662,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": "Скрытые серверы",
@@ -632,6 +750,9 @@
"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М",
@@ -640,13 +761,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": "Объём",
@@ -950,11 +1076,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": "Низкая конфиденциальность — экранируйте средства",
@@ -1270,6 +1397,23 @@
"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-адреса",
@@ -1308,6 +1452,7 @@
"try_again": "Повторить",
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
"tt_animate_avatars": "Воспроизводить анимированные аватары контактов (GIF / WebP); при отключении показывается только первый кадр",
"tt_auto_lock": "Заблокировать кошелёк после этого времени бездействия",
"tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности",
"tt_backup": "Создать резервную копию вашего wallet.dat",
@@ -1315,10 +1460,20 @@
"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_custom_fees": "Включить ручной ввод комиссий при отправке транзакций",
"tt_custom_theme": "Пользовательская тема активна",
"tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить",
"tt_daemon_refresh": "Перечитать версию, размер и дату установленного и встроенного dragonxd, показанные выше",
"tt_daemon_update_check": "Скачать и проверить последний полный узел dragonxd из проектного Gitea, затем перезапустить для применения",
"tt_debug_collapse": "Свернуть параметры журнала отладки",
"tt_debug_expand": "Развернуть параметры журнала отладки",
@@ -1336,7 +1491,30 @@
"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": "Выберите, создать новый кошелёк, открыть существующий или восстановить его из seed-фразы",
"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, если только вы не использовали несколько аккаунтов с этой seed-фразой",
"tt_lite_restore_birthday": "Высота блока, на которой был создан кошелёк; отсюда начинается сканирование. Если не уверены, используйте 0 или самую раннюю высоту",
"tt_lite_restore_overwrite": "Заменить существующий файл кошелька этим восстановлением. Внимание: перезаписывает текущие данные кошелька",
"tt_lite_restore_seed": "24-word seed-фраза для восстановления этого кошелька; скрывается по мере ввода",
"tt_lite_save_seed_file": "Записать seed-фразу и дату создания в файл, доступный только владельцу (lite-seed-backup.txt), в папке конфигурации",
"tt_lite_show_keys": "Показать приватные ключи расходования этого кошелька. Любой, у кого есть ключ, может потратить контролируемые им средства",
"tt_lite_show_seed": "Показать seed-фразу восстановления и дату создания этого кошелька. Любой, у кого есть seed-фраза, может потратить ваши средства",
"tt_lite_unlock": "Разблокировать зашифрованный кошелёк с помощью пароля выше",
"tt_lite_unlock_pass": "Введите пароль для разблокировки зашифрованного кошелька",
"tt_lite_wallet_path": "Путь или имя файла кошелька для открытия или восстановления",
"tt_lock": "Немедленно заблокировать кошелёк",
"tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down",
"tt_merge": "Объединить несколько UTXO в один адрес",
@@ -1357,12 +1535,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-адреса локально для более быстрой загрузки",
"tt_scan_themes": "Поиск новых тем.\\nРазместите папки тем в:\\n%s",
"tt_scanline": "Эффект развёртки ЭЛТ в консоли",
"tt_screenshot_open_dir": "Открыть папку скриншотов (в каталоге конфигурации) в вашем файловом менеджере",
"tt_screenshot_sweep": "Перебрать каждую тему по всем вкладкам, сохраняя скриншот каждой в папку скриншотов конфигурации (перезаписывает предыдущий проход)",
"tt_screenshot_sweep_full": "Как проход по темам, но также захватывает каждое модальное окно / диалог / поток, используя временные офлайн-данные демонстрационного кошелька",
"tt_seed_backup": "Показать и создать резервную копию сид-фразы восстановления вашего кошелька из 24 слов",
"tt_seed_demo_chat": "Добавить примеры переписок во вкладку «Чат», чтобы проход захватил её интерфейс; только в памяти, исчезает при перезапуске",
"tt_seed_migrate": "Создать новый кошелёк с сид-фразой и перевести в него ваши средства",
"tt_set_pin": "Установить 4-8-значный PIN для быстрой разблокировки",
"tt_shield_mining": "Перевести прозрачные вознаграждения за майнинг на экранированный адрес",
@@ -1381,6 +1564,7 @@
"tt_website": "Открыть сайт DragonX",
"tt_window_opacity": "Непрозрачность фона (ниже = рабочий стол виден сквозь окно)",
"tt_wizard": "Повторно запустить мастер начальной настройки\\nДемон будет перезапущен",
"tx_chat_badge": "Сообщение",
"tx_confirmations": "%d подтверждений",
"tx_details_title": "Детали транзакции",
"tx_from_address": "Адрес отправителя:",
@@ -1449,6 +1633,8 @@
"wallets_add_folder_toggle": "+ Искать кошельки в другой папке…",
"wallets_badge_encrypted": "Зашифрован (защищён паролем)",
"wallets_badge_encrypted_short": "Зашифрован",
"wallets_badge_hd": "HD-кошелёк — невозможно подтвердить seed-фразу без открытия",
"wallets_badge_hd_short": "HD-кошелёк",
"wallets_badge_legacy": "Устаревший кошелёк (без seed-фразы)",
"wallets_badge_legacy_short": "Устаревший",
"wallets_badge_seed": "Кошелёк с seed-фразой (HD)",

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": "没有保存的地址。点击'添加新地址'创建一个。",
@@ -53,6 +54,7 @@
"amount_details": "金额详情",
"amount_exceeds_balance": "金额超过余额",
"amount_label": "金额:",
"animate_avatars": "动画头像",
"appearance": "外观",
"auto_shield": "自动屏蔽挖矿",
"av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:",
@@ -133,26 +135,99 @@
"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": "清除",
@@ -164,6 +239,7 @@
"click_copy_address": "点击复制地址",
"click_copy_uri": "点击复制 URI",
"click_to_copy": "点击复制",
"clock_format": "时间格式",
"close": "关闭",
"conf_count": "%d 确认",
"confirm_and_send": "确认并发送",
@@ -201,12 +277,18 @@
"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": "清除",
@@ -240,11 +322,14 @@
"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": "取消",
@@ -260,12 +345,14 @@
"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 命令参考",
@@ -278,6 +365,7 @@
"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": "切换行颜色强调",
@@ -286,12 +374,34 @@
"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": "复制完整地址",
@@ -464,6 +574,12 @@
"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": "地址:",
@@ -528,6 +644,7 @@
"lite_birthday_backup": "生日区块:%llu (也请一并备份)",
"lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0完整扫描更慢。",
"lite_birthday_label": "诞生区块",
"lite_console_backend_commands": "后端命令:",
"lite_console_help_passthrough": "其他任何输入都将作为轻钱包控制台命令运行。",
"lite_copy": "复制",
"lite_could_not_write": "无法写入 ",
@@ -545,6 +662,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": "隐藏的服务器",
@@ -632,6 +750,9 @@
"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",
@@ -640,13 +761,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": "成交量",
@@ -950,11 +1076,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": "隐私性低——请屏蔽资金",
@@ -1270,6 +1397,23 @@
"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 地址",
@@ -1308,6 +1452,7 @@
"try_again": "重试",
"tt_addr_url": "在区块浏览器中查看地址的基础 URL",
"tt_address_book": "管理已保存的地址以快速发送",
"tt_animate_avatars": "播放动画联系人头像GIF / WebP关闭时仅显示第一帧",
"tt_auto_lock": "在此不活动时间后锁定钱包",
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
"tt_backup": "创建 wallet.dat 的备份",
@@ -1315,10 +1460,20 @@
"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_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": "展开调试日志选项",
@@ -1336,7 +1491,30 @@
"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 合并到一个地址",
@@ -1357,12 +1535,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": "将透明挖矿奖励转移到屏蔽地址",
@@ -1381,6 +1564,7 @@
"tt_website": "打开 DragonX 网站",
"tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)",
"tt_wizard": "重新运行初始设置向导\\n守护进程将被重启",
"tx_chat_badge": "消息",
"tx_confirmations": "%d 次确认",
"tx_details_title": "交易详情",
"tx_from_address": "发送地址:",
@@ -1449,6 +1633,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)",

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 }

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 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>... # 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,18 +284,27 @@ fi
header "Windows Cross-Compile"
if $SETUP_WIN; then
win_pkgs="$(get_pkgs win)"
if [[ -n "$win_pkgs" ]]; then
install_pkgs "$win_pkgs" "Windows cross-compile"
fi
# 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"
fi
# Set posix thread model if available
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
if ! $CHECK_ONLY; then
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
# Set posix thread model if available
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
if ! $CHECK_ONLY; then
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
fi
fi
fi
@@ -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)

View File

@@ -73,6 +73,9 @@
#include "util/text_format.h"
#include "util/payment_uri.h"
#include "util/texture_loader.h"
#include "util/svg_texture.h"
#include "ui/material/colors.h"
#include "logo_dragonx_svg.h"
#include "util/bootstrap.h"
#include "util/secure_vault.h"
#include "resources/embedded_resources.h"
@@ -293,6 +296,9 @@ bool App::init()
if (!settings_->load()) {
DEBUG_LOGF("Warning: Could not load settings, using defaults\n");
}
// The initial font atlas is built (in main, before App renders) with monochrome emoji. If the saved
// setting wants color, request a rebuild so the first preFrame switches to the FreeType color atlas.
if (settings_->getChatEmojiColor()) font_rebuild_requested_ = true;
// On upgrade (version mismatch), re-save to persist new defaults + current version
if (settings_->needsUpgradeSave()) {
DEBUG_LOGF("[INFO] Wallet upgraded — re-saving settings with new defaults\n");
@@ -334,8 +340,9 @@ bool App::init()
// Ensure ObsidianDragon config directory and template files exist
util::Platform::ensureObsidianDragonSetup();
// Initialize PIN vault
vault_ = std::make_unique<util::SecureVault>();
// Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never
// offered for another (the default wallet keeps the legacy vault.dat).
vault_ = std::make_unique<util::SecureVault>(settings_ ? settings_->getActiveWalletFile() : "");
// Theme is now applied via SkinManager below after UISchema loads.
// The old SetThemeById() C++ fallback is no longer needed at startup
@@ -531,6 +538,21 @@ void App::preFrame()
DEBUG_LOGF("App: Font atlas rebuilt after user font-scale change (%.1fx)\n",
ui::Layout::userFontScale());
}
// Keep Typography's emoji-style flag in sync with the setting so any reload (font-scale, DPI, or the
// explicit request below) picks up the right emoji font. Inert unless this is a FreeType build.
if (settings_) ui::material::Typography::instance().setColorEmoji(settings_->getChatEmojiColor());
// App-wide clock format (24h/12h) — drives every user-facing timestamp via util::formatClock*.
if (settings_) util::setClock12h(settings_->getTimeFormat() == 1);
// Explicit rebuild request (e.g. the chat color-emoji toggle) — reload picks up the new emoji style.
if (font_rebuild_requested_) {
font_rebuild_requested_ = false;
auto& typo = ui::material::Typography::instance();
typo.reload(io, typo.getDpiScale());
DEBUG_LOGF("App: Font atlas rebuilt on request (chat emoji style)\n");
}
}
namespace {
@@ -634,12 +656,7 @@ void App::rebuildLiteWallet(bool force)
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
// identity + decrypted messages, lock the DB, and re-arm provisioning so it re-derives (and
// reloads the right wallet's history) from the newly opened wallet's seed.
chat_service_.clearIdentity();
chat_service_.store().clear();
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_fetch_in_flight_ = false;
chat_identity_unavailable_ = false;
resetChatSession();
}
void App::update()
@@ -653,6 +670,9 @@ void App::update()
// capture_mode_ is only set for the offline full sweep, so the live tab-only sweep is unaffected.
if (capture_mode_) return;
// If a wallet switch's daemon failed to start, revert to the previous wallet (main-thread-safe).
processWalletSwitchRevert();
// Track user interaction for auto-lock
if (io.MouseDelta.x != 0 || io.MouseDelta.y != 0 ||
io.MouseClicked[0] || io.MouseClicked[1] ||
@@ -718,20 +738,40 @@ void App::update()
// HushChat (lite): harvest chat memos from the refreshed transactions and thread them
// (no-op when the feature is off or no identity; the store dedups across refreshes).
ingestLiteChatMemos(liteModel);
// Chat note-buffer: recompute the verified/maturing self-note estimates from the fresh model.
refreshChatNoteBudget(liteModel);
}
// Deliver a completed async send/shield result to the waiting send_tab callback.
// Deliver a completed async send/shield result. Route by the in-flight op that owns the single
// broadcast channel: a user Send-tab send goes to lite_send_callback_; a chat send / contact
// request resolves its echo (real status — not the old optimistic "Sent"); a note-buffer split
// just logs. Because the controller runs one broadcast at a time, inflight_op_ correlates the
// one global result with no txid matching.
wallet::LiteBroadcastResult broadcast;
if (lite_wallet_->takeBroadcastResult(broadcast)) {
// Mirror failures into the lite Console (copyable) in addition to the toast the send UI
// shows — transient toasts are easy to miss and impossible to copy.
if (!broadcast.ok)
wallet::liteLog("Send/shield failed: " + broadcast.error);
if (lite_send_callback_) {
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
lite_send_callback_ = nullptr;
const LiteInflightOp finished = inflight_op_;
inflight_op_ = LiteInflightOp{}; // channel is free again
switch (finished.kind) {
case LiteOpKind::ChatSend:
case LiteOpKind::ContactRequest:
onChatBroadcastResult(finished, broadcast.ok, broadcast.error);
break;
case LiteOpKind::Split:
// Buffer split done; the next refresh re-counts the new (maturing) notes.
break;
case LiteOpKind::UserSend:
case LiteOpKind::None:
default:
if (lite_send_callback_) {
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error);
lite_send_callback_ = nullptr;
}
break;
}
}
// Startup lock screen: once the first refresh reveals the (auto-opened) wallet is
// encrypted, prompt to unlock if it's locked. Soft by design — balances stay viewable via
// viewing keys while locked; only spending needs the passphrase, so the user may dismiss
@@ -748,6 +788,13 @@ void App::update()
// it can act; compiled away when the feature is off (constexpr gate inside).
maybeProvisionChatIdentity();
// Chat note-buffer coordinator (BOTH variants). Full-node only: refresh the per-note counts via a
// rate-limited z_listunspent worker scan (lite gets its counts from the refresh model, above). Then
// pump: drain queued chat sends against verified notes and build/refill the buffer during idle. Both
// self-gate to no-ops unless chat is engaged; the pump serializes to one send outstanding at a time.
refreshChatNoteBudgetNode();
pumpChatNoteBuffer();
// One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup();
@@ -1170,6 +1217,16 @@ void App::update()
refreshRecentTransactionData();
}
}
// 0-conf chat: a DEDICATED ~2.5s mempool scan of the chat address, independent of the page's
// (slower, 1015s) Transactions cadence — so incoming messages surface in a few seconds. The
// normal harvest only re-scans on a new block. Self-gated (no-op without a chat identity /
// connection / on lite); the in-flight guard keeps overlapping RPCs from stacking.
chat_fast_scan_accum_ += ImGui::GetIO().DeltaTime;
const float chatPoll = settings_ ? settings_->getChatPollRateSec() : 2.5f; // user-configurable
if (chat_fast_scan_accum_ >= chatPoll) {
chat_fast_scan_accum_ = 0.0f;
fastScanChatMemos();
}
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) {
refreshAddressData();
@@ -1299,6 +1356,103 @@ void App::handleGlobalShortcuts()
}
}
// Load (and recolor per theme) the DragonX header logo texture. Called at the top of render() — BEFORE
// the first-run-wizard / lock-screen paths — so every screen shows the mark. The SVG is recolored to the
// theme accent, so it re-rasterizes whenever the accent or the dark↔light variant changes.
void App::ensureLogoTexture()
{
const bool wantDark = ui::material::IsDarkTheme();
const ImU32 logoAccent = ui::material::Primary();
if (logo_loaded_ && wantDark == logo_is_dark_variant_ && logoAccent == logo_accent_)
return; // already current
logo_loaded_ = true;
logo_is_dark_variant_ = wantDark;
logo_accent_ = logoAccent;
// The SVG's white highlight (detail) stays white on dark skins, but darkens to the theme's on-surface
// colour on light skins so it doesn't wash out against a light card/background.
const ImU32 detailCol = wantDark ? IM_COL32(255, 255, 255, 255) : ui::material::OnSurface();
// ":drgx:" custom chat emoji — themed to the accent like the logo (re-rasterized on theme change).
if (drgx_emoji_tex_) util::DestroyTexture(drgx_emoji_tex_);
drgx_emoji_tex_ = 0; drgx_emoji_w_ = 0; drgx_emoji_h_ = 0;
util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 96, logoAccent, detailCol,
&drgx_emoji_tex_, &drgx_emoji_w_, &drgx_emoji_h_);
if (logo_tex_) util::DestroyTexture(logo_tex_); // free the previous texture before replacing
logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;
// Coin/currency icon (the big DragonX mark on the balance card) — the SAME themed SVG recolored to
// the accent, rasterized here so it changes with the skin too (done first, since the header block
// below early-returns on success). Falls back to the PNG coin icon if rasterization fails.
if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
coin_logo_tex_ = 0; coin_logo_w_ = 0; coin_logo_h_ = 0;
coin_logo_loaded_ = true;
if (!util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 128, logoAccent, detailCol,
&coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
auto coinElem = ui::schema::UI().drawElement("components.main-window", "coin-icon");
auto cit = coinElem.extraColors.find("icon");
std::string coinFile = (cit != coinElem.extraColors.end() && !cit->second.empty())
? cit->second : "logos/logo_dragonx_128.png";
std::string coinPath = util::getExecutableDirectory() + "/res/img/" + coinFile;
std::error_code coinEc;
if (!(std::filesystem::exists(coinPath, coinEc) &&
util::LoadTextureFromFile(coinPath.c_str(), &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_))) {
std::string coinBasename = std::filesystem::path(coinFile).filename().string();
const auto* coinRes = resources::getEmbeddedResource(coinBasename);
if (coinRes && coinRes->data && coinRes->size > 0)
util::LoadTextureFromMemory(coinRes->data, coinRes->size, &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_);
}
}
// 0) DragonX mark — rasterize the embedded SVG recolored to the theme (body = accent, detail = white)
// at ~2x the 128px viewBox for crisp downscaling. This is the branding on every skin; the per-skin
// PNG path below is only a fallback if rasterization ever fails.
if (util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 256, logoAccent,
detailCol, &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Rendered DragonX SVG logo (%dx%d, accent %08X)\n", logo_w_, logo_h_, logoAccent);
return;
}
// 1) Fallback — theme-override logo from the active skin
const auto* activeSkin = ui::schema::SkinManager::instance().findById(
ui::schema::SkinManager::instance().activeSkinId());
std::string logoPath;
if (activeSkin && !activeSkin->logoPath.empty()) {
logoPath = activeSkin->logoPath;
} else {
// 2) Read icon filename from ui.toml (dark/light variant)
auto iconElem = ui::schema::UI().drawElement("components.main-window", "header-icon");
const char* iconKey = wantDark ? "icon-dark" : "icon-light";
auto it = iconElem.extraColors.find(iconKey);
std::string iconFile;
if (it != iconElem.extraColors.end() && !it->second.empty())
iconFile = it->second;
else
iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
}
// Only attempt the disk read when the file is actually present (dev build / theme drop-in). The
// portable single-file build has no res/img/ beside it, so skip straight to the embedded copy.
std::error_code logoEc;
if (std::filesystem::exists(logoPath, logoEc) &&
util::LoadTextureFromFile(logoPath.c_str(), &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from %s (%dx%d)\n", logoPath.c_str(), logo_w_, logo_h_);
} else {
std::string embeddedName = std::filesystem::path(logoPath).filename().string();
const auto* logoRes = resources::getEmbeddedResource(embeddedName);
if (!logoRes || !logoRes->data || logoRes->size == 0)
logoRes = resources::getEmbeddedResource(resources::RESOURCE_LOGO);
if (logoRes && logoRes->data && logoRes->size > 0) {
if (util::LoadTextureFromMemory(logoRes->data, logoRes->size, &logo_tex_, &logo_w_, &logo_h_))
DEBUG_LOGF("Loaded header logo from embedded: %s (%dx%d)\n", embeddedName.c_str(), logo_w_, logo_h_);
else
DEBUG_LOGF("Note: Failed to decode embedded logo (text-only header)\n");
} else {
DEBUG_LOGF("Note: Header logo not found at %s (text-only header)\n", logoPath.c_str());
}
}
}
void App::render()
{
// Advance the screenshot sweep FIRST — before the first-run-wizard early-return below — so the
@@ -1306,6 +1460,9 @@ void App::render()
// (Pins current_page_ too, before the sidebar reads it further down.)
updateScreenshotSweep();
// DragonX logo — load/recolor before the wizard/lock early-returns so every screen shows it.
ensureLogoTexture();
// First-run wizard gate — blocks all normal UI
if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) {
renderFirstRunWizard();
@@ -1504,95 +1661,11 @@ void App::render()
ui::SidebarStatus sbStatus;
sbStatus.peerCount = static_cast<int>(state_.peers.size());
sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running;
sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1)
// Load logo texture lazily on first frame (or after theme change)
// Also reload when dark↔light mode changes so the correct variant shows
{
bool wantDark = ui::material::IsDarkTheme();
if (!logo_loaded_ || (wantDark != logo_is_dark_variant_)) {
logo_loaded_ = true;
logo_is_dark_variant_ = wantDark;
logo_tex_ = 0; logo_w_ = 0; logo_h_ = 0;
// (DragonX logo is loaded at the top of render() via ensureLogoTexture().)
// 1) Check for theme-override logo from active skin
const auto* activeSkin = ui::schema::SkinManager::instance().findById(
ui::schema::SkinManager::instance().activeSkinId());
std::string logoPath;
if (activeSkin && !activeSkin->logoPath.empty()) {
logoPath = activeSkin->logoPath;
} else {
// 2) Read icon filename from ui.toml (dark/light variant)
auto iconElem = ui::schema::UI().drawElement("components.main-window", "header-icon");
const char* iconKey = wantDark ? "icon-dark" : "icon-light";
auto it = iconElem.extraColors.find(iconKey);
std::string iconFile;
if (it != iconElem.extraColors.end() && !it->second.empty()) {
iconFile = it->second;
} else {
// Fallback filenames
iconFile = wantDark ? "logos/logo_ObsidianDragon_dark.png" : "logos/logo_ObsidianDragon_light.png";
}
logoPath = util::getExecutableDirectory() + "/res/img/" + iconFile;
}
// Only attempt the disk read when the file is actually present (dev build / theme drop-in).
// The portable single-file build has no res/img/ beside it, so skip straight to the
// embedded copy instead of logging a spurious "failed to read".
std::error_code logoEc;
if (std::filesystem::exists(logoPath, logoEc) &&
util::LoadTextureFromFile(logoPath.c_str(), &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from %s (%dx%d)\n", logoPath.c_str(), logo_w_, logo_h_);
} else {
// Try embedded data fallback — use actual filename from path
// so light/dark variants resolve correctly on Windows single-file
std::string embeddedName = std::filesystem::path(logoPath).filename().string();
const auto* logoRes = resources::getEmbeddedResource(embeddedName);
if (!logoRes || !logoRes->data || logoRes->size == 0) {
// Final fallback: try the default dark logo constant
logoRes = resources::getEmbeddedResource(resources::RESOURCE_LOGO);
}
if (logoRes && logoRes->data && logoRes->size > 0) {
if (util::LoadTextureFromMemory(logoRes->data, logoRes->size, &logo_tex_, &logo_w_, &logo_h_)) {
DEBUG_LOGF("Loaded header logo from embedded: %s (%dx%d)\n", embeddedName.c_str(), logo_w_, logo_h_);
} else {
DEBUG_LOGF("Note: Failed to decode embedded logo (text-only header)\n");
}
} else {
DEBUG_LOGF("Note: Header logo not found at %s (text-only header)\n", logoPath.c_str());
}
}
}
}
// Load coin logo texture lazily (DragonX currency icon for balance tab)
if (!coin_logo_loaded_) {
coin_logo_loaded_ = true;
coin_logo_tex_ = 0; coin_logo_w_ = 0; coin_logo_h_ = 0;
// Read coin icon filename from ui.toml
auto coinElem = ui::schema::UI().drawElement("components.main-window", "coin-icon");
auto cit = coinElem.extraColors.find("icon");
std::string coinFile = (cit != coinElem.extraColors.end() && !cit->second.empty())
? cit->second : "logos/logo_dragonx_128.png";
std::string coinPath = util::getExecutableDirectory() + "/res/img/" + coinFile;
std::error_code coinEc;
if (std::filesystem::exists(coinPath, coinEc) &&
util::LoadTextureFromFile(coinPath.c_str(), &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
DEBUG_LOGF("Loaded coin logo from %s (%dx%d)\n", coinPath.c_str(), coin_logo_w_, coin_logo_h_);
} else {
// Try embedded resource fallback (Windows single-file distribution)
std::string coinBasename = std::filesystem::path(coinFile).filename().string();
const auto* coinRes = resources::getEmbeddedResource(coinBasename);
if (coinRes && coinRes->data && coinRes->size > 0) {
if (util::LoadTextureFromMemory(coinRes->data, coinRes->size, &coin_logo_tex_, &coin_logo_w_, &coin_logo_h_)) {
DEBUG_LOGF("Loaded coin logo from embedded: %s (%dx%d)\n", coinBasename.c_str(), coin_logo_w_, coin_logo_h_);
} else {
DEBUG_LOGF("Note: Failed to decode embedded coin logo\n");
}
} else {
DEBUG_LOGF("Note: Coin logo not found at %s\n", coinPath.c_str());
}
}
}
// (coin logo is loaded/themed at the top of render() via ensureLogoTexture().)
if (logo_tex_ != 0) {
sbStatus.logoTexID = logo_tex_;
@@ -1977,8 +2050,8 @@ void App::render()
// Send confirm popup
ui::RenderSendConfirmPopup(this);
// Console RPC Command Reference popup
console_tab_.renderCommandsPopupModal();
// Console command-reference popup (full-node RPC reference / lite backend verbs).
console_tab_.renderCommandsPopupModal(console_exec_.get());
// Key export dialog (triggered from balance tab context menu)
ui::KeyExportDialog::render(this);
@@ -2035,6 +2108,7 @@ void App::render()
renderEncryptWalletDialog();
renderDecryptWalletDialog();
renderPinDialogs();
renderSwitchStopDaemonDialog();
// Render notifications (toast messages)
ui::Notifications::instance().render();
@@ -2295,17 +2369,32 @@ void App::renderStatusBar()
// Connection / daemon status sits to the left of the version string
// with a small gap.
float gap = sbSectionGap;
float occupiedX = versionX; // leftmost X used by the version + connection status so far
if (!connection_status_.empty() && connection_status_ != "Connected") {
float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
float statusX = versionX - statusW - gap;
ImGui::SameLine(statusX);
ImGui::TextDisabled("%s", connection_status_.c_str());
occupiedX = statusX;
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
const char* errText = TR("sb_daemon_not_found");
float statusW = ImGui::CalcTextSize(errText).x;
float statusX = versionX - statusW - gap;
ImGui::SameLine(statusX);
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "%s", errText);
occupiedX = statusX;
}
// Chat note-buffer status — only while the Chat tab is active. Sits left of the version/connection
// block. Surfaces the buffer filling/draining (and doubles as a diagnostic for send readiness).
if (current_page_ == ui::NavPage::Chat) {
const std::string cb = chatBufferStatusText();
if (!cb.empty()) {
float cbW = ImGui::CalcTextSize(cb.c_str()).x;
float cbX = occupiedX - cbW - gap;
ImGui::SameLine(cbX);
ImGui::TextUnformatted(cb.c_str());
}
}
// Version always at far right
@@ -2358,12 +2447,15 @@ void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPa
gradient_tex_ = 0;
}
// Reset logo loaded flags — will reload on next render frame
// Reset logo loaded flags — will reload on next render frame (ensureLogoTexture re-rasterizes the
// SVG for the new theme). Free the current texture first so a theme switch doesn't leak it.
if (logo_tex_) util::DestroyTexture(logo_tex_);
logo_loaded_ = false;
logo_tex_ = 0;
logo_w_ = 0;
logo_h_ = 0;
coin_logo_loaded_ = false;
if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
coin_logo_tex_ = 0;
coin_logo_w_ = 0;
coin_logo_h_ = 0;
@@ -3861,6 +3953,125 @@ void App::renderAntivirusHelpDialog()
#endif
}
void App::renderSwitchStopDaemonDialog()
{
const bool confirm = show_switch_stop_daemon_confirm_;
const bool progress = wallet_switch_dialog_open_.load();
if (!confirm && !progress) return;
const auto phase = static_cast<WalletSwitchPhase>(wallet_switch_phase_.load());
const bool failed = progress && phase == WalletSwitchPhase::Failed;
// Prettify a wallet filename for display (wallet.dat → "Default wallet", wallet-savings.dat → "savings").
auto pretty = [this](const std::string& f) -> std::string {
if (f.empty() || f == "wallet.dat") return TR("switch_progress_default_wallet");
std::string s = f;
if (s.size() > 4 && s.substr(s.size() - 4) == ".dat") s.resize(s.size() - 4);
if (s.rfind("wallet-ip-", 0) == 0) return TR("switch_progress_external_wallet");
if (s.rfind("wallet-", 0) == 0) s = s.substr(7);
return s;
};
const std::string toName = pretty(settings_ ? settings_->getActiveWalletFile() : std::string());
// Progress title carries the target wallet ("Switching wallet — savings"); keep the string alive.
std::string titleStr = confirm ? std::string(TR("switch_stopnode_title"))
: failed ? std::string(TR("switch_progress_failed_title"))
: std::string(TR("switch_progress_title")) + "" + toName;
ui::material::OverlayDialogSpec ov;
ov.title = titleStr.c_str();
// A confirm can be dismissed via X/backdrop (= cancel); a running switch cannot (stay until it's up).
ov.p_open = confirm ? &show_switch_stop_daemon_confirm_ : nullptr;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 520.0f; ov.idSuffix = "switchstopnode";
if (!ui::material::BeginOverlayDialog(ov)) {
if (confirm && !show_switch_stop_daemon_confirm_) pending_switch_wallet_file_.clear(); // X = cancel
return;
}
const float dp = ui::Layout::dpiScale();
if (confirm) {
ui::material::DialogWarningHeader(TR("switch_stopnode_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("switch_stopnode_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("switch_stopnode_confirm"), ImVec2(200.0f * dp, 0))) {
const std::string w = pending_switch_wallet_file_;
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
switchToWallet(w, /*stopDaemonConfirmed=*/true); // opens the live-progress phase below
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) {
pending_switch_wallet_file_.clear();
show_switch_stop_daemon_confirm_ = false;
}
} else if (failed) {
// The title already says "Wallet switch failed" — put the actual reason in the warning header
// rather than repeating the title.
ui::material::DialogWarningHeader(wallet_switch_error_.c_str());
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// If the node reported the target wallet as corrupt, offer a one-click -salvagewallet repair
// (retries the switch to that wallet with recovery enabled).
if (switch_wallet_corrupt_.load()) {
if (ui::material::TactileButton(TR("switch_corrupt_repair"), ImVec2(240.0f * dp, 0))) {
const std::string target = wallet_switch_target_file_;
switchToWallet(target, /*stopDaemonConfirmed=*/true, /*salvage=*/true); // reopens the progress modal
}
ImGui::SameLine();
}
if (ui::material::TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) {
wallet_switch_dialog_open_.store(false);
wallet_switch_phase_.store(static_cast<int>(WalletSwitchPhase::None));
wallet_switch_error_.clear();
}
} else {
// Live progress, kept redrawing by isWalletSwitchInProgress().
// Source context (the target is already in the title).
ImGui::PushFont(ui::material::Type().caption());
ImGui::TextDisabled("%s %s", TR("switch_progress_from_label"), pretty(wallet_switch_prev_file_).c_str());
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Primary status + detail. During Reconnecting the node is loading the block index / verifying /
// rescanning — surface its live init stage (state_.warmup_status/description) instead of a bare
// "Reconnecting…", so the ~30-60s wait shows real progress.
std::string primary, detail;
if (phase == WalletSwitchPhase::Stopping) {
primary = TR("switch_progress_stopping");
detail = TR("switch_progress_hint");
} else if (phase == WalletSwitchPhase::Starting) {
primary = TR("switch_progress_starting");
} else { // Reconnecting
if (!state_.warmup_status.empty()) { primary = state_.warmup_status; detail = state_.warmup_description; }
else primary = TR("switch_progress_reconnecting");
}
ui::material::Type().text(ui::material::TypeStyle::Subtitle1,
(primary + ui::material::LoadingDots()).c_str());
if (!detail.empty()) {
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::PushFont(ui::material::Type().body2());
ImGui::TextWrapped("%s", detail.c_str());
ImGui::PopFont();
}
// Elapsed timer — captured on the first progress frame (reset per switch in switchToWallet).
if (wallet_switch_started_time_ <= 0.0) wallet_switch_started_time_ = ImGui::GetTime();
const int el = static_cast<int>(ImGui::GetTime() - wallet_switch_started_time_);
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::PushFont(ui::material::Type().caption());
ImGui::TextDisabled("%s %d:%02d", TR("switch_progress_elapsed"), el / 60, el % 60);
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
// Escape hatch — the switch keeps running in the background (a toast reports the result).
if (ui::material::TactileButton(TR("switch_progress_background"), ImVec2(220.0f * dp, 0)))
wallet_switch_dialog_open_.store(false);
}
ui::material::EndOverlayDialog();
}
void App::refreshNow()
{
// Trigger immediate refresh on all categories
@@ -4133,6 +4344,56 @@ bool App::isEmbeddedDaemonRunning() const
return daemon_controller_ && daemon_controller_->isRunning();
}
bool App::stopDaemonForWalletSwitch()
{
// "Owned" means we spawned the node this session and hold a live process handle. That is the ONLY
// reliable ownership signal here: externalDaemonDetected() is latched only inside EmbeddedDaemon::start(),
// so when the app just direct-connects to an already-running, config-provided node (the common
// keep-node-running case) start() is never called and that flag stays false even though we clearly did
// not start the node. Gate on the handle instead.
const bool owned = daemon_controller_ && isEmbeddedDaemonRunning();
if (owned) {
// We spawned it — stopEmbeddedDaemon() BLOCKS for full process exit (handle wait + SIGTERM/SIGKILL
// escalation), so the datadir lock + RPC port are released by the time it returns; a short port
// check just confirms.
stopEmbeddedDaemon();
for (int i = 0; i < 200 && daemon::EmbeddedDaemon::isRpcPortInUse() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return shutting_down_ || !daemon::EmbeddedDaemon::isRpcPortInUse();
}
// Adopted OR direct-connected: no process handle, so RPC "stop" is the only lever, and
// stopEmbeddedDaemon()'s temp connection (autoDetectConfig) can't be trusted here. Send a graceful RPC
// "stop" over the exact creds we're connected with (saved_config_) — guaranteed to reach our node, and
// only OUR node accepts them, so a foreign dragonxd (different rpcpassword) is a safe no-op. switchToWallet
// disconnects rpc_ before this worker runs, so build a fresh temporary connection.
bool sent = false;
{
auto tmp = std::make_unique<rpc::RPCClient>();
if (tmp->connect(saved_config_.host, saved_config_.port, saved_config_.rpcuser,
saved_config_.rpcpassword, saved_config_.use_tls)) {
sent = sendStopCommandSafely(*tmp, "wallet-switch stop");
tmp->disconnect();
}
}
DEBUG_LOGF("[App] wallet-switch stop of unowned node (saved_config_): %s\n", sent ? "sent" : "FAILED");
// CRITICAL: wait for the node to FULLY EXIT before the caller starts the replacement — NOT just for the
// RPC port. On Windows isRpcPortInUse() is a connect() probe that reads "free" the moment the daemon
// stops accepting RPC (early in shutdown), but the process keeps the DATADIR LOCK until it exits, and a
// graceful shutdown can take 60-90s when the node's network threads block on peer timeouts. Starting a
// replacement into a still-locked datadir fails ("Cannot obtain a lock…") and wedges the switch. So gate
// on the PROCESS being gone (isDaemonProcessRunning) AND the port free. Bounded ~120s while stopping; if
// we couldn't even send the stop (foreign node / bad creds → its port never frees), only a brief grace.
const int maxTicks = sent ? 1200 : 50;
auto stillUp = []() {
return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning();
};
for (int i = 0; i < maxTicks && stillUp() && !shutting_down_; ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
return shutting_down_ || !stillUp();
}
void App::rescanBlockchain()
{
if (!supportsFullNodeLifecycleActions()) {
@@ -4154,6 +4415,11 @@ void App::rescanBlockchain()
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
// Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start.
if (daemon_restarting_) {
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
return;
}
DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -rescan flag...");
@@ -4201,6 +4467,10 @@ void App::repairWallet()
ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress.");
return;
}
if (daemon_restarting_) {
ui::Notifications::instance().warning("The node is busy restarting — try again in a moment.");
return;
}
DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n");
ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)...");
@@ -4374,6 +4644,10 @@ void App::beginShutdown()
// the adopt task will NOT restart the daemon (it checks shutting_down_ before startEmbeddedDaemon).
if (async_tasks_.isRunning("Adopt seed wallet"))
async_tasks_.join("Adopt seed wallet");
// The wallet-switch task also drives daemon stop/start — let it finish before shutdown touches the
// daemon so it can't orphan a freshly-started dragonxd (it checks shutting_down_ before starting).
if (async_tasks_.isRunning("Switch wallet"))
async_tasks_.join("Switch wallet");
// Signal the RPC worker to stop accepting new tasks (non-blocking), and abort any call
// already in flight so the later join() doesn't wait out a request timeout.
@@ -5046,6 +5320,11 @@ void App::renderLoadingOverlay(float contentH)
void App::shutdown()
{
// Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer
// never fires if the user quits sooner, which would otherwise leave a key/seed resident.
// (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().)
clearSecretClipboardIfArmed();
// Clean up bootstrap if running
if (bootstrap_) {
bootstrap_->cancel();
@@ -5114,7 +5393,10 @@ bool App::isFirstRun() const {
}
bool App::hasPinVault() const {
return vault_ && vault_->hasVault() && settings_ && settings_->getPinEnabled();
// Gate purely on the (now per-wallet) vault presence — a vault exists iff a PIN was set for THIS
// wallet. The old extra `getPinEnabled()` gate was a GLOBAL flag, so disabling PIN on one wallet
// wrongly suppressed another wallet's PIN quick-unlock after a switch.
return vault_ && vault_->hasVault();
}
bool App::debugGateRequiresAuth() const {
@@ -5245,10 +5527,9 @@ void App::copySecretToClipboard(const std::string& secret)
ui::Notifications::instance().info("Copied — clipboard auto-clears in 45s", 4.0f);
}
void App::pumpSecretClipboardClear()
void App::clearSecretClipboardIfArmed()
{
if (clipboard_clear_deadline_ <= 0.0) return;
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
if (clipboard_secret_hash_ == 0) return;
// Only clear if the clipboard STILL holds our secret (the user may have copied something else).
if (const char* cb = ImGui::GetClipboardText()) {
std::uint64_t h = 1469598103934665603ULL;
@@ -5259,6 +5540,13 @@ void App::pumpSecretClipboardClear()
clipboard_secret_hash_ = 0;
}
void App::pumpSecretClipboardClear()
{
if (clipboard_clear_deadline_ <= 0.0) return;
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
clearSecretClipboardIfArmed();
}
void App::maybeFinishTransactionSendProgress()
{
using Job = services::NetworkRefreshService::Job;

192
src/app.h
View File

@@ -13,6 +13,7 @@
#include <chrono>
#include <unordered_map>
#include <unordered_set>
#include <deque>
#include <nlohmann/json_fwd.hpp>
#include "data/transaction_history_cache.h"
#include "data/address_book.h"
@@ -139,6 +140,9 @@ 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()); }
@@ -168,6 +172,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 +183,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 +208,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_; }
@@ -389,6 +405,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
@@ -407,6 +431,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(); }
@@ -449,6 +479,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)
@@ -456,6 +490,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;
@@ -474,7 +512,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);
@@ -531,6 +573,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);
}
@@ -572,10 +617,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,
@@ -653,11 +701,96 @@ 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();
@@ -671,11 +804,23 @@ private:
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.
@@ -725,6 +870,37 @@ 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_;
// 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};
@@ -915,6 +1091,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;
@@ -1125,6 +1304,7 @@ private:
void renderDecryptWalletDialog();
void renderPinDialogs();
void renderAntivirusHelpDialog();
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
void processDeferredEncryption();
// Private methods - connection

File diff suppressed because it is too large Load Diff

View File

@@ -241,16 +241,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) {
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;
stopEmbeddedDaemon();
if (token.cancelled() || shutting_down_) return;
startEmbeddedDaemon();
// tryConnect will be called by the update loop
// 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_) {
stopEmbeddedDaemon();
if (!token.cancelled() && !shutting_down_) startEmbeddedDaemon();
}
} catch (...) {}
daemon_restarting_ = false; // re-arm reconnect (tryConnect runs from the update loop)
});
} else {
ui::Notifications::instance().warning(
@@ -892,8 +899,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) {

View File

@@ -14,6 +14,7 @@
#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"
@@ -31,6 +32,7 @@
#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"
@@ -90,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
@@ -350,6 +376,14 @@ void App::buildSweepCatalog()
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(); },
@@ -373,6 +407,29 @@ void App::buildSweepCatalog()
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,

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,6 +194,11 @@ 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_);
@@ -419,6 +444,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_;
@@ -428,6 +468,10 @@ 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_;

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,10 +227,22 @@ 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_; }
@@ -467,6 +526,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;
@@ -489,7 +560,11 @@ private:
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

View File

@@ -61,6 +61,11 @@ bool DaemonController::externalDaemonDetected() const
return daemon_->externalDaemonDetected();
}
void DaemonController::clearExternalDaemonDetected()
{
daemon_->clearExternalDaemonDetected();
}
DaemonController::State DaemonController::state() const
{
return daemon_->getState();
@@ -116,6 +121,11 @@ void DaemonController::setZapOnNextStart(bool enabled)
daemon_->setZapOnNextStart(enabled);
}
void DaemonController::setSalvageOnNextStart(bool enabled)
{
daemon_->setSalvageOnNextStart(enabled);
}
bool DaemonController::zapOnNextStart() const
{
return daemon_->zapOnNextStart();

View File

@@ -93,6 +93,7 @@ public:
bool isRunning() const;
bool externalDaemonDetected() const;
void clearExternalDaemonDetected();
State state() const;
const std::string& lastError() const;
int crashCount() const;
@@ -106,6 +107,7 @@ public:
bool rescanOnNextStart() const;
void setZapOnNextStart(bool enabled);
bool zapOnNextStart() const;
void setSalvageOnNextStart(bool enabled);
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
bool externalDaemonDetected,

View File

@@ -386,52 +386,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;
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == INVALID_SOCKET) { WSACleanup(); return false; }
struct sockaddr_in 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));
closesocket(sock);
bool inUse = false;
{ // IPv4 127.0.0.1
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
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");
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);
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));
close(sock);
return (result == 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;
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 ok;
};
return connProbe(AF_INET, "127.0.0.1") || connProbe(AF_INET6, "::1");
#endif
}
@@ -496,9 +524,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
@@ -656,17 +691,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;
@@ -1247,5 +1289,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

@@ -142,6 +142,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 +201,11 @@ 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(); }
/**
* @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 +226,15 @@ 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();
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
static bool tcpPortInUse(int port);
@@ -261,6 +279,7 @@ 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::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

@@ -110,9 +110,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 +136,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"
@@ -208,19 +208,20 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
try {
fs::create_directories(fs::path(outPath).parent_path());
std::ofstream ofs(outPath);
std::ofstream ofs(outPath, std::ios::trunc);
if (!ofs.is_open()) {
last_error_ = "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
// Restrict to owner (0600) BEFORE writing any secret material (API token, wallet
// address, worker name). The file is still empty here, so the config is never
// world-readable — closing the window between creation and the previous post-write chmod.
chmod(outPath.c_str(), 0600);
#endif
ofs << j.dump(4);
ofs.close();
return true;
} catch (const std::exception& e) {
last_error_ = std::string("Config write error: ") + e.what();

View File

@@ -54,6 +54,7 @@ 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");
e.avatar = entry.value("avatar", "");
if (!e.address.empty()) {
entries_.push_back(e);
@@ -86,6 +87,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 +144,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
*/

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>
@@ -44,13 +45,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 +72,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

@@ -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[])
@@ -763,11 +772,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 +1975,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;
}

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;
}
@@ -191,6 +208,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
@@ -317,7 +338,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 +376,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 +393,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 +518,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

@@ -168,20 +168,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)
{
@@ -448,8 +434,15 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
result.market.market_cap = data.value("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;
@@ -920,7 +913,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 +941,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;

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

@@ -57,6 +57,21 @@ inline ImU32 ReadableError() {
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;
@@ -1347,10 +1362,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;

View File

@@ -0,0 +1,193 @@
// 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 = 12.0f * dp, gap = 6.0f * dp;
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 = ImGui::GetStyle().FrameRounding;
const float a = ImGui::GetStyle().Alpha; // BeginDisabled() lowers this
ImU32 bg = 0, border = 0, fg = OnSurface();
bool glass = false;
switch (tier) {
case ActionTier::Primary:
bg = WithAlpha(Primary(), act ? 255 : (hov ? 245 : 220));
fg = IM_COL32(255, 255, 255, 240);
break;
case ActionTier::Secondary:
bg = WithAlpha(OnSurface(), hov ? 26 : 16);
border = WithAlpha(OnSurface(), 40);
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] = {};

File diff suppressed because it is too large Load Diff

View File

@@ -149,6 +149,7 @@ struct SidebarStatus {
int unconfirmedTxCount = 0; // badge on History
bool miningActive = false; // green dot on Mining
int peerCount = 0; // badge on Peers
int chatUnreadCount = 0; // badge on Chat (unread incoming messages)
// Exit
bool exitClicked = false;
// Branding logo (optional — loaded at startup)
@@ -715,6 +716,8 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
dotOnly = true; badgeCol = Success();
} else if (item.page == NavPage::Peers && status.peerCount > 0) {
badgeCount = status.peerCount;
} else if (item.page == NavPage::Chat && status.chatUnreadCount > 0) {
badgeCount = status.chatUnreadCount;
}
if (badgeCount > 0 || dotOnly) {

View File

@@ -70,6 +70,7 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
float spacingSm,
float spacingXs)
{
(void)spacingXs; // trailing button now insets by rowPadLeft (mirrors the left margin)
AddressRowLayout layout;
layout.contentStartX = rowX + rowPadLeft;
layout.contentStartY = rowY + spacingMd;
@@ -77,7 +78,9 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
const float rightEdge = rowX + rowWidth;
const float favoriteX = rightEdge - layout.buttonSize - spacingXs;
// Inset the trailing (favorite/star) button by the card's inner padding — the same margin the
// left content uses (rowPadLeft) — so it mirrors the left edge instead of hugging the card edge.
const float favoriteX = rightEdge - layout.buttonSize - rowPadLeft;
const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};

View File

@@ -203,10 +203,7 @@ void BlockInfoDialog::render(App* app)
ImGui::Text("%s", TR("block_timestamp"));
ImGui::SameLine(lbl.position);
if (s_block_time > 0) {
std::time_t t = static_cast<std::time_t>(s_block_time);
char time_buf[64];
std::strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", std::localtime(&t));
ImGui::Text("%s", time_buf);
ImGui::Text("%s", dragonx::util::formatClockDateTime(s_block_time, /*withSeconds=*/true).c_str());
} else {
ImGui::TextDisabled("%s", TR("unknown"));
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,38 @@ class App;
namespace ui {
/**
* @brief Render the Chat tab (read-only HushChat conversation view — Phase 3).
* @brief Render the Chat tab (HushChat conversation view).
*
* A two-pane view: a conversation list on the left and the selected thread on
* the right, both read from the App-owned ChatService store (App::chatService()).
* Peer z-addresses are resolved to contact names via the address book when known.
* Read-only for now — composing/sending arrives in a later phase. The tab is only
* reachable when built with DRAGONX_ENABLE_CHAT (gated via WalletUiSurface::Chat).
* A two-pane view: a conversation list on the left and the selected thread on the
* right, both read from the App-owned ChatService store (App::chatService()). Peer
* z-addresses are resolved to contact names via the address book when known.
* Composing/sending and starting new conversations are wired (App::sendChatMessage /
* startChatConversation). Reachable when built with DRAGONX_ENABLE_CHAT
* (gated via WalletUiSurface::Chat).
*
* @param app Pointer to the app instance.
*/
void RenderChatTab(App* app);
/**
* @brief Render the chat-customization controls (emoji style, poll rate, bubble style/color,
* density, text size, chat timestamps, Enter-to-send). Shared by the Chat tab's settings
* "notch" modal and the Settings tab's Chat & Contacts section. The app-wide clock format
* (which chat timestamps fall back to) lives separately in Settings → General.
* @param app Pointer to the app instance.
* @param contentWidth Explicit row width for right-aligning controls; pass the card's inner content
* width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto
* (use the current content region, correct inside the chat modal's dialog).
*/
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f);
/**
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation
* plaintext buffers + the selected-conversation ids). Called by
* App::resetChatSession() on a wallet switch/lock so one wallet's typed
* plaintext can't resurface (or linger unwiped in RAM) under the next wallet.
*/
void ResetChatTab();
} // namespace ui
} // namespace dragonx

View File

@@ -4,6 +4,7 @@
#include "console_command_executor.h"
#include "console_channel.h"
#include "console_command_reference.h" // consoleCommandCategories / liteConsoleCommandCategories
#include "console_input_model.h" // BuildConsoleRpcCall
#include "../../app.h"
@@ -28,6 +29,10 @@ namespace ui {
using namespace material;
// Classifies a diagnostics line into a console channel (defined below in an anonymous namespace); both
// executors drain the same LiteDiagnostics ring, so declare it up front for the full-node drain.
namespace { ConsoleChannel liteLogChannel(const std::string& line); }
// ============================================================================
// FullNodeConsoleExecutor
// ============================================================================
@@ -172,6 +177,25 @@ void FullNodeConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
} else {
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
}
// Chat diagnostics ring: chat code logs via wallet::liteLog on both variants (send lifecycle, the
// 0-conf harvest, the note buffer). Surface those lines here as App messages so the full-node console
// shows chat activity too. Same generation-cursor drain the lite console uses.
{
auto& diag = wallet::LiteDiagnostics::instance();
const std::uint64_t gen = diag.generation();
if (gen != diag_gen_) {
const auto snap = diag.snapshot();
std::size_t startIdx = 0;
if (diag_gen_ != static_cast<std::uint64_t>(-1)) {
const std::uint64_t added = gen - diag_gen_;
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
}
for (std::size_t i = startIdx; i < snap.size(); ++i)
add(snap[i], liteLogChannel(snap[i]));
diag_gen_ = gen;
}
}
}
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
@@ -194,6 +218,11 @@ void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
add(TR("console_tab_completion"), ConsoleChannel::Info);
}
const std::vector<ConsoleCommandCategory>* FullNodeConsoleExecutor::commandReference() const
{
return &consoleCommandCategories();
}
ConsoleStatusLine FullNodeConsoleExecutor::toolbarStatus() const
{
ConsoleStatusLine s;
@@ -258,7 +287,7 @@ bool LiteConsoleExecutor::pollResult(std::string& result, bool& isError)
if (!lw) return false;
wallet::LiteConsoleResult res;
if (!lw->takeConsoleResult(res)) return false;
result = res.response.empty() ? std::string("(no output)") : res.response;
result = res.response.empty() ? std::string(TR("console_no_output")) : res.response;
isError = !res.ok;
return true;
}
@@ -286,6 +315,19 @@ void LiteConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
add(TR("console_help_clear"), ConsoleChannel::None);
add(TR("console_help_help"), ConsoleChannel::None);
add(TR("lite_console_help_passthrough"), ConsoleChannel::Info);
// The lite backend's own command verbs (literal command tokens, not RPC methods — mirrors the
// backend's get_commands() registry). Listed for discoverability: the C++ tab intercepts `help`
// before it can reach the backend's own HelpCommand, so its "type help" advice would dead-end.
add(TR("lite_console_backend_commands"), ConsoleChannel::Info);
add(" sync syncstatus balance addresses height info list notes encryptionstatus", ConsoleChannel::None);
add(" send shield new seed import timport export", ConsoleChannel::None);
add(" encrypt decrypt lock unlock rescan save sietch saplingtree coinsupply", ConsoleChannel::None);
add(TR("console_click_commands"), ConsoleChannel::Info);
}
const std::vector<ConsoleCommandCategory>* LiteConsoleExecutor::commandReference() const
{
return &liteConsoleCommandCategories();
}
std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
@@ -294,20 +336,20 @@ std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
wallet::LiteWalletController* lw = app_->liteWallet();
if (lw && lw->walletOpen()) {
const SyncInfo& sync = app_->state().sync;
char buf[96];
char buf[128];
if (sync.syncing && !sync.isSynced()) {
double vp = sync.verification_progress;
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
std::snprintf(buf, sizeof(buf), "Syncing %.1f%% (block %d / %d)",
vp * 100.0, sync.blocks, sync.headers);
std::snprintf(buf, sizeof(buf), "%s %.1f%% (block %d / %d)",
TR("lite_net_syncing"), vp * 100.0, sync.blocks, sync.headers);
} else {
std::snprintf(buf, sizeof(buf), "Synced (block %d)", sync.blocks);
std::snprintf(buf, sizeof(buf), "%s (block %d)", TR("lite_net_synced"), sync.blocks);
}
out.push_back({std::string(buf), OnSurfaceMedium(), false});
}
const std::string& err = app_->liteOpenError();
if (!err.empty() && (!lw || !lw->walletOpen()))
out.push_back({std::string("Last error: ") + err, Error(), false});
out.push_back({std::string(TR("console_last_error")) + " " + err, Error(), false});
return out;
}
@@ -315,10 +357,10 @@ ConsoleStatusLine LiteConsoleExecutor::toolbarStatus() const
{
ConsoleStatusLine s;
wallet::LiteWalletController* lw = app_->liteWallet();
if (!lw) { s.text = "No backend"; s.color = Error(); return s; }
if (lw->walletOpen()) { s.text = "Connected"; s.color = Success(); }
else if (lw->openInProgress()) { s.text = "Connecting"; s.color = Warning(); s.pulse = true; }
else { s.text = "Disconnected"; s.color = Error(); }
if (!lw) { s.text = TR("console_backend_unavailable"); s.color = Error(); return s; }
if (lw->walletOpen()) { s.text = TR("lite_net_connected"); s.color = Success(); }
else if (lw->openInProgress()) { s.text = TR("lite_net_connecting"); s.color = Warning(); s.pulse = true; }
else { s.text = TR("lite_net_disconnected"); s.color = Error(); }
return s;
}

View File

@@ -25,6 +25,8 @@ namespace dragonx {
class App;
namespace ui {
struct ConsoleCommandCategory; // console_command_reference.h — command-reference table
using ConsoleAddLineFn = std::function<void(const std::string&, ConsoleChannel)>;
struct ConsoleStatusLine {
@@ -64,7 +66,13 @@ public:
virtual void pollLogLines(const ConsoleAddLineFn& add) { (void)add; }
// UI-chrome capabilities.
virtual bool hasRpcReference() const { return false; } // show the RPC command-reference popup
// True when this backend speaks JSON-RPC to a daemon (the full node). Gates daemon-specific
// console behavior (the 'stop' shutdown confirm, "not connected to daemon" wording) and the
// command-reference modal's JSON string-arg quoting — the lite backend takes bare tokens.
virtual bool hasRpcReference() const { return false; }
// The command-reference table this backend offers (browsed by the console's reference modal),
// or nullptr for none. Full node = the JSON-RPC reference; lite = its own backend verbs.
virtual const std::vector<ConsoleCommandCategory>* commandReference() const { return nullptr; }
// Which log-filter toggles the toolbar should show (default: none).
virtual ConsoleLogFilterCaps logFilterCaps() const { return {}; }
@@ -88,6 +96,7 @@ public:
bool pollResult(std::string& result, bool& isError) override;
void pollLogLines(const ConsoleAddLineFn& add) override;
bool hasRpcReference() const override { return true; }
const std::vector<ConsoleCommandCategory>* commandReference() const override;
// Full node: daemon/xmrig log, errors-only, RPC trace, and app messages.
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
void printHelp(const ConsoleAddLineFn& add) override;
@@ -104,6 +113,9 @@ private:
bool last_rpc_connected_ = false;
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
std::mutex results_mutex_;
// Chat diagnostics ring cursor: chat code logs via wallet::liteLog on BOTH variants, so the full-node
// console surfaces those lines (as App messages) too — mirrors LiteConsoleExecutor's diag drain.
std::uint64_t diag_gen_ = static_cast<std::uint64_t>(-1);
};
// ── Lite: diagnostics ring + backend console command ─────────────────────────
@@ -119,6 +131,7 @@ public:
// Lite: no daemon log / RPC trace — its diagnostics ring maps to the App + Error
// channels, so offer errors-only + app-messages (plus the always-shown text filter).
ConsoleLogFilterCaps logFilterCaps() const override { return {false, true, false, true}; }
const std::vector<ConsoleCommandCategory>* commandReference() const override;
void printHelp(const ConsoleAddLineFn& add) override;
std::vector<ConsoleStatusLine> statusLines() const override;
ConsoleStatusLine toolbarStatus() const override;

View File

@@ -280,6 +280,105 @@ const ConsoleCommandEntry kUtilityCommands[] = {
"reconsiderblock \"0000000000abc123\"", "undo invalidate accept block again re-enable block restore chain reconsider fix rollback fork", true},
};
// ============================================================================
// Lite backend command set — the verbs the SDXL lite backend accepts (see its
// commands.rs get_commands()). Unlike the full node these are NOT JSON-RPC: the
// backend takes plain, space-separated tokens (no quoting), so the reference
// modal inserts them bare. clear/help/quit are intentionally omitted — the C++
// console tab intercepts those before they reach the backend.
// ============================================================================
const ConsoleCommandEntry kLiteWalletCommands[] = {
{"balance", "Show your DRGX balance", "",
"Shows the DRGX balance held in this wallet across all its shielded and transparent addresses.",
"balance", "money funds amount total holdings"},
{"addresses", "List all addresses in the wallet", "",
"Lists every address this wallet owns \xE2\x80\x94 shielded (zs1...) and transparent (R.../t...) \xE2\x80\x94 so you can pick one to receive to.",
"addresses", "receive address list wallet mine deposit"},
{"new", "Create a new address in this wallet", "type",
"Creates a fresh receive address. Pass zs for a shielded (private) sapling address, or R (a capital R) for a transparent one \xE2\x80\x94 those exact tokens (case-sensitive).",
"new zs", "create address receive generate new shielded transparent zs"},
{"list", "List all transactions in the wallet", "",
"Shows the wallet's transaction history \xE2\x80\x94 sends, receives and shields \xE2\x80\x94 with amounts, addresses and confirmations.",
"list", "history transactions txs payments activity sent received"},
{"notes", "List sapling notes and UTXOs", "[all]",
"Lists the individual shielded notes and transparent UTXOs that make up your balance. Pass all to include spent ones.",
"notes", "utxo notes unspent coins inputs sapling"},
{"info", "Get the lightwalletd server's info", "",
"Reports the lightwalletd server the wallet is connected to \xE2\x80\x94 its version, chain and block height.",
"info", "server node lightwalletd version connection status"},
};
const ConsoleCommandEntry kLiteSyncCommands[] = {
{"sync", "Download compact blocks and sync to the server", "",
"Fetches new compact blocks from the lightwalletd server and scans them for transactions belonging to this wallet.",
"sync", "update refresh scan blocks download catch up"},
{"syncstatus", "Get the sync status of the wallet", "",
"Reports how far the wallet has synced \xE2\x80\x94 whether a sync is in progress and the block it has reached.",
"syncstatus", "progress status syncing scanning percent blocks"},
{"height", "Get the latest block height the wallet is at", "",
"Shows the block height the wallet has scanned up to. Compare with the network height to gauge sync.",
"height", "block height number chain tip synced"},
{"rescan", "Rescan the wallet from scratch", "",
"Discards the scanned state and re-downloads/re-scans every block from the wallet's birthday. Slow, but fixes a stuck or incomplete balance.",
"rescan", "rescan resync repair fix balance rebuild from scratch"},
{"save", "Save the wallet file to disk", "",
"Writes the current wallet state to disk. The wallet also saves automatically after a sync or send.",
"save", "save persist write disk store"},
};
const ConsoleCommandEntry kLiteSendCommands[] = {
{"send", "Send DRGX to an address", "[{\"address\":\"zs1...\",\"amount\":0}]",
"Sends DRGX from the console using a JSON array of recipients (the Send tab is the easy way; this is the power-user form). amount is in puposhis (the base unit); memo is optional and delivered privately to shielded recipients.",
"send [{\"address\":\"zs1exampleaddress\",\"amount\":100000000,\"memo\":\"thanks\"}]",
"pay transfer send spend money transaction json", true},
{"shield", "Shield transparent DRGX into a sapling address", "[address]",
"Moves your transparent (public) DRGX into a shielded sapling address for privacy. With no address it shields to the wallet's own sapling address.",
"shield", "shield private sapling transparent move protect", true},
};
const ConsoleCommandEntry kLiteKeyCommands[] = {
{"seed", "Display the wallet seed phrase", "",
"Reveals the seed phrase that backs up this wallet. Anyone who sees it can spend your funds \xE2\x80\x94 keep it secret and offline.",
"seed", "seed phrase mnemonic backup recovery words secret", true},
{"export", "Export the private key for an address", "[address]",
"Prints the private/spending key for a wallet address \xE2\x80\x94 anyone with it controls the funds. With no address it exports every key.",
"export zs1exampleaddress", "export private key spending backup secret", true},
{"import", "Import a spending or viewing key", "key",
"Imports a shielded spending or viewing key (pass just the key). The wallet rescans from the sapling activation height to find the key's transactions.",
"import somekey", "import restore key spending viewing add watch"},
{"timport", "Import a transparent WIF private key", "wif",
"Imports a transparent private key in WIF format (begins with U, 5, K or L) so the wallet can spend its funds.",
"timport somewifkey", "import transparent wif private key taddr"},
{"encrypt", "Encrypt the wallet with a password", "password",
"Encrypts the wallet with a password and locks it immediately. You will need the password to send or reveal keys afterwards. If you forget it, only the seed phrase can recover the wallet.",
"encrypt strongpassword", "encrypt password protect lock secure passphrase", true},
{"decrypt", "Completely remove wallet encryption", "password",
"Permanently removes the wallet's password encryption, leaving it unprotected on disk. Requires the current password.",
"decrypt strongpassword", "decrypt remove encryption password unprotect", true},
{"unlock", "Unlock the wallet for spending", "password",
"Temporarily unlocks an encrypted wallet so it can send or reveal keys. Use lock to re-lock it.",
"unlock strongpassword", "unlock password spend open temporarily"},
{"lock", "Lock a temporarily-unlocked wallet", "",
"Re-locks a wallet that was unlocked for spending, without removing its encryption.",
"lock", "lock secure re-lock protect close"},
{"encryptionstatus", "Check if the wallet is encrypted and locked", "",
"Reports whether the wallet is encrypted and, if so, whether it is currently locked or unlocked.",
"encryptionstatus", "encryption status locked unlocked encrypted state"},
};
const ConsoleCommandEntry kLiteAdvancedCommands[] = {
{"sietch", "Create a Sietch address", "[type]",
"Creates a Sietch address, used for enhanced-privacy sends. Pass zs for a sapling Sietch address.",
"sietch zs", "sietch privacy address decoy sapling advanced"},
{"saplingtree", "Dump the latest Sapling commitment tree (debug)", "",
"Prints the latest Sapling commitment tree state \xE2\x80\x94 a debugging aid, not needed for everyday use.",
"saplingtree", "sapling tree commitment debug advanced merkle"},
{"coinsupply", "Get the coin supply info", "",
"Reports coin-supply figures for the chain as seen by the wallet's server.",
"coinsupply", "supply coins total emission circulating amount"},
};
} // namespace
const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
@@ -296,5 +395,17 @@ const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
return categories;
}
const std::vector<ConsoleCommandCategory>& liteConsoleCommandCategories()
{
static const std::vector<ConsoleCommandCategory> categories = {
{"Wallet", kLiteWalletCommands, CountOf(kLiteWalletCommands)},
{"Sync", kLiteSyncCommands, CountOf(kLiteSyncCommands)},
{"Send", kLiteSendCommands, CountOf(kLiteSendCommands)},
{"Keys & Security", kLiteKeyCommands, CountOf(kLiteKeyCommands)},
{"Advanced", kLiteAdvancedCommands, CountOf(kLiteAdvancedCommands)},
};
return categories;
}
} // namespace ui
} // namespace dragonx

View File

@@ -23,7 +23,12 @@ struct ConsoleCommandCategory {
int count;
};
// The full-node daemon's JSON-RPC command reference (browsed by the console's command-reference modal).
const std::vector<ConsoleCommandCategory>& consoleCommandCategories();
// The lite backend's own command set (the ~25 verbs the SDXL backend accepts) — the lite-variant
// analog of the RPC reference, shown by the same modal when the executor is the lite one.
const std::vector<ConsoleCommandCategory>& liteConsoleCommandCategories();
} // namespace ui
} // namespace dragonx

View File

@@ -311,8 +311,12 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
computeVisibleLines(has_text_filter_, filter_lower_);
// Main console layout
// NoScrollWithMouse: the inner ConsoleOutput owns wheel scrolling (via ApplySmoothScroll). Without
// this, a wheel over the output would scroll BOTH the output (smooth-scroll) and this outer container
// (ImGui forwards the NoScrollWithMouse child's wheel to its scrollable ancestor) — a double-scroll.
// Safe because the output panel is sized to fill the remaining height, so this outer never overflows.
ImGui::BeginChild("ConsoleContainer", ImVec2(0, 0), false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Optional status header (lite: sync + last error) above the toolbar.
renderStatusHeader(exec);
@@ -460,12 +464,18 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec)
ImGui::EndChild();
}
void ConsoleTab::renderCommandsPopupModal()
void ConsoleTab::renderCommandsPopupModal(ConsoleCommandExecutor* exec)
{
if (!show_commands_popup_) {
return;
}
renderCommandsPopup();
// Need a backend that offers a reference table. If the console hasn't built its executor yet
// (popup can't have been opened normally) or the backend has none, just dismiss.
if (!exec || !exec->commandReference()) {
show_commands_popup_ = false;
return;
}
renderCommandsPopup(*exec);
}
void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
@@ -511,8 +521,7 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
// Clear button
if (TactileButton(TR("console_clear"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
clear();
selection_.clear();
clear(); // also drops the stale visible_indices_ + selection (see ConsoleTab::clear)
}
ImGui::SameLine();
@@ -541,14 +550,16 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
ImGui::SameLine();
// Commands reference button (full-node RPC reference only)
if (exec.hasRpcReference()) {
// Commands reference button — shown whenever the backend offers a reference table (full-node
// JSON-RPC commands, or the lite backend's own verbs).
if (exec.commandReference()) {
if (TactileButton(TR("console_commands"), ImVec2(0, 0), schema::UI().resolveFont("button"))) {
command_search_[0] = '\0'; // fresh search each open (dismiss paths don't all reset it)
show_commands_popup_ = true;
}
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("console_show_rpc_ref"));
material::Tooltip("%s", exec.hasRpcReference() ? TR("console_show_rpc_ref")
: TR("console_show_backend_ref"));
}
ImGui::SameLine();
}
@@ -569,12 +580,14 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
ImGui::SameLine();
{
float btnSz = ImGui::GetFrameHeight();
// Capture the flag BEFORE the button can flip it — the push/pop guards must use the SAME value,
// or a click leaves the colour stack unbalanced (ImGui then draws a red error rect on the window).
const bool dim = !s_line_accents_enabled;
const char* icon = s_line_accents_enabled ? ICON_MD_FORMAT_COLOR_FILL : ICON_MD_FORMAT_COLOR_RESET;
if (!s_line_accents_enabled)
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
if (dim) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
if (TactileButton(icon, ImVec2(btnSz, btnSz), Type().iconMed()))
s_line_accents_enabled = !s_line_accents_enabled;
if (!s_line_accents_enabled) ImGui::PopStyleColor();
if (dim) ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_accents"));
}
@@ -582,11 +595,11 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
ImGui::SameLine();
{
float btnSz = ImGui::GetFrameHeight();
if (!s_line_text_color_enabled)
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
const bool dim = !s_line_text_color_enabled; // capture before the click flips it (balanced push/pop)
if (dim) ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
if (TactileButton(ICON_MD_FORMAT_COLOR_TEXT, ImVec2(btnSz, btnSz), Type().iconMed()))
s_line_text_color_enabled = !s_line_text_color_enabled;
if (!s_line_text_color_enabled) ImGui::PopStyleColor();
if (dim) ImGui::PopStyleColor();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color"));
}
@@ -606,7 +619,7 @@ void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec)
ImVec2 cp = ImGui::GetCursorScreenPos();
float dotR = schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size * Layout::hScale();
float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f;
float dotX = cp.x + dotR + 2;
float dotX = cp.x + dotR + 2.0f * Layout::dpiScale();
if (st.pulse) {
float a = schema::UI().drawElement("animations", "pulse-base-glow").size + schema::UI().drawElement("animations", "pulse-amp-glow").size * (float)std::sin((double)ImGui::GetTime() * schema::UI().drawElement("animations", "pulse-speed-fast").size);
@@ -616,7 +629,7 @@ void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec)
dl->AddCircleFilled(ImVec2(dotX, dotY), dotR, st.color);
}
ImGui::Dummy(ImVec2(dotR * 2 + 6, 0));
ImGui::Dummy(ImVec2(dotR * 2 + 6.0f * Layout::dpiScale(), 0));
ImGui::SameLine();
Type().textColored(TypeStyle::Caption, st.color, st.text.c_str());
} else {
@@ -1183,9 +1196,9 @@ void ConsoleTab::drawOutputContextMenu()
}
ImGui::Separator();
if (ImGui::MenuItem(TR("console_clear_console"))) {
// View-only clear (main thread) — drop the visible lines and the selection.
model_.clear();
selection_.clear();
// View-only clear — route through clear() so the stale visible_indices_/selection are dropped too
// (a bare model_.clear() mid-frame would leave renderOutput() indexing the emptied model_ → crash).
clear();
}
ImGui::EndPopup();
}
@@ -1429,18 +1442,22 @@ bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::s
exec.printHelp(add);
} else if (first == "quit" || first == "exit") {
addLine(TR("console_quit_note"), ConsoleChannel::Info);
} else if (first == "stop") {
} else if (first == "stop" && exec.hasRpcReference()) {
// Full-node 'stop' shuts down the daemon (destructive) — gate behind a confirming second
// 'stop'. Lite has no node: `stop` isn't a backend verb, so it falls through below and the
// backend reports it as unknown — no misleading "shut down the node" warning or dead gate.
if (!stop_confirm_pending_) {
stop_confirm_pending_ = true;
addLine("'stop' will shut down the node and disconnect the wallet. Type 'stop' again to confirm.",
ConsoleChannel::Warning);
addLine(TR("console_stop_confirm_node"), ConsoleChannel::Warning);
} else {
stop_confirm_pending_ = false;
if (!exec.isReady()) addLine(TR("console_not_connected"), ConsoleChannel::Error);
else exec.submit(cmd);
}
} else if (!exec.isReady()) {
addLine(TR("console_not_connected"), ConsoleChannel::Error);
// Full node connects to a daemon; the lite backend opens a wallet — word the error per variant.
addLine(exec.hasRpcReference() ? TR("console_not_connected") : TR("console_not_connected_lite"),
ConsoleChannel::Error);
} else {
exec.submit(cmd);
}
@@ -1552,6 +1569,11 @@ const char* consoleCategoryLabel(const char* name)
if (!std::strcmp(name, "Wallet")) return TR("console_cat_wallet");
if (!std::strcmp(name, "Raw Transactions")) return TR("console_cat_raw_transactions");
if (!std::strcmp(name, "Utility")) return TR("console_cat_utility");
// Lite backend reference categories.
if (!std::strcmp(name, "Sync")) return TR("console_cat_sync");
if (!std::strcmp(name, "Send")) return TR("console_cat_send");
if (!std::strcmp(name, "Keys & Security")) return TR("console_cat_keys");
if (!std::strcmp(name, "Advanced")) return TR("console_cat_advanced");
return name;
}
} // namespace
@@ -1571,7 +1593,7 @@ void ConsoleTab::insertCommandToInput(const ConsoleCommandEntry& cmd)
show_commands_popup_ = false;
}
void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char* catLabel)
void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char* catLabel, bool jsonArgs)
{
using namespace material;
float dp = Layout::dpiScale();
@@ -1622,8 +1644,11 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
ImGui::PopFont();
ImGui::SameLine(labelW);
ImGui::SetNextItemWidth(-1);
// Lite backend args are bare freeform tokens, so its param types (string/number) don't
// apply — show a neutral "value" hint there instead of a misleading "number".
std::string typeHint = jsonArgs ? s.type : std::string(TR("console_ref_value"));
std::string hint = s.optional
? (s.type + " \xC2\xB7 " + std::string(TR("console_ref_optional"))) : s.type;
? (typeHint + " \xC2\xB7 " + std::string(TR("console_ref_optional"))) : typeHint;
ImGui::InputTextWithHint("##pv", hint.c_str(), cmd_param_bufs_[k], sizeof(cmd_param_bufs_[k]));
ImGui::PopID();
}
@@ -1653,7 +1678,9 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
built += " " + specs[k].raw;
complete = false;
} else {
if (specs[k].type == "string" && val.front() != '"' && val.front() != '\'' &&
// JSON-RPC (full node) auto-quotes string args; the lite backend takes bare tokens, so
// leave the value exactly as typed there (quoting would break its address/key parsing).
if (jsonArgs && specs[k].type == "string" && val.front() != '"' && val.front() != '\'' &&
val.front() != '[' && val.front() != '{')
val = "\"" + val + "\"";
built += " " + val;
@@ -1726,13 +1753,16 @@ void ConsoleTab::renderCommandDetail(const ConsoleCommandEntry& cmd, const char*
}
}
void ConsoleTab::renderCommandsPopup()
void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec)
{
using namespace material;
float dp = Layout::dpiScale();
// Full node speaks JSON-RPC (quote string args); the lite backend takes bare tokens.
const bool jsonArgs = exec.hasRpcReference();
material::OverlayDialogSpec ov;
ov.title = TR("console_rpc_reference");
ov.title = jsonArgs ? TR("console_rpc_reference") : TR("console_backend_reference");
ov.p_open = &show_commands_popup_;
ov.style = material::OverlayStyle::BlurFloat; // floating content on the blur, plain heading
ov.cardWidth = 960.0f; // wide enough for two panes
@@ -1761,7 +1791,7 @@ void ConsoleTab::renderCommandsPopup()
std::transform(q.begin(), q.end(), q.begin(), ::tolower);
const bool searching = !q.empty();
const auto& categories = consoleCommandCategories();
const auto& categories = *exec.commandReference(); // non-null: guarded by renderCommandsPopupModal
// Flat display order of (cat,idx): ranked when searching, category order when browsing. Drives
// keyboard nav + auto-selection; the browse view still renders grouped headers below.
@@ -1903,7 +1933,7 @@ void ConsoleTab::renderCommandsPopup()
if (cmd_sel_cat_ >= 0 && cmd_sel_cat_ < (int)categories.size() &&
cmd_sel_idx_ >= 0 && cmd_sel_idx_ < categories[cmd_sel_cat_].count) {
renderCommandDetail(categories[cmd_sel_cat_].commands[cmd_sel_idx_],
consoleCategoryLabel(categories[cmd_sel_cat_].name));
consoleCategoryLabel(categories[cmd_sel_cat_].name), jsonArgs);
} else {
ImVec2 av = ImGui::GetContentRegionAvail();
ImGui::SetCursorPosY(av.y * 0.4f);
@@ -2004,6 +2034,12 @@ void ConsoleTab::clear()
// View-only clear (main thread). The executor keeps its own log cursors, so new output
// still appends. The "cleared" line is ingested and appears on the next frame's drain.
model_.clear();
// visible_indices_ was computed at the top of render() (line 311), BEFORE the toolbar's Clear button
// ran; those indices now point past the emptied model_. renderOutput() (this same frame, after the
// toolbar) indexes model_[visible_indices_[vi]] — so drop them (and the selection, which also holds
// line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame.
visible_indices_.clear();
selection_.clear();
stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing
addLine(TR("console_cleared"), ConsoleChannel::Info);
}

View File

@@ -44,10 +44,11 @@ public:
void render(ConsoleCommandExecutor& exec);
/**
* @brief Render the RPC Command Reference popup at top-level scope.
* Must be called outside any child window so the modal blocks all input.
* @brief Render the command-reference popup at top-level scope.
* Must be called outside any child window so the modal blocks all input. `exec` supplies the
* reference table (full-node RPC vs lite backend verbs); may be null (no console yet) -> no-op.
*/
void renderCommandsPopupModal();
void renderCommandsPopupModal(ConsoleCommandExecutor* exec);
// Debug/UI-sweep hook: force the RPC command-reference popup open/closed so the full UI sweep
// can capture it (the popup is otherwise opened only by a toolbar button).
@@ -115,8 +116,9 @@ private:
// Format a completed command result (JSON role -> channel) into console lines.
void addFormattedResult(const std::string& result, bool is_error);
void renderStatusHeader(ConsoleCommandExecutor& exec);
void renderCommandsPopup();
void renderCommandDetail(const ConsoleCommandEntry& cmd, const char* catLabel); // right pane
void renderCommandsPopup(ConsoleCommandExecutor& exec);
// right pane; jsonArgs=true (full-node RPC) auto-quotes string args, false (lite) inserts bare
void renderCommandDetail(const ConsoleCommandEntry& cmd, const char* catLabel, bool jsonArgs);
void insertCommandToInput(const ConsoleCommandEntry& cmd); // fill input + close the modal
// renderToolbar() draws the top bar; these are its sub-steps:
@@ -186,10 +188,11 @@ private:
// consumed by the renderer + hit-testing.
mutable ConsoleLayout layout_;
// Commands popup (RPC command explorer)
// Commands popup (command explorer) — indexes into the active executor's commandReference()
// table (full-node RPC commands or the lite backend verbs), not always the full-node one.
bool show_commands_popup_ = false;
char command_search_[128] = {0}; // RPC-reference search filter (cleared when the modal opens)
int cmd_sel_cat_ = -1; // detail-pane selection: category index into consoleCommandCategories()
char command_search_[128] = {0}; // reference search filter (cleared when the modal opens)
int cmd_sel_cat_ = -1; // detail-pane selection: category index into that table
int cmd_sel_idx_ = -1; // detail-pane selection: command index within that category
std::string pending_submit_; // command to run next frame (deferred so the modal needs no executor)
const ConsoleCommandEntry* run_confirm_cmd_ = nullptr; // destructive "Insert & run" awaiting confirmation

File diff suppressed because it is too large Load Diff

View File

@@ -21,5 +21,15 @@ namespace ui {
*/
void RenderContactsTab(App* app);
// True if an animated avatar frame was drawn since the previous call (clear-on-read). The main render
// loop uses this to keep drawing while an avatar animation plays, then idle when it stops.
bool ConsumeContactsAvatarAnimation();
// UI-sweep ONLY: open the revamped add/edit dialog on a seeded demo contact, in the given avatar
// mode (0 = badge, 1 = icon, 2 = image), so the sweep can capture the preview + avatar picker.
// Pair with ContactsSweepCloseDialog(). Do not use outside the sweep.
void ContactsSweepOpenEditDialog(int avatarMode);
void ContactsSweepCloseDialog();
} // namespace ui
} // namespace dragonx

View File

@@ -1127,10 +1127,8 @@ static void renderBlockDetailModal(App* app) {
// Row 1: Timestamp | Confirmations
drawLabelValue(dl, gx, gy, labelW, TR("block_timestamp"), "", capFont, sub1);
if (s_detail_time > 0) {
std::time_t t = static_cast<std::time_t>(s_detail_time);
char time_buf[64];
std::strftime(time_buf, sizeof(time_buf), "%Y-%m-%d %H:%M:%S", std::localtime(&t));
dl->AddText(sub1, sub1->LegacySize, ImVec2(gx + labelW, gy), OnSurface(), time_buf);
const std::string tstr = dragonx::util::formatClockDateTime(s_detail_time, /*withSeconds=*/true);
dl->AddText(sub1, sub1->LegacySize, ImVec2(gx + labelW, gy), OnSurface(), tstr.c_str());
}
dl->AddText(capFont, capFont->LegacySize, ImVec2(gx + halfW, gy), OnSurfaceMedium(), TR("confirmations"));
snprintf(buf, sizeof(buf), "%d", s_detail_confirmations);

View File

@@ -4,6 +4,7 @@
#include "export_all_keys_dialog.h"
#include "../../app.h"
#include <sodium.h> // sodium_memzero — wipe exported key material from memory (B7)
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include "../../util/i18n.h"
@@ -180,13 +181,12 @@ void ExportAllKeysDialog::render(App* app)
for (const auto& addr : z_addrs) {
try {
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
auto result = rpc->call("z_exportkey", {addr});
if (result.is_string()) {
keys += "# Address: " + addr + "\n";
keys += result.get<std::string>() + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
}
} catch (...) {}
std::string k = rpc->callSecretString("z_exportkey", {addr}); // scrubs raw body + json node (B7)
keys += "# Address: " + addr + "\n";
keys += k + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
if (!k.empty()) sodium_memzero(&k[0], k.size()); // wipe the transient copy
} catch (...) {} // non-string / locked key → skip (not counted)
}
}
@@ -196,13 +196,12 @@ void ExportAllKeysDialog::render(App* app)
for (const auto& addr : t_addrs) {
try {
rpc::RPCClient::TraceScope trace("Settings / Export all keys");
auto result = rpc->call("dumpprivkey", {addr});
if (result.is_string()) {
keys += "# Address: " + addr + "\n";
keys += result.get<std::string>() + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
}
} catch (...) {}
std::string k = rpc->callSecretString("dumpprivkey", {addr}); // scrubs raw body + json node (B7)
keys += "# Address: " + addr + "\n";
keys += k + "\n\n";
exported++; // count only real successes (locked/failed keys don't count)
if (!k.empty()) sodium_memzero(&k[0], k.size()); // wipe the transient copy
} catch (...) {} // non-string / locked key → skip (not counted)
}
}
@@ -212,13 +211,13 @@ void ExportAllKeysDialog::render(App* app)
std::string filepath = configDir + "/" + filename;
bool writeOk = false;
if (exported > 0) {
std::ofstream file(filepath);
if (file.is_open()) {
file << keys;
file.close();
writeOk = true;
}
// Write the plaintext private keys 0600 + atomically (never
// world/group-readable, never a half-written file) — the same restricted
// atomic-write idiom the PIN vault uses. A default ofstream would create
// the key dump with umask-derived (often 0644) permissions.
writeOk = util::Platform::writeFileAtomically(filepath, keys, /*restrictPermissions=*/true);
}
if (!keys.empty()) sodium_memzero(&keys[0], keys.size()); // don't leave every key in freed heap
return [exported, total, filepath, writeOk]() {
s_exported_count = exported;

View File

@@ -36,21 +36,30 @@ static bool s_exporting = false;
// Helper to escape CSV field
static std::string escapeCSV(const std::string& field)
{
if (field.find(',') != std::string::npos ||
field.find('"') != std::string::npos ||
field.find('\n') != std::string::npos) {
// Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
// tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
// memo/address execute on open. Prefix such fields with a single quote so they render as text.
std::string safe = field;
if (!safe.empty()) {
const char c0 = safe.front();
if (c0 == '=' || c0 == '+' || c0 == '-' || c0 == '@' || c0 == '\t' || c0 == '\r')
safe.insert(safe.begin(), '\'');
}
if (safe.find(',') != std::string::npos ||
safe.find('"') != std::string::npos ||
safe.find('\n') != std::string::npos) {
// Escape quotes and wrap in quotes
std::string escaped;
escaped.reserve(field.size() + 4);
escaped.reserve(safe.size() + 4);
escaped += '"';
for (char c : field) {
for (char c : safe) {
if (c == '"') escaped += "\"\"";
else escaped += c;
}
escaped += '"';
return escaped;
}
return field;
return safe;
}
void ExportTransactionsDialog::show()

View File

@@ -0,0 +1,531 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// In-app, Material-styled image picker. A modal overlay that browses the filesystem and lets the
// user pick an image file, shown as a thumbnail grid. Used by the Contacts edit dialog to choose a
// custom contact avatar. Like FolderPicker, only one overlay renders at a time (the framework does
// not nest), so the caller suppresses its own overlay while ImagePicker::isOpen().
//
// Thumbnails are decoded to raw pixels, box-downscaled to a small texture, and cached per directory
// (destroyed on navigate/close) so browsing a Pictures folder full of large photos stays cheap.
#pragma once
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "imgui.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/texture_loader.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../layout.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
namespace dragonx {
namespace ui {
class ImagePicker {
public:
// Open the picker starting at `startDir` (falls back to the user's Pictures dir, then home, if
// empty/invalid). `onPick` receives the chosen absolute image path when the user confirms.
static void open(const std::string& startDir, std::function<void(const std::string&)> onPick) {
namespace fs = std::filesystem;
std::error_code ec;
std::string start = startDir;
if (start.empty() || !fs::is_directory(fs::path(start), ec)) {
if (!s_dir.empty() && fs::is_directory(fs::path(s_dir), ec)) start = s_dir; // reopen last
else start = defaultStartDir();
}
navigate(start);
s_onPick = std::move(onPick);
s_selected.clear();
s_open = true;
}
static bool isOpen() { return s_open; }
static void close() { s_open = false; clearThumbs(); }
static void render() {
if (!s_open) return;
using namespace material;
const float dp = Layout::dpiScale();
s_loadedThisFrame = 0; // budget: decode at most a few thumbnails per frame (no scroll hitch)
ImFont* rowFont = Type().body1();
ImFont* icoFont = Type().iconSmall();
ImFont* metaFont = Type().caption();
const float vpH = ImGui::GetMainViewport()->Size.y;
const float cardH = (vpH * 0.82f) / dp; // logical; the framework re-applies dp + caps at vp-32
OverlayDialogSpec ov;
ov.title = TR("img_picker_title");
ov.p_open = &s_open;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 760.0f;
ov.cardHeight = cardH;
ov.idSuffix = "imagepicker";
if (!BeginOverlayDialog(ov)) { if (!s_open) clearThumbs(); return; }
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_open = false;
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
auto fitPath = [&](std::string s, ImFont* f, float maxW){
const std::string ell = "\xE2\x80\xA6";
if (tw(f,s) <= maxW) return s;
while (s.size()>1 && tw(f, ell+s) > maxW){
s.erase(s.begin());
while (!s.empty() && (static_cast<unsigned char>(s.front()) & 0xC0) == 0x80) s.erase(s.begin());
}
return ell + s;
};
auto fit = [&](std::string s, ImFont* f, float maxW){
bool t=false;
while (s.size()>1 && tw(f,s)>maxW){
while (s.size()>1 && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
if (s.size()>1) s.pop_back();
t=true;
}
if (t) s += "\xE2\x80\xA6";
return s;
};
std::string pendingNav;
// Inner padded body so the filled list + thumbnail grid keep a clear margin from the card's
// rounded edges (the overlay's own content padding is tight for edge-to-edge filled content).
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f * dp, 8.0f * dp));
ImGui::BeginChild("##imgPickBody", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// ---- Path bar: Up + Home + Pictures + current path strip ------------------------------
{
const float bh = ImGui::GetFrameHeight();
IconButtonStyle st; st.color = OnSurfaceMedium(); st.hoverBg = StateHover();
st.bgRounding = 5.0f * dp;
std::filesystem::path cur(s_dir);
const bool hasParent = cur.has_parent_path() && cur.parent_path() != cur;
st.tooltip = TR("picker_up");
if (IconButton("##imgUp", ICON_MD_ARROW_UPWARD, icoFont, ImVec2(bh, bh), st) && hasParent)
pendingNav = cur.parent_path().string();
ImGui::SameLine(0.0f, Layout::spacingXs());
IconButtonStyle hst = st; hst.tooltip = TR("picker_home");
if (IconButton("##imgHome", ICON_MD_HOME, icoFont, ImVec2(bh, bh), hst))
pendingNav = util::Platform::getHomeDir();
ImGui::SameLine(0.0f, Layout::spacingXs());
IconButtonStyle pst = st; pst.tooltip = TR("img_picker_pictures");
if (IconButton("##imgPics", ICON_MD_IMAGE, icoFont, ImVec2(bh, bh), pst))
pendingNav = defaultStartDir();
ImGui::SameLine(0.0f, Layout::spacingSm());
ImVec2 sMin = ImGui::GetCursorScreenPos();
const float stripW = ImGui::GetContentRegionAvail().x;
ImVec2 sMax(sMin.x + stripW, sMin.y + bh);
ImDrawList* wdl = ImGui::GetWindowDrawList();
wdl->AddRectFilled(sMin, sMax, WithAlpha(OnSurface(), 14), 6.0f * dp);
wdl->AddRect(sMin, sMax, WithAlpha(OnSurface(), 40), 6.0f * dp, 0, 1.0f);
const float tpad = Layout::spacingSm();
wdl->AddText(rowFont, rowFont->LegacySize,
ImVec2(sMin.x + tpad, sMin.y + (bh - rowFont->LegacySize) * 0.5f),
OnSurface(), fitPath(s_dir, rowFont, stripW - tpad * 2.0f).c_str());
ImGui::Dummy(ImVec2(stripW, bh));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Body: subfolder rows (to navigate) then a thumbnail grid of images ----------------
const ImGuiStyle& gstyle = ImGui::GetStyle();
const float footerBlockH = Layout::spacingXs() + metaFont->LegacySize // status line
+ Layout::spacingSm() + 1.0f + Layout::spacingSm() // separator block
+ ImGui::GetFrameHeight() // footer buttons
+ 6.0f * gstyle.ItemSpacing.y;
float listH = ImGui::GetContentRegionAvail().y - footerBlockH;
listH = std::max(listH, 160.0f * dp);
const float fullW = ImGui::GetContentRegionAvail().x;
// Bordered/rounded outer frame whose 6px padding insets the inner scrollbar so it clears the
// card's rounded corners; the inner child scrolls smoothly (ApplySmoothScroll lerps the wheel).
ImGui::PushStyleColor(ImGuiCol_ChildBg, WithAlpha(OnSurface(), 20));
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f * dp, 6.0f * dp));
ImGui::BeginChild("##imgListFrame", ImVec2(fullW, listH), true,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImGui::PopStyleVar(2); // ChildRounding + outer WindowPadding (captured by the frame child)
ImGui::PopStyleColor(); // ChildBg — the frame already drew it; the inner child stays transparent
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp);
ImGui::BeginChild("##imgList", ImVec2(0, 0), false, ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* dl = ImGui::GetWindowDrawList();
const float padX = Layout::spacingSm();
if (s_subdirs.empty() && s_images.empty()) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Indent(padX);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_empty"));
ImGui::Unindent(padX);
}
// Folders: a 2-column grid of thin rounded rectangles (folder icon + name), clickable to descend.
if (!s_subdirs.empty()) {
const int cols = 2;
const float cellGap = Layout::spacingSm();
const float availW = ImGui::GetContentRegionAvail().x;
const float cellW = (availW - cellGap * (cols - 1)) / (float)cols;
const float cellH = rowFont->LegacySize + Layout::spacingSm() * 1.5f;
int col = 0;
for (std::size_t i = 0; i < s_subdirs.size(); ++i) {
if (col != 0) ImGui::SameLine(0, cellGap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cellW, mn.y + cellH);
ImGui::PushID((int)(i + 1));
const bool clicked = ImGui::InvisibleButton("##idir", ImVec2(cellW, cellH));
const bool hov = ImGui::IsItemHovered();
ImGui::PopID();
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), hov ? 34 : 16), 6.0f * dp);
if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 70), 6.0f * dp, 0, 1.0f); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
const float midY = (mn.y + mx.y) * 0.5f;
const float icoSz = rowFont->LegacySize * 1.05f;
float x = mn.x + padX;
dl->AddText(icoFont, icoSz, ImVec2(x, midY - icoSz * 0.5f), hov ? Primary() : OnSurfaceMedium(), ICON_MD_FOLDER);
x += icoSz + Layout::spacingSm();
std::string nm = fit(s_subdirs[i], rowFont, (mx.x - padX) - x);
dl->AddText(rowFont, rowFont->LegacySize, ImVec2(x, midY - rowFont->LegacySize * 0.5f), OnSurface(), nm.c_str());
if (clicked) pendingNav = (std::filesystem::path(s_dir) / s_subdirs[i]).string();
col = (col + 1) % cols;
}
if (!s_images.empty()) ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // gap before the thumbnails
}
// Thumbnail grid: a fixed 6 columns whose square cells scale to fill the width.
{
const float availW = ImGui::GetContentRegionAvail().x;
const float gap = Layout::spacingSm();
const int cols = 6;
const float cell = std::max(24.0f * dp, (availW - gap * (cols - 1)) / (float)cols);
int col = 0;
for (std::size_t i = 0; i < s_images.size(); ++i) {
if (col != 0) ImGui::SameLine(0, gap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell);
const std::string abs = (std::filesystem::path(s_dir) / s_images[i]).string();
const bool sel = (s_selected == abs);
ImGui::PushID((int)(i + 1));
const bool clicked = ImGui::InvisibleButton("##thumb", ImVec2(cell, cell));
const bool dbl = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && ImGui::IsItemHovered();
const bool hov = ImGui::IsItemHovered();
ImGui::PopID();
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 18), 6.0f * dp);
// Only decode thumbnails for cells actually on-screen, and only a few per frame.
const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr;
ImTextureID tex = t ? t->tex : 0;
int tw = t ? t->w : 0, thh = t ? t->h : 0;
// Hovering an animated image loads its frames in the BACKGROUND (no UI hang) and plays
// them once ready; the still thumbnail shows meanwhile.
if (t && hov && t->animated) {
Thumb& mt = s_thumbs[abs]; // t is non-null => the entry exists
driveThumbAnim(mt, abs);
if (mt.animReady && mt.frames.size() > 1 && mt.totalDur > 0.0f) {
s_animatingThisFrame = true;
double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur);
double acc = 0.0;
for (size_t k = 0; k < mt.delays.size() && k < mt.frames.size(); ++k) {
acc += mt.delays[k];
if (phase < acc) { tex = mt.frames[k]; tw = mt.aw; thh = mt.ah; break; }
}
}
}
if (tex) {
float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square
if (tw > thh) { float m=(tw-thh)*0.5f/tw; u0=m; u1=1-m; }
else if (thh > tw) { float m=(thh-tw)*0.5f/thh; v0=m; v1=1-m; }
const float ins = 3.0f * dp;
dl->AddImageRounded(tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp);
} else {
ImVec2 cc(mn.x + cell*0.5f, mn.y + cell*0.5f);
dl->AddText(icoFont, cell*0.34f, ImVec2(cc.x - cell*0.17f, cc.y - cell*0.17f),
OnSurfaceDisabled(), ICON_MD_IMAGE);
}
// "Animated" badge (bottom-right) on GIF/WebP that move — hidden while it plays on hover.
if (t && t->animated && !hov) {
float bh = std::max(13.0f * dp, cell * 0.24f);
float bw = bh * 1.15f;
ImVec2 bmax(mx.x - 4.0f * dp, mx.y - 4.0f * dp);
ImVec2 bmin(bmax.x - bw, bmax.y - bh);
dl->AddRectFilled(bmin, bmax, IM_COL32(0, 0, 0, 180), 4.0f * dp);
float gsz = bh * 0.92f;
ImVec2 gs = icoFont->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_PLAY_ARROW);
dl->AddText(icoFont, gsz,
ImVec2((bmin.x + bmax.x) * 0.5f - gs.x * 0.5f, (bmin.y + bmax.y) * 0.5f - gs.y * 0.5f),
IM_COL32(255, 255, 255, 235), ICON_MD_PLAY_ARROW);
}
if (sel) dl->AddRect(mn, mx, WithAlpha(Primary(), 220), 6.0f * dp, 0, 2.0f * dp);
else if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 90), 6.0f * dp, 0, 1.5f * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
if (hov) material::Tooltip("%s", s_images[i].c_str());
if (dbl && s_onPick) { s_onPick(abs); s_open = false; }
else if (clicked) s_selected = abs;
col = (col + 1) % cols;
}
}
ImGui::EndChild(); // inner scrolling list
ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding
ImGui::EndChild(); // outer frame
// ---- Status ----------------------------------------------------------------------------
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
if (!s_images.empty()) {
char buf[96];
snprintf(buf, sizeof(buf), TR("img_picker_count"), (int)s_images.size());
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf);
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_none"));
}
// ---- Footer: Use image / Cancel (centered) ---------------------------------------------
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
{
const float useW = 200.0f * dp, cancelW = 120.0f * dp;
const float total = useW + cancelW + ImGui::GetStyle().ItemSpacing.x;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
std::max(0.0f, (ImGui::GetContentRegionAvail().x - total) * 0.5f));
ImGui::BeginDisabled(s_selected.empty());
if (StyledButton(TR("img_picker_use"), ImVec2(useW, 0))) {
if (s_onPick && !s_selected.empty()) s_onPick(s_selected);
s_open = false;
}
ImGui::EndDisabled();
ImGui::SameLine();
if (StyledButton(TR("cancel"), ImVec2(cancelW, 0))) s_open = false;
}
ImGui::EndChild(); // ##imgPickBody
ImGui::PopStyleVar(); // WindowPadding
EndOverlayDialog();
if (!pendingNav.empty()) navigate(pendingNav);
if (!s_open) clearThumbs(); // release GL textures the frame the picker dismisses
}
public:
// Clear-on-read: true if an animated thumbnail was playing this frame (mouse over it), so the render
// loop keeps drawing while a hover-preview animates. Consumed by ConsumeContactsAvatarAnimation.
static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; }
private:
// Background decode job: a detached worker fills `result` off the UI thread and sets `done`. The
// shared_ptr keeps it alive if the owning Thumb is destroyed mid-decode (no blocking on navigate).
struct AnimJob {
std::atomic<bool> done{false};
util::AnimFrames result;
};
struct Thumb {
ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail
bool animated = false; // multi-frame GIF/WebP (badge)
// Async animation load (kicked off on hover): the full frame decode runs on a worker thread,
// then frames are uploaded to textures a few per UI frame — so hovering never blocks the UI.
bool animRequested = false, staged = false, animReady = false;
std::shared_ptr<AnimJob> job;
util::AnimFrames pending; // decoded frames awaiting GPU upload
size_t uploaded = 0;
std::vector<ImTextureID> frames;
std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f;
};
// Drive the async animation load for a hovered thumbnail: (1) kick off a background decode once,
// (2) move its result to a staging buffer when done, (3) upload a few frames per UI frame. Never
// blocks — the static thumbnail keeps showing until the sequence is fully uploaded.
static void driveThumbAnim(Thumb& th, const std::string& path) {
if (th.animReady) return;
if (!th.animRequested) {
if (s_animInFlight->load() >= kMaxAnimInFlight) return; // cap workers; retry a later frame
th.animRequested = true;
th.job = std::make_shared<AnimJob>();
s_animInFlight->fetch_add(1);
std::shared_ptr<AnimJob> job = th.job;
std::shared_ptr<std::atomic<int>> inflight = s_animInFlight; // co-owned: safe past teardown
std::string p = path;
std::thread([job, inflight, p]() {
util::LoadAnimatedRGBA(p.c_str(), kThumbMaxFrames, job->result);
job->done.store(true, std::memory_order_release);
inflight->fetch_sub(1);
}).detach();
return;
}
if (th.job && th.job->done.load(std::memory_order_acquire)) {
th.pending = std::move(th.job->result);
th.job.reset();
if (th.pending.frames.size() <= 1) { // turned out not to animate
th.animated = false; th.pending = util::AnimFrames{}; th.animReady = true; return;
}
th.staged = true;
}
if (th.staged) {
s_animatingThisFrame = true; // keep redrawing so the upload progresses
int budget = 4;
while (th.uploaded < th.pending.frames.size() && budget-- > 0) {
ImTextureID t = 0;
if (util::CreateRawTexture(th.pending.frames[th.uploaded].data(), th.pending.w, th.pending.h, false, &t)) {
th.frames.push_back(t);
th.delays.push_back(th.uploaded < th.pending.delaysSec.size() ? th.pending.delaysSec[th.uploaded] : 0.1f);
}
th.uploaded++;
}
if (th.uploaded >= th.pending.frames.size()) {
th.aw = th.pending.w; th.ah = th.pending.h;
for (float d : th.delays) th.totalDur += d;
th.pending = util::AnimFrames{};
th.animReady = true;
}
}
}
static bool isImageName(const std::string& name) {
auto lower = name;
for (char& c : lower) c = (char)std::tolower((unsigned char)c);
static const char* kExt[] = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp",
".tga", ".psd", ".pnm", ".ppm", ".pgm", ".pic" };
for (const char* e : kExt) {
const std::string ext(e);
if (lower.size() > ext.size() && lower.compare(lower.size()-ext.size(), ext.size(), ext) == 0)
return true;
}
return false;
}
// Only these can hold multiple frames in our decoders; skip the hover re-decode for other formats.
static bool isAnimatableName(const std::string& name) {
auto lo = name;
for (char& c : lo) c = (char)std::tolower((unsigned char)c);
auto ends = [&](const char* e){ std::string s(e); return lo.size()>s.size() && lo.compare(lo.size()-s.size(), s.size(), s)==0; };
return ends(".gif") || ends(".webp");
}
static std::string defaultStartDir() {
namespace fs = std::filesystem;
std::error_code ec;
const std::string home = util::Platform::getHomeDir();
fs::path pics = fs::path(home) / "Pictures";
if (fs::is_directory(pics, ec)) return pics.string();
return home;
}
// Lazy, budgeted thumbnail loader: decodes to raw pixels, box-downscales to <=THUMB px, uploads a
// small texture. Returns null (placeholder icon shown) until the budget lets it load.
static const Thumb* thumbFor(const std::string& absPath) {
auto it = s_thumbs.find(absPath);
if (it != s_thumbs.end()) return &it->second;
if (s_loadedThisFrame >= kLoadsPerFrame) return nullptr; // defer to a later frame
s_loadedThisFrame++;
Thumb th; th.tried = true;
// Cheap animation probe (header/structure only) so animated items can show a badge without
// decoding all frames; only .gif/.webp can be animations in our decoders.
if (isAnimatableName(absPath)) th.animated = util::IsAnimatedImageFile(absPath.c_str());
int sw = 0, sh = 0;
unsigned char* raw = util::LoadRawPixelsFromFile(absPath.c_str(), &sw, &sh);
if (raw && sw > 0 && sh > 0) {
int dw = sw, dh = sh;
if (sw > kThumbPx || sh > kThumbPx) {
float s = (float)kThumbPx / (float)std::max(sw, sh);
dw = std::max(1, (int)(sw * s));
dh = std::max(1, (int)(sh * s));
}
std::vector<unsigned char> small((size_t)dw * dh * 4);
for (int y = 0; y < dh; y++) {
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
for (int x = 0; x < dw; x++) {
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
uint32_t r=0,g=0,b=0,a=0,n=0;
for (int yy = sy0; yy < sy1; yy++)
for (int xx = sx0; xx < sx1; xx++) {
const unsigned char* p = raw + ((size_t)yy * sw + xx) * 4;
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
}
unsigned char* d = small.data() + ((size_t)y * dw + x) * 4;
d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
}
}
if (util::CreateRawTexture(small.data(), dw, dh, false, &th.tex)) { th.w = dw; th.h = dh; }
}
if (raw) util::FreeRawPixels(raw);
auto& slot = (s_thumbs[absPath] = th);
return &slot;
}
static void clearThumbs() {
for (auto& kv : s_thumbs) {
if (kv.second.tex) util::DestroyTexture(kv.second.tex);
for (ImTextureID f : kv.second.frames) if (f) util::DestroyTexture(f);
}
s_thumbs.clear();
}
static void navigate(const std::string& dir) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path p(dir);
if (!fs::is_directory(p, ec)) return;
clearThumbs(); // thumbnails are per-directory
fs::path abs = fs::absolute(p, ec);
s_dir = ec ? dir : abs.lexically_normal().string();
s_subdirs.clear();
s_images.clear();
fs::directory_iterator it(p, ec), end;
int guard = 0;
for (; !ec && it != end && guard < 8000; it.increment(ec), ++guard) {
std::error_code fec;
std::string name = it->path().filename().string();
if (name.empty() || name[0] == '.') continue; // skip hidden/dotfiles
if (it->is_directory(fec)) s_subdirs.push_back(name);
else if (it->is_regular_file(fec) && isImageName(name)) s_images.push_back(name);
}
auto ci = [](const std::string& a, const std::string& b){
return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(),
[](char x, char y){ return std::tolower((unsigned char)x) < std::tolower((unsigned char)y); });
};
std::sort(s_subdirs.begin(), s_subdirs.end(), ci);
std::sort(s_images.begin(), s_images.end(), ci);
}
static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled)
static constexpr int kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames)
static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation
static constexpr int kMaxAnimInFlight = 2; // max concurrent background decodes
// Heap-owned so a detached worker (which decrements it) can safely outlive static teardown at
// process exit — the worker holds a shared_ptr copy, so it never touches a destroyed static.
static inline std::shared_ptr<std::atomic<int>> s_animInFlight = std::make_shared<std::atomic<int>>(0);
static inline bool s_open = false;
// Main-thread-only (set in render/driveThumbAnim, read+cleared in consumeAnimationActive); NOT atomic
// on purpose — do not read/write it from a worker thread.
static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing
static inline int s_loadedThisFrame = 0;
static inline std::string s_dir;
static inline std::string s_selected;
static inline std::vector<std::string> s_subdirs;
static inline std::vector<std::string> s_images;
static inline std::unordered_map<std::string, Thumb> s_thumbs;
static inline std::function<void(const std::string&)> s_onPick;
};
} // namespace ui
} // namespace dragonx

View File

@@ -5,6 +5,7 @@
#include "key_export_dialog.h"
#include "../../app.h"
#include "../../wallet/lite_wallet_controller.h"
#include <sodium.h> // sodium_memzero — wipe transient secret copies (B7)
#include <nlohmann/json.hpp>
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
@@ -37,7 +38,7 @@ std::string KeyExportDialog::s_error;
void KeyExportDialog::releaseQr()
{
if (s_qr_tex) { FreeQRTexture(s_qr_tex); s_qr_tex = 0; }
s_qr_cached.clear();
wallet::secureWipeLiteSecret(s_qr_cached); // held a plaintext copy of the key for the QR
s_show_qr = false;
}
@@ -49,7 +50,7 @@ void KeyExportDialog::show(const std::string& address, KeyType type)
releaseQr();
s_key_type = type;
s_address = address;
s_key.clear();
wallet::secureWipeLiteSecret(s_key); // zero any prior secret before reuse
s_error.clear();
}
@@ -62,7 +63,7 @@ void KeyExportDialog::hide()
{
s_open = false;
s_fetching = false;
s_key.clear();
wallet::secureWipeLiteSecret(s_key); // zero the displayed secret, don't just drop the buffer
s_show_key = false;
s_error.clear();
releaseQr();
@@ -85,9 +86,20 @@ void KeyExportDialog::render(App* app)
(void)keyDisplay;
(void)copyBtn;
// Modal scales to 85% of the window width. BeginOverlayDialog multiplies the width by
// Layout::dpiScale(); divide it out here so the final card is exactly 85% at any font scale.
const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale();
// Size the card to the address field's own natural width — the widest deterministic element before
// the key is revealed — so the field fills the card and sits under its left-aligned "Address:" label
// instead of floating centered in an over-wide card. This adapts to the address type (z vs t) and the
// font scale automatically (no magic width). Mirrors AddressCopyField's boxW math; the revealed
// key+QR layout adapts to whatever width this yields (it stacks the QR when narrow). Bounded to 85%
// of the window as an upper safety limit. BeginOverlayDialog re-applies dpiScale(), so divide it out.
const std::string chunkedAddr = widgets::ChunkString(s_address, 4);
const float addrPadX = ImGui::GetStyle().FramePadding.x + 4.0f; // matches AddressCopyField
const float addrBoxW = ImGui::CalcTextSize(chunkedAddr.c_str()).x + addrPadX * 2.0f + 2.0f;
// The BlurFloat overlay card insets content by 28px per side (draw_helpers BeginOverlayDialog); add
// that (+ a little slack) so the address box fits on one line under its label instead of wrapping.
const float wantW = addrBoxW + 28.0f * 2.0f + 12.0f;
const float maxW = 0.85f * ImGui::GetMainViewport()->Size.x;
const float cardW = (wantW < maxW ? wantW : maxW) / Layout::dpiScale();
material::OverlayDialogSpec ov;
ov.title = title; ov.p_open = &s_open;
ov.style = material::OverlayStyle::BlurFloat;
@@ -176,18 +188,18 @@ void KeyExportDialog::render(App* app)
std::string error;
try {
rpc::RPCClient::TraceScope trace("Settings / Export key");
auto result = rpc->call(method, {addr});
key = result.get<std::string>();
key = rpc->callSecretString(method, {addr}); // scrubs raw body + json node (B7)
} catch (const std::exception& e) {
error = e.what();
}
return [key, error]() {
return [key = std::move(key), error]() mutable {
if (error.empty()) {
s_key = key;
s_show_key = false; // Don't show by default
} else {
s_error = error;
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
s_fetching = false;
};
});
@@ -202,18 +214,18 @@ void KeyExportDialog::render(App* app)
std::string error;
try {
rpc::RPCClient::TraceScope trace("Settings / Export viewing key");
auto result = rpc->call("z_exportviewingkey", {addr});
key = result.get<std::string>();
key = rpc->callSecretString("z_exportviewingkey", {addr}); // scrubs raw body + json node (B7)
} catch (const std::exception& e) {
error = e.what();
}
return [key, error]() {
return [key = std::move(key), error]() mutable {
if (error.empty()) {
s_key = key;
s_show_key = true; // Viewing keys are less sensitive
} else {
s_error = error;
}
if (!key.empty()) sodium_memzero(&key[0], key.size()); // wipe the last transient copy
s_fetching = false;
};
});
@@ -306,8 +318,8 @@ void KeyExportDialog::render(App* app)
if (material::TactileButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
s_open = false;
// Clear sensitive data
s_key.clear();
// Zero the secret, don't just drop the buffer (leaves the key in freed heap otherwise).
wallet::secureWipeLiteSecret(s_key);
s_show_key = false;
releaseQr();
}
@@ -315,9 +327,9 @@ void KeyExportDialog::render(App* app)
material::EndOverlayDialog();
}
// Dialog dismissed any other way (scrim click / Esc): drop the key + its QR texture.
// Dialog dismissed any other way (scrim click / Esc): wipe the key + its QR texture.
if (!s_open) {
if (!s_key.empty()) s_key.clear();
wallet::secureWipeLiteSecret(s_key);
s_show_key = false;
releaseQr();
}

View File

@@ -31,6 +31,7 @@
#include <algorithm>
#include <cmath>
#include <ctime>
#include <unordered_set>
namespace dragonx {
namespace ui {
@@ -132,7 +133,7 @@ struct PfEditState {
bool showValue = true;
bool show24h = false;
bool showSparkline = false;
int sparkInterval = 0; // 0=min 1=hour 2=day 3=week 4=month
int sparkInterval = 4; // 0=min 1=hour 2=day 3=week 4=month (default month)
};
static PfEditState s_pfEdit;
@@ -162,16 +163,16 @@ static bool pfProjectSeries(const std::vector<double>& hist, ImVec2 mn, ImVec2 m
}
static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
const std::vector<double>& hist, ImU32 col)
const std::vector<double>& hist, ImU32 col, float dp)
{
std::vector<ImVec2> pts;
if (!pfProjectSeries(hist, mn, mx, pts)) return;
dl->AddPolyline(pts.data(), (int)pts.size(), col, ImDrawFlags_None, 1.2f);
dl->AddPolyline(pts.data(), (int)pts.size(), col, ImDrawFlags_None, 1.2f * dp);
}
// Sparkline with a soft fill under the line — for the card's dedicated bottom strip.
static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
const std::vector<double>& hist, ImU32 lineCol)
const std::vector<double>& hist, ImU32 lineCol, float dp)
{
std::vector<ImVec2> pts;
if (!pfProjectSeries(hist, mn, mx, pts)) return;
@@ -180,7 +181,7 @@ static void pfDrawSparklineFilled(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
dl->PathLineTo(ImVec2(pts[n - 1].x, mx.y));
dl->PathLineTo(ImVec2(pts[0].x, mx.y));
dl->PathFillConcave(WithAlpha(lineCol, 28));
dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f);
dl->AddPolyline(pts.data(), n, WithAlpha(lineCol, 220), ImDrawFlags_None, 1.4f * dp);
}
@@ -283,7 +284,7 @@ static void PortfolioBeginEdit(App* app, int index)
s_pfEdit.showValue = true;
s_pfEdit.show24h = false;
s_pfEdit.showSparkline = false;
s_pfEdit.sparkInterval = 0;
s_pfEdit.sparkInterval = 4; // default month (a real curve from the daily series)
}
s_pfEdit.search[0] = '\0';
s_pfEdit.typeFilter = 0;
@@ -380,14 +381,14 @@ static bool pfIndexVisible(App* app, int i)
const auto& es = app->settings()->getPortfolioEntries();
if (i < 0 || i >= (int)es.size()) return false;
const std::string h = app->activeWalletIdentityHash();
return es[i].scope.empty() || h.empty() || es[i].scope == h;
return es[i].scope.empty() || (!h.empty() && es[i].scope == h);
}
static int pfFirstVisibleIndex(App* app)
{
const auto& es = app->settings()->getPortfolioEntries();
const std::string h = app->activeWalletIdentityHash();
for (int i = 0; i < (int)es.size(); i++)
if (es[i].scope.empty() || h.empty() || es[i].scope == h) return i;
if (es[i].scope.empty() || (!h.empty() && es[i].scope == h)) return i;
return -1;
}
@@ -667,14 +668,18 @@ static void pfDrawAddressSection(App* app)
}
filtered.push_back(&a);
}
// Build the selected-address set once — the sort comparator and the per-row membership test below
// both consulted it via a linear PortfolioEntryContains scan (O(n) each) every frame.
std::unordered_set<std::string> selSet(s_pfEdit.addrs.begin(), s_pfEdit.addrs.end());
std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) {
bool sx = data::PortfolioEntryContains(s_pfEdit.addrs, x->address);
bool sy = data::PortfolioEntryContains(s_pfEdit.addrs, y->address);
bool sx = selSet.count(x->address) != 0;
bool sy = selSet.count(y->address) != 0;
if (sx != sy) return sx;
return x->balance > y->balance;
});
if (selAllClicked)
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pfEdit.addrs, a->address);
for (const AddressInfo* a : filtered)
if (data::PortfolioEntryAdd(s_pfEdit.addrs, a->address)) selSet.insert(a->address);
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImDrawList* ldl = material::BeginFadeScrollChild("##pfAddrList", s_pfEdit.addrFade,
ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp);
@@ -684,7 +689,7 @@ static void pfDrawAddressSection(App* app)
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
for (const AddressInfo* ap : filtered) {
const AddressInfo& a = *ap;
bool inSet = data::PortfolioEntryContains(s_pfEdit.addrs, a.address);
bool inSet = selSet.count(a.address) != 0;
ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
@@ -733,8 +738,8 @@ static void pfDrawAddressSection(App* app)
ImGui::PushID(a.address.c_str());
ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH));
if (ImGui::IsItemClicked()) {
if (inSet) data::PortfolioEntryRemove(s_pfEdit.addrs, a.address);
else data::PortfolioEntryAdd(s_pfEdit.addrs, a.address);
if (inSet) { data::PortfolioEntryRemove(s_pfEdit.addrs, a.address); selSet.erase(a.address); }
else { data::PortfolioEntryAdd(s_pfEdit.addrs, a.address); selSet.insert(a.address); }
}
ImGui::PopID();
}
@@ -823,7 +828,9 @@ static void RenderPortfolioEditor(App* app)
const std::string activeHash = app->activeWalletIdentityHash();
std::vector<int> vis;
for (int i = 0; i < (int)entries.size(); i++)
if (entries[i].scope.empty() || activeHash.empty() || entries[i].scope == activeHash)
// Global groups always; this wallet's groups only when the identity is known — never
// another wallet's scoped groups during the switch/first-load window.
if (entries[i].scope.empty() || (!activeHash.empty() && entries[i].scope == activeHash))
vis.push_back(i);
if (vis.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
@@ -905,7 +912,11 @@ static void RenderPortfolioEditor(App* app)
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Add immediately creates a persisted "Untitled" group and selects it for editing.
// Add immediately creates a persisted "Untitled" group scoped to this wallet — so it's disabled
// while the wallet identity is unknown (switch / first-load window), otherwise a new group would
// get an empty scope and show under every wallet.
const bool pfIdentityKnown = !app->activeWalletIdentityHash().empty();
ImGui::BeginDisabled(!pfIdentityKnown);
if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
// Auto-save the current group, then start a fresh one selected for editing.
pfCommitIfNeeded(app);
@@ -918,6 +929,9 @@ static void RenderPortfolioEditor(App* app)
s_pfEdit.sel = (int)es.size() - 1;
PortfolioBeginEdit(app, s_pfEdit.sel);
}
ImGui::EndDisabled();
if (!pfIdentityKnown && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR("portfolio_wallet_loading"));
}
ImGui::EndGroup();
@@ -947,7 +961,7 @@ static void RenderPortfolioEditor(App* app)
ImVec2 pMin = ImGui::GetCursorScreenPos();
ImVec2 pMax(pMin.x + pw, pMin.y + ph);
ImU32 accent = s_pfEdit.color ? (ImU32)s_pfEdit.color : 0;
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40;
GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 22; g.borderAlpha = 40;
DrawGlassPanel(pdl, pMin, pMax, g);
if (accent) {
int oa = (int)(std::max(0, std::min(100, s_pfEdit.outlineOpacity)) * 2.55f + 0.5f);
@@ -959,7 +973,7 @@ static void RenderPortfolioEditor(App* app)
ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80);
ImVec2 spMin(pMin.x + ppad, pMin.y + ph * 0.46f);
ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f);
pfDrawSparkline(pdl, spMin, spMax, h, spCol);
pfDrawSparkline(pdl, spMin, spMax, h, spCol, dp);
}
}
float rowY = pMin.y + ppad, nameX = pMin.x + ppad;
@@ -1071,23 +1085,63 @@ bool PortfolioEditorActive() { return s_pfEdit.open; }
// 2=value-hero. No left accent strip — identity comes from the tinted icon + a subtle accent
// border. Fixed right-aligned columns keep numbers aligned across rows; on-but-absent fields show a
// muted em-dash so a row never looks broken. Pure drawing into dl.
// TABLE-style (portfolio_style 0) right-edge column anchors — shared by pfDrawRow's row body and the
// column-header strip so the header labels sit exactly over the values they name.
// TABLE-style (style 0) column widths (logical px) — shared by pfTableCols (which turns them into
// right-edge anchors) and pfDrawRow (which feeds valW/drgxW to fit() truncation), so truncated text
// always lines up with the column edges. Change here and both stay in sync.
static constexpr float kPfChgColW = 62.0f;
static constexpr float kPfValColW = 108.0f;
static constexpr float kPfDrgxColW = 118.0f;
struct PfTableCols { float labelR, drgxR, valR, chgR; };
static PfTableCols pfTableCols(float right, float dp)
{
const float colGap = Layout::spacingMd();
PfTableCols c;
c.chgR = right;
c.valR = c.chgR - kPfChgColW * dp - colGap;
c.drgxR = c.valR - kPfValColW * dp - colGap;
c.labelR = c.drgxR - kPfDrgxColW * dp - colGap;
return c;
}
// Portfolio row height + inter-row gap by style — one source shared by the row drawing
// (mktDrawPortfolio) and the scroll-region height budget (RenderMarketTab) so they can't drift and clip.
// Row heights: Table 48dp (a dedicated trend column needs vertical room), Cards 68dp (two-zone),
// Spotlight 92dp (a dominant bottom chart band under the hero value). Uniform per style.
static float pfRowHeight(int style, float dp) { return (style == 0 ? 48.0f : style == 1 ? 68.0f : 92.0f) * dp; }
// Fraction of the Table label+trend zone given to the label; the rest is the centre TREND column.
// Shared by the row body and the header strip so the sparkline and its "TREND" label line up.
static constexpr float kPfTableTrendFrac = 0.38f;
static float pfRowGapFor(int style, float dp) { return style == 0 ? 2.0f * dp : Layout::spacingSm(); }
static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
const config::Settings::PortfolioEntry& e, const WalletState& state,
const MarketInfo& market, int style, float dp,
ImFont* sub1, ImFont* capFont, bool hov)
ImFont* sub1, ImFont* capFont, bool hov, bool tableReserveSpark = false)
{
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
const double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
ImU32 accent = e.color ? (ImU32)e.color : 0;
const bool zeroBal = (bal <= 0.0); // empty groups render dimmed (content only, not the container)
// Card — a touch more fill than before so rows read as distinct cards, plus a subtle accent
// border for identity (the left accent strip was removed).
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 42 : 30; g.borderAlpha = 55;
DrawGlassPanel(dl, rowMin, rowMax, g);
if (accent) {
int oa = (int)(std::max(0, std::min(100, e.outlineOpacity)) * 2.55f + 0.5f);
dl->AddRect(rowMin, rowMax, WithAlpha(accent, hov ? std::min(255, oa + 45) : oa), 10.0f, 0, hov ? 1.8f : 1.2f);
} else if (hov) {
dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 80), 10.0f, 0, 1.2f);
// ---- Per-style CONTAINER: Table (0) = borderless ledger row (hairline + hover wash); Cards (1) =
// glass card with a faint identity tint; Spotlight (2) = a heavier glass tile. No accent line —
// the group's colour already reads from its icon and the sparkline, so it adds nothing here. ----
const float round = 10.0f * dp;
if (style == 0) {
if (hov) dl->AddRectFilled(rowMin, rowMax, WithAlpha(OnSurface(), 14), 0.0f);
dl->AddLine(ImVec2(rowMin.x, rowMax.y - 0.5f), ImVec2(rowMax.x, rowMax.y - 0.5f),
WithAlpha(OnSurface(), 24), 1.0f);
} else if (style == 1) {
GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 42 : 30; g.borderAlpha = 55;
DrawGlassPanel(dl, rowMin, rowMax, g);
if (accent) dl->AddRectFilled(rowMin, rowMax, WithAlpha(accent, hov ? 16 : 10), round); // faint identity wash
if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 80), round, 0, 1.2f);
} else {
GlassPanelSpec g; g.rounding = round; g.fillAlpha = hov ? 56 : 46; g.borderAlpha = 60;
DrawGlassPanel(dl, rowMin, rowMax, g);
if (hov) dl->AddRect(rowMin, rowMax, WithAlpha(OnSurface(), 70), round, 0, 1.2f);
}
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
@@ -1121,76 +1175,138 @@ static void pfDrawRow(ImDrawList* dl, ImVec2 rowMin, ImVec2 rowMax,
float right = rowMax.x - padX;
float midY = (rowMin.y + rowMax.y) * 0.5f;
// Empty groups: dim the BODY content (text/pill/bar/sparkline) to ~half alpha so a $0 group reads as
// muted, while the container (card/tick) stays full-strength. Done by scaling the alpha of the
// vertices this body emits — one place instead of tinting every draw call.
const int zdVtx = zeroBal ? dl->VtxBuffer.Size : -1;
if (style == 0) {
// ---- Compact: icon + label (left); fixed right-aligned columns DRGX | value | 24h | spark.
float iconSz = sub1->LegacySize, x = left;
// ---- TABLE: icon + label (left); a dedicated centre TREND column (aligned + header-labelled);
// right-aligned numeric columns DRGX | value | 24h pill. The trend column is present for the
// whole list when any group opts in (tableReserveSpark); each row fills it when it has one.
const PfTableCols cols = pfTableCols(right, dp);
const float chgR = cols.chgR, valR = cols.valR, drgxR = cols.drgxR, labelR = cols.labelR;
const float valW = kPfValColW*dp, drgxW = kPfDrgxColW*dp; // fit() widths (shared with pfTableCols)
float iconSz = capFont->LegacySize, x = left;
if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, midY), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
x += iconSz + Layout::spacingSm();
}
const float sparkW = 92.0f*dp, chgW = 60.0f*dp, valW = 112.0f*dp, drgxW = 122.0f*dp;
float sparkL = right - sparkW;
float chgR = sparkL - colGap;
float valR = chgR - chgW - colGap;
float drgxR = valR - valW - colGap;
float labelR = drgxR - drgxW - colGap;
if (e.showDrgx) rtext(capFont, drgxR, midY, OnSurfaceMedium(), fit(drgxStr, capFont, drgxW));
if (wantValue) rtext(sub1, valR, midY, hasValue?OnSurface():OnSurfaceDisabled(), hasValue?fit(valStr,sub1,valW):kDash);
if (wantChange) rtext(capFont, chgR, midY, hasChange?chgCol:OnSurfaceDisabled(), hasChange?chgStr:kDash);
if (hasSpark) pfDrawSparkline(dl, ImVec2(sparkL, rowMin.y+padY), ImVec2(right, rowMax.y-padY), spark, sparkCol);
float lblMaxW = std::max(24.0f*dp, labelR - x);
if (wantChange) {
const std::string s = hasChange ? chgStr : kDash;
const float pw = tw(capFont, s) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
const ImVec2 pmn(chgR - pw, midY - ph*0.5f);
if (hasChange) dl->AddRectFilled(pmn, ImVec2(chgR, midY + ph*0.5f), WithAlpha(chgCol, 32), ph*0.5f);
dl->AddText(capFont, capFont->LegacySize, ImVec2(pmn.x + 6.0f*dp, midY - capFont->LegacySize*0.5f),
hasChange?chgCol:OnSurfaceDisabled(), s.c_str());
}
// Dedicated centre TREND column: when the list has any sparkline (tableReserveSpark), cap the
// label to a fixed boundary so the column + its header label align across every row; each row
// fills it only when that group opts in. Rows without any list-wide trend keep the full label.
// Trend-column origin from the fixed LEFT (icon-independent) so it lines up with the "TREND"
// header and across rows whether or not a group has an icon (x may be shifted past the icon).
const float zoneW = std::max(24.0f*dp, labelR - left);
const float trendX0 = left + zoneW * kPfTableTrendFrac;
const float lblMaxW = tableReserveSpark ? std::max(24.0f*dp, trendX0 - colGap - x) : std::max(24.0f*dp, labelR - x);
dl->AddText(sub1, sub1->LegacySize, ImVec2(x, midY - sub1->LegacySize*0.5f), OnSurface(), fit(e.label,sub1,lblMaxW).c_str());
if (hasSpark && tableReserveSpark && labelR - trendX0 > 40.0f * dp)
pfDrawSparklineFilled(dl, ImVec2(trendX0, rowMin.y + padY), ImVec2(labelR, rowMax.y - padY), spark, sparkCol, dp);
} else if (style == 1) {
// ---- Detailed: 2x2 grid — label/value on top, DRGX/24h below — plus a tall sparkline strip.
float iconSz = sub1->LegacySize, x = left;
const float sparkW = 150.0f*dp;
float sparkL = right - sparkW;
float textR = hasSpark ? (sparkL - colGap) : right;
float topY = rowMin.y + padY;
float botY = rowMax.y - padY - capFont->LegacySize;
// ---- CARDS: a balanced snapshot — a LEFT-aligned info stack (icon + label / value+delta chip /
// DRGX) grouped on the left, with a full-height sparkline filling the rest of the width.
const float zoneTop = rowMin.y + padY;
const float zoneBot = rowMax.y - padY;
// Icon centred in the info zone; the label/value/DRGX stack sits to its right.
const float iconSz = capFont->LegacySize;
float textX = left;
if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+iconSz*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
x += iconSz + Layout::spacingSm();
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(left + iconSz*0.5f, (zoneTop + zoneBot)*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
textX = left + iconSz + Layout::spacingSm();
}
// value (top-right, emphasized)
std::string vTop = wantValue ? (hasValue?valStr:kDash) : std::string();
if (wantValue) dl->AddText(sub1, sub1->LegacySize, ImVec2(textR - tw(sub1,vTop), topY), hasValue?OnSurface():OnSurfaceDisabled(), vTop.c_str());
// label (top-left, fills up to the value column)
float labelR = wantValue ? (textR - tw(sub1,vTop) - colGap) : textR;
dl->AddText(sub1, sub1->LegacySize, ImVec2(x, topY), OnSurface(), fit(e.label,sub1,std::max(24.0f*dp, labelR - x)).c_str());
// DRGX (bottom-left, muted) + 24h (bottom-right, colored)
if (e.showDrgx) dl->AddText(capFont, capFont->LegacySize, ImVec2(left, botY), OnSurfaceMedium(), drgxStr.c_str());
if (wantChange) { std::string s = hasChange?chgStr:kDash; dl->AddText(capFont, capFont->LegacySize, ImVec2(textR - tw(capFont,s), botY), hasChange?chgCol:OnSurfaceDisabled(), s.c_str()); }
// tall sparkline strip spanning both lines
if (hasSpark) pfDrawSparklineFilled(dl, ImVec2(sparkL, topY), ImVec2(right, rowMax.y-padY), spark, sparkCol);
// Info zone driven by the ACTUAL content width so a short group hands the extra room to the chart;
// bounded [28%, 52%] so the info never cramps and the chart always keeps a decent band.
float infoR = right, cSparkL = right;
if (hasSpark) {
const std::string vTxt0 = wantValue ? (hasValue?valStr:kDash) : std::string();
const float chipW = (wantChange && hasChange) ? (Layout::spacingSm() + tw(capFont, chgStr) + 12.0f*dp) : 0.0f;
const float valLineW = wantValue ? (tw(sub1, vTxt0) + chipW) : 0.0f;
const float contentW = std::max(std::max(tw(capFont, e.label), valLineW), e.showDrgx ? tw(capFont, drgxStr) : 0.0f);
const float span = right - left;
const float infoW = std::min(std::max((textX - left) + contentW + colGap * 2.0f, span * 0.28f), span * 0.52f);
cSparkL = left + infoW;
infoR = cSparkL - colGap;
}
const float gap = Layout::spacingXs();
const float stackH = capFont->LegacySize + gap + sub1->LegacySize + gap + capFont->LegacySize;
float cy = zoneTop + std::max(0.0f, (zoneBot - zoneTop - stackH) * 0.5f);
// label (muted)
dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, cy), OnSurfaceMedium(), fit(e.label, capFont, std::max(24.0f*dp, infoR - textX)).c_str());
cy += capFont->LegacySize + gap;
// value (leads via a soft shadow) + delta chip trailing it
if (wantValue) {
const std::string vTxt = hasValue ? valStr : kDash;
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(textX, cy), hasValue?OnSurface():OnSurfaceDisabled(), vTxt.c_str());
if (wantChange && hasChange) {
const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
const float chipX = textX + tw(sub1, vTxt) + Layout::spacingSm();
const float chipY = cy + (sub1->LegacySize - ph)*0.5f;
if (chipX + pw <= infoR) {
dl->AddRectFilled(ImVec2(chipX, chipY), ImVec2(chipX+pw, chipY+ph), WithAlpha(chgCol, 34), ph*0.5f);
dl->AddText(capFont, capFont->LegacySize, ImVec2(chipX+6.0f*dp, chipY + (ph-capFont->LegacySize)*0.5f), chgCol, chgStr.c_str());
}
}
}
cy += sub1->LegacySize + gap;
// DRGX (muted)
if (e.showDrgx) dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, cy), OnSurfaceMedium(), fit(drgxStr, capFont, std::max(24.0f*dp, infoR - textX)).c_str());
if (hasSpark) pfDrawSparklineFilled(dl, ImVec2(cSparkL, zoneTop), ImVec2(right, zoneBot), spark, sparkCol, dp);
} else {
// ---- Value-hero: label; big neutral value; 24h; DRGX + sparkline fill the right.
// ---- SPOTLIGHT: "big number over a chart" hero tile — a top zone (muted name + DRGX, then a
// LARGE value coloured by 24h with a delta chip) over a dominant full-width sparkline band
// filling the bottom. The value spans the full width now (no competing right strip).
const float heroSz = sub1->LegacySize * 1.5f; // ImGui 1.92 rebakes at this size (crisp, not upscaled)
const float topY = rowMin.y + padY;
float iconSz = capFont->LegacySize, x = left;
const float sparkW = (right-left)*0.40f;
float sparkL = right - sparkW;
float topY = rowMin.y + padY;
if (!e.icon.empty()) {
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+iconSz*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
material::project_icons::drawByName(dl, e.icon.c_str(), ImVec2(x+iconSz*0.5f, topY+capFont->LegacySize*0.5f), accent?accent:OnSurfaceMedium(), Type().iconSmall(), iconSz);
x += iconSz + Layout::spacingSm();
}
float rightZoneL = hasSpark ? sparkL : right;
dl->AddText(capFont, capFont->LegacySize, ImVec2(x, topY), OnSurfaceMedium(), fit(e.label,capFont,std::max(24.0f*dp, rightZoneL - x - colGap)).c_str());
// hero value — neutral bold (accent stays in the icon/border, so it can't clash with the 24h)
float valY = topY + capFont->LegacySize + Layout::spacingXs();
std::string hero = hasValue ? valStr : (e.showDrgx ? drgxStr : kDash);
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(left, valY), hasValue?OnSurface():OnSurfaceDisabled(), hero.c_str());
// 24h below the value (colored / dash)
float botY = valY + sub1->LegacySize + Layout::spacingXs();
if (wantChange) dl->AddText(capFont, capFont->LegacySize, ImVec2(left, botY), hasChange?chgCol:OnSurfaceDisabled(), (hasChange?chgStr:kDash).c_str());
// DRGX — right-aligned on the value line; fills the right when there's no sparkline.
if (e.showDrgx && hasValue) {
float dr = hasSpark ? (sparkL - colGap) : right;
dl->AddText(capFont, capFont->LegacySize, ImVec2(dr - tw(capFont,drgxStr), valY + (sub1->LegacySize - capFont->LegacySize)*0.5f), OnSurfaceMedium(), drgxStr.c_str());
// name (top-left, muted) + DRGX (top-right, muted)
const float drgxW2 = e.showDrgx ? tw(capFont, drgxStr) : 0.0f;
if (e.showDrgx) rtext(capFont, right, topY + capFont->LegacySize*0.5f, OnSurfaceMedium(), drgxStr);
const float nameMaxW = std::max(24.0f*dp, (right - (e.showDrgx ? drgxW2 + colGap : 0.0f)) - x);
dl->AddText(capFont, capFont->LegacySize, ImVec2(x, topY), OnSurfaceMedium(), fit(e.label,capFont,nameMaxW).c_str());
// hero value — coloured by 24h direction (neutral when no live change)
const float valY = topY + capFont->LegacySize + Layout::spacingXs();
const std::string hero = hasValue ? valStr : (e.showDrgx ? drgxStr : kDash);
const ImU32 heroCol = !hasValue ? OnSurfaceDisabled() : (hasChange ? chgCol : OnSurface());
DrawTextShadow(dl, sub1, heroSz, ImVec2(left, valY), heroCol, hero.c_str());
const float heroW = sub1->CalcTextSizeA(heroSz, FLT_MAX, 0, hero.c_str()).x;
// delta chip trailing the hero value
if (wantChange && hasChange) {
const float pw = tw(capFont, chgStr) + 12.0f*dp, ph = capFont->LegacySize + 5.0f*dp;
const float chipX = left + heroW + Layout::spacingSm();
const float chipY = valY + (heroSz - ph)*0.5f;
if (chipX + pw <= right) {
dl->AddRectFilled(ImVec2(chipX, chipY), ImVec2(chipX+pw, chipY+ph), WithAlpha(chgCol, 34), ph*0.5f);
dl->AddText(capFont, capFont->LegacySize, ImVec2(chipX+6.0f*dp, chipY + (ph-capFont->LegacySize)*0.5f), chgCol, chgStr.c_str());
}
}
// full-width sparkline band filling the bottom of the tile, below the hero value
if (hasSpark) {
const float bandTop = valY + heroSz + Layout::spacingXs();
if (rowMax.y - padY - bandTop > 12.0f * dp)
pfDrawSparklineFilled(dl, ImVec2(left, bandTop), ImVec2(right, rowMax.y - padY), spark, sparkCol, dp);
}
}
if (zdVtx >= 0) { // fade the just-emitted body vertices for an empty group
for (int vi = zdVtx; vi < dl->VtxBuffer.Size; ++vi) {
ImDrawVert& v = dl->VtxBuffer[vi];
v.col = material::ScaleAlpha(v.col, 0.5f);
}
// filled sparkline (right ~40%)
if (hasSpark) pfDrawSparklineFilled(dl, ImVec2(sparkL, valY - 2*dp), ImVec2(right, rowMax.y-padY), spark, sparkCol);
}
}
@@ -1387,6 +1503,23 @@ static void mktDrawPriceHero(const MktCtx& cx)
float tradeBtnX = cardMax.x - pad - tradeBtnW;
float tradeBtnY = cardMin.y + Layout::spacingSm();
// Chart line/candle segmented control, immediately left of the trade button — only when the
// selected range has per-exchange candles (Live / aggregate is line-only, nothing to toggle).
if (cx.chartCandles && cx.chartCandles->size() >= 2) {
const char* styleIcons[2] = { ICON_MD_SHOW_CHART, ICON_MD_CANDLESTICK_CHART };
const float csW = 2.0f * tradeBtnH;
const ImVec2 csOrigin(tradeBtnX - csW - Layout::spacingSm(), tradeBtnY);
const ImVec2 csSaved = ImGui::GetCursorScreenPos();
const int csCur = s_mkt.chartStyle;
const int csClk = SegmentedControl(dl, csOrigin, csW, tradeBtnH, styleIcons, 2, csCur,
Type().iconSmall(), "##chartStyleSeg", dp);
if (csClk >= 0 && csClk != csCur) {
s_mkt.chartStyle = csClk;
if (app->settings()) { app->settings()->setChartStyle(csClk); app->settings()->save(); }
}
ImGui::SetCursorScreenPos(csSaved);
}
ImVec2 tMin(tradeBtnX, tradeBtnY), tMax(tradeBtnX + tradeBtnW, tradeBtnY + tradeBtnH);
bool tradeHov = material::IsRectHovered(tMin, tMax);
@@ -1509,30 +1642,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
bx += bw + Layout::spacingXs();
}
// Line/candle toggle — only when the selected range has per-exchange candles (the aggregate /
// Live view is line-only, so the toggle is hidden there).
if (cx.chartCandles && cx.chartCandles->size() >= 2) {
bx += Layout::spacingSm();
ImFont* icoF = material::Typography::instance().iconSmall();
const bool isCandle = (s_mkt.chartStyle == 1);
const char* styleIcon = isCandle ? ICON_MD_CANDLESTICK_CHART : ICON_MD_SHOW_CHART;
ImVec2 tmn(bx, rowTop), tmx(bx + pillH, rowTop + pillH);
bool thov = material::IsRectHovered(tmn, tmx);
if (thov) { dl->AddRectFilled(tmn, tmx, IM_COL32(255, 255, 255, 20), 4.0f * mktDp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
ImVec2 tiSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, styleIcon);
dl->AddText(icoF, icoF->LegacySize,
ImVec2(tmn.x + (pillH - tiSz.x) * 0.5f, tmn.y + (pillH - tiSz.y) * 0.5f),
thov ? OnSurface() : OnSurfaceMedium(), styleIcon);
ImGui::SetCursorScreenPos(tmn);
if (ImGui::InvisibleButton("##ChartStyle", ImVec2(pillH, pillH))) {
s_mkt.chartStyle = isCandle ? 0 : 1;
if (app->settings()) { app->settings()->setChartStyle(s_mkt.chartStyle); app->settings()->save(); }
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR(isCandle ? "market_style_line" : "market_style_candle"));
bx += pillH;
}
// (Line/candle chart-style toggle now lives top-right of the price hero, left of the trade button.)
// Refresh button (far right).
float rEdge = chartMax.x - chartPad;
@@ -1616,7 +1726,7 @@ static void mktDrawPriceChart(const MktCtx& cx)
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
// Keep the axis label inside the card even on narrow windows (min-padding may be
// smaller than the label width) so it never spills onto the tab background.
float lblX = std::max(chartMin.x + 3.0f, plotLeft - labelSz.x - 6);
float lblX = std::max(chartMin.x + 3.0f * mktDp, plotLeft - labelSz.x - 6.0f * mktDp);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(lblX, gy - labelSz.y * 0.5f),
OnSurfaceDisabled(), buf);
@@ -1679,8 +1789,8 @@ static void mktDrawPriceChart(const MktCtx& cx)
tipX = std::max(plotLeft, tipX);
float tipY = plotTop + 4.0f * mktDp;
ImVec2 tMin(tipX, tipY), tMax(tipX + tw + pad * 2, tipY + th);
dl->AddRectFilled(tMin, tMax, IM_COL32(20, 20, 30, 235), 4.0f);
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
dl->AddRectFilled(tMin, tMax, IM_COL32(20, 20, 30, 235), 4.0f * mktDp);
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
ImU32 cCol = (hc.close >= hc.open) ? Success() : Error();
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad), OnSurface(), when);
dl->AddText(capFont, lh, ImVec2(tipX + pad, tipY + pad + lh + gap), cCol, l2);
@@ -1783,16 +1893,16 @@ static void mktDrawPriceChart(const MktCtx& cx)
snprintf(buf, sizeof(buf), "%s", FormatPrice(s_mkt.history[idx]).c_str());
ImVec2 tipSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float tipPad = Layout::spacingSm() + Layout::spacingXs();
float tipX = px + 10;
float tipY = py - tipSz.y - tipPad * 2 - 4;
float tipX = px + 10.0f * mktDp;
float tipY = py - tipSz.y - tipPad * 2 - 4.0f * mktDp;
if (tipX + tipSz.x + tipPad * 2 > plotRight)
tipX = px - tipSz.x - tipPad * 2 - 10;
if (tipY < plotTop) tipY = py + 10;
tipX = px - tipSz.x - tipPad * 2 - 10.0f * mktDp;
if (tipY < plotTop) tipY = py + 10.0f * mktDp;
ImVec2 tipMin(tipX, tipY);
ImVec2 tipMax(tipX + tipSz.x + tipPad * 2, tipY + tipSz.y + tipPad * 2);
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f);
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f, 0, 1.0f);
dl->AddRectFilled(tipMin, tipMax, IM_COL32(20, 20, 30, 230), 4.0f * mktDp);
dl->AddRect(tipMin, tipMax, IM_COL32(255, 255, 255, 30), 4.0f * mktDp, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
}
@@ -1858,12 +1968,29 @@ static void mktDrawPortfolio(const MktCtx& cx)
}
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
// "Manage…" button, right-aligned on the header row — opens the portfolio editor.
// Portfolio-style segmented control (Table / Cards / Spotlight) + "Manage…" button, right-aligned on
// the header row (style picker left of Manage). Manage opens the portfolio editor.
{
const char* ml = TR("portfolio_manage");
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
const char* styleItems[3] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
TR("portfolio_style_featured") };
const float segH = ImGui::GetFrameHeight();
const float segW = 210.0f * mktDp;
const float sGap = Layout::spacingSm();
ImGui::SameLine();
material::RightAlignX(mBtnW);
RightAlignX(segW + sGap + mBtnW); // right-align the [style picker | Manage] group
const ImVec2 segOrigin = ImGui::GetCursorScreenPos();
const int curStyle = app->settings() ? app->settings()->getPortfolioStyle() : 0;
const int segClk = SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH,
styleItems, 3, curStyle, Type().caption(), "##pfStyleSeg", mktDp);
if (segClk >= 0 && segClk != curStyle && app->settings()) {
app->settings()->setPortfolioStyle(segClk);
app->settings()->save();
}
ImGui::SetCursorScreenPos(segOrigin);
ImGui::Dummy(ImVec2(segW, segH)); // reserve + restore layout flow
ImGui::SameLine(0.0f, sGap);
if (material::TactileButton(ml, ImVec2(mBtnW, 0))) {
// Open the editor on the first group visible to this wallet (or the empty state if none) —
// never a raw index 0 that might belong to a different wallet.
@@ -1967,12 +2094,22 @@ static void mktDrawPortfolio(const MktCtx& cx)
std::vector<int> vis;
for (int i = 0; i < (int)allEntries.size(); i++) {
const auto& e = allEntries[i];
if (e.scope.empty() || activeHash.empty() || e.scope == activeHash) vis.push_back(i);
// Global groups always; this wallet's only when the identity is known (never another
// wallet's scoped groups during the switch/first-load window).
if (e.scope.empty() || (!activeHash.empty() && e.scope == activeHash)) vis.push_back(i);
}
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
float rowH = (style == 0 ? 46.0f : style == 1 ? 64.0f : 84.0f) * mktDp;
float rowGap = Layout::spacingSm();
// The Table's dedicated TREND column (+ its header label) is present for the whole list when any
// visible group opts into a sparkline, so the column aligns and doesn't flicker per row.
bool anySpark = false;
for (int i : vis) {
const auto& e = allEntries[i];
if (e.showSparkline && (e.priceBasis == 0 || e.priceBasis == 1)) { anySpark = true; break; }
}
float rowH = pfRowHeight(style, mktDp);
// Table rows abut like a ledger (thin gap); Cards/Spotlight breathe.
float rowGap = pfRowGapFor(style, mktDp);
float rowsH = std::max(rowH, portfolioH - pfSummaryH);
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
@@ -1991,6 +2128,31 @@ static void mktDrawPortfolio(const MktCtx& cx)
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
} else {
// TABLE column-header strip (style 0): a thin static header labelling the numeric columns —
// a signature the card styles don't have. Uses the shared pfTableCols so it aligns with rows.
if (style == 0) {
const float headerH = capFont->LegacySize + 8.0f * mktDp;
const ImVec2 hp = ImGui::GetCursorScreenPos();
const float hLeft = hp.x + Layout::spacingMd();
const float hRight = hp.x + rowW - Layout::spacingMd();
const PfTableCols hc = pfTableCols(hRight, mktDp);
ImFont* ovF = Type().overline();
const float ty = hp.y + (headerH - ovF->LegacySize) * 0.5f;
const ImU32 hcol = OnSurfaceDisabled();
auto hdr = [&](float xr, const char* s){
const float w = ovF->CalcTextSizeA(ovF->LegacySize, FLT_MAX, 0, s).x;
rdl->AddText(ovF, ovF->LegacySize, ImVec2(xr - w, ty), hcol, s);
};
rdl->AddText(ovF, ovF->LegacySize, ImVec2(hLeft, ty), hcol, TR("market_col_name"));
if (anySpark) // TREND column header, left-aligned at the same x the row sparklines start
rdl->AddText(ovF, ovF->LegacySize, ImVec2(hLeft + (hc.labelR - hLeft) * kPfTableTrendFrac, ty), hcol, TR("market_col_trend"));
hdr(hc.drgxR, DRAGONX_TICKER);
hdr(hc.valR, TR("market_col_value"));
hdr(hc.chgR, TR("market_24h"));
rdl->AddLine(ImVec2(hLeft, hp.y + headerH - 1.0f), ImVec2(hRight, hp.y + headerH - 1.0f),
WithAlpha(OnSurface(), 20), 1.0f);
ImGui::Dummy(ImVec2(rowW, headerH));
}
for (int vi = 0; vi < (int)vis.size(); vi++) {
int i = vis[vi];
ImVec2 rMin = ImGui::GetCursorScreenPos();
@@ -2000,7 +2162,7 @@ static void mktDrawPortfolio(const MktCtx& cx)
bool hov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID();
pfDrawRow(rdl, rMin, rMax, allEntries[i], state, market, style, mktDp, sub1, capFont, hov);
pfDrawRow(rdl, rMin, rMax, allEntries[i], state, market, style, mktDp, sub1, capFont, hov, anySpark);
if (clicked) { PortfolioBeginEdit(app, i); s_pfEdit.open = true; }
if (vi + 1 < (int)vis.size()) ImGui::Dummy(ImVec2(rowW, rowGap));
}
@@ -2036,6 +2198,10 @@ void RenderMarketTab(App* app)
// Also kick the historical price-chart fetch (self-throttled) so the chart's hour/day/week/
// month intervals populate promptly when the user opens the Market tab.
app->refreshMarketChart();
// ...and the SELECTED pair's per-exchange OHLC candles (self-throttled, in-flight-guarded). Without
// this, the candle series only loaded on a pair-chip / refresh click, so the candlestick toggle was
// missing (and "switch to candlesticks" silently did nothing) when the tab was just opened.
app->refreshExchangeChart();
const auto& registry = EffectiveRegistry(market);
// Load persisted exchange/pair on first frame
@@ -2047,14 +2213,16 @@ void RenderMarketTab(App* app)
// Left/Right arrows: cycle portfolio row styles (compact / detailed / featured), mirroring the
// Overview tab's layout switch. Skip while typing, when Ctrl is held (theme cycle), or while the
// portfolio editor modal is open.
if (app->settings() && !s_pfEdit.open && !ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
// portfolio editor / market-settings modal is open.
if (app->settings() && !s_pfEdit.open &&
!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl) {
bool prev = ImGui::IsKeyPressed(ImGuiKey_LeftArrow);
bool next = ImGui::IsKeyPressed(ImGuiKey_RightArrow);
if (prev || next) {
int st = app->settings()->getPortfolioStyle();
st = next ? (st + 1) % 3 : (st + 2) % 3;
app->settings()->setPortfolioStyle(st);
app->settings()->save(); // persist the choice (matches the gear-modal control)
const char* names[3] = { TR("portfolio_style_compact"), TR("portfolio_style_detailed"),
TR("portfolio_style_featured") };
Notifications::instance().info(std::string(TR("portfolio_style_label")) + ": " + names[st]);
@@ -2110,17 +2278,18 @@ void RenderMarketTab(App* app)
const std::string pfActiveHash = app->activeWalletIdentityHash();
int pfVisN = 0;
for (const auto& e : pfEntriesGeo)
if (e.scope.empty() || pfActiveHash.empty() || e.scope == pfActiveHash) pfVisN++;
if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++;
int pfStyle = app->settings()->getPortfolioStyle();
float pfRowH = (pfStyle == 0 ? 46.0f : pfStyle == 1 ? 64.0f : 84.0f) * mktDp;
float pfRowGap = Layout::spacingSm();
float pfRowH = pfRowHeight(pfStyle, mktDp);
float pfRowGap = pfRowGapFor(pfStyle, mktDp); // Table rows abut
float pfHeaderH = (pfStyle == 0) ? (capFont->LegacySize + 8.0f * mktDp) : 0.0f; // Table column strip
const int pfMaxVisibleRows = 4; // rows shown before the list scrolls
// Height for up to pfMaxVisibleRows rows. ItemSpacing is zeroed inside the row child, so the
// content is exactly rows + inter-row gaps; one extra gap of bottom breathing room keeps the
// last visible row off the clip edge. More groups than this scroll internally.
int pfVisRows = std::min(pfVisN, pfMaxVisibleRows);
float pfGroupsH = (pfVisRows > 0)
? (pfVisRows * pfRowH + (pfVisRows - 1) * pfRowGap + pfRowGap)
? (pfHeaderH + pfVisRows * pfRowH + (pfVisRows - 1) * pfRowGap + pfRowGap)
: 0.0f;
float portfolioH = pfSummaryH + pfGroupsH;

View File

@@ -57,7 +57,10 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
// --- Compute thread grid layout based on controls card width ---
// Estimate controlsW first to compute cols correctly
float estControlsW = availWidth - std::min(schema::UI().drawElement("tabs.mining", "button-max-width-clamp").size, miningBtnMaxW) - miningBtnGap;
// The Mine button is square (= card height, which scales with DPI), so the width we
// reserve for it here must scale too — a RAW clamp under-reserves at >100% scaling, which
// over-estimates the grid width and lets the thread cells overflow the card (e.g. at 150%).
float estControlsW = availWidth - std::min(schema::UI().drawElement("tabs.mining", "button-max-width-clamp").size * dp, miningBtnMaxW) - miningBtnGap;
float innerW = estControlsW - pad * 2;
float cellSz = std::clamp(schema::UI().drawElement("tabs.mining", "cell-size").size * vs, schema::UI().drawElement("tabs.mining", "cell-min-size").size, schema::UI().drawElement("tabs.mining", "cell-max-size").sizeOr(42.0f));
float cellGap = std::max(schema::UI().drawElement("tabs.mining", "cell-gap-min").size, cellSz * schema::UI().drawElement("tabs.mining", "cell-gap-ratio").size);

View File

@@ -308,11 +308,14 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
pdl->AddRectFilled(rowMin, rowMax, IM_COL32(255, 255, 255, 10));
if (rowHov && !inXZone)
pdl->AddRectFilled(rowMin, rowMax, StateHover());
// Item text with internal padding
// Item text with internal padding, middle-truncated so a long saved URL
// can't run under the trailing X (delete) button.
float textY = rowMin.y + (rowH - rowFontSz) * 0.5f;
float maxTextW = popupInnerW - xZoneW - textPadX * 2.0f;
std::string urlDisp = material::TruncateToWidth(url, rowFont, rowFontSz, maxTextW);
pdl->AddText(rowFont, rowFontSz,
ImVec2(rowMin.x + textPadX, textY),
isCurrent ? Primary() : OnSurface(), url.c_str());
isCurrent ? Primary() : OnSurface(), urlDisp.c_str());
// X button — flush with right edge, icon centered
{
ImVec2 xMin(rowMax.x - xZoneW, rowMin.y);
@@ -467,11 +470,14 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
pdl->AddRectFilled(rowMin, rowMax, IM_COL32(255, 255, 255, 10));
if (rowHov && !inXZone)
pdl->AddRectFilled(rowMin, rowMax, StateHover());
// Full address text with internal padding
// Address text with internal padding, middle-truncated so a full z-address
// (~78 chars) can't run under the trailing X (delete) button.
float textY = rowMin.y + (wRowH - wRowFontSz) * 0.5f;
float wMaxTextW = wPopupInnerW - wXZoneW - wTextPadX * 2.0f;
std::string addrDisp = material::TruncateToWidth(addr, wRowFont, wRowFontSz, wMaxTextW);
pdl->AddText(wRowFont, wRowFontSz,
ImVec2(rowMin.x + wTextPadX, textY),
isCurrent ? Primary() : OnSurface(), addr.c_str());
isCurrent ? Primary() : OnSurface(), addrDisp.c_str());
// Tooltip for long addresses
if (rowHov && !inXZone)
material::Tooltip("%s", addr.c_str());

View File

@@ -98,7 +98,12 @@ void RenderPeersTab(App* app)
// Scrollable child to contain all content within available space
ImVec2 peersAvail = ImGui::GetContentRegionAvail();
ImGui::BeginChild("##PeersScroll", peersAvail, false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
// NoScrollWithMouse: the inner ##PeersList owns wheel scrolling (via ApplySmoothScroll). Without
// this, a wheel over the list would scroll BOTH the list (smooth-scroll) and this outer container
// (ImGui forwards the NoScrollWithMouse child's wheel to its scrollable ancestor) — a double-scroll.
// Safe because the peer panel is sized to fill the remaining height, so this outer never overflows.
ImGui::BeginChild("##PeersScroll", peersAvail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Responsive: scale factors per frame
float availWidth = ImGui::GetContentRegionAvail().x;

View File

@@ -64,9 +64,18 @@ void QRPopupDialog::render(App* app)
auto qr = S.drawElement("dialogs.qr-popup", "qr-code");
auto actionBtn = S.button("dialogs.qr-popup", "action-button");
// Match the key-export modal: 85% of the window width (divide out the dpiScale that
// BeginOverlayDialog re-applies, so the final card is exactly 85% at any font scale).
const float cardW = (0.85f * ImGui::GetMainViewport()->Size.x) / Layout::dpiScale();
// Size the card to the address's own natural (chunked) box width — the widest content element — so
// AddressCopyField fills the card under its label instead of floating centered in an over-wide card
// (matches the key-export dialog). Adapts to address type + font scale; bounded to 85% of the window.
const float dp = Layout::dpiScale();
const std::string chunkedAddr = widgets::ChunkString(s_address, 4);
const float addrPadX = ImGui::GetStyle().FramePadding.x + 4.0f; // matches AddressCopyField
const float addrBoxW = ImGui::CalcTextSize(chunkedAddr.c_str()).x + addrPadX * 2.0f + 2.0f;
// The BlurFloat overlay card insets content by 28px per side (draw_helpers BeginOverlayDialog); add
// that (+ a little slack) so the address box fits on one line under its label instead of wrapping.
const float wantW = addrBoxW + 28.0f * 2.0f + 12.0f;
const float maxW = 0.85f * ImGui::GetMainViewport()->Size.x;
const float cardW = (wantW < maxW ? wantW : maxW) / dp;
material::OverlayDialogSpec ov;
ov.title = TR("qr_title"); ov.p_open = &s_open;
ov.style = material::OverlayStyle::BlurFloat;
@@ -83,10 +92,10 @@ void QRPopupDialog::render(App* app)
// Center the QR code (responsive — larger, to suit the wider modal).
float window_width = ImGui::GetWindowWidth();
float qr_size = qr.size > 0 ? (float)qr.size : 280.0f;
const float responsive = window_width * 0.5f;
float qr_size = (qr.size > 0 ? (float)qr.size : 280.0f) * dp; // schema/fallback are logical px
const float responsive = window_width * 0.5f; // window_width is already dp-scaled
if (responsive > qr_size) qr_size = responsive;
if (qr_size > 420.0f) qr_size = 420.0f;
if (qr_size > 420.0f * dp) qr_size = 420.0f * dp;
float padding = (window_width - qr_size) / 2.0f;
if (padding < 0.0f) padding = 0.0f;

View File

@@ -26,6 +26,7 @@
#include <ctime>
#include <cmath>
#include <unordered_map>
#include <unordered_set>
#include <vector>
namespace dragonx {
@@ -64,10 +65,7 @@ struct DisplayTx {
std::string DisplayTx::getTimeString() const {
if (timestamp <= 0) return TR("pending");
std::time_t t = static_cast<std::time_t>(timestamp);
char buf[64];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", std::localtime(&t));
return buf;
return dragonx::util::formatClockDateTime(timestamp); // honors the app-wide 24h/12h clock
}
// Relative time string (localized long form, e.g. "5 minutes ago")
@@ -144,6 +142,12 @@ void RenderTransactionsTab(App* app)
const auto addrLabel = S.label("tabs.transactions", "address-label");
const auto& state = app->state();
// Txids that carried a chat message (sent or received) — drives the "Message" badge and the Chat
// filter below. Rebuilt each frame from the in-memory chat store (cheap: O(chat messages), which
// is small); 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 lookup serves full-node and lite.
const std::unordered_set<std::string> chatTxids = app->chatService().store().chatTxids();
// Responsive scale factors (recomputed every frame)
ImVec2 contentAvail = ImGui::GetContentRegionAvail();
const float hs = Layout::hScale(contentAvail.x);
@@ -253,8 +257,9 @@ void RenderTransactionsTab(App* app)
innerPad, iconSz, glassSpec, ovFont, capFont, body2, "mined", goldCol, "mined_upper",
minedCount, minedTotal, "+", 3, type_filter);
// Selected card accent
if (type_filter > 0) {
// Selected card accent (only the three summary cards map to a card position — the Chat filter
// (4) has no card, so guard the idx_map lookup to 1..3).
if (type_filter > 0 && type_filter <= 3) {
int idx_map[] = {-1, 1, 0, 2};
int idx = idx_map[type_filter];
float xOff = idx * (cardW + cardGap);
@@ -284,7 +289,8 @@ void RenderTransactionsTab(App* app)
ImGui::SameLine(0, filterGap);
float comboW = std::max(80.0f, ((filterCombo.width > 0) ? filterCombo.width : 120.0f) * hs);
ImGui::SetNextItemWidth(comboW);
const char* types[] = { TR("all_filter"), TR("sent_filter"), TR("received_filter"), TR("mined_filter") };
const char* types[] = { TR("all_filter"), TR("sent_filter"), TR("received_filter"),
TR("mined_filter"), TR("chat_filter") };
ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types));
// Sort selector
@@ -479,6 +485,7 @@ void RenderTransactionsTab(App* app)
if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue;
if (type_filter == 2 && dtx.display_type != "receive") continue;
if (type_filter == 3 && dtx.display_type != "generate" && dtx.display_type != "immature" && dtx.display_type != "mined") continue;
if (type_filter == 4 && chatTxids.count(dtx.txid) == 0) continue; // chat-only
}
if (!search_str.empty()) {
if (!containsIgnoreCase(dtx.address, search_str) &&
@@ -660,6 +667,7 @@ void RenderTransactionsTab(App* app)
// Determine type info
bool shieldedDisplay = tx.display_type == "shield";
const bool isChatTx = chatTxids.count(tx.txid) > 0; // carried a chat message
ImU32 iconCol;
const char* typeStr;
if (shieldedDisplay) {
@@ -752,12 +760,17 @@ void RenderTransactionsTab(App* app)
}
// Position status badge in the middle-right area
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
const char* shieldedStr = TR("shielded_type");
ImVec2 shieldSz = shieldedDisplay
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, shieldedStr)
// Optional "top" badge stacked above the status pill: "Message" for a chat tx or
// "Shielded" for an autoshield. Chat txs are send/receive (not the "shield" type),
// so the two are mutually exclusive; chat takes precedence.
const bool showTopBadge = isChatTx || shieldedDisplay;
const char* topBadgeStr = isChatTx ? TR("tx_chat_badge") : TR("shielded_type");
const ImU32 topBadgeCol = isChatTx ? Secondary() : Primary();
ImVec2 topSz = showTopBadge
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, topBadgeStr)
: ImVec2(0, 0);
float shieldPillW = shieldSz.x + Layout::spacingSm() * 2.0f;
float stackW = shieldedDisplay ? std::max(sSz.x, shieldPillW) : sSz.x;
float topPillW = topSz.x + Layout::spacingSm() * 2.0f;
float stackW = showTopBadge ? std::max(sSz.x, topPillW) : sSz.x;
float statusX = amtX - stackW - Layout::spacingXxl();
float minStatusX = cx + innerW * 0.25f; // don't overlap address
if (statusX < minStatusX) statusX = minStatusX;
@@ -766,18 +779,17 @@ void RenderTransactionsTab(App* app)
// row background on every skin (faint alpha-30 fills used to vanish on dark-red /
// near-white / gradient skins). Fill 48, border 90 of the state color's RGB.
const float pillRound = schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size;
if (shieldedDisplay) {
float shieldX = statusX + (stackW - shieldSz.x) * 0.5f;
ImU32 shieldCol = Primary();
ImVec2 shieldPillMin(shieldX - Layout::spacingSm(), cy - 1.0f);
ImVec2 shieldPillMax(shieldX + shieldSz.x + Layout::spacingSm(),
shieldPillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(shieldPillMin, shieldPillMax,
(shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
dl->AddRect(shieldPillMin, shieldPillMax,
(shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
if (showTopBadge) {
float topX = statusX + (stackW - topSz.x) * 0.5f;
ImVec2 topPillMin(topX - Layout::spacingSm(), cy - 1.0f);
ImVec2 topPillMax(topX + topSz.x + Layout::spacingSm(),
topPillMin.y + capFont->LegacySize + Layout::spacingXs());
dl->AddRectFilled(topPillMin, topPillMax,
(topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
dl->AddRect(topPillMin, topPillMax,
(topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(shieldX, cy), shieldCol, shieldedStr);
ImVec2(topX, cy), topBadgeCol, topBadgeStr);
}
// Background pill
ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1);

View File

@@ -288,14 +288,32 @@ public:
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
const bool bLock = pres.probed && pres.encrypted;
const bool bSeed = pres.probed && pres.hdSeed;
const bool bLegacy = pres.probed && pres.complete && !pres.hdSeed;
const bool bUnknown = pres.probed && !pres.complete && !pres.encrypted;
// Seed-phrase vs legacy. Runtime status (z_exportmnemonic → activeWalletSeedBadge) is
// authoritative for the ACTIVE wallet; otherwise the offline probe reads the hdchain
// record's fMnemonicSeed flag directly (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy
// with no phrase, 0 = couldn't tell) — which, unlike bare HD-record presence, actually
// distinguishes the two. seed uses the same 1/2/0 encoding.
const int activeBadge = rowActive[i] ? app->activeWalletSeedBadge() : 0;
int seed = activeBadge;
if (seed == 0 && pres.probed) {
if (pres.mnemonic != 0) seed = pres.mnemonic; // read the flag off disk
else if (pres.complete && !pres.hdSeed) seed = 2; // no HD records at all → no phrase
}
const bool bSeed = (seed == 1);
const bool bLegacy = (seed == 2);
// seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
// wallet (we just couldn't read the phrase flag — e.g. the tier-1 byte-scan fallback), so
// label it "HD wallet" rather than a bare "?"; only a scan that couldn't even establish
// that (incomplete, no HD marker yet) stays "Unknown". Both yield to the Lock badge when
// encrypted, so an encrypted-but-unclassified row shows just the lock.
const bool bHd = (seed == 0) && pres.probed && pres.hdSeed && !pres.encrypted;
const bool bUnknown = (seed == 0) && pres.probed && !pres.hdSeed && !pres.encrypted;
struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
Badge bl[4]; int nb = 0;
Badge bl[4]; int nb = 0; // seed/legacy/hd/unknown are mutually exclusive → at most one + lock
if (bSeed) bl[nb++] = { ICON_MD_ECO, WithAlpha(Success(), 235), TR("wallets_badge_seed_short"), TR("wallets_badge_seed") };
if (bLock) bl[nb++] = { ICON_MD_LOCK, WithAlpha(Warning(), 240), TR("wallets_badge_encrypted_short"), TR("wallets_badge_encrypted") };
if (bLegacy) bl[nb++] = { ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 220), TR("wallets_badge_legacy_short"), TR("wallets_badge_legacy") };
if (bHd) bl[nb++] = { ICON_MD_ACCOUNT_TREE, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_hd_short"), TR("wallets_badge_hd") };
if (bUnknown) bl[nb++] = { ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 185), TR("wallets_badge_unknown_short"), TR("wallets_badge_unknown") };
const float bGap = Layout::spacingSm();
float maxLabelW = 0.0f;
@@ -542,6 +560,7 @@ private:
int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
int txCount = 0; // wallet transaction records
long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown
int mnemonic = 0; // hdchain fMnemonicSeed flag: 0 unknown, 1 seed-phrase, 2 no phrase
};
struct ProbeBatch {
std::mutex mtx;
@@ -768,6 +787,7 @@ private:
const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
if (bt.parsed && bt.complete) {
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch };
res.mnemonic = bt.mnemonicSeed; // read straight off the hdchain record
budget -= std::min(budget, bt.bytesRead);
} else {
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));

View File

@@ -31,6 +31,31 @@ static bool endsWith(const std::string& s, const std::string& suffix) {
return s.compare(s.size() - suffix.size(), suffix.size(), suffix) == 0;
}
// Reject archive member names that would escape the extraction root (zip-slip).
// The snapshot legitimately carries sub-paths (blocks/, chainstate/), so — unlike the
// updaters, which flatten to baseName() — we keep the relative path but refuse any entry
// that is absolute, drive/UNC-rooted, or contains a ".." component.
static bool isSafeArchivePath(const std::string& name) {
if (name.empty()) return false;
std::string n = name;
std::replace(n.begin(), n.end(), '\\', '/'); // normalize Windows separators
if (n.front() == '/') return false; // absolute POSIX path
const char c0 = n[0];
if (n.size() >= 2 && n[1] == ':' &&
((c0 >= 'A' && c0 <= 'Z') || (c0 >= 'a' && c0 <= 'z')))
return false; // Windows drive letter (C:...)
size_t start = 0;
while (start <= n.size()) {
const size_t slash = n.find('/', start);
const std::string comp =
n.substr(start, slash == std::string::npos ? std::string::npos : slash - start);
if (comp == "..") return false; // path traversal component
if (slash == std::string::npos) break;
start = slash + 1;
}
return true;
}
static size_t writeFileCallback(void* contents, size_t size, size_t nmemb, void* userp) {
size_t total = size * nmemb;
FILE* fp = static_cast<FILE*>(userp);
@@ -383,6 +408,16 @@ bool Bootstrap::extract(const std::string& zipPath, const std::string& dataDir)
std::string filename = stat.m_filename;
// *** SECURITY: reject zip-slip / path-traversal entries before building any path ***
// A legitimate snapshot from the project host never contains these; an entry that does
// indicates a malicious/corrupt archive, so abort rather than silently skip.
if (!isSafeArchivePath(filename)) {
DEBUG_LOGF("[Bootstrap] Unsafe archive path rejected: %s\n", filename.c_str());
setProgress(State::Failed, "Refusing to extract unsafe archive entry: " + filename);
mz_zip_reader_end(&zip);
return false;
}
// *** CRITICAL: Skip wallet.dat ***
if (filename == "wallet.dat" || endsWith(filename, "/wallet.dat")) {
DEBUG_LOGF("[Bootstrap] Skipping wallet.dat (protected)\n");

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