Commit Graph

636 Commits

Author SHA1 Message Date
fc414eeed4 feat(chat): hide conversations + contact address picker for new conversation
Hide conversations:
- A "Hide" action in the thread header drops a conversation from the list. The
  messages stay in the seed-encrypted store (on-chain history can't be deleted);
  a new INCOMING message un-hides it (you can't un-receive), so nothing is lost.
- Hidden cids persist in settings (mirrors the mute list) and are skipped by both
  the conversation list and the unread badge.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:08:04 -05:00