41 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
64 changed files with 7858 additions and 841 deletions

6
.gitignore vendored
View File

@@ -11,8 +11,8 @@ prebuilt-binaries/dragonxd-win/*
!prebuilt-binaries/dragonxd-win/.gitkeep !prebuilt-binaries/dragonxd-win/.gitkeep
prebuilt-binaries/dragonxd-mac/* prebuilt-binaries/dragonxd-mac/*
!prebuilt-binaries/dragonxd-mac/.gitkeep !prebuilt-binaries/dragonxd-mac/.gitkeep
prebuilt-binaries/xmrig-hac/* prebuilt-binaries/drg-xmrig/*
!prebuilt-binaries/xmrig-hac/.gitkeep !prebuilt-binaries/drg-xmrig/.gitkeep
# External sources / toolchains (created by scripts/setup.sh) # External sources / toolchains (created by scripts/setup.sh)
@@ -33,7 +33,7 @@ imgui.ini
*.bak* *.bak*
*.params *.params
asmap.dat asmap.dat
/external/xmrig-hac /external/drg-xmrig
/memory /memory
/todo.md /todo.md
/.github/ /.github/

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_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_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") 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 set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
litelib_wallet_exists litelib_wallet_exists
litelib_initialize_new litelib_initialize_new
@@ -126,36 +125,24 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}") 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}") message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
endif() endif()
if(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE) # Note (F15-1): the former signature-metadata gate was removed. It trusted a
if(NOT DRAGONX_LITE_BACKEND_MANIFEST) # "verification_status: verified" field that scripts/build-lite-backend-artifact.sh
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires DRAGONX_LITE_BACKEND_MANIFEST") # self-attested with no cryptographic check (the "verified" SHA was just the artifact's
endif() # own SHA). The trust root is now build-from-source: that script builds the backend from
file(READ "${DRAGONX_LITE_BACKEND_MANIFEST}" DRAGONX_LITE_BACKEND_MANIFEST_JSON) # the vendored in-tree source and refuses prebuilt artifacts, so the library linked here
string(JSON DRAGONX_LITE_SIGNATURE_STATUS ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_STATUS_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_status) # is the one built from reviewed source. The required-symbol inventory check above stays.
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()
add_library(dragonx_lite_backend UNKNOWN IMPORTED) add_library(dragonx_lite_backend UNKNOWN IMPORTED)
set_target_properties(dragonx_lite_backend PROPERTIES set_target_properties(dragonx_lite_backend PROPERTIES
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}" 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(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
if(NOT IS_DIRECTORY "${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}") message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
@@ -304,6 +291,15 @@ FetchContent_Declare(
GIT_REPOSITORY https://github.com/webmproject/libwebp.git GIT_REPOSITORY https://github.com/webmproject/libwebp.git
GIT_TAG v1.4.0 GIT_TAG v1.4.0
GIT_SHALLOW TRUE 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_LINK_STATIC ON CACHE BOOL "" FORCE)
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE) set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
@@ -555,6 +551,7 @@ set(APP_SOURCES
src/util/platform.cpp src/util/platform.cpp
src/util/payment_uri.cpp src/util/payment_uri.cpp
src/util/texture_loader.cpp src/util/texture_loader.cpp
src/util/svg_texture.cpp
src/util/noise_texture.cpp src/util/noise_texture.cpp
src/daemon/embedded_daemon.cpp src/daemon/embedded_daemon.cpp
src/daemon/seed_wallet_creator.cpp src/daemon/seed_wallet_creator.cpp
@@ -1203,5 +1200,5 @@ message(STATUS " Lite backend: ${DRAGONX_LITE_BACKEND_READY}")
message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}") message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}") message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}") 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 "") 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-win/
- prebuilt-binaries/dragonxd-mac/ - prebuilt-binaries/dragonxd-mac/
**xmrig HAC fork** (https://git.dragonx.is/dragonx/xmrig-hac): **DRG-XMRig fork** (https://git.dragonx.is/DragonX/drg-xmrig):
- prebuilt-binaries/xmrig-hac/ - prebuilt-binaries/drg-xmrig/
## Build Steps ## Build Steps

View File

@@ -131,7 +131,7 @@ fi
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses # truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX. # DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
_cml="$SCRIPT_DIR/CMakeLists.txt" _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) _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_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) _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/" [[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
fi fi
# Bundle xmrig for mining support # 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" [[ -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 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/" [[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
fi fi
# Bundle xmrig for mining support # 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"; } [[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
# Desktop entry # Desktop entry
@@ -478,18 +478,28 @@ APPRUN
done done
[[ -f "$bd/_deps/sdl3-build/libSDL3.so" ]] && cp "$bd/_deps/sdl3-build/libSDL3.so"* "$APPDIR/usr/lib/" 2>/dev/null || true [[ -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="" local APPIMAGETOOL=""
if command -v appimagetool &>/dev/null; then if command -v appimagetool &>/dev/null; then
APPIMAGETOOL="appimagetool" APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
elif [[ -f "$bd/appimagetool-x86_64.AppImage" ]]; then
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
else else
info "Downloading appimagetool ..." local at="$bd/appimagetool-x86_64.AppImage"
wget -q -O "$bd/appimagetool-x86_64.AppImage" \ # Re-verify any cached copy too; a stale unverified download must not be trusted.
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" if [[ ! -f "$at" ]] || ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
chmod +x "$bd/appimagetool-x86_64.AppImage" info "Downloading appimagetool 1.9.0 (pinned) ..."
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage" 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 fi
local ARCH local ARCH
@@ -628,8 +638,8 @@ HDR
info "Lite mode: skipping embedded daemon binaries" info "Lite mode: skipping embedded daemon binaries"
fi fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ──────────────── # ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac" 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 # 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 # 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 # the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
@@ -781,7 +791,7 @@ HDR
fi fi
# Bundle xmrig for mining support # 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" [[ -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 cp -r bin/res "$dist_dir/" 2>/dev/null || true
@@ -891,8 +901,26 @@ build_release_mac() {
fi fi
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)" info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
else else
# Native macOS: build universal binary (arm64 + x86_64) # Native macOS: build universal (arm64 + x86_64) by default. Override with
MAC_ARCH="universal" # 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" export MACOSX_DEPLOYMENT_TARGET="11.0"
fi fi
@@ -980,7 +1008,7 @@ TOOLCHAIN
need_sodium=true need_sodium=true
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
# Rebuild if existing lib is not universal (single-arch won't link) # 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 ..." info "Existing libsodium is not universal — rebuilding ..."
rm -rf "$SCRIPT_DIR/libs/libsodium" rm -rf "$SCRIPT_DIR/libs/libsodium"
need_sodium=true need_sodium=true
@@ -991,13 +1019,13 @@ TOOLCHAIN
"$SCRIPT_DIR/scripts/fetch-libsodium.sh" "$SCRIPT_DIR/scripts/fetch-libsodium.sh"
fi fi
info "Configuring (native universal arm64+x86_64) ..." info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
cmake "$SCRIPT_DIR" \ cmake "$SCRIPT_DIR" \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \ -DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
-DDRAGONX_USE_SYSTEM_SDL3=OFF \ -DDRAGONX_USE_SYSTEM_SDL3=OFF \
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \ -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ -DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
"${CMAKE_LITE_ARGS[@]}" "${CMAKE_LITE_ARGS[@]}"
fi fi
@@ -1027,8 +1055,12 @@ TOOLCHAIN
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)" info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
# ── Create .app bundle ─────────────────────────────────────────────────── # ── Create .app bundle ───────────────────────────────────────────────────
rm -rf "$out"
mkdir -p "$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 APP="$out/${APP_BASENAME}.app"
local CONTENTS="$APP/Contents" local CONTENTS="$APP/Contents"
@@ -1078,8 +1110,8 @@ TOOLCHAIN
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling" info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
fi fi
# xmrig binary (from prebuilt-binaries/xmrig-hac/) # xmrig binary (from prebuilt-binaries/drg-xmrig/)
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig" local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
if [[ -f "$XMRIG_MAC" ]]; then if [[ -f "$XMRIG_MAC" ]]; then
cp "$XMRIG_MAC" "$MACOS/xmrig" cp "$XMRIG_MAC" "$MACOS/xmrig"
chmod +x "$MACOS/xmrig" chmod +x "$MACOS/xmrig"
@@ -1228,8 +1260,10 @@ PLIST
fi fi
# ── Create DMG ─────────────────────────────────────────────────────────── # ── Create DMG ───────────────────────────────────────────────────────────
local DMG_BASENAME="DragonX_Wallet" # DMG filename matches the app bundle name (ObsidianDragon / ObsidianDragonLite).
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite" # 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" local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
if command -v create-dmg &>/dev/null; then if command -v create-dmg &>/dev/null; then
@@ -1311,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/windows" ]] && echo -e " ${CYAN}windows/${NC} — .exe + .zip"
[[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg" [[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg"
fi 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()

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.

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

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Minimal", "chat_bubble_minimal": "Minimal",
"chat_bubble_rounded": "Abgerundet", "chat_bubble_rounded": "Abgerundet",
"chat_bubble_square": "Eckig", "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_cancel": "Abbrechen",
"chat_contact_added": "Kontakt hinzugefügt benenne ihn in Kontakte um", "chat_contact_added": "Kontakt hinzugefügt benenne ihn in Kontakte um",
"chat_contact_request": "kontaktanfrage", "chat_contact_request": "kontaktanfrage",
@@ -162,6 +167,7 @@
"chat_export_done": "Unterhaltung exportiert", "chat_export_done": "Unterhaltung exportiert",
"chat_export_failed": "Exportdatei konnte nicht geschrieben werden.", "chat_export_failed": "Exportdatei konnte nicht geschrieben werden.",
"chat_export_warn": "Speichert die entschlüsselten Nachrichten als Klartext. Bewahre die Datei sicher auf.", "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_hidden_toast": "Unterhaltung ausgeblendet eine neue Nachricht holt sie zurück",
"chat_hide": "Ausblenden", "chat_hide": "Ausblenden",
"chat_hide_hidden": "Ausgeblendete verbergen", "chat_hide_hidden": "Ausgeblendete verbergen",
@@ -1454,11 +1460,20 @@
"tt_blur": "Unschärfe-Stärke (0%% = aus, 100%% = maximum)", "tt_blur": "Unschärfe-Stärke (0%% = aus, 100%% = maximum)",
"tt_change_pass": "Die Wallet-Verschlüsselungspassphrase ändern", "tt_change_pass": "Die Wallet-Verschlüsselungspassphrase ändern",
"tt_change_pin": "Ihre Entsperr-PIN ä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_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_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_fees": "Manuelle Gebühreneingabe beim Senden von Transaktionen aktivieren",
"tt_custom_theme": "Benutzerdefiniertes Theme aktiv", "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_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_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_collapse": "Debug-Protokollierungsoptionen einklappen",
"tt_debug_expand": "Debug-Protokollierungsoptionen ausklappen", "tt_debug_expand": "Debug-Protokollierungsoptionen ausklappen",
@@ -1476,7 +1491,30 @@
"tt_keep_daemon": "Der Daemon wird beim Ausführen des Einrichtungsassistenten gestoppt", "tt_keep_daemon": "Der Daemon wird beim Ausführen des Einrichtungsassistenten gestoppt",
"tt_language": "Schnittstellensprache der Wallet-UI", "tt_language": "Schnittstellensprache der Wallet-UI",
"tt_layout_hotkey": "Hotkey: Links-/Rechts-Pfeiltasten zum Wechseln der Balance-Layouts", "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_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_lock": "Die Wallet sofort sperren",
"tt_low_spec": "Alle aufwendigen visuellen Effekte deaktivieren\\nHotkey: Ctrl+Shift+Down", "tt_low_spec": "Alle aufwendigen visuellen Effekte deaktivieren\\nHotkey: Ctrl+Shift+Down",
"tt_merge": "Mehrere UTXOs einer Adresse zusammenführen", "tt_merge": "Mehrere UTXOs einer Adresse zusammenführen",
@@ -1497,12 +1535,17 @@
"tt_rpc_host": "Hostname des DragonX-Daemons", "tt_rpc_host": "Hostname des DragonX-Daemons",
"tt_rpc_pass": "RPC-Authentifizierungspasswort", "tt_rpc_pass": "RPC-Authentifizierungspasswort",
"tt_rpc_port": "Port für RPC-Verbindungen des Daemons", "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_rpc_user": "RPC-Authentifizierungsbenutzername",
"tt_save_settings": "Alle Einstellungen auf der Festplatte speichern", "tt_save_settings": "Alle Einstellungen auf der Festplatte speichern",
"tt_save_ztx": "Z-Adresse-Transaktionsverlauf lokal für schnelleres Laden 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_scan_themes": "Nach neuen Themes suchen.\\nTheme-Ordner ablegen in:\\n%s",
"tt_scanline": "CRT-Scanlinieneffekt in der Konsole", "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_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_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_set_pin": "Eine 4-8-stellige PIN für schnelles Entsperren festlegen",
"tt_shield_mining": "Transparente Mining-Belohnungen an eine geschirmte Adresse verschieben", "tt_shield_mining": "Transparente Mining-Belohnungen an eine geschirmte Adresse verschieben",
@@ -1521,6 +1564,7 @@
"tt_website": "Die DragonX-Website öffnen", "tt_website": "Die DragonX-Website öffnen",
"tt_window_opacity": "Hintergrund-Deckkraft (niedriger = Desktop durch Fenster sichtbar)", "tt_window_opacity": "Hintergrund-Deckkraft (niedriger = Desktop durch Fenster sichtbar)",
"tt_wizard": "Den Ersteinrichtungsassistenten erneut ausführen\\nDer Daemon wird neu gestartet", "tt_wizard": "Den Ersteinrichtungsassistenten erneut ausführen\\nDer Daemon wird neu gestartet",
"tx_chat_badge": "Nachricht",
"tx_confirmations": "%d Bestätigungen", "tx_confirmations": "%d Bestätigungen",
"tx_details_title": "Transaktionsdetails", "tx_details_title": "Transaktionsdetails",
"tx_from_address": "Von Adresse:", "tx_from_address": "Von Adresse:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Mínima", "chat_bubble_minimal": "Mínima",
"chat_bubble_rounded": "Redondeada", "chat_bubble_rounded": "Redondeada",
"chat_bubble_square": "Cuadrada", "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_cancel": "Cancelar",
"chat_contact_added": "Contacto añadido: renómbralo en Contactos", "chat_contact_added": "Contacto añadido: renómbralo en Contactos",
"chat_contact_request": "solicitud de contacto", "chat_contact_request": "solicitud de contacto",
@@ -162,6 +167,7 @@
"chat_export_done": "Conversación exportada", "chat_export_done": "Conversación exportada",
"chat_export_failed": "No se pudo escribir el archivo de exportación.", "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_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_hidden_toast": "Conversación oculta: un mensaje nuevo la recupera",
"chat_hide": "Ocultar", "chat_hide": "Ocultar",
"chat_hide_hidden": "Ocultar ocultos", "chat_hide_hidden": "Ocultar ocultos",
@@ -1454,11 +1460,20 @@
"tt_blur": "Cantidad de desenfoque (0%% = apagado, 100%% = máximo)", "tt_blur": "Cantidad de desenfoque (0%% = apagado, 100%% = máximo)",
"tt_change_pass": "Cambiar la contraseña de cifrado de la billetera", "tt_change_pass": "Cambiar la contraseña de cifrado de la billetera",
"tt_change_pin": "Cambiar su PIN de desbloqueo", "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_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_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_fees": "Habilitar entrada manual de comisiones al enviar transacciones",
"tt_custom_theme": "Tema personalizado activo", "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_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_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_collapse": "Colapsar opciones de registro de depuración",
"tt_debug_expand": "Expandir opciones de registro de depuración", "tt_debug_expand": "Expandir opciones de registro de depuración",
@@ -1476,7 +1491,30 @@
"tt_keep_daemon": "El daemon se detendrá cuando ejecute el asistente de configuración", "tt_keep_daemon": "El daemon se detendrá cuando ejecute el asistente de configuración",
"tt_language": "Idioma de la interfaz de la billetera", "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_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_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_lock": "Bloquear la billetera inmediatamente",
"tt_low_spec": "Desactivar todos los efectos visuales pesados\\nAtajo: Ctrl+Shift+Down", "tt_low_spec": "Desactivar todos los efectos visuales pesados\\nAtajo: Ctrl+Shift+Down",
"tt_merge": "Consolidar múltiples UTXOs en una dirección", "tt_merge": "Consolidar múltiples UTXOs en una dirección",
@@ -1497,12 +1535,17 @@
"tt_rpc_host": "Nombre de host del daemon DragonX", "tt_rpc_host": "Nombre de host del daemon DragonX",
"tt_rpc_pass": "Contraseña de autenticación RPC", "tt_rpc_pass": "Contraseña de autenticación RPC",
"tt_rpc_port": "Puerto para conexiones RPC del daemon", "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_rpc_user": "Nombre de usuario de autenticación RPC",
"tt_save_settings": "Guardar todas las configuraciones en disco", "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_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_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_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_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_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_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", "tt_shield_mining": "Mover recompensas de minería transparentes a una dirección blindada",
@@ -1521,6 +1564,7 @@
"tt_website": "Abrir el sitio web de DragonX", "tt_website": "Abrir el sitio web de DragonX",
"tt_window_opacity": "Opacidad del fondo (menor = escritorio visible a través de la ventana)", "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", "tt_wizard": "Volver a ejecutar el asistente de configuración inicial\\nEl daemon será reiniciado",
"tx_chat_badge": "Mensaje",
"tx_confirmations": "%d confirmaciones", "tx_confirmations": "%d confirmaciones",
"tx_details_title": "Detalles de Transacción", "tx_details_title": "Detalles de Transacción",
"tx_from_address": "Dirección Origen:", "tx_from_address": "Dirección Origen:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Minimale", "chat_bubble_minimal": "Minimale",
"chat_bubble_rounded": "Arrondie", "chat_bubble_rounded": "Arrondie",
"chat_bubble_square": "Carrée", "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_cancel": "Annuler",
"chat_contact_added": "Contact ajouté — renommez-le dans Contacts", "chat_contact_added": "Contact ajouté — renommez-le dans Contacts",
"chat_contact_request": "demande de contact", "chat_contact_request": "demande de contact",
@@ -162,6 +167,7 @@
"chat_export_done": "Conversation exportée", "chat_export_done": "Conversation exportée",
"chat_export_failed": "Impossible d'écrire le fichier d'exportation.", "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_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_hidden_toast": "Conversation masquée — un nouveau message la fait réapparaître",
"chat_hide": "Masquer", "chat_hide": "Masquer",
"chat_hide_hidden": "Masquer masqués", "chat_hide_hidden": "Masquer masqués",
@@ -1454,11 +1460,20 @@
"tt_blur": "Quantité de flou (0%% = désactivé, 100%% = maximum)", "tt_blur": "Quantité de flou (0%% = désactivé, 100%% = maximum)",
"tt_change_pass": "Changer la phrase secrète de chiffrement du portefeuille", "tt_change_pass": "Changer la phrase secrète de chiffrement du portefeuille",
"tt_change_pin": "Changer votre PIN de déverrouillage", "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_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_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_fees": "Activer la saisie manuelle des frais lors de l'envoi de transactions",
"tt_custom_theme": "Thème personnalisé actif", "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_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_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_collapse": "Réduire les options de journalisation de débogage",
"tt_debug_expand": "Développer les options de journalisation de débogage", "tt_debug_expand": "Développer les options de journalisation de débogage",
@@ -1476,7 +1491,30 @@
"tt_keep_daemon": "Le daemon s'arrêtera lors de l'exécution de l'assistant de configuration", "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_language": "Langue de l'interface du portefeuille",
"tt_layout_hotkey": "Raccourci : touches fléchées gauche/droite pour changer les dispositions de Balance", "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_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_lock": "Verrouiller le portefeuille immédiatement",
"tt_low_spec": "Désactiver tous les effets visuels lourds\\nRaccourci : Ctrl+Shift+Down", "tt_low_spec": "Désactiver tous les effets visuels lourds\\nRaccourci : Ctrl+Shift+Down",
"tt_merge": "Consolider plusieurs UTXOs vers une adresse", "tt_merge": "Consolider plusieurs UTXOs vers une adresse",
@@ -1497,12 +1535,17 @@
"tt_rpc_host": "Nom d'hôte du daemon DragonX", "tt_rpc_host": "Nom d'hôte du daemon DragonX",
"tt_rpc_pass": "Mot de passe d'authentification RPC", "tt_rpc_pass": "Mot de passe d'authentification RPC",
"tt_rpc_port": "Port pour les connexions RPC du daemon", "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_rpc_user": "Nom d'utilisateur d'authentification RPC",
"tt_save_settings": "Enregistrer tous les paramètres sur le disque", "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_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_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_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_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_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_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", "tt_shield_mining": "Déplacer les récompenses de minage transparentes vers une adresse blindée",
@@ -1521,6 +1564,7 @@
"tt_website": "Ouvrir le site web DragonX", "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_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é", "tt_wizard": "Relancer l'assistant de configuration initiale\\nLe daemon sera redémarré",
"tx_chat_badge": "Message",
"tx_confirmations": "%d confirmations", "tx_confirmations": "%d confirmations",
"tx_details_title": "Détails de la transaction", "tx_details_title": "Détails de la transaction",
"tx_from_address": "Adresse d'origine :", "tx_from_address": "Adresse d'origine :",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "ミニマル", "chat_bubble_minimal": "ミニマル",
"chat_bubble_rounded": "角丸", "chat_bubble_rounded": "角丸",
"chat_bubble_square": "角ばった", "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_cancel": "キャンセル",
"chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます", "chat_contact_added": "連絡先を追加しました — 連絡先で名前を変更できます",
"chat_contact_request": "連絡リクエスト", "chat_contact_request": "連絡リクエスト",
@@ -162,6 +167,7 @@
"chat_export_done": "会話をエクスポートしました", "chat_export_done": "会話をエクスポートしました",
"chat_export_failed": "エクスポートファイルを書き込めませんでした。", "chat_export_failed": "エクスポートファイルを書き込めませんでした。",
"chat_export_warn": "復号したメッセージを平文で保存します。ファイルは安全に保管してください。", "chat_export_warn": "復号したメッセージを平文で保存します。ファイルは安全に保管してください。",
"chat_filter": "チャット",
"chat_hidden_toast": "会話を非表示にしました — 新しいメッセージが届くと再表示されます", "chat_hidden_toast": "会話を非表示にしました — 新しいメッセージが届くと再表示されます",
"chat_hide": "非表示", "chat_hide": "非表示",
"chat_hide_hidden": "非表示を隠す", "chat_hide_hidden": "非表示を隠す",
@@ -1454,11 +1460,20 @@
"tt_blur": "ぼかし量0%% = オフ、100%% = 最大)", "tt_blur": "ぼかし量0%% = オフ、100%% = 最大)",
"tt_change_pass": "ウォレットの暗号化パスフレーズを変更", "tt_change_pass": "ウォレットの暗号化パスフレーズを変更",
"tt_change_pin": "アンロック PIN を変更", "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_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除",
"tt_clock_format": "24時間または12時間表示アプリ全体。チャットで上書きできます。", "tt_clock_format": "24時間または12時間表示アプリ全体。チャットで上書きできます。",
"tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化", "tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化",
"tt_custom_theme": "カスタムテーマがアクティブ", "tt_custom_theme": "カスタムテーマがアクティブ",
"tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します", "tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します",
"tt_daemon_refresh": "上に表示されているインストール済みおよび同梱のdragonxdのバージョン、サイズ、日付を再読み込みします",
"tt_daemon_update_check": "プロジェクトの Gitea から最新の dragonxd フルノードをダウンロードして検証し、再起動して適用します", "tt_daemon_update_check": "プロジェクトの Gitea から最新の dragonxd フルノードをダウンロードして検証し、再起動して適用します",
"tt_debug_collapse": "デバッグログオプションを折りたたむ", "tt_debug_collapse": "デバッグログオプションを折りたたむ",
"tt_debug_expand": "デバッグログオプションを展開", "tt_debug_expand": "デバッグログオプションを展開",
@@ -1476,7 +1491,30 @@
"tt_keep_daemon": "セットアップウィザード実行時にデーモンは停止します", "tt_keep_daemon": "セットアップウィザード実行時にデーモンは停止します",
"tt_language": "ウォレット UI のインターフェース言語", "tt_language": "ウォレット UI のインターフェース言語",
"tt_layout_hotkey": "ホットキー:左右矢印キーでバランスレイアウトを切り替え", "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_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_lock": "ウォレットを即座にロック",
"tt_low_spec": "すべての重い視覚効果を無効化\\nホットキーCtrl+Shift+Down", "tt_low_spec": "すべての重い視覚効果を無効化\\nホットキーCtrl+Shift+Down",
"tt_merge": "複数の UTXO を一つのアドレスに統合", "tt_merge": "複数の UTXO を一つのアドレスに統合",
@@ -1497,12 +1535,17 @@
"tt_rpc_host": "DragonX デーモンのホスト名", "tt_rpc_host": "DragonX デーモンのホスト名",
"tt_rpc_pass": "RPC 認証パスワード", "tt_rpc_pass": "RPC 認証パスワード",
"tt_rpc_port": "デーモン RPC 接続用ポート", "tt_rpc_port": "デーモン RPC 接続用ポート",
"tt_rpc_toggle": "デーモンの読み取り専用のRPC接続情報ホスト、ポート、ユーザー、パスワードを表示または非表示にします",
"tt_rpc_user": "RPC 認証ユーザー名", "tt_rpc_user": "RPC 認証ユーザー名",
"tt_save_settings": "すべての設定をディスクに保存", "tt_save_settings": "すべての設定をディスクに保存",
"tt_save_ztx": "z-address トランザクション履歴をローカルに保存して高速読み込み", "tt_save_ztx": "z-address トランザクション履歴をローカルに保存して高速読み込み",
"tt_scan_themes": "新しいテーマをスキャン。\\nテーマフォルダーをここに配置\\n%s", "tt_scan_themes": "新しいテーマをスキャン。\\nテーマフォルダーをここに配置\\n%s",
"tt_scanline": "コンソールでの CRT スキャンライン効果", "tt_scanline": "コンソールでの CRT スキャンライン効果",
"tt_screenshot_open_dir": "スクリーンショットフォルダ(設定ディレクトリ内)をファイルマネージャーで開きます",
"tt_screenshot_sweep": "すべてのタブですべてのテーマを順に切り替え、それぞれのスクリーンショットを設定のスクリーンショットフォルダに保存します(前回の実行を上書きします)",
"tt_screenshot_sweep_full": "テーマの実行と同様ですが、一時的なオフラインのデモウォレットデータを使って、すべてのモーダル/ダイアログ/フローも撮影します",
"tt_seed_backup": "ウォレットの24単語の復元シードフレーズを表示してバックアップします", "tt_seed_backup": "ウォレットの24単語の復元シードフレーズを表示してバックアップします",
"tt_seed_demo_chat": "実行でUIを撮影できるように、チャットタブにサンプルの会話を挿入します。メモリ上のみで、再起動で消えます",
"tt_seed_migrate": "新しいシードフレーズウォレットを作成し、資金をそこへ移動します", "tt_seed_migrate": "新しいシードフレーズウォレットを作成し、資金をそこへ移動します",
"tt_set_pin": "クイックアンロック用の 4-8 桁 PIN を設定", "tt_set_pin": "クイックアンロック用の 4-8 桁 PIN を設定",
"tt_shield_mining": "透明マイニング報酬をシールドアドレスに移動", "tt_shield_mining": "透明マイニング報酬をシールドアドレスに移動",
@@ -1521,6 +1564,7 @@
"tt_website": "DragonX ウェブサイトを開く", "tt_website": "DragonX ウェブサイトを開く",
"tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)", "tt_window_opacity": "背景の不透明度(低い = デスクトップがウィンドウ越しに見える)",
"tt_wizard": "初期セットアップウィザードを再実行\\nデーモンは再起動されます", "tt_wizard": "初期セットアップウィザードを再実行\\nデーモンは再起動されます",
"tx_chat_badge": "メッセージ",
"tx_confirmations": "%d 確認", "tx_confirmations": "%d 確認",
"tx_details_title": "取引の詳細", "tx_details_title": "取引の詳細",
"tx_from_address": "送信元アドレス:", "tx_from_address": "送信元アドレス:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "미니멀", "chat_bubble_minimal": "미니멀",
"chat_bubble_rounded": "둥근", "chat_bubble_rounded": "둥근",
"chat_bubble_square": "각진", "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_cancel": "취소",
"chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요", "chat_contact_added": "연락처 추가됨 — 연락처에서 이름을 변경하세요",
"chat_contact_request": "연락 요청", "chat_contact_request": "연락 요청",
@@ -162,6 +167,7 @@
"chat_export_done": "대화를 내보냈습니다", "chat_export_done": "대화를 내보냈습니다",
"chat_export_failed": "내보내기 파일을 쓸 수 없습니다.", "chat_export_failed": "내보내기 파일을 쓸 수 없습니다.",
"chat_export_warn": "복호화된 메시지를 일반 텍스트로 저장합니다. 파일을 안전하게 보관하세요.", "chat_export_warn": "복호화된 메시지를 일반 텍스트로 저장합니다. 파일을 안전하게 보관하세요.",
"chat_filter": "채팅",
"chat_hidden_toast": "대화를 숨겼습니다 — 새 메시지가 오면 다시 표시됩니다", "chat_hidden_toast": "대화를 숨겼습니다 — 새 메시지가 오면 다시 표시됩니다",
"chat_hide": "숨기기", "chat_hide": "숨기기",
"chat_hide_hidden": "숨김 접기", "chat_hide_hidden": "숨김 접기",
@@ -1454,11 +1460,20 @@
"tt_blur": "블러 양 (0%% = 끔, 100%% = 최대)", "tt_blur": "블러 양 (0%% = 끔, 100%% = 최대)",
"tt_change_pass": "지갑 암호화 비밀번호 변경", "tt_change_pass": "지갑 암호화 비밀번호 변경",
"tt_change_pin": "잠금 해제 PIN 변경", "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_clear_ztx": "로컬에 캐시된 z-트랜잭션 기록 삭제",
"tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.", "tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.",
"tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화", "tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화",
"tt_custom_theme": "사용자 지정 테마 활성화됨", "tt_custom_theme": "사용자 지정 테마 활성화됨",
"tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다", "tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다",
"tt_daemon_refresh": "위에 표시된 설치 및 번들 dragonxd의 버전, 크기, 날짜를 다시 읽어옵니다",
"tt_daemon_update_check": "프로젝트 Gitea에서 최신 dragonxd 풀 노드를 다운로드하고 검증한 다음, 재시작하여 적용합니다", "tt_daemon_update_check": "프로젝트 Gitea에서 최신 dragonxd 풀 노드를 다운로드하고 검증한 다음, 재시작하여 적용합니다",
"tt_debug_collapse": "디버그 로깅 옵션 접기", "tt_debug_collapse": "디버그 로깅 옵션 접기",
"tt_debug_expand": "디버그 로깅 옵션 펼치기", "tt_debug_expand": "디버그 로깅 옵션 펼치기",
@@ -1476,7 +1491,30 @@
"tt_keep_daemon": "설정 마법사를 실행하면 데몬이 여전히 중지됩니다", "tt_keep_daemon": "설정 마법사를 실행하면 데몬이 여전히 중지됩니다",
"tt_language": "지갑 UI 인터페이스 언어", "tt_language": "지갑 UI 인터페이스 언어",
"tt_layout_hotkey": "단축키: 좌/우 화살표 키로 잔액 레이아웃 전환", "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_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_lock": "지갑 즉시 잠금",
"tt_low_spec": "모든 고부하 시각 효과 비활성화\\n단축키: Ctrl+Shift+Down", "tt_low_spec": "모든 고부하 시각 효과 비활성화\\n단축키: Ctrl+Shift+Down",
"tt_merge": "여러 UTXO를 하나의 주소로 통합", "tt_merge": "여러 UTXO를 하나의 주소로 통합",
@@ -1497,12 +1535,17 @@
"tt_rpc_host": "DragonX 데몬 호스트 이름", "tt_rpc_host": "DragonX 데몬 호스트 이름",
"tt_rpc_pass": "RPC 인증 비밀번호", "tt_rpc_pass": "RPC 인증 비밀번호",
"tt_rpc_port": "데몬 RPC 연결 포트", "tt_rpc_port": "데몬 RPC 연결 포트",
"tt_rpc_toggle": "데몬의 읽기 전용 RPC 연결 정보(호스트, 포트, 사용자, 비밀번호)를 표시하거나 숨깁니다",
"tt_rpc_user": "RPC 인증 사용자 이름", "tt_rpc_user": "RPC 인증 사용자 이름",
"tt_save_settings": "모든 설정을 디스크에 저장", "tt_save_settings": "모든 설정을 디스크에 저장",
"tt_save_ztx": "z-address 거래 기록을 로컬에 저장하여 빠른 로딩", "tt_save_ztx": "z-address 거래 기록을 로컬에 저장하여 빠른 로딩",
"tt_scan_themes": "새 테마 검색.\\n테마 폴더를 여기에 배치:\\n%s", "tt_scan_themes": "새 테마 검색.\\n테마 폴더를 여기에 배치:\\n%s",
"tt_scanline": "콘솔에서 CRT 스캔라인 효과", "tt_scanline": "콘솔에서 CRT 스캔라인 효과",
"tt_screenshot_open_dir": "파일 관리자에서 screenshots 폴더(설정 디렉터리 아래)를 엽니다",
"tt_screenshot_sweep": "모든 탭에서 모든 테마를 순환하며 각각의 스크린샷을 설정 screenshots 폴더에 저장합니다(마지막 스윕을 덮어씀)",
"tt_screenshot_sweep_full": "테마 스윕과 유사하지만 임시 오프라인 데모 지갑 데이터를 사용하여 모든 모달 / 대화 상자 / 흐름도 캡처합니다",
"tt_seed_backup": "지갑의 24단어 복구 시드 문구를 표시하고 백업합니다", "tt_seed_backup": "지갑의 24단어 복구 시드 문구를 표시하고 백업합니다",
"tt_seed_demo_chat": "스윕이 UI를 캡처하도록 샘플 대화를 채팅 탭에 삽입합니다; 메모리에만 저장되며 재시작 시 사라집니다",
"tt_seed_migrate": "새 시드 문구 지갑을 만들고 자금을 그곳으로 옮깁니다", "tt_seed_migrate": "새 시드 문구 지갑을 만들고 자금을 그곳으로 옮깁니다",
"tt_set_pin": "빠른 잠금 해제를 위한 4-8자리 PIN 설정", "tt_set_pin": "빠른 잠금 해제를 위한 4-8자리 PIN 설정",
"tt_shield_mining": "투명 채굴 보상을 차폐 주소로 이동", "tt_shield_mining": "투명 채굴 보상을 차폐 주소로 이동",
@@ -1521,6 +1564,7 @@
"tt_website": "DragonX 웹사이트 열기", "tt_website": "DragonX 웹사이트 열기",
"tt_window_opacity": "배경 불투명도 (낮을수록 = 창을 통해 바탕 화면이 보임)", "tt_window_opacity": "배경 불투명도 (낮을수록 = 창을 통해 바탕 화면이 보임)",
"tt_wizard": "초기 설정 마법사 다시 실행\\n데몬이 재시작됩니다", "tt_wizard": "초기 설정 마법사 다시 실행\\n데몬이 재시작됩니다",
"tx_chat_badge": "메시지",
"tx_confirmations": "%d 확인", "tx_confirmations": "%d 확인",
"tx_details_title": "거래 상세", "tx_details_title": "거래 상세",
"tx_from_address": "보낸 주소:", "tx_from_address": "보낸 주소:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Mínimo", "chat_bubble_minimal": "Mínimo",
"chat_bubble_rounded": "Arredondado", "chat_bubble_rounded": "Arredondado",
"chat_bubble_square": "Quadrado", "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_cancel": "Cancelar",
"chat_contact_added": "Contato adicionado — renomeie em Contatos", "chat_contact_added": "Contato adicionado — renomeie em Contatos",
"chat_contact_request": "solicitação de contato", "chat_contact_request": "solicitação de contato",
@@ -162,6 +167,7 @@
"chat_export_done": "Conversa exportada", "chat_export_done": "Conversa exportada",
"chat_export_failed": "Não foi possível gravar o arquivo de exportação.", "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_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_hidden_toast": "Conversa ocultada — uma nova mensagem a traz de volta",
"chat_hide": "Ocultar", "chat_hide": "Ocultar",
"chat_hide_hidden": "Ocultar ocultas", "chat_hide_hidden": "Ocultar ocultas",
@@ -1454,11 +1460,20 @@
"tt_blur": "Quantidade de desfoque (0%% = desligado, 100%% = máximo)", "tt_blur": "Quantidade de desfoque (0%% = desligado, 100%% = máximo)",
"tt_change_pass": "Alterar a frase secreta de encriptação da carteira", "tt_change_pass": "Alterar a frase secreta de encriptação da carteira",
"tt_change_pin": "Alterar seu PIN de desbloqueio", "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_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_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_fees": "Ativar entrada manual de taxas ao enviar transações",
"tt_custom_theme": "Tema personalizado ativo", "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_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_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_collapse": "Recolher opções de registro de depuração",
"tt_debug_expand": "Expandir opções de registro de depuração", "tt_debug_expand": "Expandir opções de registro de depuração",
@@ -1476,7 +1491,30 @@
"tt_keep_daemon": "O daemon será parado ao executar o assistente de configuração", "tt_keep_daemon": "O daemon será parado ao executar o assistente de configuração",
"tt_language": "Idioma da interface da carteira", "tt_language": "Idioma da interface da carteira",
"tt_layout_hotkey": "Atalho: teclas de seta esquerda/direita para alternar layouts de Saldo", "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_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_lock": "Bloquear a carteira imediatamente",
"tt_low_spec": "Desativar todos os efeitos visuais pesados\\nAtalho: Ctrl+Shift+Down", "tt_low_spec": "Desativar todos os efeitos visuais pesados\\nAtalho: Ctrl+Shift+Down",
"tt_merge": "Consolidar múltiplos UTXOs em um endereço", "tt_merge": "Consolidar múltiplos UTXOs em um endereço",
@@ -1497,12 +1535,17 @@
"tt_rpc_host": "Nome do host do daemon DragonX", "tt_rpc_host": "Nome do host do daemon DragonX",
"tt_rpc_pass": "Senha de autenticação RPC", "tt_rpc_pass": "Senha de autenticação RPC",
"tt_rpc_port": "Porta para conexões RPC do daemon", "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_rpc_user": "Nome de usuário de autenticação RPC",
"tt_save_settings": "Salvar todas as configurações no disco", "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_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_scan_themes": "Procurar novos temas.\\nColoque pastas de temas em:\\n%s",
"tt_scanline": "Efeito de linhas de varredura CRT no console", "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_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_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_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", "tt_shield_mining": "Mover recompensas de mineração transparentes para um endereço blindado",
@@ -1521,6 +1564,7 @@
"tt_website": "Abrir o site do DragonX", "tt_website": "Abrir o site do DragonX",
"tt_window_opacity": "Opacidade do fundo (menor = área de trabalho visível através da janela)", "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", "tt_wizard": "Executar novamente o assistente de configuração inicial\\nO daemon será reiniciado",
"tx_chat_badge": "Mensagem",
"tx_confirmations": "%d confirmações", "tx_confirmations": "%d confirmações",
"tx_details_title": "Detalhes da Transação", "tx_details_title": "Detalhes da Transação",
"tx_from_address": "Endereço de Origem:", "tx_from_address": "Endereço de Origem:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "Минимальный", "chat_bubble_minimal": "Минимальный",
"chat_bubble_rounded": "Скруглённый", "chat_bubble_rounded": "Скруглённый",
"chat_bubble_square": "Прямоугольный", "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_cancel": "Отмена",
"chat_contact_added": "Контакт добавлен — переименуйте его в Контактах", "chat_contact_added": "Контакт добавлен — переименуйте его в Контактах",
"chat_contact_request": "запрос контакта", "chat_contact_request": "запрос контакта",
@@ -162,6 +167,7 @@
"chat_export_done": "Разговор экспортирован", "chat_export_done": "Разговор экспортирован",
"chat_export_failed": "Не удалось записать файл экспорта.", "chat_export_failed": "Не удалось записать файл экспорта.",
"chat_export_warn": "Сохраняет расшифрованные сообщения в виде обычного текста. Храните файл в надёжном месте.", "chat_export_warn": "Сохраняет расшифрованные сообщения в виде обычного текста. Храните файл в надёжном месте.",
"chat_filter": "Чат",
"chat_hidden_toast": "Разговор скрыт — новое сообщение вернёт его", "chat_hidden_toast": "Разговор скрыт — новое сообщение вернёт его",
"chat_hide": "Скрыть", "chat_hide": "Скрыть",
"chat_hide_hidden": "Скрыть скрытые", "chat_hide_hidden": "Скрыть скрытые",
@@ -1454,11 +1460,20 @@
"tt_blur": "Степень размытия (0%% = выкл., 100%% = максимум)", "tt_blur": "Степень размытия (0%% = выкл., 100%% = максимум)",
"tt_change_pass": "Сменить пароль шифрования кошелька", "tt_change_pass": "Сменить пароль шифрования кошелька",
"tt_change_pin": "Изменить PIN-код разблокировки", "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_clear_ztx": "Удалить локально кешированную историю z-транзакций",
"tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.", "tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.",
"tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций", "tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций",
"tt_custom_theme": "Пользовательская тема активна", "tt_custom_theme": "Пользовательская тема активна",
"tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить", "tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить",
"tt_daemon_refresh": "Перечитать версию, размер и дату установленного и встроенного dragonxd, показанные выше",
"tt_daemon_update_check": "Скачать и проверить последний полный узел dragonxd из проектного Gitea, затем перезапустить для применения", "tt_daemon_update_check": "Скачать и проверить последний полный узел dragonxd из проектного Gitea, затем перезапустить для применения",
"tt_debug_collapse": "Свернуть параметры журнала отладки", "tt_debug_collapse": "Свернуть параметры журнала отладки",
"tt_debug_expand": "Развернуть параметры журнала отладки", "tt_debug_expand": "Развернуть параметры журнала отладки",
@@ -1476,7 +1491,30 @@
"tt_keep_daemon": "Демон будет остановлен при запуске мастера настройки", "tt_keep_daemon": "Демон будет остановлен при запуске мастера настройки",
"tt_language": "Язык интерфейса кошелька", "tt_language": "Язык интерфейса кошелька",
"tt_layout_hotkey": "Горячая клавиша: стрелки влево/вправо для переключения раскладок Баланса", "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_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_lock": "Немедленно заблокировать кошелёк",
"tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down", "tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down",
"tt_merge": "Объединить несколько UTXO в один адрес", "tt_merge": "Объединить несколько UTXO в один адрес",
@@ -1497,12 +1535,17 @@
"tt_rpc_host": "Имя хоста демона DragonX", "tt_rpc_host": "Имя хоста демона DragonX",
"tt_rpc_pass": "Пароль аутентификации RPC", "tt_rpc_pass": "Пароль аутентификации RPC",
"tt_rpc_port": "Порт для RPC-подключений демона", "tt_rpc_port": "Порт для RPC-подключений демона",
"tt_rpc_toggle": "Показать или скрыть параметры RPC-подключения только для чтения (хост, порт, пользователь, пароль) для демона",
"tt_rpc_user": "Имя пользователя аутентификации RPC", "tt_rpc_user": "Имя пользователя аутентификации RPC",
"tt_save_settings": "Сохранить все настройки на диск", "tt_save_settings": "Сохранить все настройки на диск",
"tt_save_ztx": "Хранить историю транзакций z-адреса локально для более быстрой загрузки", "tt_save_ztx": "Хранить историю транзакций z-адреса локально для более быстрой загрузки",
"tt_scan_themes": "Поиск новых тем.\\nРазместите папки тем в:\\n%s", "tt_scan_themes": "Поиск новых тем.\\nРазместите папки тем в:\\n%s",
"tt_scanline": "Эффект развёртки ЭЛТ в консоли", "tt_scanline": "Эффект развёртки ЭЛТ в консоли",
"tt_screenshot_open_dir": "Открыть папку скриншотов (в каталоге конфигурации) в вашем файловом менеджере",
"tt_screenshot_sweep": "Перебрать каждую тему по всем вкладкам, сохраняя скриншот каждой в папку скриншотов конфигурации (перезаписывает предыдущий проход)",
"tt_screenshot_sweep_full": "Как проход по темам, но также захватывает каждое модальное окно / диалог / поток, используя временные офлайн-данные демонстрационного кошелька",
"tt_seed_backup": "Показать и создать резервную копию сид-фразы восстановления вашего кошелька из 24 слов", "tt_seed_backup": "Показать и создать резервную копию сид-фразы восстановления вашего кошелька из 24 слов",
"tt_seed_demo_chat": "Добавить примеры переписок во вкладку «Чат», чтобы проход захватил её интерфейс; только в памяти, исчезает при перезапуске",
"tt_seed_migrate": "Создать новый кошелёк с сид-фразой и перевести в него ваши средства", "tt_seed_migrate": "Создать новый кошелёк с сид-фразой и перевести в него ваши средства",
"tt_set_pin": "Установить 4-8-значный PIN для быстрой разблокировки", "tt_set_pin": "Установить 4-8-значный PIN для быстрой разблокировки",
"tt_shield_mining": "Перевести прозрачные вознаграждения за майнинг на экранированный адрес", "tt_shield_mining": "Перевести прозрачные вознаграждения за майнинг на экранированный адрес",
@@ -1521,6 +1564,7 @@
"tt_website": "Открыть сайт DragonX", "tt_website": "Открыть сайт DragonX",
"tt_window_opacity": "Непрозрачность фона (ниже = рабочий стол виден сквозь окно)", "tt_window_opacity": "Непрозрачность фона (ниже = рабочий стол виден сквозь окно)",
"tt_wizard": "Повторно запустить мастер начальной настройки\\nДемон будет перезапущен", "tt_wizard": "Повторно запустить мастер начальной настройки\\nДемон будет перезапущен",
"tx_chat_badge": "Сообщение",
"tx_confirmations": "%d подтверждений", "tx_confirmations": "%d подтверждений",
"tx_details_title": "Детали транзакции", "tx_details_title": "Детали транзакции",
"tx_from_address": "Адрес отправителя:", "tx_from_address": "Адрес отправителя:",

View File

@@ -146,6 +146,11 @@
"chat_bubble_minimal": "极简", "chat_bubble_minimal": "极简",
"chat_bubble_rounded": "圆角", "chat_bubble_rounded": "圆角",
"chat_bubble_square": "方形", "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_cancel": "取消",
"chat_contact_added": "已添加联系人——可在联系人中重命名", "chat_contact_added": "已添加联系人——可在联系人中重命名",
"chat_contact_request": "联系人请求", "chat_contact_request": "联系人请求",
@@ -162,6 +167,7 @@
"chat_export_done": "会话已导出", "chat_export_done": "会话已导出",
"chat_export_failed": "无法写入导出文件。", "chat_export_failed": "无法写入导出文件。",
"chat_export_warn": "将解密后的消息保存为纯文本。请妥善保管该文件。", "chat_export_warn": "将解密后的消息保存为纯文本。请妥善保管该文件。",
"chat_filter": "聊天",
"chat_hidden_toast": "会话已隐藏——收到新消息后会重新显示", "chat_hidden_toast": "会话已隐藏——收到新消息后会重新显示",
"chat_hide": "隐藏", "chat_hide": "隐藏",
"chat_hide_hidden": "收起已隐藏", "chat_hide_hidden": "收起已隐藏",
@@ -1454,11 +1460,20 @@
"tt_blur": "模糊程度0%% = 关闭100%% = 最大)", "tt_blur": "模糊程度0%% = 关闭100%% = 最大)",
"tt_change_pass": "更改钱包加密密码", "tt_change_pass": "更改钱包加密密码",
"tt_change_pin": "更改您的解锁 PIN", "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_clear_ztx": "删除本地缓存的 z-交易历史",
"tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。", "tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。",
"tt_custom_fees": "发送交易时启用手动费用输入", "tt_custom_fees": "发送交易时启用手动费用输入",
"tt_custom_theme": "自定义主题已激活", "tt_custom_theme": "自定义主题已激活",
"tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启", "tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启",
"tt_daemon_refresh": "重新读取上方显示的已安装及内置 dragonxd 版本、大小和日期",
"tt_daemon_update_check": "从项目 Gitea 下载并验证最新的 dragonxd 全节点,然后重启以应用", "tt_daemon_update_check": "从项目 Gitea 下载并验证最新的 dragonxd 全节点,然后重启以应用",
"tt_debug_collapse": "折叠调试日志选项", "tt_debug_collapse": "折叠调试日志选项",
"tt_debug_expand": "展开调试日志选项", "tt_debug_expand": "展开调试日志选项",
@@ -1476,7 +1491,30 @@
"tt_keep_daemon": "运行设置向导时守护进程仍会停止", "tt_keep_daemon": "运行设置向导时守护进程仍会停止",
"tt_language": "钱包界面语言", "tt_language": "钱包界面语言",
"tt_layout_hotkey": "快捷键:左/右箭头键切换余额布局", "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_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_lock": "立即锁定钱包",
"tt_low_spec": "禁用所有重度视觉效果\\n快捷键Ctrl+Shift+Down", "tt_low_spec": "禁用所有重度视觉效果\\n快捷键Ctrl+Shift+Down",
"tt_merge": "将多个 UTXO 合并到一个地址", "tt_merge": "将多个 UTXO 合并到一个地址",
@@ -1497,12 +1535,17 @@
"tt_rpc_host": "DragonX 守护进程主机名", "tt_rpc_host": "DragonX 守护进程主机名",
"tt_rpc_pass": "RPC 认证密码", "tt_rpc_pass": "RPC 认证密码",
"tt_rpc_port": "守护进程 RPC 连接端口", "tt_rpc_port": "守护进程 RPC 连接端口",
"tt_rpc_toggle": "显示或隐藏守护进程的只读 RPC 连接详情(主机、端口、用户、密码)",
"tt_rpc_user": "RPC 认证用户名", "tt_rpc_user": "RPC 认证用户名",
"tt_save_settings": "将所有设置保存到磁盘", "tt_save_settings": "将所有设置保存到磁盘",
"tt_save_ztx": "将 z-address 交易历史存储在本地以加快加载速度", "tt_save_ztx": "将 z-address 交易历史存储在本地以加快加载速度",
"tt_scan_themes": "扫描新主题。\\n将主题文件夹放在\\n%s", "tt_scan_themes": "扫描新主题。\\n将主题文件夹放在\\n%s",
"tt_scanline": "控制台中的 CRT 扫描线效果", "tt_scanline": "控制台中的 CRT 扫描线效果",
"tt_screenshot_open_dir": "在你的文件管理器中打开截图文件夹(位于配置目录下)",
"tt_screenshot_sweep": "在每个标签页遍历每种主题,将每种主题的截图保存到配置文件夹的 screenshots 目录(覆盖上一次遍历)",
"tt_screenshot_sweep_full": "与主题遍历类似,但同时捕获每个使用临时离线演示钱包数据的模态框 / 对话框 / 流程",
"tt_seed_backup": "显示并备份您钱包的 24 词恢复助记词", "tt_seed_backup": "显示并备份您钱包的 24 词恢复助记词",
"tt_seed_demo_chat": "向聊天标签页注入示例对话,以便遍历能捕获其界面;仅在内存中,重启后消失",
"tt_seed_migrate": "创建一个新的助记词钱包并将您的资金转入其中", "tt_seed_migrate": "创建一个新的助记词钱包并将您的资金转入其中",
"tt_set_pin": "设置 4-8 位 PIN 以快速解锁", "tt_set_pin": "设置 4-8 位 PIN 以快速解锁",
"tt_shield_mining": "将透明挖矿奖励转移到屏蔽地址", "tt_shield_mining": "将透明挖矿奖励转移到屏蔽地址",
@@ -1521,6 +1564,7 @@
"tt_website": "打开 DragonX 网站", "tt_website": "打开 DragonX 网站",
"tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)", "tt_window_opacity": "背景不透明度(越低 = 桌面透过窗口可见)",
"tt_wizard": "重新运行初始设置向导\\n守护进程将被重启", "tt_wizard": "重新运行初始设置向导\\n守护进程将被重启",
"tx_chat_badge": "消息",
"tx_confirmations": "%d 次确认", "tx_confirmations": "%d 次确认",
"tx_details_title": "交易详情", "tx_details_title": "交易详情",
"tx_from_address": "发送地址:", "tx_from_address": "发送地址:",

View File

@@ -115,10 +115,13 @@ surface-inline-alpha = 0.58
background-inline-alpha = 0.40 background-inline-alpha = 0.40
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Theme Visual Effects — Jade (polished stone sheen + gold veining) # Theme Visual Effects — Jade (veins of gold shifting through the stone)
# A soft jade specular highlight drifts across panels like light on # Jade's signature is a slow jade→gold color-shifting border on every glass
# polished nephrite; the active sidebar button traces a jade-to-gold # panel + the active nav button — a vein of gold surfacing through nephrite.
# border echoing the stone's veins — restrained, like Obsidian. # 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] [effects]
hue-cycle-enabled = { size = 0.0 } hue-cycle-enabled = { size = 0.0 }
@@ -130,25 +133,45 @@ shimmer-enabled = { size = 0.0 }
positional-hue-enabled = { size = 0.0 } positional-hue-enabled = { size = 0.0 }
glow-pulse-enabled = { size = 0.0 } glow-pulse-enabled = { size = 0.0 }
edge-trace-enabled = { size = 0.0 }
# Specular glare — soft blurred jade highlights # Edge-trace OFF — its hand-walked perimeter chamfers rounded corners.
specular-glare-enabled = { size = 1.0 } # 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-speed = { size = 0.018 }
specular-glare-intensity = { size = 0.008 } specular-glare-intensity = { size = 0.008 }
specular-glare-radius = { size = 0.65 } specular-glare-radius = { size = 0.65 }
specular-glare-count = { size = 1.0 } specular-glare-count = { size = 1.0 }
specular-glare-color = { color = "rgba(150,220,180,1.0)" } specular-glare-color = { color = "rgba(150,220,180,1.0)" }
# Jade-to-gold color-shifting border on the active sidebar button # 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-enabled = { size = 1.0 }
gradient-border-speed = { size = 0.12 } gradient-border-panels = { size = 1.0 }
gradient-border-speed = { size = 0.10 }
gradient-border-thickness = { size = 1.5 } gradient-border-thickness = { size = 1.5 }
gradient-border-alpha = { size = 0.55 } gradient-border-alpha = { size = 0.55 }
gradient-border-color-a = { color = "#7FD1B5" } gradient-border-color-a = { color = "#7FD1B5" }
gradient-border-color-b = { color = "#C9A24E" } gradient-border-color-b = { color = "#C9A24E" }
ember-rise-enabled = { size = 0.0 } # 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 # Shader-like viewport overlay — deep green stone atmosphere
viewport-wash-enabled = { size = 1.0 } viewport-wash-enabled = { size = 1.0 }

View File

@@ -1,5 +1,17 @@
#!/usr/bin/env bash #!/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 set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -67,25 +79,9 @@ Options:
--backend-dir PATH SilentDragonXLite/lib source directory. --backend-dir PATH SilentDragonXLite/lib source directory.
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path. --silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
--out-dir PATH Output directory for copied artifact and metadata. --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. --reproducible Add deterministic Rust path remaps for clean builds.
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible. --remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
--builder NAME Redacted builder/provenance label. Default: local. --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. -j, --jobs N Cargo parallel jobs.
--cargo-arg ARG Extra argument forwarded to cargo build. --cargo-arg ARG Extra argument forwarded to cargo build.
-h, --help Show this help. -h, --help Show this help.
@@ -95,9 +91,13 @@ Outputs:
<out>/<platform>/lite-backend-symbols.txt <out>/<platform>/lite-backend-symbols.txt
<out>/<platform>/lite-backend-artifact-manifest.json <out>/<platform>/lite-backend-artifact-manifest.json
The script captures symbols, checksums, and optional read-only signature The lite backend is always built from the vendored in-tree source
verification metadata only. It does not load the library, resolve function (third_party/silentdragonxlite), which is the trust root. Prebuilt artifacts
pointers, call SDXL, sign, upload, or publish 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 EOF
} }
@@ -166,15 +166,8 @@ while [[ $# -gt 0 ]]; do
OUT_DIR="$(absolute_path "$2")" OUT_DIR="$(absolute_path "$2")"
shift 2 shift 2
;; ;;
--artifact) --artifact|--no-build)
[[ $# -ge 2 ]] || die "--artifact requires a value" 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."
ARTIFACT_PATH="$(absolute_path "$2")"
BUILD_ARTIFACT=false
shift 2
;;
--no-build)
BUILD_ARTIFACT=false
shift
;; ;;
--reproducible) --reproducible)
REPRODUCIBLE=true REPRODUCIBLE=true
@@ -191,54 +184,11 @@ while [[ $# -gt 0 ]]; do
BUILDER="$2" BUILDER="$2"
shift 2 shift 2
;; ;;
--signature-required) --signature-required|--signature-file|--signature-path|--signature-format|\
SIGNATURE_REQUIRED=true --signature-verification-tool|--signature-tool|--signature-verification-command|\
shift --signature-key-fingerprint|--signature-certificate-identity|--signature-certificate-issuer|\
;; --signature-transparency-log-url|--signature-verified-sha256)
--signature-file|--signature-path) 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."
[[ $# -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
;; ;;
-j|--jobs) -j|--jobs)
[[ $# -ge 2 ]] || die "--jobs requires a value" [[ $# -ge 2 ]] || die "--jobs requires a value"
@@ -374,6 +324,9 @@ prepare_backend_source() {
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src" 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" [[ -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" [[ -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" [[ -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 # 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. # "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 ' },\n'
printf ' "provenance": {\n' printf ' "provenance": {\n'
printf ' "owner_ready": true,\n' printf ' "owner_ready": true,\n'
printf ' "built_from_source": true,\n'
printf ' "metadata_provided": true,\n' printf ' "metadata_provided": true,\n'
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n' printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n' printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n'

View File

@@ -24,18 +24,27 @@ if [ ! -f "${BUILD_DIR}/bin/ObsidianDragon" ]; then
exit 1 exit 1
fi 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="" APPIMAGETOOL=""
if command -v appimagetool &> /dev/null; then if command -v appimagetool &> /dev/null; then
APPIMAGETOOL="appimagetool" APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
elif [ -f "${BUILD_DIR}/appimagetool-x86_64.AppImage" ]; then
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
else else
print_status "Downloading appimagetool..." AT="${BUILD_DIR}/appimagetool-x86_64.AppImage"
wget -q -O "${BUILD_DIR}/appimagetool-x86_64.AppImage" \ if [ ! -f "$AT" ] || ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" print_status "Downloading appimagetool 1.9.0 (pinned)..."
chmod +x "${BUILD_DIR}/appimagetool-x86_64.AppImage" wget -q -O "$AT" "$APPIMAGETOOL_URL"
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage" 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 fi
print_status "Creating AppDir structure..." 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}" echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
fi fi
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ──────────────── # ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac" XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe" cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))" echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"

View File

@@ -1,25 +1,31 @@
#!/usr/bin/env bash #!/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 # The wallet verifies a detached ed25519 signature over the archive bytes against a public key
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is # pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is MANDATORY
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature # (kDaemonRequireSignature = true): an in-app update is refused unless a valid "<archive>.sig" is
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the # published next to the archive. The wallet also checks each archive's SHA-256 against a markdown
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset. # 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), # Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl. OpenSSL's ed25519 is PureEdDSA (RFC 8032), the
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible # same primitive libsodium's crypto_sign_verify_detached checks, so the signatures are compatible.
# (the same flow the wallet's unit tests verify for the miner updater).
# #
# Usage: # Usage:
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64} # 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 pubkey <secret.key> # print the base64 public key to pin
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file # scripts/sign-daemon-release.sh sign <secret.key> <file>... # sign existing files -> <file>.sig
# scripts/sign-daemon-release.sh release <secret.key> <version> [--src DIR] [--out DIR]
# # zip prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,macos,
# # win64}.zip, sign each, and print the SHA-256 checksum table. Platforms with no dragonxd
# # binary staged are skipped.
# #
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into # Keep the secret key (.ed25519.key) OFFLINE (mode 600). Paste the base64 public key into
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h. # kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
die() { echo "error: $*" >&2; exit 1; } die() { echo "error: $*" >&2; exit 1; }
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)" 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. # 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; } 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 cmd="${1:-}"; shift || true
case "$cmd" in case "$cmd" in
keygen) keygen)
@@ -42,26 +68,90 @@ case "$cmd" in
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):" echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
echo " $pub" echo " $pub"
;; ;;
pubkey) pubkey)
[ $# -ge 1 ] || die "usage: pubkey <secret.key>" [ $# -ge 1 ] || die "usage: pubkey <secret.key>"
pubkey_b64 "$1" pubkey_b64 "$1"
;; ;;
sign) sign)
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..." [ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
key="$1"; shift key="$1"; shift
[ -f "$key" ] || die "no such key: $key" [ -f "$key" ] || die "no such key: $key"
for f in "$@"; do for f in "$@"; do
[ -f "$f" ] || die "no such file: $f" [ -f "$f" ] || die "no such file: $f"
raw="$(mktemp)" sign_file "$key" "$f"
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
openssl base64 -A -in "$raw" > "$f.sig"
printf '\n' >> "$f.sig"
rm -f "$raw"
echo "signed: $f -> $f.sig" echo "signed: $f -> $f.sig"
done done
echo "Upload each .sig as a release asset next to its archive." 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 esac

View File

@@ -133,7 +133,7 @@ pkgs_core_arch="base-devel cmake git pkg-config
libxkbcommon wayland libsodium curl libxkbcommon wayland libsodium curl
autoconf automake libtool wget python xxd" autoconf automake libtool wget python xxd"
pkgs_core_macos="cmake python xxd" pkgs_core_macos="bash cmake python xxd"
# Windows cross-compile (from Linux) # Windows cross-compile (from Linux)
pkgs_win_debian="mingw-w64 zip" pkgs_win_debian="mingw-w64 zip"
@@ -284,18 +284,27 @@ fi
header "Windows Cross-Compile" header "Windows Cross-Compile"
if $SETUP_WIN; then if $SETUP_WIN; then
win_pkgs="$(get_pkgs win)" # Only touch apt / update-alternatives (which need sudo) when the toolchain is missing. If it is
if [[ -n "$win_pkgs" ]]; then # already installed, skip them so `./setup.sh --win` can run WITHOUT sudo — important because the
install_pkgs "$win_pkgs" "Windows cross-compile" # daemon cross-compile that follows should run as the invoking user. Running the whole setup under
fi # 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 # Set posix thread model if available
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
if ! $CHECK_ONLY; then if ! $CHECK_ONLY; then
sudo update-alternatives --set x86_64-w64-mingw32-gcc \ sudo update-alternatives --set x86_64-w64-mingw32-gcc \
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true /usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
sudo update-alternatives --set x86_64-w64-mingw32-g++ \ sudo update-alternatives --set x86_64-w64-mingw32-g++ \
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true /usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
fi
fi fi
fi fi
@@ -391,11 +400,26 @@ elif $SETUP_SAPLING; then
SPEND_URL="https://z.cash/downloads/sapling-spend.params" SPEND_URL="https://z.cash/downloads/sapling-spend.params"
OUTPUT_URL="https://z.cash/downloads/sapling-output.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" && \ if curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" \
ok "Downloaded sapling-spend.params" && echo "${SPEND_SHA256} $PARAMS_DIR/sapling-spend.params" | sha256sum -c --status; then
curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" && \ ok "Downloaded + verified sapling-spend.params"
ok "Downloaded sapling-output.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 fi
else else
skip "Sapling params not found (use --sapling to download, or they'll be extracted at runtime from embedded builds)" 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" warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
fi fi
# ── 7. xmrig-hac (mining binary) ──────────────────────────────────────────── # ── 7. drg-xmrig (mining binary) ────────────────────────────────────────────
header "xmrig-hac Mining Binary" header "drg-xmrig Mining Binary"
XMRIG_SRC="$PROJECT_DIR/external/xmrig-hac" XMRIG_SRC="$PROJECT_DIR/external/drg-xmrig"
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/xmrig-hac" # 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 # Clean previous prebuilt xmrig binaries so we always rebuild
# Only clean the binary for the platform(s) we are actually building, # Only clean the binary for the platform(s) we are actually building,
@@ -700,14 +726,14 @@ if ! $CHECK_ONLY; then
fi fi
fi fi
# Helper: clone xmrig-hac if not present # Helper: clone drg-xmrig if not present
clone_xmrig_if_needed() { clone_xmrig_if_needed() {
if [[ ! -d "$XMRIG_SRC" ]]; then if [[ ! -d "$XMRIG_SRC" ]]; then
info "Cloning xmrig-hac..." info "Cloning drg-xmrig..."
git clone https://git.dragonx.is/dragonx/xmrig-hac.git "$XMRIG_SRC" git clone https://git.dragonx.is/DragonX/drg-xmrig.git "$XMRIG_SRC"
else else
ok "xmrig-hac source already present" ok "drg-xmrig source already present"
info "Pulling latest xmrig-hac..." info "Pulling latest drg-xmrig..."
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true) (cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
fi fi
} }
@@ -728,15 +754,15 @@ else
rm -rf "$XMRIG_SRC/build" rm -rf "$XMRIG_SRC/build"
# Build dependencies (libuv, hwloc, openssl) # 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" cd "$XMRIG_SRC/scripts"
sh build_deps.sh sh build_deps.sh
) )
ok "xmrig-hac dependencies built" ok "drg-xmrig dependencies built"
# Build xmrig # Build xmrig
info "Building xmrig-hac (Linux)..." info "Building drg-xmrig (Linux)..."
mkdir -p "$XMRIG_SRC/build" mkdir -p "$XMRIG_SRC/build"
( (
cd "$XMRIG_SRC/build" cd "$XMRIG_SRC/build"
@@ -753,7 +779,7 @@ else
mkdir -p "$XMRIG_PREBUILT" mkdir -p "$XMRIG_PREBUILT"
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX" 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 else
err "xmrig (Linux) build failed — binary not found" err "xmrig (Linux) build failed — binary not found"
MISSING=$((MISSING + 1)) MISSING=$((MISSING + 1))
@@ -777,7 +803,7 @@ else
# Clean previous Windows build # Clean previous Windows build
rm -rf "$XMRIG_SRC/build-windows" rm -rf "$XMRIG_SRC/build-windows"
info "Building xmrig-hac (Windows cross-compile)..." info "Building drg-xmrig (Windows cross-compile)..."
( (
cd "$XMRIG_SRC/scripts" cd "$XMRIG_SRC/scripts"
bash build_windows.sh bash build_windows.sh
@@ -787,7 +813,7 @@ else
mkdir -p "$XMRIG_PREBUILT" mkdir -p "$XMRIG_PREBUILT"
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN" 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 else
err "xmrig.exe (Windows) build failed — binary not found" err "xmrig.exe (Windows) build failed — binary not found"
MISSING=$((MISSING + 1)) MISSING=$((MISSING + 1))
@@ -797,7 +823,7 @@ fi
# ── 8. Binary directories ─────────────────────────────────────────────────── # ── 8. Binary directories ───────────────────────────────────────────────────
header "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" dir="$PROJECT_DIR/prebuilt-binaries/$platform"
if [[ -d "$dir" ]]; then if [[ -d "$dir" ]]; then
# Count actual files (not .gitkeep) # Count actual files (not .gitkeep)

View File

@@ -73,6 +73,9 @@
#include "util/text_format.h" #include "util/text_format.h"
#include "util/payment_uri.h" #include "util/payment_uri.h"
#include "util/texture_loader.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/bootstrap.h"
#include "util/secure_vault.h" #include "util/secure_vault.h"
#include "resources/embedded_resources.h" #include "resources/embedded_resources.h"
@@ -735,20 +738,40 @@ void App::update()
// HushChat (lite): harvest chat memos from the refreshed transactions and thread them // 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). // (no-op when the feature is off or no identity; the store dedups across refreshes).
ingestLiteChatMemos(liteModel); 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; wallet::LiteBroadcastResult broadcast;
if (lite_wallet_->takeBroadcastResult(broadcast)) { if (lite_wallet_->takeBroadcastResult(broadcast)) {
// Mirror failures into the lite Console (copyable) in addition to the toast the send UI // 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. // shows — transient toasts are easy to miss and impossible to copy.
if (!broadcast.ok) if (!broadcast.ok)
wallet::liteLog("Send/shield failed: " + broadcast.error); wallet::liteLog("Send/shield failed: " + broadcast.error);
if (lite_send_callback_) { const LiteInflightOp finished = inflight_op_;
lite_send_callback_(broadcast.ok, broadcast.ok ? broadcast.txid : broadcast.error); inflight_op_ = LiteInflightOp{}; // channel is free again
lite_send_callback_ = nullptr; 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 // 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 // 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 // viewing keys while locked; only spending needs the passphrase, so the user may dismiss
@@ -765,6 +788,13 @@ void App::update()
// it can act; compiled away when the feature is off (constexpr gate inside). // it can act; compiled away when the feature is off (constexpr gate inside).
maybeProvisionChatIdentity(); 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). // One-time reminder to back up the wallet's seed phrase (mnemonic wallets only).
maybeRemindSeedBackup(); maybeRemindSeedBackup();
@@ -1326,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() void App::render()
{ {
// Advance the screenshot sweep FIRST — before the first-run-wizard early-return below — so the // Advance the screenshot sweep FIRST — before the first-run-wizard early-return below — so the
@@ -1333,6 +1460,9 @@ void App::render()
// (Pins current_page_ too, before the sidebar reads it further down.) // (Pins current_page_ too, before the sidebar reads it further down.)
updateScreenshotSweep(); 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 // First-run wizard gate — blocks all normal UI
if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) { if (wizard_phase_ != WizardPhase::None && wizard_phase_ != WizardPhase::Done) {
renderFirstRunWizard(); renderFirstRunWizard();
@@ -1533,94 +1663,9 @@ void App::render()
sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running; sbStatus.miningActive = state_.mining.generate || state_.pool_mining.xmrig_running;
sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1) sbStatus.chatUnreadCount = chatUnreadCount(); // unread badge on the Chat nav item (Q1)
// Load logo texture lazily on first frame (or after theme change) // (DragonX logo is loaded at the top of render() via ensureLogoTexture().)
// 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;
// 1) Check for theme-override logo from active skin // (coin logo is loaded/themed at the top of render() via ensureLogoTexture().)
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());
}
}
}
if (logo_tex_ != 0) { if (logo_tex_ != 0) {
sbStatus.logoTexID = logo_tex_; sbStatus.logoTexID = logo_tex_;
@@ -2324,17 +2369,32 @@ void App::renderStatusBar()
// Connection / daemon status sits to the left of the version string // Connection / daemon status sits to the left of the version string
// with a small gap. // with a small gap.
float gap = sbSectionGap; float gap = sbSectionGap;
float occupiedX = versionX; // leftmost X used by the version + connection status so far
if (!connection_status_.empty() && connection_status_ != "Connected") { if (!connection_status_.empty() && connection_status_ != "Connected") {
float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x; float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x;
float statusX = versionX - statusW - gap; float statusX = versionX - statusW - gap;
ImGui::SameLine(statusX); ImGui::SameLine(statusX);
ImGui::TextDisabled("%s", connection_status_.c_str()); ImGui::TextDisabled("%s", connection_status_.c_str());
occupiedX = statusX;
} else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) { } else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) {
const char* errText = TR("sb_daemon_not_found"); const char* errText = TR("sb_daemon_not_found");
float statusW = ImGui::CalcTextSize(errText).x; float statusW = ImGui::CalcTextSize(errText).x;
float statusX = versionX - statusW - gap; float statusX = versionX - statusW - gap;
ImGui::SameLine(statusX); ImGui::SameLine(statusX);
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "%s", errText); 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 // Version always at far right
@@ -2387,12 +2447,15 @@ void App::reloadThemeImages(const std::string& bgPath, const std::string& logoPa
gradient_tex_ = 0; 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_loaded_ = false;
logo_tex_ = 0; logo_tex_ = 0;
logo_w_ = 0; logo_w_ = 0;
logo_h_ = 0; logo_h_ = 0;
coin_logo_loaded_ = false; coin_logo_loaded_ = false;
if (coin_logo_tex_) util::DestroyTexture(coin_logo_tex_);
coin_logo_tex_ = 0; coin_logo_tex_ = 0;
coin_logo_w_ = 0; coin_logo_w_ = 0;
coin_logo_h_ = 0; coin_logo_h_ = 0;
@@ -5257,6 +5320,11 @@ void App::renderLoadingOverlay(float contentH)
void App::shutdown() 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 // Clean up bootstrap if running
if (bootstrap_) { if (bootstrap_) {
bootstrap_->cancel(); bootstrap_->cancel();
@@ -5459,10 +5527,9 @@ void App::copySecretToClipboard(const std::string& secret)
ui::Notifications::instance().info("Copied — clipboard auto-clears in 45s", 4.0f); 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 (clipboard_secret_hash_ == 0) return;
if (ImGui::GetTime() < clipboard_clear_deadline_) return;
// Only clear if the clipboard STILL holds our secret (the user may have copied something else). // Only clear if the clipboard STILL holds our secret (the user may have copied something else).
if (const char* cb = ImGui::GetClipboardText()) { if (const char* cb = ImGui::GetClipboardText()) {
std::uint64_t h = 1469598103934665603ULL; std::uint64_t h = 1469598103934665603ULL;
@@ -5473,6 +5540,13 @@ void App::pumpSecretClipboardClear()
clipboard_secret_hash_ = 0; 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() void App::maybeFinishTransactionSendProgress()
{ {
using Job = services::NetworkRefreshService::Job; using Job = services::NetworkRefreshService::Job;

View File

@@ -13,6 +13,7 @@
#include <chrono> #include <chrono>
#include <unordered_map> #include <unordered_map>
#include <unordered_set> #include <unordered_set>
#include <deque>
#include <nlohmann/json_fwd.hpp> #include <nlohmann/json_fwd.hpp>
#include "data/transaction_history_cache.h" #include "data/transaction_history_cache.h"
#include "data/address_book.h" #include "data/address_book.h"
@@ -478,6 +479,10 @@ public:
// Coin logo texture accessor (DragonX currency icon for balance tab) // Coin logo texture accessor (DragonX currency icon for balance tab)
ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; } 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 * @brief Reload theme images (background gradient + logo) from new paths
* @param bgPath Path to background image override (empty = use default) * @param bgPath Path to background image override (empty = use default)
@@ -485,6 +490,10 @@ public:
*/ */
void reloadThemeImages(const std::string& bgPath, const std::string& logoPath); 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 // Wizard / first-run
WizardPhase getWizardPhase() const { return wizard_phase_; } WizardPhase getWizardPhase() const { return wizard_phase_; }
bool isFirstRun() const; bool isFirstRun() const;
@@ -564,6 +573,9 @@ public:
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear. // plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
void copySecretToClipboard(const std::string& secret); void copySecretToClipboard(const std::string& secret);
void pumpSecretClipboardClear(); 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 { bool isTransactionRefreshInProgress() const {
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions); return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
} }
@@ -605,10 +617,13 @@ private:
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row). // 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 // 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. // 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, void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
const std::string& memo, const nlohmann::json& recipients, const std::string& memo, const nlohmann::json& recipients,
const char* traceLabel, bool markFeeGapRetry, 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, void markPendingSendTransactionSucceeded(const std::string& opid,
const std::string& txid); const std::string& txid);
void removePendingSendTransactions(const std::vector<std::string>& opids, void removePendingSendTransactions(const std::vector<std::string>& opids,
@@ -695,6 +710,72 @@ private:
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across // thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
// wallets. In-memory only (resets on app restart). // wallets. In-memory only (resets on app restart).
std::map<std::string, std::int64_t> chat_seen_watermark_; 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: public:
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the // Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
// feature is off / no identity. // feature is off / no identity.
@@ -1010,6 +1091,9 @@ private:
int logo_h_ = 0; int logo_h_ = 0;
bool logo_loaded_ = false; bool logo_loaded_ = false;
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded 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) // Coin logo texture (DragonX currency icon, separate from wallet branding)
ImTextureID coin_logo_tex_ = 0; ImTextureID coin_logo_tex_ = 0;

View File

@@ -40,6 +40,7 @@
#include "config/settings.h" #include "config/settings.h"
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing #include "wallet/lite_wallet_controller.h" // lite send/new-address routing
#include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest #include "wallet/lite_wallet_state_mapper.h" // LiteWalletAppRefreshModel for lite chat harvest
#include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics
#include "config/version.h" #include "config/version.h"
#include "daemon/daemon_controller.h" #include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h" #include "daemon/embedded_daemon.h"
@@ -629,6 +630,11 @@ void App::onDisconnected(const std::string& reason)
consecutive_core_failures_ = 0; consecutive_core_failures_ = 0;
send_progress_active_ = false; send_progress_active_ = false;
send_submissions_in_flight_ = 0; send_submissions_in_flight_ = 0;
// Release the chat note-buffer's single-split guard on a plain disconnect (which doesn't run
// resetChatSession) so a re-split isn't suppressed until the 1200s watchdog after reconnect.
chat_split_outstanding_ = false;
chat_split_submitted_at_ = 0.0;
inflight_op_ = LiteInflightOp{}; // the in-flight chat send's callback was just fired above (Failed)
network_refresh_.resetJobs(); network_refresh_.resetJobs();
rescan_status_poll_in_progress_ = false; rescan_status_poll_in_progress_ = false;
opid_poll_in_progress_ = false; opid_poll_in_progress_ = false;
@@ -2756,6 +2762,25 @@ void App::provisionChatIdentityFromSecret(std::string secret)
// local — peers only ever exchange public keys). // local — peers only ever exchange public keys).
void App::resetChatSession() void App::resetChatSession()
{ {
// Lite send channel — reset UNCONDITIONALLY (independent of the chat feature flag; user sends set
// inflight_op_/lite_send_callback_ even with chat off). Clear the note-buffer coordinator so wallet A's
// queued sends can't drain under wallet B, and DON'T orphan a user Send callback across a controller
// rebuild: rebuildLiteWallet(force) destroys the controller holding the in-flight send, so its result
// slot dies with it and the callback would hang the Send dialog forever. Fire the orphaned callback on a
// moved-out copy AFTER the channel state is reset, so a re-entrant sendTransaction stays clean.
auto orphanedSend = std::move(lite_send_callback_);
lite_send_callback_ = nullptr;
chat_send_queue_.clear();
inflight_op_ = LiteInflightOp{};
chat_verified_note_budget_ = 0;
chat_pipeline_note_count_ = 0;
chat_verified_shielded_zat_ = 0;
chat_note_model_seen_ = false;
chat_split_outstanding_ = false;
chat_split_submitted_at_ = 0.0;
chat_note_scan_in_flight_ = false; // a stale in-flight note scan is dropped by its scanGen guard
if (orphanedSend) orphanedSend(false, "Wallet/server changed — send aborted");
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
// Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning. // Drop identity + decrypted plaintext in RAM, lock the seed-encrypted DB, re-arm provisioning.
chat_service_.clearIdentity(); chat_service_.clearIdentity();
@@ -2955,18 +2980,19 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
const auto sessionGen = chat_session_generation_; const auto sessionGen = chat_session_generation_;
if (lite_wallet_) { if (lite_wallet_) {
const bool ok = broadcastChatMemosLite(memos); // Lite chat is owned by the note-buffer coordinator (enqueue + drain + resolve from the real
// The lite backend has no per-send completion callback here; resolve optimistically on a // broadcast result). Route any caller here through the queue rather than resolving optimistically
// successful queue (its own broadcast log surfaces a later failure). // (the old code marked a merely-queued send "Sent", masking real failures). Kept for safety —
if (ok && sessionGen == chat_session_generation_) // the send/Retry paths call enqueueChatSend directly and don't hit this branch.
chat_service_.resolveOutgoing(echoLocalId, chat::ChatDelivery::Sent); (void)sessionGen;
return ok; enqueueChatSend(LiteOpKind::ChatSend, memos, echoLocalId);
return true;
} }
if (!state_.connected || !rpc_ || !worker_) { // Coordinator-owned primitive (pumpChatNoteBuffer): the pump already gates on connected/unlocked and a
ui::Notifications::instance().error(TR("chat_toast_not_connected")); // verified-note budget, so these refusals are belt-and-suspenders. Return false SILENTLY (no per-frame
return false; // toast) — the pump leaves the message queued ("Sending") and retries when the guard clears.
} if (!state_.connected || !rpc_ || !worker_) return false;
// Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee // Chat moves 0 value, and dragonxd REJECTS a 0-value tx whose fee exceeds the default miners fee
// (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting // (0.0001) — so pin chat to exactly kChatMinFeeDrgx, independent of the user's default-fee setting
@@ -2976,10 +3002,7 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
// Pay from a z-address that can actually cover the fee. The memo still advertises the identity // Pay from a z-address that can actually cover the fee. The memo still advertises the identity
// reply address, so the peer replies to the right place regardless of which note paid. // reply address, so the peer replies to the right place regardless of which note paid.
const std::string from = chatPayFromZaddr(fee); const std::string from = chatPayFromZaddr(fee);
if (from.empty()) { if (from.empty()) return false; // no fundable note yet — pump keeps it queued, retries after a scan
ui::Notifications::instance().error(TR("chat_toast_need_funds"));
return false;
}
// Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected). // Full-node: each memo needs the daemon's "utf8:" prefix (raw JSON/hex is otherwise rejected).
const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true); const auto outputs = chat::chatSendOutputs(memos, /*utf8Prefix=*/true);
@@ -2994,14 +3017,20 @@ bool App::broadcastChatMemos(const chat::OutgoingChatMemos& memos, const std::st
// markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a // markFeeGapRetry=true is deliberate: it suppresses the fee-gap auto-retry, which rebuilds a
// SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output. // SINGLE-recipient tx from the scalar to/amount/memo and would drop the second (payload) output.
// The callback flips the echo to Sent/Failed once the async op resolves — real delivery status. // This is now the note-buffer coordinator's full-node submit primitive (called only from the pump).
// The terminal callback (fired once by the opid poller) releases the send channel and routes the
// result through onChatBroadcastResult, so a transient "Insufficient shielded funds" requeues instead
// of hard-failing. The sessionGen guard drops a stale wallet's echo AND protects the inflight_op_
// clear from a callback landing after a wallet switch (which would otherwise clear a NEW op's token).
submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients, submitZSendMany(from, memos.recipientZaddr, 0.0, fee, /*memo*/"", recipients,
"HushChat / broadcast", /*markFeeGapRetry*/ true, "HushChat / broadcast", /*markFeeGapRetry*/ true,
[this, echoLocalId, sessionGen](bool ok, const std::string& /*result*/) { [this, echoLocalId, sessionGen](bool ok, const std::string& result) {
if (sessionGen != chat_session_generation_) return; // wallet locked/switched — stale if (sessionGen != chat_session_generation_) return; // stale wallet — don't touch new session
chat_service_.resolveOutgoing(echoLocalId, inflight_op_ = LiteInflightOp{}; // release the channel for this session
ok ? chat::ChatDelivery::Sent : chat::ChatDelivery::Failed); LiteInflightOp op; op.echoLocalId = echoLocalId; op.sessionGen = sessionGen;
}); onChatBroadcastResult(op, ok, result);
},
/*background*/ true); // status is shown on the chat message, not the global send UI
return true; // submitted (async build/broadcast; the callback resolves the final status) return true; // submitted (async build/broadcast; the callback resolves the final status)
} }
@@ -3021,11 +3050,9 @@ bool App::broadcastChatMemosLite(const chat::OutgoingChatMemos& memos)
recipient.memo = out.memo; recipient.memo = out.memo;
req.recipients.push_back(std::move(recipient)); req.recipients.push_back(std::move(recipient));
} }
if (!lite_wallet_->sendTransaction(req)) { // No busy toast here: the coordinator (pumpChatNoteBuffer) owns this call, retries next frame when the
ui::Notifications::instance().error(TR("chat_toast_lite_busy")); // single broadcast channel frees, and surfaces status on the message itself.
return false; return lite_wallet_->sendTransaction(req);
}
return true;
} }
void App::sendChatMessage(const std::string& conversationId, const std::string& text) void App::sendChatMessage(const std::string& conversationId, const std::string& text)
@@ -3073,10 +3100,9 @@ void App::sendChatMessage(const std::string& conversationId, const std::string&
echo.payload_position = 0; echo.payload_position = 0;
echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it echo.delivery = chat::ChatDelivery::Sending; // shows immediately; the broadcast callback resolves it
chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status chat_service_.recordOutgoingPending(echo); // in-memory now; persisted with the final status
// A synchronous refusal (not connected / no funds) resolves to Failed right away so the Retry // Both variants: hand to the note-buffer coordinator, which drains the queue against verified notes
// affordance appears; otherwise the async callback flips it to Sent/Failed. // (building/refilling the buffer as needed) and resolves the echo Sent/Failed from the REAL result.
if (!broadcastChatMemos(memos, echo.txid)) enqueueChatSend(LiteOpKind::ChatSend, memos, echo.txid);
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed);
} }
void App::startChatConversation(const std::string& peerZaddr, const std::string& text) void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
@@ -3116,10 +3142,369 @@ void App::sendContactRequestForCid(const std::string& cid, const std::string& pe
echo.payload_position = 0; echo.payload_position = 0;
echo.delivery = chat::ChatDelivery::Sending; echo.delivery = chat::ChatDelivery::Sending;
chat_service_.recordOutgoingPending(echo); chat_service_.recordOutgoingPending(echo);
if (broadcastChatMemos(memos, echo.txid)) // Both variants: enqueue through the coordinator (resolves the echo from the real result).
ui::Notifications::instance().success(TR("chat_toast_request_queued")); enqueueChatSend(LiteOpKind::ContactRequest, memos, echo.txid);
else ui::Notifications::instance().success(TR("chat_toast_request_queued"));
chat_service_.resolveOutgoing(echo.txid, chat::ChatDelivery::Failed); }
// ── Chat note-buffer coordinator (lite only) ─────────────────────────────────────────────────────
// Design in app.h. Keeps ~kChatBufferTarget verified self-notes so a burst of messages each spends a
// separate note; refilled from change + background self-splits. Shares the controller's single broadcast
// channel with user sends (inflight_op_ tracks ownership). Pumped from App::update().
namespace {
constexpr int kChatBufferTarget = 10; // verified self-notes we aim to keep
constexpr int kChatRefillTrigger = 4; // (re)build only when the maturing pipeline drops below this
// A note is spendable when its witness count > ANCHOR_OFFSET, i.e. block depth >= ANCHOR_OFFSET+1 = 5
// (backend data.rs:543 needs anchor_offset+1 witnesses; ANCHOR_OFFSET=4 in lib.rs:25). Depth 4 is NOT yet
// spendable — counting it there wastes a proving round-trip on a guaranteed "insufficient verified funds".
constexpr int kChatConfsRequired = 5;
constexpr double kChatNoteSizeDrgx = 0.001; // each buffer note (change after a send stays >= fee)
constexpr double kChatSplitWatchdogSecs = 1200.0; // 20 min: clear a stuck split flag if it never mines (expiry/reorg)
constexpr int kMaxChatSendRetries = 20; // transient-funds requeues before giving up
inline std::uint64_t chatDrgxToZat(double drgx) {
return static_cast<std::uint64_t>(std::llround(drgx * 100000000.0));
}
} // namespace
// Confirmations a self-note needs before it's spendable: lite requires ANCHOR_OFFSET+1 witnesses (=5,
// data.rs:543); the full node's chat z_sendmany uses minconf=1 (app_network.cpp z_sendmany call).
int App::chatConfsRequired() const { return lite_wallet_ ? kChatConfsRequired : 1; }
// Status-bar summary of the chat note buffer (shown while the Chat tab is active). Empty unless chat has
// an identity. Surfaces the buffer state so the user can see it filling / draining, and it doubles as a
// diagnostic: "…" = no note data yet; "0/10 ready" = counted but no verified notes; "N/10 ready" = armed.
std::string App::chatBufferStatusText() {
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return "";
const int queued = static_cast<int>(chat_send_queue_.size());
const int verified = std::max(0, std::min(chat_verified_note_budget_, kChatBufferTarget));
const int target = static_cast<int>(kChatBufferTarget);
char buf[128];
if (queued > 0) {
snprintf(buf, sizeof(buf), TR(queued == 1 ? "chat_buffer_sending_one" : "chat_buffer_sending"), queued);
return buf;
}
if (chat_split_outstanding_) {
snprintf(buf, sizeof(buf), TR("chat_buffer_preparing"), verified, target);
return buf;
}
if (!chat_note_model_seen_)
return TR("chat_buffer_loading");
snprintf(buf, sizeof(buf), TR("chat_buffer_ready"), verified, target);
return buf;
}
bool App::isTransientVerifiedFundsError(const std::string& error) {
// Both variants' "only unconfirmed change is short" phrasing, treated as transient (retry after the
// buffer matures): lite = "Insufficient verified funds" (lightwallet.rs:2402); full node =
// "Insufficient shielded funds" (asyncrpcoperation_sendmany, parsed at parseInsufficientShielded).
return error.find("Insufficient verified funds") != std::string::npos
|| error.find("Insufficient shielded funds") != std::string::npos;
}
// Verified notes = ANY spendable shielded note (>= a fee) with >= kChatConfsRequired confirmations — the
// ones a chat send can spend RIGHT NOW. We count notes at ALL addresses, not just the reply address,
// because a chat send pays via chatPayFromZaddr (any funded z-addr) — gating on reply-address-only notes
// would queue a sendable message whenever funds sit elsewhere. NB: the model's per-note `spendable` flag
// means "unspent", NOT verified (lite_result_parsers.cpp recomputes it), so we derive it from depth here.
int App::verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) {
if (!model.hasSpendableOutputs) return 0;
// Use the cached tip (refreshChatNoteBudget updates it), not this model's — a notes-carrying model may
// not carry sync status that cycle, which would otherwise zero the whole count.
const std::int64_t tip = chat_last_chain_height_;
if (tip <= 0) return 0;
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
int n = 0;
for (const auto& o : model.spendableOutputs) {
if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue; // shielded notes only
if (o.spent || o.unconfirmedSpent || o.pending) continue;
if (o.valueZatoshis < minVal) continue; // skip dust below a fee
if (!o.createdInBlock.has_value()) continue; // unknown depth -> not verified
if (tip - *o.createdInBlock + 1 >= chatConfsRequired()) ++n;
}
return n;
}
// Notes that are verified OR still maturing (any unspent shielded note of usable size) — i.e. notes that
// will become spendable WITHOUT another split. Drives the refill trigger so we don't over-split while
// change is confirming or when the wallet already holds enough notes anywhere.
int App::pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model) {
if (!model.hasSpendableOutputs) return 0;
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
int n = 0;
for (const auto& o : model.spendableOutputs) {
if (o.kind != wallet::LiteSpendableOutputKind::UnspentNote) continue;
if (o.spent || o.unconfirmedSpent) continue; // already consumed
if (o.valueZatoshis < minVal) continue;
++n; // verified or maturing (any addr)
}
return n;
}
// Recompute the cached note estimates from a fresh refresh model (called from update() when one arrives).
void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model) {
if (!lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild()) return;
// Cache the chain tip from whatever height source THIS model carries (sync status or chain info) — a
// model with notes may lack sync that cycle, so verifiedSelfNoteCount reads the cache below.
if (model.hasSyncStatus && model.sync.chainHeight > 0)
chat_last_chain_height_ = static_cast<std::int64_t>(model.sync.chainHeight);
else if (model.hasChainInfo && model.chain.latestBlockHeight.has_value() &&
*model.chain.latestBlockHeight > 0)
chat_last_chain_height_ = static_cast<std::int64_t>(*model.chain.latestBlockHeight);
if (model.hasSpendableOutputs) {
chat_note_model_seen_ = true;
chat_verified_note_budget_ = verifiedSelfNoteCount(model);
chat_pipeline_note_count_ = pipelineSelfNoteCount(model);
}
if (model.hasBalance)
chat_verified_shielded_zat_ = model.balance.verifiedShieldedZatoshis;
// Release the single-split guard once the outstanding split's outputs are mined+visible (pipeline
// recovered) — or once a watchdog window elapses, so a split that never mines can't wedge refill.
if (chat_split_outstanding_ &&
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
chat_split_outstanding_ = false;
}
}
// Full-node twin of refreshChatNoteBudget: the shared balance poll discards per-note data, so run our own
// rate-limited z_listunspent worker scan (mirrors fastScanChatMemos) to count verified/maturing spendable
// notes. z_listunspent returns only the wallet's OWN notes, and a chat send pays from any of them
// (chatPayFromZaddr), so we count ALL addresses — gating on the reply address only would queue a sendable
// message when funds sit elsewhere. minconf=0 exposes maturing change so the pipeline count doesn't over-
// split. On an older daemon that rejects z_listunspent it leaves chat_note_model_seen_ false → the pump
// won't drain a phantom budget or split (graceful degradation to naive one-at-a-time sends).
void App::refreshChatNoteBudgetNode() {
if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
if (!state_.connected || !rpc_ || !worker_ || state_.isLocked()) return;
if (chat_note_scan_in_flight_) return; // one scan at a time
const double now = ImGui::GetTime();
if (now - chat_note_scan_last_ < 4.0) return; // rate limit; self-throttles via the in-flight flag too
chat_note_scan_last_ = now;
chat_note_scan_in_flight_ = true;
const int scanGen = chat_session_generation_;
const int confsNeeded = chatConfsRequired(); // == 1 for full node
const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx);
worker_->post([this, scanGen, confsNeeded, minVal]() -> rpc::RPCWorker::MainCb {
int verified = 0, pipeline = 0;
std::uint64_t verifiedZat = 0;
bool ok = false;
try {
rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan");
nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing
if (notes.is_array()) {
ok = true;
for (const auto& nz : notes) {
if (!nz.is_object()) continue;
if (nz.value("locked", false)) continue; // tied up by an in-flight send
const std::uint64_t amtZat = chatDrgxToZat(nz.value("amount", 0.0));
if (amtZat < minVal) continue; // skip dust below a fee
// rawconfirmations is the TRUE depth; `confirmations` is dPoW-clamped to 1 and understates it.
const int confs = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
? nz["rawconfirmations"].get<int>()
: nz.value("confirmations", 0);
++pipeline; // unspent self-note (verified or maturing)
if (confs >= confsNeeded) { ++verified; verifiedZat += amtZat; }
}
}
} catch (const std::exception&) {}
return [this, scanGen, ok, verified, pipeline, verifiedZat]() {
if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag)
chat_note_scan_in_flight_ = false;
if (!ok) return; // z_listunspent unavailable — leave caches as-is
chat_note_model_seen_ = true;
chat_verified_note_budget_ = verified;
chat_pipeline_note_count_ = pipeline;
chat_verified_shielded_zat_ = verifiedZat;
if (chat_split_outstanding_ &&
(chat_pipeline_note_count_ >= kChatRefillTrigger ||
ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) {
chat_split_outstanding_ = false;
}
};
});
}
void App::enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) {
QueuedChatOp op;
op.kind = kind;
op.memos = memos; // kept so a transient-funds retry re-broadcasts, never recomposes
op.echoLocalId = echoLocalId;
op.sessionGen = chat_session_generation_;
chat_send_queue_.push_back(std::move(op));
wallet::liteLog("chat: queued outgoing " +
std::string(kind == LiteOpKind::ContactRequest ? "contact request" : "message") +
" (queue depth " + std::to_string(chat_send_queue_.size()) +
", verified notes ~" + std::to_string(chat_verified_note_budget_) + ")");
// pumpChatNoteBuffer() (next update tick) submits it when the channel is free + a verified note exists.
}
// Self-send that mints `noteCount` reply-address notes (funds stay in the wallet), building/refilling the
// buffer. Returns false if the single broadcast channel is busy (the pump retries next frame).
bool App::broadcastSelfSplitLite(int noteCount) {
if (!lite_wallet_ || noteCount < 1) return false;
const std::string myZ = chatReplyZaddr();
if (myZ.empty()) return false;
wallet::LiteSendRequest req;
const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
for (int i = 0; i < noteCount; ++i) {
wallet::LiteSendRecipient r;
r.address = myZ;
r.amountZatoshis = noteZat;
req.recipients.push_back(std::move(r));
}
if (!lite_wallet_->sendTransaction(req)) return false; // channel busy — retry next frame
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(noteCount) +
" notes (self-send) so messages can be sent back-to-back");
return true;
}
// Full-node twin of broadcastSelfSplitLite: a z_sendmany self-send with `noteCount` outputs to our reply
// address (funds stay in the wallet), building/refilling the buffer. Caps outputs to stay well under the
// per-tx size limit after Sietch decoy padding. markFeeGapRetry=true is load-bearing — the fee-gap retry
// rebuilds a SINGLE-recipient tx and would collapse the multi-output split. The terminal callback releases
// the send channel; chat_split_outstanding_ stays set until a scan sees the outputs.
bool App::broadcastSelfSplitNode(int noteCount) {
if (lite_wallet_ || noteCount < 1) return false;
if (!state_.connected || !rpc_ || !worker_) return false;
const std::string myZ = chatReplyZaddr();
if (myZ.empty()) return false;
const double fee = kChatMinFeeDrgx;
// Pay from any funded z-address; the split's OUTPUTS still go to the reply address (building the buffer
// there). The note count now includes notes at every address and is sized against the total verified
// balance, so paying from wherever the funds actually sit is consistent — and it can't stall when funds
// aren't at the reply address (which paying strictly from myZ would).
const std::string from = chatPayFromZaddr(fee);
if (from.empty()) return false; // no fundable note — pump retries after a scan
const int n = std::min(noteCount, 40); // bounded so the split tx stays under max_tx_size after decoys
nlohmann::json recipients = nlohmann::json::array();
for (int i = 0; i < n; ++i) {
nlohmann::json r;
r["address"] = myZ;
r["amount"] = util::formatAmountFixed(kChatNoteSizeDrgx);
recipients.push_back(std::move(r));
}
const auto sessionGen = chat_session_generation_;
submitZSendMany(from, myZ, 0.0, fee, /*memo*/"", recipients,
"HushChat / note-buffer split", /*markFeeGapRetry*/ true,
[this, sessionGen](bool ok, const std::string& /*result*/) {
if (sessionGen != chat_session_generation_) return; // stale wallet
inflight_op_ = LiteInflightOp{}; // release the channel
// On success the outputs aren't mined yet — chat_split_outstanding_ clears on the
// next scan (pipeline recovered) or the watchdog. On FAILURE (e.g. a stale-scan
// insufficient-funds throw) clear it now so the pump can retry after the next scan
// instead of waiting out the 1200s watchdog. (No notes were created, so it's safe.)
if (!ok) chat_split_outstanding_ = false;
},
/*background*/ true); // autonomous buffer maintenance — no global send UI
wallet::liteLog("Chat buffer: splitting funds into " + std::to_string(n) +
" notes (self-send) so messages can be sent back-to-back");
return true;
}
bool App::shouldSplitChatBuffer() {
// Only for engaged chat users (has a conversation or a queued send) — never split for someone who
// never chats. Connected, unlocked, channel free, no split already outstanding, pipeline genuinely
// depleted, funded. NB: a wall-clock cooldown is deliberately NOT used — a split's outputs stay
// invisible until mined (~1 block, far longer than any UI cooldown), so we gate on a single-split-
// in-flight flag (cleared when the pipeline recovers or the watchdog fires) to prevent runaway splits.
if (chat_service_.store().conversationIds().empty() && chat_send_queue_.empty()) return false;
if (!state_.connected || state_.isLocked()) return false;
// Channel busy = our own op inflight, OR a user send holds the shared channel. Lite user sends set
// lite_send_callback_; full-node user sends don't touch inflight_op_, so they're detected via the
// shared single-flight counter + any outstanding opid (a chat/user/sweep z_sendmany not yet resolved).
if (inflight_op_.kind != LiteOpKind::None) return false;
if (lite_wallet_ ? (bool)lite_send_callback_
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return false;
if (chat_split_outstanding_) return false;
if (!chat_note_model_seen_) return false;
if (chat_pipeline_note_count_ >= kChatRefillTrigger) return false; // change refill covers it
if (chat_verified_shielded_zat_ < chatDrgxToZat(kChatNoteSizeDrgx + kChatMinFeeDrgx)) return false;
return true;
}
// Resolve the just-completed chat/contact broadcast onto its echo. The in-flight op is the queue FRONT
// (the pump submits it without popping) so we can requeue it in place on a transient-funds failure.
void App::onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error) {
if (op.sessionGen != chat_session_generation_) return; // wallet switched since submit — echo is gone
const bool frontMatches = !chat_send_queue_.empty()
&& chat_send_queue_.front().echoLocalId == op.echoLocalId;
if (ok) {
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Sent);
if (frontMatches) chat_send_queue_.pop_front();
wallet::liteLog("chat: message broadcast (Sent); " + std::to_string(chat_send_queue_.size()) +
" still queued");
return;
}
if (frontMatches && isTransientVerifiedFundsError(error)
&& chat_send_queue_.front().retries < kMaxChatSendRetries) {
// The optimistic budget was wrong (change not confirmed yet). Stop draining until the next refresh
// restores the true verified-note count; keep the message queued ("Sending") and retry then.
chat_verified_note_budget_ = 0;
++chat_send_queue_.front().retries;
wallet::liteLog("chat: send deferred — funds still confirming (retry " +
std::to_string(chat_send_queue_.front().retries) + "); message stays 'Sending'");
return; // leave at front
}
chat_service_.resolveOutgoing(op.echoLocalId, chat::ChatDelivery::Failed); // hard failure -> Retry affordance
if (frontMatches) chat_send_queue_.pop_front();
wallet::liteLog("chat: send FAILED: " + error);
}
// Per-frame pump: drain the chat queue against verified notes (priority 1); otherwise build/refill the
// buffer during idle (priority 2). One op per frame — the single broadcast channel serializes the rest.
void App::pumpChatNoteBuffer() {
if (!chat::hushChatFeatureEnabledAtBuild()) return;
if (!chat_service_.hasIdentity() || !state_.connected) return;
// A locked wallet can't spend: submitting would return a non-transient lock error and flap the queued
// message to Failed. Hold everything (send + split) until unlock — messages stay "Sending".
if (state_.isLocked()) return;
// Channel busy — exactly one send must be outstanding. Lite serializes via inflight_op_ + the send
// callback; the full node has no single-broadcast channel, so we also honour the shared single-flight
// counter and any outstanding opid (a chat/user/sweep z_sendmany not yet terminally resolved). Without
// the latter two a chat send could overlap a user Send-tab send (which never sets inflight_op_).
if (inflight_op_.kind != LiteOpKind::None) return;
if (lite_wallet_ ? (bool)lite_send_callback_
: (send_submissions_in_flight_ > 0 || !pending_opids_.empty())) return;
// Priority 1: drain a queued chat send while we (optimistically) still have a verified note.
if (!chat_send_queue_.empty()) {
QueuedChatOp& front = chat_send_queue_.front();
if (front.sessionGen != chat_session_generation_) { chat_send_queue_.pop_front(); return; }
if (chat_verified_note_budget_ >= 1) {
// Submit onto the wallet's send channel; NOT popped until the async result resolves (so a
// transient-funds failure can requeue it in place). Full-node broadcastChatMemos registers a
// terminal opid callback that clears inflight_op_ + routes onChatBroadcastResult.
const bool submitted = lite_wallet_ ? broadcastChatMemosLite(front.memos)
: broadcastChatMemos(front.memos, front.echoLocalId);
if (submitted) {
inflight_op_ = LiteInflightOp{ front.kind, front.echoLocalId, front.sessionGen, ImGui::GetTime() };
--chat_verified_note_budget_; // this note is now (optimistically) spent
}
return; // one op per frame; the front waits until resolved
}
// No verified note available: leave it queued ("Sending"); fall through to maybe build the buffer.
}
// Priority 2: build/refill the buffer during idle.
if (shouldSplitChatBuffer()) {
const int need = kChatBufferTarget - chat_pipeline_note_count_;
const std::uint64_t noteZat = chatDrgxToZat(kChatNoteSizeDrgx);
const std::uint64_t feeZat = chatDrgxToZat(kChatMinFeeDrgx);
int affordable = 0;
if (chat_verified_shielded_zat_ > feeZat && noteZat > 0)
affordable = static_cast<int>((chat_verified_shielded_zat_ - feeZat) / noteZat);
const int n = std::min(need, affordable);
const bool submitted = n >= 1 && (lite_wallet_ ? broadcastSelfSplitLite(n)
: broadcastSelfSplitNode(n));
if (submitted) {
inflight_op_ = LiteInflightOp{ LiteOpKind::Split, {}, chat_session_generation_, ImGui::GetTime() };
// Block further splits until this one's outputs mine in (or the watchdog fires): the outputs are
// invisible to the note count for ~1 block, so without this we'd fire a fresh split every frame.
chat_split_outstanding_ = true;
chat_split_submitted_at_ = ImGui::GetTime();
}
}
} }
// HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's // HushChat (lite variant): the full-node harvest works off z_viewtransaction, but the lite wallet's
@@ -3133,6 +3518,7 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid; std::unordered_map<std::string, chat::HushChatTransactionInput> byTxid;
std::unordered_map<std::string, std::int64_t> txTimestamps; std::unordered_map<std::string, std::int64_t> txTimestamps;
bool anyUnconfirmedChatTx = false; // did a chat-carrying tx arrive still-unconfirmed (0-conf mempool)?
for (const auto& tx : model.transactions) { for (const auto& tx : model.transactions) {
if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue; // only incoming carry chat if (tx.kind != wallet::LiteWalletAppTransactionKind::Receive) continue; // only incoming carry chat
if (tx.memo.empty() || !tx.position) continue; if (tx.memo.empty() || !tx.position) continue;
@@ -3140,6 +3526,7 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
input.txid = tx.txid; input.txid = tx.txid;
input.outputs.push_back({static_cast<std::size_t>(*tx.position), tx.memo}); input.outputs.push_back({static_cast<std::size_t>(*tx.position), tx.memo});
txTimestamps[tx.txid] = tx.timestamp; txTimestamps[tx.txid] = tx.timestamp;
if (tx.unconfirmed) anyUnconfirmedChatTx = true;
} }
if (byTxid.empty()) return; if (byTxid.empty()) return;
@@ -3151,6 +3538,10 @@ void App::ingestLiteChatMemos(const wallet::LiteWalletAppRefreshModel& model)
if (!metadata.empty()) { if (!metadata.empty()) {
std::vector<std::string> newChatCids; std::vector<std::string> newChatCids;
chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids); chat_service_.ingest(metadata, txTimestamps, std::time(nullptr), &newChatCids);
if (!newChatCids.empty())
wallet::liteLog("chat: harvested " + std::to_string(newChatCids.size()) + " new message(s) " +
(anyUnconfirmedChatTx ? "(0-conf / mempool — monitor working)"
: "(confirmed only — not seen at mempool speed)"));
// Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't // Mirror the full-node paths: a new incoming message un-hides a hidden conversation (you can't
// un-receive), and a non-muted new message toasts when we're off the Chat tab. // un-receive), and a non-muted new message toasts when we're off the Chat tab.
if (settings_) { if (settings_) {
@@ -3182,6 +3573,8 @@ void App::fastScanChatMemos()
const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks
worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb { worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb {
std::vector<chat::HushChatTransactionMetadata> metadata; std::vector<chat::HushChatTransactionMetadata> metadata;
int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal)
std::string scanError;
try { try {
rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan"); rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan");
nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool
@@ -3194,6 +3587,7 @@ void App::fastScanChatMemos()
const std::string txid = note.value("txid", std::string()); const std::string txid = note.value("txid", std::string());
const std::string memo = note.value("memoStr", std::string()); const std::string memo = note.value("memoStr", std::string());
if (txid.empty() || memo.empty()) continue; if (txid.empty() || memo.empty()) continue;
++rawMemoCount;
std::size_t pos = fallbackPos; std::size_t pos = fallbackPos;
for (const char* key : {"position", "outputIndex", "outindex"}) for (const char* key : {"position", "outputIndex", "outindex"})
if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) { if (note.contains(key) && note[key].is_number_integer() && note[key].get<int>() >= 0) {
@@ -3209,13 +3603,25 @@ void App::fastScanChatMemos()
for (auto& m : extracted.metadata) metadata.push_back(std::move(m)); for (auto& m : extracted.metadata) metadata.push_back(std::move(m));
} }
} }
} catch (const std::exception&) {} } catch (const std::exception& e) { scanError = e.what(); }
return [this, scanGen, metadata = std::move(metadata)]() mutable { const int parsedCount = static_cast<int>(metadata.size());
return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError]() mutable {
// The wallet was switched/locked between post and now — this metadata belongs to the previous // The wallet was switched/locked between post and now — this metadata belongs to the previous
// session. resetChatSession already reset the in-flight flag, so just drop; clearing it here // session. resetChatSession already reset the in-flight flag, so just drop; clearing it here
// would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards). // would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards).
if (scanGen != chat_session_generation_) return; if (scanGen != chat_session_generation_) return;
chat_fast_scan_in_flight_ = false; chat_fast_scan_in_flight_ = false;
// Diagnostic (console App/chat channel): log when the memo-note count at the reply address
// CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages
// are reaching us at all, and how many parse as chat vs are unrelated memos.
if (!scanError.empty()) {
wallet::liteLog("chat[0conf]: reply-addr scan failed: " + scanError);
} else if (rawMemoCount != chat_fast_scan_last_seen_) {
chat_fast_scan_last_seen_ = rawMemoCount;
if (rawMemoCount > 0)
wallet::liteLog("chat[0conf]: reply-addr scan sees " + std::to_string(rawMemoCount) +
" memo note(s), " + std::to_string(parsedCount) + " parse as chat");
}
if (metadata.empty()) return; if (metadata.empty()) return;
// Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they // Skip HIDDEN conversations — the mempool fast path deliberately doesn't surface them; they
// still come back through the normal confirmed harvest (which un-hides on a new message). // still come back through the normal confirmed harvest (which un-hides on a new message).
@@ -3229,6 +3635,9 @@ void App::fastScanChatMemos()
std::unordered_map<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now std::unordered_map<std::string, std::int64_t> noTimes; // mempool: no block time → ingest uses now
std::vector<std::string> newChatCids; std::vector<std::string> newChatCids;
chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids); chat_service_.ingest(visible, noTimes, std::time(nullptr), &newChatCids);
if (!newChatCids.empty())
wallet::liteLog("chat[0conf]: harvested " + std::to_string(newChatCids.size()) +
" new message(s) from the mempool");
if (current_page_ != ui::NavPage::Chat && if (current_page_ != ui::NavPage::Chat &&
std::any_of(newChatCids.begin(), newChatCids.end(), std::any_of(newChatCids.begin(), newChatCids.end(),
[this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); })) [this](const std::string& cid){ return !(settings_ && settings_->isChatMuted(cid)); }))
@@ -4087,6 +4496,9 @@ void App::sendTransaction(const std::string& from, const std::string& to,
return; return;
} }
lite_send_callback_ = std::move(callback); // delivered from update() lite_send_callback_ = std::move(callback); // delivered from update()
// Claim the shared broadcast channel so the chat note-buffer coordinator yields to this user send
// and routes the one global broadcast result back to lite_send_callback_ (not a chat echo).
inflight_op_ = LiteInflightOp{ LiteOpKind::UserSend, {}, chat_session_generation_, ImGui::GetTime() };
return; return;
} }
@@ -4149,12 +4561,13 @@ void App::sendTransaction(const std::string& from, const std::string& to,
void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee, void App::submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
const std::string& memo, const nlohmann::json& recipients, const std::string& memo, const nlohmann::json& recipients,
const char* traceLabel, bool markFeeGapRetry, const char* traceLabel, bool markFeeGapRetry,
std::function<void(bool, const std::string&)> callback) std::function<void(bool, const std::string&)> callback,
bool background)
{ {
send_progress_active_ = true; if (!background) send_progress_active_ = true; // autonomous chat/split: status shown on the message
++send_submissions_in_flight_; ++send_submissions_in_flight_;
worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel, worker_->post([this, from, to, amount, fee, memo, recipients, callback, traceLabel,
markFeeGapRetry]() -> rpc::RPCWorker::MainCb { markFeeGapRetry, background]() -> rpc::RPCWorker::MainCb {
bool ok = false; bool ok = false;
std::string result_str; std::string result_str;
try { try {
@@ -4169,7 +4582,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
// below would never run, leaving a stuck "Sending…" spinner. // below would never run, leaving a stuck "Sending…" spinner.
result_str = "Send failed (unknown error)"; result_str = "Send failed (unknown error)";
} }
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry]() { return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry, background]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_; if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;
if (ok) { if (ok) {
// A send changes address balances — refresh on next cycle // A send changes address balances — refresh on next cycle
@@ -4192,7 +4605,7 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
callback(true, result_str); // no opid to track — report as-is callback(true, result_str); // no opid to track — report as-is
} }
} else { } else {
send_progress_active_ = false; if (!background) send_progress_active_ = false;
if (callback) callback(false, result_str); if (callback) callback(false, result_str);
} }
}; };

View File

@@ -228,6 +228,17 @@ bool ChatDatabase::ensureOpen()
exec("PRAGMA journal_mode=WAL"); exec("PRAGMA journal_mode=WAL");
exec("PRAGMA synchronous=NORMAL"); 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()) { if (!createSchema()) {
close(); close();
return false; return false;

View File

@@ -16,7 +16,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
const std::string& conversationId, const std::string& conversationId,
const char* type, const char* type,
const std::string& streamHeaderHex, const std::string& streamHeaderHex,
const std::string& publicKeyHex) const std::string& publicKeyHex,
std::int64_t sentAt)
{ {
nlohmann::json header; nlohmann::json header;
header["h"] = 1; // header number (>= 1) header["h"] = 1; // header number (>= 1)
@@ -26,6 +27,7 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
header["t"] = type; // "Memo" or "Cont" header["t"] = type; // "Memo" or "Cont"
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont) header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key 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(); return header.dump();
} }
@@ -67,7 +69,8 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
OutgoingChatMemos memos; OutgoingChatMemos memos;
memos.recipientZaddr = peerZaddr; 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; memos.payloadMemo = ciphertextHex;
if (memos.headerMemo.size() > kHushChatMemoByteLimit || if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.size() > kHushChatMemoByteLimit) { memos.payloadMemo.size() > kHushChatMemoByteLimit) {
@@ -95,7 +98,8 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
OutgoingChatMemos memos; OutgoingChatMemos memos;
memos.recipientZaddr = peerZaddr; 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; memos.payloadMemo = requestText;
if (memos.headerMemo.size() > kHushChatMemoByteLimit || if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
memos.payloadMemo.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, "t", type, error)) return fail(error);
if (!readRequiredString(object, "e", header.secretstream_header_hex, 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); 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.header_number < 1) return fail("header number must be positive");
if (header.version != kHushChatSupportedVersion) return fail("unsupported HushChat version"); 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.sender_public_key_hex = pair.header.public_key_hex;
metadata.secretstream_header_hex = pair.header.secretstream_header_hex; metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
metadata.payload_memo = pair.payload_memo; 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)); result.metadata.push_back(std::move(metadata));
} }

View File

@@ -23,6 +23,9 @@ struct HushChatHeader {
HushChatHeaderType type = HushChatHeaderType::Message; HushChatHeaderType type = HushChatHeaderType::Message;
std::string secretstream_header_hex; std::string secretstream_header_hex;
std::string public_key_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 { struct HushChatHeaderParseResult {
@@ -80,6 +83,7 @@ struct HushChatTransactionMetadata {
std::string sender_public_key_hex; // header "p": peer crypto_kx public key (hex) 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 secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (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 { struct HushChatTransactionExtractionResult {

View File

@@ -45,8 +45,21 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
message.conversation_id = meta.conversation_id; message.conversation_id = meta.conversation_id;
message.peer_zaddr = meta.reply_zaddr; message.peer_zaddr = meta.reply_zaddr;
message.peer_public_key_hex = meta.sender_public_key_hex; 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); 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; message.payload_position = meta.payload_position;
if (meta.type == HushChatHeaderType::ContactRequest) { if (meta.type == HushChatHeaderType::ContactRequest) {

View File

@@ -28,6 +28,16 @@ public:
// Distinct conversation ids, in first-seen order. // Distinct conversation ids, in first-seen order.
std::vector<std::string> conversationIds() const; 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(); } std::size_t size() const { return messages_.size(); }
bool empty() const { return messages_.empty(); } bool empty() const { return messages_.empty(); }
void clear(); void clear();

View File

@@ -136,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). // 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
cli.disconnect(); cli.disconnect();
temp.stop(20000); temp.stop(20000);

View File

@@ -2,7 +2,7 @@
// Copyright 2024-2026 The Hush Developers // Copyright 2024-2026 The Hush Developers
// Released under the GPLv3 // 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. // Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
#include "xmrig_manager.h" #include "xmrig_manager.h"
@@ -208,19 +208,20 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
try { try {
fs::create_directories(fs::path(outPath).parent_path()); fs::create_directories(fs::path(outPath).parent_path());
std::ofstream ofs(outPath); std::ofstream ofs(outPath, std::ios::trunc);
if (!ofs.is_open()) { if (!ofs.is_open()) {
last_error_ = "Cannot write xmrig config: " + outPath; last_error_ = "Cannot write xmrig config: " + outPath;
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str()); DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
return false; return false;
} }
ofs << j.dump(4);
ofs.close();
#ifndef _WIN32 #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); chmod(outPath.c_str(), 0600);
#endif #endif
ofs << j.dump(4);
ofs.close();
return true; return true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
last_error_ = std::string("Config write error: ") + e.what(); last_error_ = std::string("Config write error: ") + e.what();

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

@@ -25,9 +25,11 @@ namespace {
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree // 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. // that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
void scrubJsonSecrets(nlohmann::json& j) { // 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()) { if (j.is_string()) {
auto& s = j.get_ref<std::string&>(); auto& s = j.template get_ref<std::string&>();
if (!s.empty()) sodium_memzero(&s[0], s.size()); if (!s.empty()) sodium_memzero(&s[0], s.size());
} else if (j.is_object() || j.is_array()) { } else if (j.is_object() || j.is_array()) {
for (auto& el : j) scrubJsonSecrets(el); for (auto& el : j) scrubJsonSecrets(el);
@@ -96,6 +98,10 @@ void RPCClient::setTraceSource(std::string source)
// Callback for libcurl to write response data // Callback for libcurl to write response data
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) { static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
size_t totalSize = size * nmemb; 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); userp->append((char*)contents, totalSize);
return totalSize; return totalSize;
} }
@@ -202,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). // budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L; const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout); 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 // 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 // local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
@@ -508,14 +518,22 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
} }
auto& result = oj["result"]; auto& result = oj["result"];
std::string out;
if (result.is_null()) { if (result.is_null()) {
return "null"; out = "null";
} else if (result.is_string()) { } else if (result.is_string()) {
// Return the raw string (not JSON-encoded) — caller wraps as needed // Return the raw string (not JSON-encoded) — caller wraps as needed
return result.get<std::string>(); out = result.get<std::string>();
} else { } 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) void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)

View File

@@ -160,6 +160,10 @@ void ThemeEffects::loadFromTheme() {
// ---- Gradient Border Shift ---- // ---- Gradient Border Shift ----
gradient_border_.enabled = eff("gradient-border-enabled").sizeOr(0.0f) > 0.5f; 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_.speed = eff("gradient-border-speed").sizeOr(0.15f);
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f); gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f);
gradient_border_.alpha = eff("gradient-border-alpha").sizeOr(0.6f); 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, 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; if (!enabled_ || !gradient_border_.enabled) return;
// Smooth sinusoidal oscillation between color A and color B // 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; // 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 // Extract RGBA from both colors and lerp
RGB ca = unpackRGB(gradient_border_.colorA); 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 r = ca.r + (int)((cb.r - ca.r) * phase);
int g = ca.g + (int)((cb.g - ca.g) * phase); int g = ca.g + (int)((cb.g - ca.g) * phase);
int b = ca.b + (int)((cb.b - ca.b) * 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 // Draw the shifting border
dl->AddRect(pMin, pMax, IM_COL32(r, g, b, a), 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 { float rounding) const {
if (!enabled_ || effects::isLowSpecMode()) return; 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 // Edge trace on panels — use position-based phase offset so each
// panel's tracer is at a different position around the border // panel's tracer is at a different position around the border
if (edge_trace_.enabled) { if (edge_trace_.enabled) {

View File

@@ -69,9 +69,12 @@ public:
void drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax, void drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
float rounding) const; 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, 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) /// Draw ember particles that rise from an element (fire theme)
void drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const; void drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const;
@@ -186,6 +189,7 @@ private:
struct GradientBorderConfig { struct GradientBorderConfig {
bool enabled = false; 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 speed = 0.15f; ///< full color shift cycles per second
float thickness = 1.5f; ///< border line thickness in pixels float thickness = 1.5f; ///< border line thickness in pixels
float alpha = 0.6f; ///< peak alpha 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); 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. // Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
inline const char* LoadingDots() { inline const char* LoadingDots() {
int n = ((int)(ImGui::GetTime() * 3.0f)) % 4; int n = ((int)(ImGui::GetTime() * 3.0f)) % 4;

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

File diff suppressed because it is too large Load Diff

View File

@@ -70,6 +70,7 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
float spacingSm, float spacingSm,
float spacingXs) float spacingXs)
{ {
(void)spacingXs; // trailing button now insets by rowPadLeft (mirrors the left margin)
AddressRowLayout layout; AddressRowLayout layout;
layout.contentStartX = rowX + rowPadLeft; layout.contentStartX = rowX + rowPadLeft;
layout.contentStartY = rowY + spacingMd; layout.contentStartY = rowY + spacingMd;
@@ -77,7 +78,9 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f; const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
const float rightEdge = rowX + rowWidth; 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; const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize}; layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};

View File

@@ -26,8 +26,10 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cfloat> #include <cfloat>
#include <cmath> // std::cos/std::sin — radial byte gauge in the composer
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <functional>
#include <ctime> #include <ctime>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -41,9 +43,41 @@ namespace {
std::string s_selected_cid; std::string s_selected_cid;
std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next frame std::string s_scroll_to_cid; // when set, scroll the thread to the bottom next frame
// Auto-scroll: keep the thread pinned to the newest message only while the user is at the bottom.
// A wheel-up detaches it (mirrors console_tab.cpp's ScrollController); returning to the bottom re-arms it.
bool s_chat_auto_scroll = true;
float s_chat_scroll_cooldown = 0.0f; // seconds left before an at-bottom position may re-arm auto-scroll
// Message text selection: click-drag inside a bubble selects a byte range of its body; while a range is
// active a small copy button appears at the bubble's top on the opposite side. Offsets index m.body bytes.
std::string s_msgsel_cid; // conversation of the selected message ("" = no selection)
int s_msgsel_index = -1; // message index within that conversation
int s_msgsel_anchor = 0; // drag-start byte offset
int s_msgsel_head = 0; // drag-current byte offset
bool s_msgsel_dragging = false;
// Composer + new-conversation UI state. // Composer + new-conversation UI state.
char s_compose[512] = ""; char s_compose[512] = "";
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
// On-chain chat body cap in bytes = (512 len("utf8:"))/2 secretstream ABYTES (see chat_outgoing.cpp).
// The composer hard-caps input to this; the emoji picker respects it too.
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
float s_composerAnimH = 0.0f; // animated composer-box height — grows with newlines, pushing the thread up
float s_composerTargetH = 0.0f; // target height measured in the composer block (wrapped content), consumed next frame
// Composer InputText callback: insert a newline at the cursor on Shift+Enter. ImGui 1.92's Enter handling
// (imgui_widgets.cpp:5078) resolves Enter via Shortcut() with EXACT modifiers, so Shift+Enter matches
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
int composeInputCallback(ImGuiInputTextCallbackData* data) {
ImGuiIO& io = ImGui::GetIO();
if (io.KeyShift
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
&& data->BufTextLen < kChatBodyMaxBytes) {
data->InsertChars(data->CursorPos, "\n");
}
return 0;
}
bool s_show_new_convo = false; bool s_show_new_convo = false;
char s_new_zaddr[128] = ""; char s_new_zaddr[128] = "";
char s_new_msg[256] = ""; char s_new_msg[256] = "";
@@ -165,6 +199,155 @@ ImU32 avatarColor(const std::string& seed) {
return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))]; return kPalette[h % (sizeof(kPalette) / sizeof(kPalette[0]))];
} }
// Lay out (and optionally draw) a chat message body with paragraph spacing: each '\n'-delimited paragraph
// word-wraps at wrapW, and every paragraph after the first gets `paraGap` of extra leading ABOVE it — so
// an explicit newline reads as a paragraph break (a bit more space than a soft wrap, which stays tight).
// Returns the total laid-out size (max wrapped width × total height). When dl != nullptr, draws at `origin`.
// Both the size pass (dl == nullptr) and the draw pass MUST use identical args so the bubble fits the text.
ImVec2 layoutChatBody(ImDrawList* dl, ImFont* font, float size, const std::string& body,
float wrapW, float paraGap, ImVec2 origin, ImU32 col) {
float maxW = 0.0f, y = 0.0f;
bool first = true;
size_t start = 0;
for (;;) {
const size_t nl = body.find('\n', start);
const bool last = (nl == std::string::npos);
const char* pbeg = body.c_str() + start;
const char* pend = body.c_str() + (last ? body.size() : nl);
if (!first) y += paraGap; // paragraph break gets extra room
const ImVec2 psz = font->CalcTextSizeA(size, FLT_MAX, wrapW, pbeg, pend);
const float ph = std::max(psz.y, size); // a blank line still takes one line
if (dl && pend > pbeg)
dl->AddText(font, size, ImVec2(origin.x, origin.y + y), col, pbeg, pend, wrapW);
y += ph;
maxW = std::max(maxW, psz.x);
first = false;
if (last) break;
start = nl + 1;
}
return ImVec2(maxW, y);
}
// One wrapped visual line of a laid-out chat body: byte range [b,e) into body + its y-offset from the
// body origin (each line is `size` tall). Mirrors layoutChatBody's paragraph/wrap walk so selection
// hit-testing and highlighting align with the drawn text.
struct ChatBodyLine { std::size_t b, e; float y; };
std::vector<ChatBodyLine> chatBodyLines(ImFont* font, float size, const std::string& body,
float wrapW, float paraGap) {
std::vector<ChatBodyLine> out;
const float scale = size / std::max(1.0f, font->LegacySize);
float y = 0.0f; bool first = true; std::size_t start = 0;
for (;;) {
const std::size_t nl = body.find('\n', start);
const bool last = (nl == std::string::npos);
const std::size_t pend = last ? body.size() : nl;
if (!first) y += paraGap;
std::size_t p = start;
if (p >= pend) { out.push_back({p, p, y}); y += size; } // blank paragraph = one empty line
else {
while (p < pend) {
const char* w = font->CalcWordWrapPositionA(scale, body.c_str() + p, body.c_str() + pend, wrapW);
std::size_t we = (w > body.c_str() + p) ? static_cast<std::size_t>(w - body.c_str()) : p + 1;
if (we > pend) we = pend;
out.push_back({p, we, y});
y += size;
p = we;
while (p < pend && body[p] == ' ') ++p; // AddText skips blanks at a wrap; match it
}
}
first = false;
if (last) break;
start = nl + 1;
}
return out;
}
// Advance one UTF-8 codepoint (byte offset) within [.. limit).
inline std::size_t chatNextCp(const std::string& s, std::size_t i, std::size_t limit) {
if (i >= limit) return limit;
++i;
while (i < limit && (static_cast<unsigned char>(s[i]) & 0xC0) == 0x80) ++i;
return i;
}
// Map a point to the nearest body byte offset (for drag selection). origin is the body's top-left.
std::size_t chatBodyHitTest(ImFont* font, float size, const std::string& body, float wrapW,
float paraGap, ImVec2 origin, ImVec2 pt) {
const auto lines = chatBodyLines(font, size, body, wrapW, paraGap);
if (lines.empty()) return 0;
const ChatBodyLine* L = &lines.front();
for (const auto& ln : lines) { if (pt.y >= origin.y + ln.y) L = &ln; else break; }
const float relX = pt.x - origin.x;
std::size_t best = L->b; float bestDx = 1e9f;
for (std::size_t i = L->b;; i = chatNextCp(body, i, L->e)) {
const float w = font->CalcTextSizeA(size, FLT_MAX, 0.0f, body.c_str() + L->b, body.c_str() + i).x;
const float dx = std::fabs(w - relX);
if (dx < bestDx) { bestDx = dx; best = i; }
if (i >= L->e) break;
}
return best;
}
// The custom-DragonX-emoji shortcode. Inserted by the emoji picker's DragonX tile; rendered inline as
// the mark in chat bubbles (see layoutChatBodyRich). Plain text on clients that don't support it.
static const char* kDrgxToken = ":drgx:";
// Lay out (and optionally draw) a message body that contains one or more ":drgx:" tokens, flowing text
// WORDS and the inline DragonX-emoji image left-to-right and wrapping at wrapW (each drgx renders as a
// `size`-square image). Only used for bodies that actually contain the token — plain bodies keep the
// tighter layoutChatBody path — so normal messages are unaffected. Returns the total laid-out size.
ImVec2 layoutChatBodyRich(ImDrawList* dl, ImFont* font, float size, const std::string& body, float wrapW,
float paraGap, ImVec2 origin, ImU32 col, ImTextureID drgxTex) {
const float spaceW = font->CalcTextSizeA(size, FLT_MAX, 0.0f, " ").x;
const float emojiW = size; // square, matching the line height
const std::size_t tokLen = std::strlen(kDrgxToken);
float maxW = 0.0f, y = 0.0f, x = 0.0f;
bool firstPara = true, firstOnLine = true;
// Place a token of width w on the current line, wrapping first if it doesn't fit; draw() gets the
// token's top-left screen position.
auto place = [&](float w, const std::function<void(float, float)>& draw) {
const float lead = firstOnLine ? 0.0f : spaceW;
if (!firstOnLine && x + lead + w > wrapW) { x = 0.0f; y += size; firstOnLine = true; }
const float sx = x + (firstOnLine ? 0.0f : spaceW);
if (dl) draw(origin.x + sx, origin.y + y);
x = sx + w;
if (x > maxW) maxW = x;
firstOnLine = false;
};
std::size_t pstart = 0;
for (;;) {
const std::size_t nl = body.find('\n', pstart);
const bool lastPara = (nl == std::string::npos);
const std::size_t pend = lastPara ? body.size() : nl;
if (!firstPara) y += paraGap;
x = 0.0f; firstOnLine = true;
std::size_t i = pstart;
while (i < pend) {
if (body[i] == ' ' || body[i] == '\t') { ++i; continue; }
if (body.compare(i, tokLen, kDrgxToken) == 0) {
place(emojiW, [&](float px, float py) {
if (drgxTex) dl->AddImage(drgxTex, ImVec2(px, py), ImVec2(px + emojiW, py + size));
else dl->AddText(font, size, ImVec2(px, py), col, kDrgxToken); // fallback: literal
});
i += tokLen;
continue;
}
std::size_t j = i;
while (j < pend && body[j] != ' ' && body[j] != '\t' && body.compare(j, tokLen, kDrgxToken) != 0) ++j;
const std::string word = body.substr(i, j - i);
const float ww = font->CalcTextSizeA(size, FLT_MAX, 0.0f, word.c_str()).x;
place(ww, [&, word](float px, float py) { dl->AddText(font, size, ImVec2(px, py), col, word.c_str()); });
i = j;
}
y += size;
firstPara = false;
if (lastPara) break;
pstart = nl + 1;
}
return ImVec2(maxW, y);
}
// Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary). // Outgoing-bubble accent base color for a chat_bubble_accent preset (0 = theme primary).
ImU32 bubbleAccentColor(int accent) { ImU32 bubbleAccentColor(int accent) {
switch (accent) { switch (accent) {
@@ -416,6 +599,13 @@ static const EmojiEntry kEmoji[] = {
{u8"🤢","nausea sick"},{u8"🤮","vomit sick"},{u8"🤧","sneeze sick"},{u8"😷","mask sick"},{u8"🤒","sick thermometer"}, {u8"🤢","nausea sick"},{u8"🤮","vomit sick"},{u8"🤧","sneeze sick"},{u8"😷","mask sick"},{u8"🤒","sick thermometer"},
{u8"🤕","hurt bandage injured"},{u8"😈","devil imp evil"},{u8"👿","devil angry"},{u8"💀","skull dead"},{u8"👻","ghost boo"}, {u8"🤕","hurt bandage injured"},{u8"😈","devil imp evil"},{u8"👿","devil angry"},{u8"💀","skull dead"},{u8"👻","ghost boo"},
{u8"👽","alien"},{u8"🤖","robot bot"},{u8"😺","cat smile"},{u8"🙀","cat scream"}, {u8"👽","alien"},{u8"🤖","robot bot"},{u8"😺","cat smile"},{u8"🙀","cat scream"},
// Dragon-themed set (DRGX flavour — searchable by "dragon"/"drgx"). All verified present in the
// bundled mono NotoEmoji subset + the color Twemoji font.
{u8"🐉","dragon drgx dragonx wyrm mythical creature"},{u8"🐲","dragon face drgx dragonx"},
{u8"🐍","snake serpent reptile"},{u8"🦎","lizard reptile gecko"},{u8"🐊","crocodile croc reptile gator"},
{u8"🐾","paw tracks claws prints"},{u8"🥚","egg dragon egg"},{u8"🪺","nest eggs hatch"},
{u8"🏰","castle lair fortress keep"},{u8"","swords battle crossed fight"},
{u8"🛡","shield defense guard protect"},{u8"🗡","dagger blade sword knife"},
{u8"👍","thumbs up like yes good"},{u8"👎","thumbs down dislike no bad"},{u8"👌","ok perfect"},{u8"","peace victory"}, {u8"👍","thumbs up like yes good"},{u8"👎","thumbs down dislike no bad"},{u8"👌","ok perfect"},{u8"","peace victory"},
{u8"🤞","fingers crossed luck"},{u8"🤟","love you"},{u8"🤘","rock horns"},{u8"👏","clap applause"}, {u8"🤞","fingers crossed luck"},{u8"🤟","love you"},{u8"🤘","rock horns"},{u8"👏","clap applause"},
{u8"🙌","raise hands celebrate"},{u8"👐","open hands"},{u8"🙏","pray thanks please"},{u8"💪","muscle strong flex"}, {u8"🙌","raise hands celebrate"},{u8"👐","open hands"},{u8"🙏","pray thanks please"},{u8"💪","muscle strong flex"},
@@ -434,7 +624,19 @@ static const EmojiEntry kEmoji[] = {
// Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a // Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a
// grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer. // grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer.
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) { void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) {
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space
// when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap.
auto insertToken = [&](const char* tok) {
const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
const bool needsSpace = cur > 0 && static_cast<unsigned char>(buf[cur - 1]) > ' ';
const std::size_t pad = needsSpace ? 1 : 0;
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
if (needsSpace) buf[cur] = ' ';
std::memcpy(buf + cur + pad, tok, add);
buf[cur + pad + add] = '\0';
}
};
if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; } if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
ImGui::SameLine(); ImGui::SameLine();
ImGui::SetNextItemWidth(-FLT_MIN); ImGui::SetNextItemWidth(-FLT_MIN);
@@ -460,13 +662,25 @@ void renderEmojiPickerOverlay(char* buf, std::size_t bufSize) {
es.bgRounding = 8.0f * dp; es.bgRounding = 8.0f * dp;
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing, spacing));
int shown = 0; int shown = 0;
// DragonX custom emoji tile (first cell) — inserts the ":drgx:" shortcode, which renders inline as the
// mark in message bubbles. Shown unless the search filter excludes "dragon"/"drgx".
if (q.empty() || containsCI("dragon drgx dragonx wyrm", q)) {
ImGui::PushID("drgxtile");
const ImVec2 tp = ImGui::GetCursorScreenPos();
ImGui::InvisibleButton("##drgx", ImVec2(cell, cell));
ImDrawList* gdl = ImGui::GetWindowDrawList();
if (ImGui::IsItemHovered()) gdl->AddRectFilled(tp, ImVec2(tp.x + cell, tp.y + cell), es.hoverBg, es.bgRounding);
if (drgxTex) gdl->AddImage(drgxTex, tp, ImVec2(tp.x + cell, tp.y + cell));
else gdl->AddText(emojiFont, emojiFont->LegacySize, tp, material::OnSurface(), u8"🐉");
if (ImGui::IsItemClicked()) insertToken(kDrgxToken);
ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine();
}
for (const auto& e : kEmoji) { for (const auto& e : kEmoji) {
if (!q.empty() && !containsCI(e.keywords, q)) continue; if (!q.empty() && !containsCI(e.keywords, q)) continue;
ImGui::PushID(shown); ImGui::PushID(shown);
if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es)) { if (material::IconButton("##em", e.glyph, emojiFont, ImVec2(cell, cell), es))
const std::size_t cur = std::strlen(buf), add = std::strlen(e.glyph); insertToken(e.glyph);
if (cur + add < bufSize) { std::memcpy(buf + cur, e.glyph, add); buf[cur + add] = '\0'; }
}
ImGui::PopID(); ImGui::PopID();
if (++shown % perRow != 0) ImGui::SameLine(); if (++shown % perRow != 0) ImGui::SameLine();
} }
@@ -534,6 +748,7 @@ void RenderChatTab(App* app)
if (s_selected_cid != s_compose_cid) { if (s_selected_cid != s_compose_cid) {
sodium_memzero(s_compose, sizeof(s_compose)); sodium_memzero(s_compose, sizeof(s_compose));
s_compose_cid = s_selected_cid; s_compose_cid = s_selected_cid;
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch
} }
// Abandon an in-progress header rename tied to a different conversation (switch/hide), so a // Abandon an in-progress header rename tied to a different conversation (switch/hide), so a
// half-typed buffer can't silently reappear or land on the wrong contact (Tier C review). // half-typed buffer can't silently reappear or land on the wrong contact (Tier C review).
@@ -575,7 +790,7 @@ void RenderChatTab(App* app)
material::ApplySmoothScroll(); // wheel-driven lerp scroll, matching the rest of the app material::ApplySmoothScroll(); // wheel-driven lerp scroll, matching the rest of the app
if (s_show_emoji_picker) { if (s_show_emoji_picker) {
// Emoji picker overlay takes over the conversation-list pane while open (search + cancel + grid). // Emoji picker overlay takes over the conversation-list pane while open (search + cancel + grid).
renderEmojiPickerOverlay(s_compose, sizeof(s_compose)); renderEmojiPickerOverlay(s_compose, sizeof(s_compose), app->getDrgxEmojiTexture());
} else { } else {
// "New chat" (accented) + "Show hidden (N)" toggle, side by side. Show-hidden only appears when // "New chat" (accented) + "Show hidden (N)" toggle, side by side. Show-hidden only appears when
// something is hidden; otherwise New chat spans the row. // something is hidden; otherwise New chat spans the row.
@@ -705,11 +920,28 @@ void RenderChatTab(App* app)
for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; } for (const auto& c : convs) if (c.cid == s_selected_cid) { sel = &c; break; }
const float tdp = Layout::dpiScale(); const float tdp = Layout::dpiScale();
const float composerBoxH = ImGui::GetTextLineHeight() * 2.6f; // multi-line composer (Q6) // Composer auto-grows with its content: the input word-wraps, so the box height tracks the wrapped
// Reserve a stable composer strip below the box whenever a conversation is open (thin counter row + // line count (measured in the composer block below, where the wrap width is known) and animates so the
// the input row, which sits flush with the strip/window bottom). Same height for the waiting hint so // thread above is smoothly pushed up/down. The vertical padding also centers a single line and, with
// the box doesn't jump when a reply arrives. // the left FramePadding, keeps text off the edges. We consume last frame's measured target here (a
const float composerAreaH = sel ? (composerBoxH + metaSz + 12.0f * tdp) : 0.0f; // 1-frame lag that's invisible under the smoothing); it falls back to one collapsed line.
const float lineH = ImGui::GetTextLineHeight();
const float composerVPad = 8.0f * tdp; // top+bottom inner padding
const float composerCtrlH = lineH + 2.0f * composerVPad; // one-line (collapsed) height; side controls match
const int kMaxComposerLines = 6; // beyond this the input scrolls internally
const float composerAnimTarget = (s_composerTargetH > 0.0f) ? s_composerTargetH : composerCtrlH;
if (s_composerAnimH <= 0.0f) s_composerAnimH = composerAnimTarget; // first frame: snap, don't animate in
{
const float dt = ImGui::GetIO().DeltaTime;
const float k = 1.0f - std::exp(-dt * 16.0f); // ~16/s exponential convergence
s_composerAnimH += (composerAnimTarget - s_composerAnimH) * k;
if (std::fabs(composerAnimTarget - s_composerAnimH) < 0.5f) s_composerAnimH = composerAnimTarget;
}
const float composerBoxH = s_composerAnimH;
// Reserve the (animated) composer strip below the box whenever a conversation is open — just the input
// row (the byte budget is a radial gauge inside the input, no separate counter row). Same height for
// the waiting hint so the box doesn't jump when a reply arrives.
const float composerAreaH = sel ? (composerBoxH + 10.0f * tdp) : 0.0f;
// Frosted-glass thread pane (same reasoning as the list): glass behind + transparent child bg so // Frosted-glass thread pane (same reasoning as the list): glass behind + transparent child bg so
// the message area blurs the backdrop instead of showing the sharp texture. Replaces the old // the message area blurs the backdrop instead of showing the sharp texture. Replaces the old
@@ -978,9 +1210,32 @@ void RenderChatTab(App* app)
ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y), ImGui::BeginChild("##ChatMessages", ImVec2(0, ImGui::GetContentRegionAvail().y),
ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollWithMouse); ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollWithMouse);
material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below material::ApplySmoothScroll(); // smooth wheel scroll; syncs with the auto-scroll-to-bottom below
// Detach auto-scroll when the user wheels up inside the thread; re-armed once back at the bottom
// (below). The cooldown lets the smooth-scroll animate away before we re-check position. 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 the composer is focused still detaches.
{
const ImVec2 wpos = ImGui::GetWindowPos(), wsz = ImGui::GetWindowSize();
const ImVec2 mp = ImGui::GetIO().MousePos;
const bool inThread = mp.x >= wpos.x && mp.x < wpos.x + wsz.x &&
mp.y >= wpos.y && mp.y < wpos.y + wsz.y;
if (inThread && ImGui::GetIO().MouseWheel > 0.0f) {
s_chat_auto_scroll = false;
s_chat_scroll_cooldown = 0.3f;
}
}
if (s_chat_scroll_cooldown > 0.0f) s_chat_scroll_cooldown -= ImGui::GetIO().DeltaTime;
// Was the thread at the bottom coming into this frame? (measured before this frame's content is
// laid out, so newly-arrived messages don't yank a scrolled-up reader.)
const float preScrollMaxY = ImGui::GetScrollMaxY();
const bool wasAtBottom = preScrollMaxY <= 0.0f || ImGui::GetScrollY() >= preScrollMaxY - 4.0f;
bool atBottom = true; bool atBottom = true;
const ImVec2 msgWinMin = ImGui::GetWindowPos(); const ImVec2 msgWinMin = ImGui::GetWindowPos();
const ImVec2 msgWinSize = ImGui::GetWindowSize(); const ImVec2 msgWinSize = ImGui::GetWindowSize();
// Jump-to-latest pill geometry — computed here so the clear-on-empty-click below can exclude
// the pill (a pill click shouldn't drop an active selection) and the pill draw below reuses it.
const ImVec2 jumpPillMin(msgWinMin.x + msgWinSize.x - 88.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
const ImVec2 jumpPillMax(jumpPillMin.x + 78.0f * dp, jumpPillMin.y + 26.0f * dp);
{ {
ImDrawList* dl = ImGui::GetWindowDrawList(); ImDrawList* dl = ImGui::GetWindowDrawList();
// Bubble style / accent / density / text-size from chat customization settings. // Bubble style / accent / density / text-size from chat customization settings.
@@ -1012,6 +1267,8 @@ void RenderChatTab(App* app)
ImFont* icf = material::Type().iconSmall(); ImFont* icf = material::Type().iconSmall();
const float icfSz = scaledSize(icf); const float icfSz = scaledSize(icf);
std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1; std::int64_t prevTs = -1; int prevDir = -1; std::int64_t prevDay = -1;
bool anyMsgClicked = false; // did a left-click this frame land on a bubble / copy button?
const ImTextureID drgxTex = app->getDrgxEmojiTexture(); // ":drgx:" inline emoji
for (std::size_t mi = 0; mi < messages.size(); ++mi) { for (std::size_t mi = 0; mi < messages.size(); ++mi) {
const auto& m = messages[mi]; const auto& m = messages[mi];
ImGui::PushID(static_cast<int>(mi)); ImGui::PushID(static_cast<int>(mi));
@@ -1066,7 +1323,14 @@ void RenderChatTab(App* app)
} }
// ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners). // ── Direction-aligned bubble (rounding/fill/accent from settings; grouped corners).
const ImVec2 tsz = nameFont->CalcTextSizeA(bodySz, innerW, innerW, m.body.c_str()); // Body is laid out paragraph-by-paragraph so explicit newlines get extra leading.
// A body containing ":drgx:" uses the richer inline-image flow (drgx tile → mark);
// plain bodies keep the tighter text-only path so they're unaffected.
const float paraGap = bodySz * 0.35f;
const bool hasDrgx = m.body.find(kDrgxToken) != std::string::npos;
const ImVec2 tsz = hasDrgx
? layoutChatBodyRich(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0, drgxTex)
: layoutChatBody(nullptr, nameFont, bodySz, m.body, innerW, paraGap, ImVec2(0, 0), 0);
const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad); const float bw = std::min(maxBubbleW, tsz.x + 2.0f * bpad);
const float bh = tsz.y + 2.0f * bpad; const float bh = tsz.y + 2.0f * bpad;
const ImVec2 cur = ImGui::GetCursorScreenPos(); const ImVec2 cur = ImGui::GetCursorScreenPos();
@@ -1087,8 +1351,48 @@ void RenderChatTab(App* app)
} }
} }
dl->AddRectFilled(bmin, bmax, bubCol, bround, rf); dl->AddRectFilled(bmin, bmax, bubCol, bround, rf);
dl->AddText(nameFont, bodySz, ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(),
m.body.c_str(), nullptr, innerW); // ── Text selection: click-drag inside the bubble selects a byte range of the body.
// Skipped for ":drgx:" messages — their inline-image layout (layoutChatBodyRich) doesn't
// match the plain-text hit-test/highlight geometry, so a selection would misalign.
// (Right-click "copy" still copies the whole message.)
const ImVec2 bodyOrigin(bx + bpad, cur.y + bpad);
const bool selfSel = (!hasDrgx && s_msgsel_cid == s_selected_cid && s_msgsel_index == static_cast<int>(mi));
if (!hasDrgx && ImGui::IsMouseHoveringRect(bmin, bmax) && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
s_msgsel_cid = s_selected_cid; s_msgsel_index = static_cast<int>(mi);
s_msgsel_anchor = s_msgsel_head = static_cast<int>(
chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
s_msgsel_dragging = true;
anyMsgClicked = true;
}
if (selfSel && s_msgsel_dragging) {
if (ImGui::IsMouseDown(ImGuiMouseButton_Left))
s_msgsel_head = static_cast<int>(
chatBodyHitTest(nameFont, bodySz, m.body, innerW, paraGap, bodyOrigin, ImGui::GetMousePos()));
else
s_msgsel_dragging = false;
}
// Highlight the selected range (behind the text) — one rect per wrapped line segment.
if (selfSel && s_msgsel_anchor != s_msgsel_head) {
const int a = std::min(s_msgsel_anchor, s_msgsel_head);
const int b2 = std::max(s_msgsel_anchor, s_msgsel_head);
for (const auto& ln : chatBodyLines(nameFont, bodySz, m.body, innerW, paraGap)) {
const int ls = std::max(a, static_cast<int>(ln.b));
const int le = std::min(b2, static_cast<int>(ln.e));
if (ls >= le) continue;
const float x0 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + ls).x;
const float x1 = nameFont->CalcTextSizeA(bodySz, FLT_MAX, 0.0f, m.body.c_str() + ln.b, m.body.c_str() + le).x;
dl->AddRectFilled(ImVec2(bodyOrigin.x + x0, bodyOrigin.y + ln.y),
ImVec2(bodyOrigin.x + x1, bodyOrigin.y + ln.y + bodySz),
material::WithAlpha(material::Primary(), 80));
}
}
if (hasDrgx)
layoutChatBodyRich(dl, nameFont, bodySz, m.body, innerW, paraGap,
ImVec2(bx + bpad, cur.y + bpad), material::OnSurface(), drgxTex);
else
layoutChatBody(dl, nameFont, bodySz, m.body, innerW, paraGap,
ImVec2(bx + bpad, cur.y + bpad), material::OnSurface());
// Peer avatar beside the LAST incoming bubble of a run (Tier 3). // Peer avatar beside the LAST incoming bubble of a run (Tier 3).
if (!outgoing && lastInGroup) { if (!outgoing && lastInGroup) {
@@ -1115,6 +1419,31 @@ void RenderChatTab(App* app)
ImGui::EndPopup(); ImGui::EndPopup();
} }
// Copy button for an active selection — at the bubble TOP, on the OPPOSITE side (an
// incoming bubble sits left → button to its right; an outgoing bubble sits right →
// button to its left). Copies the selected substring of the body.
if (selfSel && s_msgsel_anchor != s_msgsel_head) {
const int a = std::min(s_msgsel_anchor, s_msgsel_head);
const int b2 = std::max(s_msgsel_anchor, s_msgsel_head);
const float cbs = 22.0f * dp;
const float cbx = outgoing ? (bmin.x - cbs - 4.0f * dp) : (bmax.x + 4.0f * dp);
const ImVec2 cbmin(cbx, bmin.y), cbmax(cbx + cbs, bmin.y + cbs);
const bool cbhov = ImGui::IsMouseHoveringRect(cbmin, cbmax);
dl->AddRectFilled(cbmin, cbmax, cbhov ? material::Primary()
: material::WithAlpha(material::Primary(), 210), 5.0f * dp);
const float gs = icfSz * 0.78f;
const ImVec2 gsz = icf->CalcTextSizeA(gs, FLT_MAX, 0.0f, ICON_MD_CONTENT_COPY);
dl->AddText(icf, gs, ImVec2(cbmin.x + (cbs - gsz.x) * 0.5f, cbmin.y + (cbs - gsz.y) * 0.5f),
IM_COL32(255, 255, 255, 235), ICON_MD_CONTENT_COPY);
if (cbhov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImGui::SetClipboardText(m.body.substr(a, b2 - a).c_str());
anyMsgClicked = true;
}
}
}
// Delivery status for your messages: clock while sending, check once broadcast (Tier 2). // Delivery status for your messages: clock while sending, check once broadcast (Tier 2).
if (outgoing && !failed && (sending || lastInGroup)) { if (outgoing && !failed && (sending || lastInGroup)) {
const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE; const char* glyph = sending ? ICON_MD_SCHEDULE : ICON_MD_DONE;
@@ -1147,27 +1476,53 @@ void RenderChatTab(App* app)
ImGui::PopID(); ImGui::PopID();
prevTs = m.timestamp; prevDir = static_cast<int>(outgoing); prevDay = day; prevTs = m.timestamp; prevDir = static_cast<int>(outgoing); prevDay = day;
} }
if (s_scroll_to_cid == s_selected_cid) { // A left-click inside the thread that hit no bubble or copy button clears the selection —
// except a click on the jump-to-latest pill, which is navigation, not a deselect.
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !anyMsgClicked && ImGui::IsWindowHovered()
&& !ImGui::IsMouseHoveringRect(jumpPillMin, jumpPillMax)) {
s_msgsel_cid.clear(); s_msgsel_index = -1; s_msgsel_dragging = false;
}
// Pin to the newest message when explicitly requested (send / conversation switch) or when
// auto-scroll is armed and the thread was already at the bottom (so new messages follow).
if (s_scroll_to_cid == s_selected_cid || (s_chat_auto_scroll && wasAtBottom)) {
ImGui::SetScrollHereY(1.0f); ImGui::SetScrollHereY(1.0f);
s_scroll_to_cid.clear(); s_scroll_to_cid.clear();
} }
atBottom = ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f; atBottom = ImGui::GetScrollMaxY() <= 0.0f || ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 4.0f;
// Re-arm auto-scroll once the user returns to the bottom (after the wheel cooldown).
if (atBottom && s_chat_scroll_cooldown <= 0.0f) s_chat_auto_scroll = true;
// Jump-to-latest pill when scrolled up (Q9). Drawn LAST and INSIDE the thread child (on the
// child's own draw list) so it sits on top of the messages — a parent-window draw after
// EndChild renders UNDER the child and gets covered by the text. PushClipRect keeps the
// child's content padding / scrollbar from clipping it. Hand-drawn + IsMouseHoveringRect so
// the click lands despite the child owning hover here (a real widget would still miss it).
if (!atBottom) {
const ImVec2 pmin = jumpPillMin, pmax = jumpPillMax;
const float pw = pmax.x - pmin.x, ph = pmax.y - pmin.y;
const bool hov = ImGui::IsMouseHoveringRect(pmin, pmax);
dl->PushClipRect(pmin, pmax, false);
dl->AddRectFilled(pmin, pmax,
hov ? material::Primary() : material::WithAlpha(material::Primary(), 220),
ph * 0.5f);
ImFont* lf = ImGui::GetFont(); const float lsz = ImGui::GetFontSize();
const char* lbl = TR("chat_jump_latest");
const ImVec2 tszp = lf->CalcTextSizeA(lsz, FLT_MAX, 0.0f, lbl);
dl->AddText(lf, lsz, ImVec2(pmin.x + (pw - tszp.x) * 0.5f, pmin.y + (ph - tszp.y) * 0.5f),
IM_COL32(255, 255, 255, 235), lbl);
dl->PopClipRect();
if (hov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
s_scroll_to_cid = sel->cid;
s_chat_auto_scroll = true; // clicking Latest re-arms follow-the-newest
s_chat_scroll_cooldown = 0.0f;
}
}
}
} }
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing ImGui::PopStyleVar(2); // ##ChatMessages WindowPadding + zeroed vertical ItemSpacing
// Jump-to-latest pill when scrolled up (Q9). SetCursorScreenPos moves the parent cursor to
// the child's bottom edge; save + restore it so the composer footer below stays anchored
// (otherwise it renders ~8px too high only while the pill is shown).
if (!atBottom) {
const ImVec2 savedCursor = ImGui::GetCursorScreenPos();
const ImVec2 pill(msgWinMin.x + msgWinSize.x - 84.0f * dp, msgWinMin.y + msgWinSize.y - 34.0f * dp);
ImGui::SetCursorScreenPos(pill);
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 220)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
if (ImGui::Button(TR("chat_jump_latest"), ImVec2(74.0f * dp, 26.0f * dp))) s_scroll_to_cid = sel->cid;
ImGui::PopStyleColor(2);
ImGui::SetCursorScreenPos(savedCursor);
}
// Composer is rendered OUTSIDE this box, directly below it (see after EndChild). // Composer is rendered OUTSIDE this box, directly below it (see after EndChild).
} else { } else {
if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start")); if (convs.empty()) centeredEmptyState(ICON_MD_FORUM, TR("chat_empty_title"), TR("chat_empty_start"));
@@ -1188,6 +1543,7 @@ void RenderChatTab(App* app)
const float cx = boxMin.x; const float cx = boxMin.x;
const float cw = boxMax.x - boxMin.x; const float cw = boxMax.x - boxMin.x;
if (sel->peerPubKey.empty()) { if (sel->peerPubKey.empty()) {
s_composerTargetH = composerCtrlH; // no editable composer here — keep the strip collapsed
ImGui::SetCursorScreenPos(ImVec2(cx, boxMax.y + 8.0f * tdp)); ImGui::SetCursorScreenPos(ImVec2(cx, boxMax.y + 8.0f * tdp));
ImGui::PushFont(metaFont); ImGui::PushFont(metaFont);
ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium());
@@ -1197,31 +1553,36 @@ void RenderChatTab(App* app)
ImGui::PopStyleColor(); ImGui::PopStyleColor();
ImGui::PopFont(); ImGui::PopFont();
} else { } else {
// Multi-line composer (Q6). Enter behavior is setting-dependent (see below). A live byte // Multi-line composer (Q6). Enter behavior is setting-dependent (see below). Input is
// counter tracks the on-chain body cap = (512 len("utf8:"))/2 secretstream ABYTES. // hard-capped to the on-chain body byte budget; usage is shown as a radial gauge in the box.
static constexpr int kBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 (see chat_outgoing.cpp) const int kBodyMaxBytes = kChatBodyMaxBytes;
const float sendW = 80.0f * tdp; // scaled so the translated label never clips (B3) const float sendW = 80.0f * tdp; // scaled so the translated label never clips (B3)
const float emojiBtn = 28.0f * tdp; const float emojiBtn = 28.0f * tdp;
const float inGap = 6.0f * tdp; const float inGap = 6.0f * tdp;
const int used = static_cast<int>(std::strlen(s_compose)); const int used = static_cast<int>(std::strlen(s_compose));
const bool overCap = used > kBodyMaxBytes; const bool overCap = used > kBodyMaxBytes; // defensive — the hard byte cap should prevent this
// Thin counter row (right-aligned) just under the box; over-cap tints + prefixes a label. // Fraction of the on-chain cap used, for the radial gauge (drawn inside the input, below).
std::string counterLine = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes); const float frac = kBodyMaxBytes > 0 ? static_cast<float>(used) / kBodyMaxBytes : 0.0f;
if (overCap) counterLine = std::string(TR("chat_len_over")) + " " + counterLine;
const float cwid = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, counterLine.c_str()).x;
ImGui::GetWindowDrawList()->AddText(metaFont, metaSz,
ImVec2(cx + cw - cwid, boxMax.y + 3.0f * tdp),
overCap ? material::Error() : material::OnSurfaceMedium(), counterLine.c_str());
// Input row, flush with the strip/window bottom: [emoji] [input] [Send]. // Input row: [emoji] [input] [Send]. The input's TOP is pinned 10px below the message box and
// it grows DOWNWARD as it gets taller (the thread above shrinks), so the row bottom stays put.
const float rowY = boxMax.y + composerAreaH - composerBoxH; const float rowY = boxMax.y + composerAreaH - composerBoxH;
const float inputX = cx + emojiBtn + inGap; const float inputX = cx + emojiBtn + inGap;
const float inputW = std::max(60.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW); const float ctrlY = rowY + composerBoxH - composerCtrlH; // bottom-aligned row for side controls
// Radial byte gauge sits in the input box's bottom-right corner; a fixed small size (tied to
// the line height, NOT the animated box height) with a reserved right slot so text never
// reaches it. Geometry first so the input width floor can guarantee room for both.
const float ringR = std::max(7.0f, lineH * 0.42f);
const float ringPad = 9.0f * tdp;
const float ringSlot = 2.0f * ringR + ringPad * 1.6f;
const float inputW = std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
const float sendX = inputX + inputW + inGap; const float sendX = inputX + inputW + inGap;
const float textW = std::max(40.0f * tdp, inputW - ringSlot); // input area, left of the ring
const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR);
// Emoji toggle on the left, vertically centered in the input row. // Emoji toggle on the left, bottom-aligned with the input row (centered in the control height).
ImGui::SetCursorScreenPos(ImVec2(cx, rowY + (composerBoxH - emojiBtn) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(cx, ctrlY + (composerCtrlH - emojiBtn) * 0.5f));
{ {
material::IconButtonStyle es; material::IconButtonStyle es;
es.hoverBg = material::WithAlpha(material::OnSurface(), 30); es.hoverBg = material::WithAlpha(material::OnSurface(), 30);
@@ -1244,33 +1605,112 @@ void RenderChatTab(App* app)
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, IM_COL32(0, 0, 0, 0));
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(0, 0, 0, 0)); ImGui::PushStyleColor(ImGuiCol_FrameBgActive, IM_COL32(0, 0, 0, 0));
// FramePadding gives the text a left inset (so it doesn't hug the box edge) and, since the
// collapsed box is exactly one line + 2×VPad tall, vertically centers a single line.
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(12.0f * tdp, composerVPad));
ImGui::SetCursorScreenPos(ImVec2(inputX, rowY)); ImGui::SetCursorScreenPos(ImVec2(inputX, rowY));
// Enter-to-send (setting): on → Enter sends + Ctrl+Enter newline; off → Enter is a newline and // Enter-to-send (setting): on → Enter sends, Shift+Enter makes a newline (Ctrl+Enter too); off
// the Send button is the only way to send. NOTE: without EnterReturnsTrue, InputTextMultiline // → Enter is a newline and the Send button is the only way to send. EnterReturnsTrue needs
// returns true on EVERY edit (value-changed), so in off-mode the return value must NOT drive // CtrlEnterForNewLine alongside it or multiline plain-Enter inserts a '\n' instead of
// submit — otherwise typing one character would fire a send. // validating (imgui_widgets.cpp:5118). Shift+Enter is handled in composeInputCallback since
// ImGui's exact-modifier Enter shortcut ignores it. CallbackAlways runs that handler.
const bool enterSends = !app->settings() || app->settings()->getChatEnterSends(); const bool enterSends = !app->settings() || app->settings()->getChatEnterSends();
const ImGuiInputTextFlags composeFlags = enterSends // WordWrap: long lines wrap to the next visual line (the box grows) instead of scrolling the
? (ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine) // text horizontally off the left edge.
: ImGuiInputTextFlags_None; ImGuiInputTextFlags composeFlags = ImGuiInputTextFlags_CallbackAlways | ImGuiInputTextFlags_WordWrap;
const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, sizeof(s_compose), if (enterSends)
ImVec2(inputW, composerBoxH), composeFlags); composeFlags |= ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CtrlEnterForNewLine;
// Hard-cap the byte length at the on-chain budget: passing kBodyMaxBytes+1 as the buffer size
// stops ImGui from accepting more input (UTF-8-safe), so the user can't type past the cap.
const bool inputReturned = ImGui::InputTextMultiline("##compose", s_compose, kBodyMaxBytes + 1,
ImVec2(textW, composerBoxH), composeFlags, composeInputCallback);
ImGui::PopStyleVar(); // FramePadding
ImGui::PopStyleColor(3); // composer FrameBg (transparent — glass shows through) ImGui::PopStyleColor(3); // composer FrameBg (transparent — glass shows through)
bool submit = enterSends && inputReturned; // off-mode sends only via the Send button
// Send. // Measure the wrapped content height to drive the auto-grow (consumed next frame at the top).
ImGui::SetCursorScreenPos(ImVec2(sendX, rowY)); // Wrap width mirrors ImGui's internal InputText wrap = frame inner width (textW 2×FramePadding.x),
// so CalcTextSize's wrap matches what the widget renders.
{
const float wrapW = std::max(1.0f, textW - 2.0f * 12.0f * tdp);
float contentH = (s_compose[0] == '\0') ? lineH
: ImGui::CalcTextSize(s_compose, nullptr, false, wrapW).y;
const size_t clen = std::strlen(s_compose);
if (clen > 0 && s_compose[clen - 1] == '\n') contentH += lineH; // trailing empty line
contentH = std::min(contentH, kMaxComposerLines * lineH);
s_composerTargetH = std::max(lineH, contentH) + 2.0f * composerVPad;
}
// ── Radial byte gauge (replaces the old numeric counter): a ring in the input's right edge
// that fills clockwise and shifts green→amber→red as the message nears the on-chain cap.
// Full + red at/over the cap (Send is disabled there); exact count shows on hover.
{
ImDrawList* gdl = ImGui::GetWindowDrawList();
auto lerpCol = [](ImU32 a, ImU32 b, float t) {
if (t < 0.0f) t = 0.0f; else if (t > 1.0f) t = 1.0f;
const ImVec4 fa = ImGui::ColorConvertU32ToFloat4(a);
const ImVec4 fb = ImGui::ColorConvertU32ToFloat4(b);
return ImGui::GetColorU32(ImVec4(fa.x + (fb.x - fa.x) * t, fa.y + (fb.y - fa.y) * t,
fa.z + (fb.z - fa.z) * t, fa.w + (fb.w - fa.w) * t));
};
const ImU32 green = material::Success(), amber = material::Warning(), red = material::Error();
ImU32 fillCol;
if (frac < 0.75f) fillCol = green; // calm until genuinely near the cap
else if (frac < 1.0f) { const float t = (frac - 0.75f) / 0.25f;
fillCol = (t < 0.5f) ? lerpCol(green, amber, t / 0.5f)
: lerpCol(amber, red, (t - 0.5f) / 0.5f); }
else fillCol = red; // at/over cap
const float thick = std::max(2.0f, 2.4f * tdp);
const float dispFrac = frac < 0.0f ? 0.0f : (frac > 1.0f ? 1.0f : frac);
// Faint full-ring track (alpha kept high enough to read on light skins too).
gdl->PathArcTo(ringC, ringR, 0.0f, IM_PI * 2.0f, 48);
gdl->PathStroke(material::WithAlpha(material::OnSurface(), 42), 0, thick);
// Progress arc from 12 o'clock, clockwise, with a rounded head.
if (dispFrac > 0.0001f) {
const float a0 = -IM_PI * 0.5f;
const float a1 = a0 + dispFrac * IM_PI * 2.0f;
gdl->PathArcTo(ringC, ringR, a0, a1, 48);
gdl->PathStroke(fillCol, 0, thick);
gdl->AddCircleFilled(ImVec2(ringC.x + std::cos(a1) * ringR, ringC.y + std::sin(a1) * ringR),
thick * 0.6f, fillCol, 10);
}
// Exact count on hover (progressive disclosure; over-cap prefixes a label).
const float hitR = ringR + thick;
if (ImGui::IsMouseHoveringRect(ImVec2(ringC.x - hitR, ringC.y - hitR),
ImVec2(ringC.x + hitR, ringC.y + hitR))) {
std::string tip = std::to_string(used) + " / " + std::to_string(kBodyMaxBytes);
if (overCap) tip = std::string(TR("chat_len_over")) + " " + tip;
ImGui::BeginTooltip();
ImGui::TextUnformatted(tip.c_str());
ImGui::EndTooltip();
}
}
// Off-mode sends only via the Send button. In enter-sends mode EnterReturnsTrue makes the
// return value mean "validated" (plain Enter only) — Shift+Enter/edits don't set it.
bool submit = enterSends && inputReturned;
// Send — fixed height, bottom-aligned with the input row (doesn't stretch when the box grows).
ImGui::SetCursorScreenPos(ImVec2(sendX, ctrlY));
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205))); ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235))); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
ImGui::BeginDisabled(overCap || s_compose[0] == '\0'); ImGui::BeginDisabled(overCap || s_compose[0] == '\0');
if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerBoxH))) submit = true; if (material::TactileButton(TR("chat_send"), ImVec2(sendW, composerCtrlH))) submit = true;
ImGui::EndDisabled(); ImGui::EndDisabled();
// When Send is disabled *because the message is over the cap*, explain why on hover — the
// gauge went red but the numeric reason is no longer always-visible.
if (overCap && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
ImGui::SetTooltip("%s %d / %d", TR("chat_len_over"), used, kBodyMaxBytes);
ImGui::PopStyleColor(3); ImGui::PopStyleColor(3);
if (submit && s_compose[0] != '\0' && !overCap) { if (submit && s_compose[0] != '\0' && !overCap) {
app->sendChatMessage(sel->cid, s_compose); app->sendChatMessage(sel->cid, s_compose);
sodium_memzero(s_compose, sizeof(s_compose)); sodium_memzero(s_compose, sizeof(s_compose));
s_scroll_to_cid = sel->cid; s_scroll_to_cid = sel->cid;
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused
// Sending closes the emoji picker (it takes over the conversation-list pane) so the list
// reappears, and clears its search filter.
s_show_emoji_picker = false;
s_emoji_search[0] = '\0';
} }
} }
} }
@@ -1419,8 +1859,10 @@ void RenderChatSettingsControls(App* app, float contentWidth)
// Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard // Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard
// whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth; // whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth;
// the chat modal's dialog content region is correct, so it passes 0 (auto). // the chat modal's dialog content region is correct, so it passes 0 (auto).
const float leftX = ImGui::GetCursorPosX(); // leftX/rowW define the current column the rows lay out in; retargeted below
const float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x; // to split Appearance | Messaging into two columns when the card is wide.
float leftX = ImGui::GetCursorPosX();
float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
// Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin. // Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin.
auto beginRow = [&](const char* label) { auto beginRow = [&](const char* label) {
@@ -1485,6 +1927,17 @@ void RenderChatSettingsControls(App* app, float contentWidth)
return result; return result;
}; };
// Two internal columns when the card is wide enough: Appearance on the left,
// Messaging on the right — fills the width and roughly halves the height.
// (Mirrors the Node & Security card.) Narrow (the chat modal) stays single-column.
const float chatColGap = 24.0f * dp;
const bool chatTwoCol = rowW > 760.0f * dp;
const float chatColW = chatTwoCol ? (rowW - chatColGap) * 0.5f : rowW;
const float chatBaseLeftX = leftX;
const float chatTopY = ImGui::GetCursorPosY();
float chatLeftBottomY = 0.0f;
if (chatTwoCol) rowW = chatColW; // left column width
// ── Appearance ──────────────────────────────────────────────────────────────── // ── Appearance ────────────────────────────────────────────────────────────────
section("chat_sec_appearance"); section("chat_sec_appearance");
// Emoji style (monochrome / color). Color needs a FreeType build (native + the cross-built Windows // Emoji style (monochrome / color). Color needs a FreeType build (native + the cross-built Windows
@@ -1493,6 +1946,7 @@ void RenderChatSettingsControls(App* app, float contentWidth)
int v = st->getChatEmojiColor() ? 1 : 0; int v = st->getChatEmojiColor() ? 1 : 0;
const char* items[] = { TR("chat_emoji_mono"), TR("chat_emoji_color") }; const char* items[] = { TR("chat_emoji_mono"), TR("chat_emoji_color") };
int nv = segmented(TR("chat_opt_emoji"), items, 2, v); int nv = segmented(TR("chat_opt_emoji"), items, 2, v);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_emoji_style"));
if (nv != v) { st->setChatEmojiColor(nv == 1); st->save(); app->requestFontRebuild(); } if (nv != v) { st->setChatEmojiColor(nv == 1); st->save(); app->requestFontRebuild(); }
} }
// Bubble style (segmented) + accent color (a 6-way dropdown — too many for a segmented control). // Bubble style (segmented) + accent color (a 6-way dropdown — too many for a segmented control).
@@ -1500,6 +1954,7 @@ void RenderChatSettingsControls(App* app, float contentWidth)
const char* items[] = { TR("chat_bubble_rounded"), TR("chat_bubble_square"), TR("chat_bubble_minimal") }; const char* items[] = { TR("chat_bubble_rounded"), TR("chat_bubble_square"), TR("chat_bubble_minimal") };
int v = st->getChatBubbleStyle(); int v = st->getChatBubbleStyle();
int nv = segmented(TR("chat_opt_bubble_style"), items, 3, v); int nv = segmented(TR("chat_opt_bubble_style"), items, 3, v);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_bubble_style"));
if (nv != v) { st->setChatBubbleStyle(nv); st->save(); } if (nv != v) { st->setChatBubbleStyle(nv); st->save(); }
} }
{ {
@@ -1508,12 +1963,14 @@ void RenderChatSettingsControls(App* app, float contentWidth)
const char* items[] = { TR("chat_accent_theme"), TR("chat_accent_blue"), TR("chat_accent_green"), const char* items[] = { TR("chat_accent_theme"), TR("chat_accent_blue"), TR("chat_accent_green"),
TR("chat_accent_purple"), TR("chat_accent_amber"), TR("chat_accent_pink") }; TR("chat_accent_purple"), TR("chat_accent_amber"), TR("chat_accent_pink") };
if (ImGui::Combo("##chat_baccent", &v, items, 6)) { st->setChatBubbleAccent(v); st->save(); } if (ImGui::Combo("##chat_baccent", &v, items, 6)) { st->setChatBubbleAccent(v); st->save(); }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_bubble_accent"));
} }
// Message density (segmented). // Message density (segmented).
{ {
const char* items[] = { TR("chat_density_comfortable"), TR("chat_density_compact") }; const char* items[] = { TR("chat_density_comfortable"), TR("chat_density_compact") };
int v = st->getChatDensity(); int v = st->getChatDensity();
int nv = segmented(TR("chat_opt_density"), items, 2, v); int nv = segmented(TR("chat_opt_density"), items, 2, v);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_density"));
if (nv != v) { st->setChatDensity(nv); st->save(); } if (nv != v) { st->setChatDensity(nv); st->save(); }
} }
// Message text size (slider). // Message text size (slider).
@@ -1523,6 +1980,16 @@ void RenderChatSettingsControls(App* app, float contentWidth)
if (ImGui::SliderFloat("##chat_font", &v, 0.8f, 1.5f, "%.2fx", ImGuiSliderFlags_AlwaysClamp)) { if (ImGui::SliderFloat("##chat_font", &v, 0.8f, 1.5f, "%.2fx", ImGuiSliderFlags_AlwaysClamp)) {
st->setChatFontScale(v); st->save(); st->setChatFontScale(v); st->save();
} }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_font_size"));
}
// Move Messaging into the right column (float it with Indent so every row's
// line-start holds the column; retarget leftX so controls right-align in it).
if (chatTwoCol) {
chatLeftBottomY = ImGui::GetCursorPosY();
ImGui::SetCursorPosY(chatTopY);
ImGui::Indent(chatColW + chatColGap);
leftX = chatBaseLeftX + chatColW + chatColGap;
} }
// ── Messaging ───────────────────────────────────────────────────────────────── // ── Messaging ─────────────────────────────────────────────────────────────────
@@ -1534,12 +2001,14 @@ void RenderChatSettingsControls(App* app, float contentWidth)
if (ImGui::SliderFloat("##chat_poll", &v, 0.5f, 15.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp)) { if (ImGui::SliderFloat("##chat_poll", &v, 0.5f, 15.0f, "%.1f s", ImGuiSliderFlags_AlwaysClamp)) {
st->setChatPollRateSec(v); st->save(); st->setChatPollRateSec(v); st->save();
} }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_poll_rate"));
} }
// Chat timestamps (segmented) — overrides the app-wide clock (Settings → General) for this tab only. // Chat timestamps (segmented) — overrides the app-wide clock (Settings → General) for this tab only.
{ {
const char* items[] = { TR("chat_ts_global_short"), TR("chat_ts_24h"), TR("chat_ts_12h") }; const char* items[] = { TR("chat_ts_global_short"), TR("chat_ts_24h"), TR("chat_ts_12h") };
int v = st->getChatTimeFormat(); int v = st->getChatTimeFormat();
int nv = segmented(TR("chat_opt_timestamp"), items, 3, v); int nv = segmented(TR("chat_opt_timestamp"), items, 3, v);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_timestamp"));
if (nv != v) { st->setChatTimeFormat(nv); st->save(); } if (nv != v) { st->setChatTimeFormat(nv); st->save(); }
} }
// Enter-to-send (checkbox). // Enter-to-send (checkbox).
@@ -1547,6 +2016,15 @@ void RenderChatSettingsControls(App* app, float contentWidth)
ImGui::Dummy(ImVec2(0.0f, rowGap)); ImGui::Dummy(ImVec2(0.0f, rowGap));
bool v = st->getChatEnterSends(); bool v = st->getChatEnterSends();
if (ImGui::Checkbox(TR("chat_opt_enter_sends"), &v)) { st->setChatEnterSends(v); st->save(); } if (ImGui::Checkbox(TR("chat_opt_enter_sends"), &v)) { st->setChatEnterSends(v); st->save(); }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_chat_enter_sends"));
}
// Close the two-column band: un-indent and drop below the taller column.
if (chatTwoCol) {
ImGui::Unindent(chatColW + chatColGap);
const float chatRightBottomY = ImGui::GetCursorPosY();
ImGui::SetCursorPosX(chatBaseLeftX);
ImGui::SetCursorPosY(std::max(chatLeftBottomY, chatRightBottomY));
} }
} }

View File

@@ -29,6 +29,10 @@ namespace ui {
using namespace material; 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 // FullNodeConsoleExecutor
// ============================================================================ // ============================================================================
@@ -173,6 +177,25 @@ void FullNodeConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
} else { } else {
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts 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) void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)

View File

@@ -113,6 +113,9 @@ private:
bool last_rpc_connected_ = false; bool last_rpc_connected_ = false;
std::deque<std::pair<std::string, bool>> results_; // {text, isError} std::deque<std::pair<std::string, bool>> results_; // {text, isError}
std::mutex results_mutex_; 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 ───────────────────────── // ── Lite: diagnostics ring + backend console command ─────────────────────────

View File

@@ -1097,6 +1097,10 @@ void RenderContactsTab(App* app)
} }
ImGui::EndTable(); ImGui::EndTable();
} }
// Land the cursor at the glass-panel bottom (tpMin.y + listH) so the count footer lines up
// with the Cards/List views. The table is inset by tVpad and its outer_size is listH-2*tVpad,
// so it would otherwise end tVpad higher and pull the footer up.
ImGui::SetCursorScreenPos(ImVec2(tpMin.x, tpMin.y + listH));
} else { } else {
// ── CARDS (0) / LIST (1) mode — tactile Material items, no grid lines. ── // ── CARDS (0) / LIST (1) mode — tactile Material items, no grid lines. ──
const bool asCard = (viewMode == 0); const bool asCard = (viewMode == 0);

View File

@@ -211,12 +211,11 @@ void ExportAllKeysDialog::render(App* app)
std::string filepath = configDir + "/" + filename; std::string filepath = configDir + "/" + filename;
bool writeOk = false; bool writeOk = false;
if (exported > 0) { if (exported > 0) {
std::ofstream file(filepath); // Write the plaintext private keys 0600 + atomically (never
if (file.is_open()) { // world/group-readable, never a half-written file) — the same restricted
file << keys; // atomic-write idiom the PIN vault uses. A default ofstream would create
file.close(); // the key dump with umask-derived (often 0644) permissions.
writeOk = true; 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 if (!keys.empty()) sodium_memzero(&keys[0], keys.size()); // don't leave every key in freed heap

View File

@@ -36,21 +36,30 @@ static bool s_exporting = false;
// Helper to escape CSV field // Helper to escape CSV field
static std::string escapeCSV(const std::string& field) static std::string escapeCSV(const std::string& field)
{ {
if (field.find(',') != std::string::npos || // Neutralize spreadsheet formula injection: a field beginning with '=', '+', '-', '@',
field.find('"') != std::string::npos || // tab, or CR is interpreted as a formula by Excel/LibreOffice, letting an attacker-supplied
field.find('\n') != std::string::npos) { // 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 // Escape quotes and wrap in quotes
std::string escaped; std::string escaped;
escaped.reserve(field.size() + 4); escaped.reserve(safe.size() + 4);
escaped += '"'; escaped += '"';
for (char c : field) { for (char c : safe) {
if (c == '"') escaped += "\"\""; if (c == '"') escaped += "\"\"";
else escaped += c; else escaped += c;
} }
escaped += '"'; escaped += '"';
return escaped; return escaped;
} }
return field; return safe;
} }
void ExportTransactionsDialog::show() void ExportTransactionsDialog::show()

View File

@@ -57,7 +57,10 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
// --- Compute thread grid layout based on controls card width --- // --- Compute thread grid layout based on controls card width ---
// Estimate controlsW first to compute cols correctly // 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 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 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); 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)); pdl->AddRectFilled(rowMin, rowMax, IM_COL32(255, 255, 255, 10));
if (rowHov && !inXZone) if (rowHov && !inXZone)
pdl->AddRectFilled(rowMin, rowMax, StateHover()); 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 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, pdl->AddText(rowFont, rowFontSz,
ImVec2(rowMin.x + textPadX, textY), 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 // X button — flush with right edge, icon centered
{ {
ImVec2 xMin(rowMax.x - xZoneW, rowMin.y); 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)); pdl->AddRectFilled(rowMin, rowMax, IM_COL32(255, 255, 255, 10));
if (rowHov && !inXZone) if (rowHov && !inXZone)
pdl->AddRectFilled(rowMin, rowMax, StateHover()); 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 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, pdl->AddText(wRowFont, wRowFontSz,
ImVec2(rowMin.x + wTextPadX, textY), ImVec2(rowMin.x + wTextPadX, textY),
isCurrent ? Primary() : OnSurface(), addr.c_str()); isCurrent ? Primary() : OnSurface(), addrDisp.c_str());
// Tooltip for long addresses // Tooltip for long addresses
if (rowHov && !inXZone) if (rowHov && !inXZone)
material::Tooltip("%s", addr.c_str()); material::Tooltip("%s", addr.c_str());

View File

@@ -26,6 +26,7 @@
#include <ctime> #include <ctime>
#include <cmath> #include <cmath>
#include <unordered_map> #include <unordered_map>
#include <unordered_set>
#include <vector> #include <vector>
namespace dragonx { namespace dragonx {
@@ -141,6 +142,12 @@ void RenderTransactionsTab(App* app)
const auto addrLabel = S.label("tabs.transactions", "address-label"); const auto addrLabel = S.label("tabs.transactions", "address-label");
const auto& state = app->state(); 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) // Responsive scale factors (recomputed every frame)
ImVec2 contentAvail = ImGui::GetContentRegionAvail(); ImVec2 contentAvail = ImGui::GetContentRegionAvail();
const float hs = Layout::hScale(contentAvail.x); const float hs = Layout::hScale(contentAvail.x);
@@ -250,8 +257,9 @@ void RenderTransactionsTab(App* app)
innerPad, iconSz, glassSpec, ovFont, capFont, body2, "mined", goldCol, "mined_upper", innerPad, iconSz, glassSpec, ovFont, capFont, body2, "mined", goldCol, "mined_upper",
minedCount, minedTotal, "+", 3, type_filter); minedCount, minedTotal, "+", 3, type_filter);
// Selected card accent // Selected card accent (only the three summary cards map to a card position — the Chat filter
if (type_filter > 0) { // (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_map[] = {-1, 1, 0, 2};
int idx = idx_map[type_filter]; int idx = idx_map[type_filter];
float xOff = idx * (cardW + cardGap); float xOff = idx * (cardW + cardGap);
@@ -281,7 +289,8 @@ void RenderTransactionsTab(App* app)
ImGui::SameLine(0, filterGap); ImGui::SameLine(0, filterGap);
float comboW = std::max(80.0f, ((filterCombo.width > 0) ? filterCombo.width : 120.0f) * hs); float comboW = std::max(80.0f, ((filterCombo.width > 0) ? filterCombo.width : 120.0f) * hs);
ImGui::SetNextItemWidth(comboW); 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)); ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types));
// Sort selector // Sort selector
@@ -476,6 +485,7 @@ void RenderTransactionsTab(App* app)
if (type_filter == 1 && dtx.display_type != "send" && dtx.display_type != "shield") continue; 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 == 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 == 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 (!search_str.empty()) {
if (!containsIgnoreCase(dtx.address, search_str) && if (!containsIgnoreCase(dtx.address, search_str) &&
@@ -657,6 +667,7 @@ void RenderTransactionsTab(App* app)
// Determine type info // Determine type info
bool shieldedDisplay = tx.display_type == "shield"; bool shieldedDisplay = tx.display_type == "shield";
const bool isChatTx = chatTxids.count(tx.txid) > 0; // carried a chat message
ImU32 iconCol; ImU32 iconCol;
const char* typeStr; const char* typeStr;
if (shieldedDisplay) { if (shieldedDisplay) {
@@ -749,12 +760,17 @@ void RenderTransactionsTab(App* app)
} }
// Position status badge in the middle-right area // Position status badge in the middle-right area
ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr); ImVec2 sSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, statusStr);
const char* shieldedStr = TR("shielded_type"); // Optional "top" badge stacked above the status pill: "Message" for a chat tx or
ImVec2 shieldSz = shieldedDisplay // "Shielded" for an autoshield. Chat txs are send/receive (not the "shield" type),
? capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, shieldedStr) // 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); : ImVec2(0, 0);
float shieldPillW = shieldSz.x + Layout::spacingSm() * 2.0f; float topPillW = topSz.x + Layout::spacingSm() * 2.0f;
float stackW = shieldedDisplay ? std::max(sSz.x, shieldPillW) : sSz.x; float stackW = showTopBadge ? std::max(sSz.x, topPillW) : sSz.x;
float statusX = amtX - stackW - Layout::spacingXxl(); float statusX = amtX - stackW - Layout::spacingXxl();
float minStatusX = cx + innerW * 0.25f; // don't overlap address float minStatusX = cx + innerW * 0.25f; // don't overlap address
if (statusX < minStatusX) statusX = minStatusX; if (statusX < minStatusX) statusX = minStatusX;
@@ -763,18 +779,17 @@ void RenderTransactionsTab(App* app)
// row background on every skin (faint alpha-30 fills used to vanish on dark-red / // 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. // 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; const float pillRound = schema::UI().drawElement("tabs.transactions", "status-pill-rounding").size;
if (shieldedDisplay) { if (showTopBadge) {
float shieldX = statusX + (stackW - shieldSz.x) * 0.5f; float topX = statusX + (stackW - topSz.x) * 0.5f;
ImU32 shieldCol = Primary(); ImVec2 topPillMin(topX - Layout::spacingSm(), cy - 1.0f);
ImVec2 shieldPillMin(shieldX - Layout::spacingSm(), cy - 1.0f); ImVec2 topPillMax(topX + topSz.x + Layout::spacingSm(),
ImVec2 shieldPillMax(shieldX + shieldSz.x + Layout::spacingSm(), topPillMin.y + capFont->LegacySize + Layout::spacingXs());
shieldPillMin.y + capFont->LegacySize + Layout::spacingXs()); dl->AddRectFilled(topPillMin, topPillMax,
dl->AddRectFilled(shieldPillMin, shieldPillMax, (topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound);
(shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(48) << 24), pillRound); dl->AddRect(topPillMin, topPillMax,
dl->AddRect(shieldPillMin, shieldPillMax, (topBadgeCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
(shieldCol & 0x00FFFFFFu) | (static_cast<ImU32>(90) << 24), pillRound, 0, 1.0f);
dl->AddText(capFont, capFont->LegacySize, dl->AddText(capFont, capFont->LegacySize,
ImVec2(shieldX, cy), shieldCol, shieldedStr); ImVec2(topX, cy), topBadgeCol, topBadgeStr);
} }
// Background pill // Background pill
ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1); ImVec2 pillMin(statusTextX - Layout::spacingSm(), cy + body2->LegacySize + 1);

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; 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) { static size_t writeFileCallback(void* contents, size_t size, size_t nmemb, void* userp) {
size_t total = size * nmemb; size_t total = size * nmemb;
FILE* fp = static_cast<FILE*>(userp); 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; 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 *** // *** CRITICAL: Skip wallet.dat ***
if (filename == "wallet.dat" || endsWith(filename, "/wallet.dat")) { if (filename == "wallet.dat" || endsWith(filename, "/wallet.dat")) {
DEBUG_LOGF("[Bootstrap] Skipping wallet.dat (protected)\n"); DEBUG_LOGF("[Bootstrap] Skipping wallet.dat (protected)\n");

View File

@@ -19,10 +19,19 @@ namespace util {
namespace { namespace {
// Cap for in-memory text/JSON responses (release metadata, price/candle data). Far above any
// legitimate body, but bounds memory if a hostile/MITM'd server streams an unbounded response.
constexpr std::size_t kMaxMetadataBytes = 16u * 1024 * 1024;
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp) size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
{ {
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb); auto* s = static_cast<std::string*>(userp);
return size * nmemb; const size_t n = size * nmemb;
// Hard cap: a chunked response omits Content-Length, so CURLOPT_MAXFILESIZE cannot catch it —
// aborting here (short count) makes curl fail the transfer instead of exhausting memory.
if (s->size() + n > kMaxMetadataBytes) return 0;
s->append(static_cast<char*>(contents), n);
return n;
} }
size_t writeFileCb(void* contents, size_t size, size_t nmemb, void* userp) size_t writeFileCb(void* contents, size_t size, size_t nmemb, void* userp)
@@ -52,6 +61,7 @@ std::string httpGetString(const std::string& url, const char* logTag)
curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0"); curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE, static_cast<curl_off_t>(kMaxMetadataBytes)); // reject oversized metadata
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
const CURLcode res = curl_easy_perform(curl); const CURLcode res = curl_easy_perform(curl);

View File

@@ -266,6 +266,13 @@ void I18n::loadBuiltinEnglish()
strings_["chat_rename"] = "Rename contact"; strings_["chat_rename"] = "Rename contact";
strings_["chat_rename_hint"] = "Contact name"; strings_["chat_rename_hint"] = "Contact name";
strings_["chat_renamed"] = "Contact renamed"; strings_["chat_renamed"] = "Contact renamed";
// Chat note-buffer status (status bar, while the Chat tab is active). %d are counts;
// keep the format specifiers intact in translations.
strings_["chat_buffer_sending_one"] = "Chat: sending %d message\xE2\x80\xA6";
strings_["chat_buffer_sending"] = "Chat: sending %d messages\xE2\x80\xA6";
strings_["chat_buffer_preparing"] = "Chat buffer: preparing %d/%d\xE2\x80\xA6";
strings_["chat_buffer_loading"] = "Chat buffer: \xE2\x80\xA6";
strings_["chat_buffer_ready"] = "Chat buffer: %d/%d ready";
// Chat customization (gear modal + Settings → Chat & Contacts) // Chat customization (gear modal + Settings → Chat & Contacts)
strings_["chat_settings_title"] = "Chat settings"; strings_["chat_settings_title"] = "Chat settings";
strings_["chat_settings_tip"] = "Chat customization"; strings_["chat_settings_tip"] = "Chat customization";
@@ -714,6 +721,47 @@ void I18n::loadBuiltinEnglish()
strings_["tt_idle_delay"] = "How long to wait before starting mining"; strings_["tt_idle_delay"] = "How long to wait before starting mining";
strings_["tt_wizard"] = "Re-run the initial setup wizard\nDaemon will be restarted"; strings_["tt_wizard"] = "Re-run the initial setup wizard\nDaemon will be restarted";
strings_["tt_download_bootstrap"] = "Download blockchain bootstrap to speed up sync\nExisting block data will be replaced"; strings_["tt_download_bootstrap"] = "Download blockchain bootstrap to speed up sync\nExisting block data will be replaced";
// --- Full-node Node & Security tooltips ---
strings_["tt_rpc_toggle"] = "Show or hide the read-only RPC connection details (host, port, user, password) for the daemon";
strings_["tt_daemon_refresh"] = "Re-read the installed and bundled dragonxd version, size, and date shown above";
// --- Lite wallet Node & Security tooltips ---
strings_["tt_lite_lifecycle_toggle"] = "Show or hide the create / open / restore controls for managing your lite wallet file";
strings_["tt_lite_lifecycle_op"] = "Choose whether to create a new wallet, open an existing one, or restore one from a seed phrase";
strings_["tt_lite_wallet_path"] = "Path or name of the wallet file to open or restore into";
strings_["tt_lite_restore_seed"] = "The 24-word recovery seed phrase to restore this wallet from; hidden as you type";
strings_["tt_lite_restore_birthday"] = "Block height the wallet was created at; scanning starts here. Use 0 or the earliest height if unsure";
strings_["tt_lite_restore_account"] = "HD account index to restore; leave 0 unless you used multiple accounts under this seed";
strings_["tt_lite_restore_overwrite"] = "Replace an existing wallet file with this restore. Warning: overwrites the current wallet data";
strings_["tt_lite_lifecycle_pass"] = "Passphrase to unlock or set on the wallet during this create / open / restore operation";
strings_["tt_lite_lifecycle_run"] = "Run the selected create / open / restore operation with the values above";
strings_["tt_lite_show_seed"] = "Reveal this wallet's recovery seed phrase and birthday. Anyone with the seed can spend your funds";
strings_["tt_lite_show_keys"] = "Reveal this wallet's private spending keys. Anyone with a key can spend the funds it controls";
strings_["tt_lite_copy"] = "Copy the revealed secret to the clipboard";
strings_["tt_lite_save_seed_file"] = "Write the seed and birthday to an owner-only file (lite-seed-backup.txt) in the config folder";
strings_["tt_lite_hide_wipe"] = "Hide the revealed secret and securely wipe it from memory";
strings_["tt_lite_import_key"] = "Paste a private spending or viewing key to import; its history appears after the next sync";
strings_["tt_lite_import_key_btn"] = "Import the entered private key into this wallet; funds and history appear after the next sync";
strings_["tt_lite_encrypt_pass"] = "Passphrase to encrypt the wallet with. If lost, the wallet cannot be unlocked or recovered";
strings_["tt_lite_encrypt"] = "Encrypt the wallet with the passphrase above; it locks immediately and requires the passphrase to unlock";
strings_["tt_lite_unlock_pass"] = "Enter your passphrase to unlock the encrypted wallet";
strings_["tt_lite_unlock"] = "Unlock the encrypted wallet using the passphrase above";
strings_["tt_lite_lock"] = "Lock the wallet now; a passphrase is required to unlock and any chat session is torn down";
strings_["tt_lite_decrypt_pass"] = "Enter your passphrase to remove encryption from the wallet";
strings_["tt_lite_remove_encrypt"] = "Remove encryption and store the wallet unprotected; no passphrase will be required to open it";
// --- Chat & Contacts tooltips ---
strings_["tt_chat_emoji_style"] = "Render emoji in monochrome outline or full color";
strings_["tt_chat_bubble_style"] = "Message bubble shape: rounded, square, or minimal (flat, borderless)";
strings_["tt_chat_bubble_accent"] = "Accent color for your outgoing message bubbles (or follow the current theme)";
strings_["tt_chat_density"] = "Spacing between messages: Comfortable adds more padding; Compact fits more on screen";
strings_["tt_chat_font_size"] = "Scale chat message text from 0.8x to 1.5x. Affects only the Chat tab, not the rest of the app";
strings_["tt_chat_poll_rate"] = "How often to check for new and 0-conf messages (0.5-15 s). Faster is more responsive but uses more CPU";
strings_["tt_chat_timestamp"] = "Timestamp format for this tab only: follow the app-wide clock, or force 24-hour or 12-hour";
strings_["tt_chat_enter_sends"] = "When on, Enter sends the message and Shift+Enter adds a newline; when off, Enter adds a newline";
// --- Debug Options tooltips ---
strings_["tt_screenshot_sweep"] = "Cycle every theme across every tab, saving a screenshot of each into the config screenshots folder (overwrites the last sweep)";
strings_["tt_screenshot_sweep_full"] = "Like the theme sweep but also captures every modal / dialog / flow using temporary offline demo wallet data";
strings_["tt_screenshot_open_dir"] = "Open the screenshots folder (under the config directory) in your file manager";
strings_["tt_seed_demo_chat"] = "Inject sample conversations into the Chat tab so a sweep captures its UI; in-memory only, gone on restart";
strings_["download_bootstrap"] = "Download Bootstrap"; strings_["download_bootstrap"] = "Download Bootstrap";
strings_["download"] = "Download"; strings_["download"] = "Download";
strings_["retry"] = "Retry"; strings_["retry"] = "Retry";
@@ -1004,6 +1052,7 @@ void I18n::loadBuiltinEnglish()
strings_["sent_filter"] = "Sent"; strings_["sent_filter"] = "Sent";
strings_["received_filter"] = "Received"; strings_["received_filter"] = "Received";
strings_["mined_filter"] = "Mined"; strings_["mined_filter"] = "Mined";
strings_["chat_filter"] = "Chat";
strings_["sort_date_newest"] = "Newest first"; strings_["sort_date_newest"] = "Newest first";
strings_["sort_date_oldest"] = "Oldest first"; strings_["sort_date_oldest"] = "Oldest first";
strings_["sort_amount_high"] = "Largest amount"; strings_["sort_amount_high"] = "Largest amount";
@@ -1019,6 +1068,7 @@ void I18n::loadBuiltinEnglish()
strings_["address_upper"] = "ADDRESS"; strings_["address_upper"] = "ADDRESS";
strings_["memo_upper"] = "MEMO"; strings_["memo_upper"] = "MEMO";
strings_["shielded_type"] = "Shielded"; strings_["shielded_type"] = "Shielded";
strings_["tx_chat_badge"] = "Message";
strings_["recv_type"] = "Recv"; strings_["recv_type"] = "Recv";
strings_["sent_type"] = "Sent"; strings_["sent_type"] = "Sent";
strings_["immature_type"] = "Immature"; strings_["immature_type"] = "Immature";

View File

@@ -30,14 +30,6 @@ const std::vector<KnownPool>& knownPools()
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs, "https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
/*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true, /*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true,
}, },
KnownPool{
// The mining (stratum) host is us.dragonx.cc — pool.dragonx.cc is the
// Cloudflare-proxied web/API host and does NOT accept stratum on :3333.
// Stats still come from pool.dragonx.cc/api/pools (proxied HTTP is fine).
"dragonx-cc-pplns", "pool.dragonx.cc", "us.dragonx.cc:3333", "rx/dragonx",
"https://pool.dragonx.cc/api/pools", PoolStatsSchema::Miningcore,
/*miningcorePoolId=*/"dragonx-pplns", /*feePercent=*/3.0, /*official=*/true,
},
}; };
return pools; return pools;
} }

75
src/util/svg_texture.cpp Normal file
View File

@@ -0,0 +1,75 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "svg_texture.h"
#include "texture_loader.h" // CreateRawTexture
#include "logger.h"
// nanosvg (memononen, zlib/public-domain, vendored in libs/nanosvg). Implementations compiled ONLY here.
#define NANOSVG_IMPLEMENTATION
#include "nanosvg/nanosvg.h"
#define NANOSVGRAST_IMPLEMENTATION
#include "nanosvg/nanosvgrast.h"
#include <algorithm>
#include <string>
#include <vector>
namespace dragonx {
namespace util {
// Perceptual brightness of a nanosvg color (byte order R,G,B,A — matches ImU32/IM_COL32).
static inline float svgLuma(unsigned int c) {
const float r = (c & 0xFFu) / 255.0f;
const float g = ((c >> 8) & 0xFFu) / 255.0f;
const float b = ((c >> 16) & 0xFFu) / 255.0f;
return 0.299f * r + 0.587f * g + 0.114f * b;
}
bool LoadTextureFromSvg(const char* svgText, int pxSize, ImU32 bodyColor, ImU32 detailColor,
ImTextureID* outTex, int* outW, int* outH) {
if (!svgText || pxSize <= 0 || !outTex) return false;
// nsvgParse mutates its input buffer — parse a copy.
std::string buf(svgText);
NSVGimage* img = nsvgParse(&buf[0], "px", 96.0f);
if (!img || img->width <= 0.0f || img->height <= 0.0f) {
if (img) nsvgDelete(img);
DEBUG_LOGF("LoadTextureFromSvg: parse failed / empty image\n");
return false;
}
// Recolor by brightness: light fills -> detail, darker fills -> body. Force opaque so a theme color
// with partial alpha can't make the mark translucent. ImU32 and nanosvg color share byte order.
const unsigned int body = (static_cast<unsigned int>(bodyColor) & 0x00FFFFFFu) | 0xFF000000u;
const unsigned int detail = (static_cast<unsigned int>(detailColor) & 0x00FFFFFFu) | 0xFF000000u;
for (NSVGshape* s = img->shapes; s; s = s->next) {
if (s->fill.type == NSVG_PAINT_COLOR)
s->fill.color = (svgLuma(s->fill.color) > 0.6f) ? detail : body;
if (s->stroke.type == NSVG_PAINT_COLOR)
s->stroke.color = (svgLuma(s->stroke.color) > 0.6f) ? detail : body;
}
const float scale = static_cast<float>(pxSize) / img->width;
const int w = pxSize;
const int h = std::max(1, static_cast<int>(img->height * scale + 0.5f));
std::vector<unsigned char> rgba(static_cast<size_t>(w) * h * 4, 0);
NSVGrasterizer* rast = nsvgCreateRasterizer();
if (!rast) { nsvgDelete(img); return false; }
nsvgRasterize(rast, img, 0.0f, 0.0f, scale, rgba.data(), w, h, w * 4);
nsvgDeleteRasterizer(rast);
nsvgDelete(img);
if (!CreateRawTexture(rgba.data(), w, h, /*repeat=*/false, outTex)) {
DEBUG_LOGF("LoadTextureFromSvg: CreateRawTexture failed (%dx%d)\n", w, h);
return false;
}
if (outW) *outW = w;
if (outH) *outH = h;
return true;
}
} // namespace util
} // namespace dragonx

24
src/util/svg_texture.h Normal file
View File

@@ -0,0 +1,24 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Rasterize a (two-tone) SVG to a themed RGBA GPU texture via nanosvg. Used for the DragonX logo /
// custom emoji: the darker fill becomes `bodyColor` (the theme accent), the light fill `detailColor`.
#pragma once
#include "imgui.h"
namespace dragonx {
namespace util {
// Rasterize `svgText` (nul-terminated SVG markup) to a texture of width `pxSize` (height follows the
// SVG aspect). Fills are recolored by brightness — light fills -> detailColor, darker fills -> bodyColor
// (both forced opaque). Returns false on parse/rasterize/upload failure; on success *outTex is a texture
// the caller owns (release via util::DestroyTexture). Safe to call off the initial frame (needs a live
// GL/DX device, like the other texture loaders).
bool LoadTextureFromSvg(const char* svgText, int pxSize, ImU32 bodyColor, ImU32 detailColor,
ImTextureID* outTex, int* outW, int* outH);
} // namespace util
} // namespace dragonx

View File

@@ -314,6 +314,12 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele
return; return;
} }
} }
} else if (kXmrigRequireSignature) {
// Signatures are required but no key is pinned in this build: fail closed rather than
// silently downgrading to checksum-only (the checksum is same-origin as the archive).
fs::remove(zipPath, ec);
setProgress(State::Failed, "No signing key is pinned in this build — refusing to install.");
return;
} }
} }

View File

@@ -81,10 +81,56 @@ LiteConnectionSettings defaultLiteConnectionSettings()
return settings; return settings;
} }
// Strict dotted-decimal check for the 127.0.0.0/8 loopback block: exactly four numeric octets
// (each 0-255) with the first equal to 127. This must NOT be a prefix match — "127.0.0.1.evil.com"
// and "127.evil.com" are attacker-controlled DNS names that a startsWith("127.") test would wrongly
// treat as loopback, reopening the plaintext-downgrade hole.
static bool isNumericIpv4Loopback(const std::string& h)
{
int parts = 0;
size_t start = 0;
while (true) {
const size_t dot = h.find('.', start);
const std::string seg = h.substr(start, dot == std::string::npos ? std::string::npos : dot - start);
if (seg.empty() || seg.size() > 3) return false;
int v = 0;
for (char c : seg) { if (c < '0' || c > '9') return false; v = v * 10 + (c - '0'); }
if (v > 255) return false;
if (parts == 0 && v != 127) return false; // 127.0.0.0/8 only
++parts;
if (dot == std::string::npos) break;
start = dot + 1;
}
return parts == 4;
}
static bool isLoopbackLiteHostSpec(const std::string& hostPort)
{
std::string h = hostPort;
const size_t term = h.find_first_of("/?#"); // strip path/query/fragment
if (term != std::string::npos) h = h.substr(0, term);
const size_t at = h.rfind('@'); // strip userinfo (user:pass@host)
if (at != std::string::npos) h = h.substr(at + 1);
if (!h.empty() && h.front() == '[') { // [::1]:port (bracketed IPv6)
const size_t close = h.find(']');
h = (close == std::string::npos) ? h : h.substr(1, close - 1);
} else { // host or host:port
const size_t colon = h.find(':');
if (colon != std::string::npos) h = h.substr(0, colon);
}
return h == "localhost" || h == "::1" || isNumericIpv4Loopback(h);
}
bool isLiteServerUrlUsable(const std::string& serverUrl) bool isLiteServerUrlUsable(const std::string& serverUrl)
{ {
const std::string normalized = liteTrimCopy(serverUrl); const std::string normalized = liteTrimCopy(serverUrl);
return startsWith(normalized, "https://") || startsWith(normalized, "http://"); if (startsWith(normalized, "https://")) return true;
// SECURITY: plaintext http:// is a TLS downgrade for lightwalletd traffic (view keys,
// transactions, addresses). Permit it only for loopback (a local dev lightwalletd);
// reject remote plaintext servers instead of silently accepting the downgrade.
if (startsWith(normalized, "http://"))
return isLoopbackLiteHostSpec(normalized.substr(sizeof("http://") - 1));
return false;
} }
bool isOfficialLiteServer(const std::string& serverUrl) bool isOfficialLiteServer(const std::string& serverUrl)

View File

@@ -1066,7 +1066,16 @@ LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase)
} }
out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase)); out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase));
secureWipeLiteSecret(passphrase); secureWipeLiteSecret(passphrase);
if (out.ok) bridge_->execute("save", ""); // persist the now-encrypted wallet if (out.ok) {
// Persist the now-encrypted wallet. If the save fails, do NOT report success — the
// on-disk wallet would still be unencrypted, contradicting what the user was told.
const auto saved = bridge_->execute("save", "");
if (!saved.ok) {
out.ok = false;
out.error = "wallet encrypted in memory but saving to disk failed" +
(saved.error.empty() ? std::string() : (": " + saved.error));
}
}
return out; return out;
} }
@@ -1084,7 +1093,16 @@ LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase)
} }
out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase)); out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase));
secureWipeLiteSecret(passphrase); secureWipeLiteSecret(passphrase);
if (out.ok) bridge_->execute("save", ""); // persist the now-unencrypted wallet if (out.ok) {
// Persist the now-unencrypted wallet. If the save fails, do NOT report success — the
// on-disk wallet would still be encrypted, contradicting what the user was told.
const auto saved = bridge_->execute("save", "");
if (!saved.ok) {
out.ok = false;
out.error = "wallet decrypted in memory but saving to disk failed" +
(saved.error.empty() ? std::string() : (": " + saved.error));
}
}
return out; return out;
} }

View File

@@ -3434,9 +3434,11 @@ void testBalanceAddressListModel()
EXPECT_NEAR(layout.contentStartX, 22.0, 0.0001); EXPECT_NEAR(layout.contentStartX, 22.0, 0.0001);
EXPECT_NEAR(layout.contentStartY, 26.0, 0.0001); EXPECT_NEAR(layout.contentStartY, 26.0, 0.0001);
EXPECT_NEAR(layout.buttonSize, 38.0, 0.0001); EXPECT_NEAR(layout.buttonSize, 38.0, 0.0001);
EXPECT_NEAR(layout.favoriteButton.x, 270.0, 0.0001); // Trailing (favorite) button is inset by rowPadLeft (12) so it mirrors the left margin
EXPECT_NEAR(layout.visibilityButton.x, 228.0, 0.0001); // instead of hugging the card edge: 310 - 38 - 12 = 260.
EXPECT_NEAR(layout.contentRight, 224.0, 0.0001); EXPECT_NEAR(layout.favoriteButton.x, 260.0, 0.0001);
EXPECT_NEAR(layout.visibilityButton.x, 218.0, 0.0001);
EXPECT_NEAR(layout.contentRight, 214.0, 0.0001);
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(2.0, 3.5), std::string("$7.00")); EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(2.0, 3.5), std::string("$7.00"));
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.001, 2.0), std::string("$0.002000")); EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.001, 2.0), std::string("$0.002000"));
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string("")); EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string(""));
@@ -5774,23 +5776,20 @@ void testXmrigLiveInstall()
void testPoolRegistryLookup() void testPoolRegistryLookup()
{ {
using namespace dragonx::util; using namespace dragonx::util;
EXPECT_EQ(knownPools().size(), static_cast<std::size_t>(2)); // pool.dragonx.cc was removed from the built-in defaults; pool.dragonx.is is the sole entry.
EXPECT_EQ(knownPools().size(), static_cast<std::size_t>(1));
// The algo follows the pool. Note pool.dragonx.cc's stratum host is us.dragonx.cc // The algo follows the pool: pool.dragonx.is resolves to rx/hush regardless of the caller default.
// (the .cc domain is only the Cloudflare-proxied web/API host).
EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc:3333", "rx/hush"), std::string("rx/dragonx"));
EXPECT_EQ(resolvePoolAlgo("pool.dragonx.is:3433", "rx/dragonx"), std::string("rx/hush")); EXPECT_EQ(resolvePoolAlgo("pool.dragonx.is:3433", "rx/dragonx"), std::string("rx/hush"));
// Bare host (no port) still matches; scheme + path are tolerated. // The former us.dragonx.cc (pool.dragonx.cc) host is now unknown -> caller's fallback algo.
EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc", "rx/hush"), std::string("rx/dragonx")); EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc:3333", "rx/hush"), std::string("rx/hush"));
EXPECT_EQ(resolvePoolAlgo("stratum+tcp://us.dragonx.cc:3333/x", "rx/hush"),
std::string("rx/dragonx"));
// Unknown host -> fallback algo.
EXPECT_EQ(resolvePoolAlgo("my.pool.example:1234", "rx/hush"), std::string("rx/hush")); EXPECT_EQ(resolvePoolAlgo("my.pool.example:1234", "rx/hush"), std::string("rx/hush"));
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:3333") != nullptr); EXPECT_TRUE(findKnownPoolByUrl("pool.dragonx.is:3433") != nullptr);
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:3333") == nullptr); // removed built-in default
EXPECT_TRUE(findKnownPoolByUrl("unknown.host:1") == nullptr); EXPECT_TRUE(findKnownPoolByUrl("unknown.host:1") == nullptr);
// A mismatched explicit port must NOT match a known pool. // A mismatched explicit port must NOT match a known pool.
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:9999") == nullptr); EXPECT_TRUE(findKnownPoolByUrl("pool.dragonx.is:9999") == nullptr);
} }
// Schema-aware pool hashrate parsing (the two pools speak different APIs). // Schema-aware pool hashrate parsing (the two pools speak different APIs).
@@ -6198,12 +6197,17 @@ void testHushChatOutgoing()
ChatService bobSvc; ChatService bobSvc;
bobSvc.setIdentity(bob); bobSvc.setIdentity(bob);
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 5), 1); // Realistic receive-time fallback (the header "ts" is a real std::time stamp; an artificially-tiny
// fallback would trip the future-plausibility clamp in ingest and reject the real ts).
EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, static_cast<std::int64_t>(std::time(nullptr))), 1);
std::vector<ChatMessage> conv = bobSvc.store().conversation(cid); std::vector<ChatMessage> conv = bobSvc.store().conversation(cid);
EXPECT_EQ((int)conv.size(), 1); EXPECT_EQ((int)conv.size(), 1);
EXPECT_EQ(conv[0].body, std::string("hello bob")); EXPECT_EQ(conv[0].body, std::string("hello bob"));
EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); // Bob learns Alice's key EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); // Bob learns Alice's key
EXPECT_EQ(conv[0].peer_zaddr, aliceZ); EXPECT_EQ(conv[0].peer_zaddr, aliceZ);
// The header now carries the sender's compose time (header "ts"); ingest prefers it over the fallback
// (5) so both ends show the same send time. A real std::time() stamp is well past year-2001.
EXPECT_TRUE(conv[0].timestamp > 1000000000);
// Plaintext contact request Alice -> Bob. // Plaintext contact request Alice -> Bob.
OutgoingChatMemos creq; OutgoingChatMemos creq;
@@ -6240,7 +6244,9 @@ void testHushChatOutgoing()
echo.peer_zaddr = aliceZ; echo.peer_zaddr = aliceZ;
echo.peer_public_key_hex = ra.public_key_hex; echo.peer_public_key_hex = ra.public_key_hex;
echo.body = "reply!"; echo.body = "reply!";
echo.timestamp = 7; // A reply is sent AFTER the received message; the incoming "hello bob" now carries a real compose-time
// "ts" (~now), so stamp the echo just after it (not the old artificial 7, which would sort before it).
echo.timestamp = static_cast<std::int64_t>(std::time(nullptr)) + 10;
echo.txid = "out:local1"; echo.txid = "out:local1";
echo.payload_position = 0; echo.payload_position = 0;
EXPECT_TRUE(bobSvc.recordOutgoing(echo)); EXPECT_TRUE(bobSvc.recordOutgoing(echo));
@@ -6248,6 +6254,27 @@ void testHushChatOutgoing()
EXPECT_EQ((int)conv2.size(), 2); EXPECT_EQ((int)conv2.size(), 2);
EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing); EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing);
EXPECT_EQ(conv2.back().body, std::string("reply!")); EXPECT_EQ(conv2.back().body, std::string("reply!"));
// Future-clock clamp: a header "ts" implausibly ahead of the receive time is rejected (falls back to
// the receive time), so a wrong/ahead-clocked peer can't pin their messages to the bottom of a thread.
{
const std::int64_t nowSec = static_cast<std::int64_t>(std::time(nullptr));
const std::int64_t futureTs = nowSec + 10 * 24 * 3600; // 10 days ahead — well past the 1h tolerance
nlohmann::json h;
h["h"] = 1; h["v"] = 0; h["z"] = aliceZ; h["cid"] = "conv-clamp";
h["t"] = "Cont"; h["e"] = ""; h["p"] = ra.public_key_hex; h["ts"] = futureTs;
HushChatTransactionInput ctx;
ctx.txid = "txclamp";
ctx.outputs.push_back({0, h.dump()}); // header at the lower position
ctx.outputs.push_back({1, "please add"}); // contact-request plaintext payload (must not start with '{')
auto exc = extractHushChatTransactionMetadata(ctx, true);
EXPECT_EQ((int)exc.metadata.size(), 1);
EXPECT_EQ(exc.metadata[0].sent_at, futureTs); // the future ts parses through
bobSvc.ingest(exc.metadata, {}, nowSec); // realistic receive time
auto cc = bobSvc.store().conversation("conv-clamp");
EXPECT_EQ((int)cc.size(), 1);
EXPECT_EQ(cc[0].timestamp, nowSec); // clamped to receive time, NOT the future ts
}
} }
// Phase 5: the transport encoding (chatSendOutputs) — header first, "utf8:" for full-node / raw for // Phase 5: the transport encoding (chatSendOutputs) — header first, "utf8:" for full-node / raw for

View File

@@ -0,0 +1,12 @@
# Rust toolchain pin for the vendored SilentDragonXLite (SDXL) backend.
#
# The pinned librustzcash + transitive crates (notably traitobject 0.1.0) rely on
# pre-1.70 trait-coherence rules and fail to compile on newer rustc with
# error[E0119]: conflicting implementations of trait `Trait` for type `(dyn Send + Sync)`
# so the backend must be built with 1.63 (the toolchain scripts/build-lite-backend-artifact.sh
# and CLAUDE.md target). rustup auto-selects this when cargo runs in this tree, so no
# RUSTUP_TOOLCHAIN / `cargo +1.63.0` is needed.
#
# Install it once with: rustup toolchain install 1.63.0
[toolchain]
channel = "1.63.0"