Compare commits
169 Commits
v1.2.0-rc1
...
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 24e8fb4942 | |||
| 1393d9ae1a | |||
| 3ce62326f9 | |||
| b20e7efb16 | |||
| 4473e7e00a | |||
| 2e8e214689 | |||
| 69a6fb3e64 | |||
| ed6120eef9 | |||
| a5bd2dadd7 | |||
| 25ee1496b4 | |||
| e2bc3623b6 | |||
| b32fe07cb1 | |||
| a0532275dd | |||
| 7df00b0909 | |||
| 090e288c44 | |||
| 6544c10ac1 | |||
| b2e104358d | |||
| de70e68472 | |||
| 0fe12d65df | |||
| 37c8287a12 | |||
| 6ff80354df | |||
| f58d009703 | |||
| 0e2c786ebf | |||
| d54c7f9e11 | |||
| b3251e9244 | |||
| c40f4d5815 | |||
| 5547ab1cac | |||
| f0867084f3 | |||
| 5167b52cbd | |||
| e7d11f620a | |||
| 168cae9306 | |||
| 317d9028a3 | |||
| 09ab8d52c5 | |||
| 101c835c46 | |||
| c71c3c3378 | |||
| 2e115aef39 | |||
| 574307f6ac | |||
| 88851f5eea | |||
| 5796664b51 | |||
| b71f8ae0a8 | |||
| 555f541c84 | |||
| 2b70ee5cd8 | |||
| d8c055c125 | |||
| 2470675746 | |||
| 3a597482da | |||
| b9b2d469d4 | |||
| 25fef8ff4d | |||
| bc788d008e | |||
| 9ee8f9a43b | |||
| bf91c4eb6c | |||
| 4d78ca0d7d | |||
| 00ee61fe64 | |||
| 3a4998f57c | |||
| d27017daeb | |||
| 2182c060e6 | |||
| 4ee830c5dd | |||
| b6567ee196 | |||
| 1a8d6fd30f | |||
| 9389859ee9 | |||
| e63aba6959 | |||
| fa240e7b99 | |||
| e21f7bf8aa | |||
| 47f228fa47 | |||
| 9f82bba260 | |||
| 323cb341f1 | |||
| ee6cac41c4 | |||
| 094771af81 | |||
| e40962cdf2 | |||
| 63b3a04716 | |||
| cf77c6cbe0 | |||
| 560f2bcf91 | |||
| 255d9399fa | |||
| 1fb6dc44d9 | |||
| 2ba8a799ff | |||
| 41b380449e | |||
| 4a65dce947 | |||
| c8183241c3 | |||
| 0bf80d2757 | |||
| 6f9123f651 | |||
| 320c659689 | |||
| 6ff1fda870 | |||
| 8c51b092f8 | |||
| 788d71a549 | |||
| 3d4b013b0c | |||
| dc07491abb | |||
| 7e568e4bf1 | |||
| 85a1080b52 | |||
| dbeae3ac98 | |||
| 9ff5508989 | |||
| 79e5adcbd3 | |||
| 6531d0c4d2 | |||
| 142a6826af | |||
| 070a516f4e | |||
| 3cec333d84 | |||
| fc438ab962 | |||
| 8c2c1c2aaf | |||
| a605e35409 | |||
| 6ed80d2d79 | |||
| e978db85ca | |||
| e00772db6e | |||
| 8f22db5eea | |||
| 5c883d4b91 | |||
| 274f7ea1af | |||
| 3799330bb0 | |||
| 53a10e149d | |||
| 1bc7f5c8cd | |||
| 20b22410e9 | |||
| 7195c25376 | |||
| a6921bca60 | |||
| 8ba4233b9b | |||
| 732d892d4d | |||
| afd612be7e | |||
| 4d769c8719 | |||
| 0e1b19d0f2 | |||
| b24212fb8f | |||
| 64fe8fc6c9 | |||
| b9881278af | |||
| 85b53baeaf | |||
| eece57c025 | |||
| 8765fdf362 | |||
| 98e0cce8ec | |||
| 5c87bc6e87 | |||
| 946958b591 | |||
| f5561c0dac | |||
| b3c2282b53 | |||
| ca14aaddc7 | |||
| a5da5562cf | |||
| c676ec8287 | |||
| f474b0d633 | |||
| af252575cf | |||
| 74bd22958a | |||
| cd60bded9f | |||
| 59b8c4da81 | |||
| 950d7ace50 | |||
| d52d3d1b7f | |||
| 9569b0ba43 | |||
| 50b0419dfe | |||
| 4f7a4fb38e | |||
| f511c0d509 | |||
| 235504657d | |||
| 76c2ac5db8 | |||
| 89bd21018a | |||
| 9d7054b245 | |||
| 70608fcb7a | |||
| 0c819afdd4 | |||
| db4778e6a7 | |||
| eb6114ee19 | |||
| 5d317f6be3 | |||
| a677c09984 | |||
| 6611d57147 | |||
| 6a4e98b7ed | |||
| 4b9d6f7db5 | |||
| 043cdc7128 | |||
| 2daea67a1e | |||
| e6b91ca661 | |||
| c6e28fc4da | |||
| 268eba6321 | |||
| 3119440cd9 | |||
| 59c55e33f8 | |||
| a8b5d2f6a3 | |||
| 012341b1a4 | |||
| 5586f334a4 | |||
| 863d015628 | |||
| a78a13edf3 | |||
| e95ad50e41 | |||
| 975743f754 | |||
| 948ef419ac | |||
| 9edab31728 | |||
| ee8a08e569 |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -40,4 +40,17 @@ asmap.dat
|
||||
/ObsidianDragon-agent/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
# Local-only archive of superseded lite-wallet design/planning docs (untracked)
|
||||
docs/_archive/
|
||||
|
||||
# ed25519 release-signing keys — the secret key must NEVER be committed
|
||||
*.ed25519.key
|
||||
*.ed25519.pub.b64
|
||||
|
||||
|
||||
# Lite-backend deps are fetched (or `cargo vendor`-ed locally for offline); not committed.
|
||||
third_party/silentdragonxlite/lib/vendor/
|
||||
|
||||
# Generated by configure_file from res/ObsidianDragon.manifest.in (do not track)
|
||||
res/ObsidianDragon.manifest
|
||||
|
||||
102
CLAUDE.md
Normal file
102
CLAUDE.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
ObsidianDragon is a portable, full-node GUI wallet for DragonX (DRGX), written in C++17 using SDL3 + Dear ImGui (immediate-mode). It drives a `dragonxd` full node over JSON-RPC and can embed/extract the daemon itself. A separate **Lite** variant (`ObsidianDragonLite`) drops the full node and instead talks to an external lite-wallet backend library.
|
||||
|
||||
## Build & run
|
||||
|
||||
`build.sh` is the single entry point for all builds. `setup.sh` (repo root) installs/validates dependencies.
|
||||
|
||||
```bash
|
||||
./build.sh # Dev build (native, no packaging) -> build/linux/bin/ObsidianDragon
|
||||
./build.sh --lite # Dev build of the Lite variant -> build/linux/bin/ObsidianDragonLite
|
||||
./build.sh --clean # Wipe the build dir first
|
||||
./build.sh --linux-release # Release zip + AppImage -> release/linux/
|
||||
./build.sh --win-release # Windows cross-compile (mingw-w64) -> release/windows/
|
||||
./build.sh --mac-release # macOS .app bundle + DMG
|
||||
./setup.sh --check # Report missing build deps without installing
|
||||
```
|
||||
|
||||
Dev builds use `build/linux/` (or `build/mac/`). To re-build incrementally without re-running CMake config: `cmake --build build/linux -j$(nproc)`.
|
||||
|
||||
The wallet connects to the daemon using credentials in `~/.hush/DRAGONX/DRAGONX.conf` (`rpcuser`/`rpcpassword`/`rpcport`). It searches for `dragonxd`/`dragonx-cli` binaries in the **executable's own directory first**, so dropping custom node builds next to the wallet binary overrides the bundled ones.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests live in `tests/test_phase4.cpp` — a single large translation unit using a custom assertion harness (`EXPECT_TRUE`/`EXPECT_EQ`/`EXPECT_NEAR` macros, one `main()`, exit code = failure count). `include(CTest)` enables `BUILD_TESTING=ON` by default, so the `ObsidianDragonTests` executable is built alongside the app.
|
||||
|
||||
```bash
|
||||
cd build/linux && ctest --output-on-failure # run the suite
|
||||
./build/linux/bin/ObsidianDragonTests # run the binary directly (same thing)
|
||||
```
|
||||
|
||||
There is no per-test filtering — it is one binary that runs every assertion. The suite exercises the services layer, lite-wallet bridge, and pure helpers (parsers, formatters, model classes) without launching the GUI. Fixtures are under `tests/fixtures/` (path injected as `DRAGONX_TEST_FIXTURE_DIR`).
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry & main loop.** `src/main.cpp` owns SDL3 window creation, ImGui/OpenGL(or DX11 on Windows) setup, and the frame loop. The `App` class is the central controller; because it is large it is split across four files that all implement the same class:
|
||||
- `src/app.cpp` — core lifecycle, the per-frame `render()`, tab dispatch
|
||||
- `src/app_network.cpp` — RPC orchestration, sync, peers, daemon lifecycle
|
||||
- `src/app_security.cpp` — encryption, PIN/lock screen, key import/export, backup
|
||||
- `src/app_wizard.cpp` — first-run wizard
|
||||
|
||||
**RPC.** All daemon calls go through `src/rpc/` (`rpc_client`, `connection`, `rpc_worker`). **Never block the main/UI thread with synchronous network I/O — dispatch through `RPCWorker`** (async). `rpc/types.h` holds the shared DTOs.
|
||||
|
||||
**Services** (`src/services/`) hold the non-UI state machines that the `App` owns: `NetworkRefreshService` + `RefreshScheduler` (polling/refresh of balance, peers, txs on intervals) and the `WalletSecurity*` controller/workflow stack (encryption & unlock flows).
|
||||
|
||||
**Data model** (`src/data/`): `WalletState`, `address_book`, `transaction_history_cache`, `exchange_info`. UI reads from these.
|
||||
|
||||
**UI** (`src/ui/`): `windows/` are the tabs and dialogs (one pair per screen, e.g. `send_tab`, `mining_tab`, `console_tab`), `pages/` are multi-section screens (Settings), `material/` is the design-system layer (the live helpers `color_theme`, `colors`, `type`/`typography`, `draw_helpers`, `layout`, `project_icons`, `components/buttons`), `schema/` loads the TOML UI schema/skins, `effects/` is GL post-processing (blur/acrylic).
|
||||
|
||||
**Lite wallet** (`src/wallet/`): the bridge to an external `litelib_*` C-ABI backend. `lite_client_bridge` loads the backend (via direct `litelib_*` externs in `linkedSdxl()`) and owns each Rust string through `lite_owned_string` (copy-before-free / free-once). On top sit `lite_connection_service`, `lite_sync_service`, `lite_result_parsers`, `lite_wallet_gateway`, `lite_wallet_state_mapper`, and `lite_wallet_lifecycle_service`, all driven by `lite_wallet_controller`. The real frontend entry points are `lite_wallet_lifecycle_ui_adapter` and `lite_wallet_server_selection_adapter` (used by `src/ui/pages/settings_page.cpp`); everything else is reachable through them. (The prebuilt-backend symbol check for `DRAGONX_ENABLE_LITE_BACKEND` is done in CMake against the symbols inventory — see below — not in C++.)
|
||||
|
||||
> ⚠️ **Do not regrow the `_plan`/`_batch` churn.** This directory previously held ~160 dead `lite_wallet_*_plan` / `*_batch*_receipt_custody_acceptance_confirmation_archive_handoff_*` files (filenames up to 250 chars) — auto-generated scaffolding that never reached the shipping binary. They were deleted. When extending lite-wallet behavior, **edit the named service/bridge/runtime files in place**; never add another "promotion/receipt/custody/handoff/stewardship" wrapper layer. `scripts/check-source-hygiene.sh` (wired as a `.git/hooks/pre-commit` hook) blocks >80-char filenames and chained churn-token names — run it in CI too.
|
||||
|
||||
**Chat** (`src/chat/chat_protocol.cpp`): experimental HushChat protocol, compiled in only when `DRAGONX_ENABLE_CHAT=ON`.
|
||||
|
||||
## Build variants & feature gating
|
||||
|
||||
Variants are selected with CMake options (set by `build.sh` flags), surfaced to C++ as compile definitions:
|
||||
- `DRAGONX_BUILD_LITE` (`--lite`) → `DRAGONX_LITE_BUILD` define; renames the app to `ObsidianDragonLite` and excludes embedded-daemon / full-node assets (Sapling params, asmap, dragonxd).
|
||||
- `DRAGONX_ENABLE_LITE_BACKEND` → links a real external lite backend. Requires `--lite`, link mode `imported`, ABI `sdxl-c-v1`, and a symbols inventory file (built by `scripts/build-lite-backend-artifact.sh`); CMake hard-fails if any required `litelib_*` symbol is missing. The backend **source is vendored in-tree** at `third_party/silentdragonxlite/` — the `qtlib` C-ABI wrapper (`lib/`, produces `libsilentdragonxlite.a`) and the `silentdragonxlitelib` core (`silentdragonxlite-cli/lib/`, with `proto/` + `res/`). `build-lite-backend-artifact.sh` defaults `--backend-dir` there, so the lite wallet builds **without** the upstream SilentDragonXLite repo. External build inputs are limited to the **Rust toolchain (rustc/cargo 1.63)** plus two project-controlled sources on `git.dragonx.is`: the librustzcash crates come from the mirror `git.dragonx.is/DragonX/librustzcash` (the 6 `git =` deps in the core `Cargo.toml`, pinned to rev `acff1444…`), and the **Sapling params are not committed** (gitignored) — the build fetches them from the `git.dragonx.is/DragonX/zcash-params` release `sapling-v1` and verifies their SHA-256 before rust-embed bakes them in (`ensure_sapling_params`; override the URL with `SAPLING_PARAMS_BASE_URL`). Other crate deps come from crates.io. For a fully offline build, `cargo vendor` into `third_party/silentdragonxlite/lib/vendor/` and add a `vendored-sources` redirect to `lib/.cargo/config.toml` (the build script symlinks `vendor/` into its prepared dir if present); `vendor/` is gitignored.
|
||||
- `DRAGONX_ENABLE_CHAT` → `DRAGONX_ENABLE_CHAT` define gating the chat module.
|
||||
|
||||
Guard full-node-only code paths with `#if DRAGONX_LITE_BUILD` / chat code with `DRAGONX_ENABLE_CHAT`.
|
||||
|
||||
## Lite wallet status
|
||||
|
||||
The Lite variant is **functionally complete and runtime-verified on Linux + Windows** (work lives on branch `cleanup/lite-plan-churn`, **local-only — not pushed yet**):
|
||||
- **Implemented:** lifecycle (create/open/restore + auto-open on startup), sync, refresh, send / shield / import / export / seed, persistence (the backend does *not* auto-save after sync/send/shield — the controller triggers `save` at those points), and passphrase **encryption** (encrypt/unlock/lock/decrypt + Settings UI + send-time & startup unlock; the backend locks immediately on `encrypt`). All controller-tested against the fake backend (`tests/fake_lite_backend.h`) and smoke-verified against the real SDXL backend via `tools/lite_smoke` (incl. a full sync). GUI is wired end-to-end with lite-appropriate wording; the full-node RPC connect loop / wizard / daemon strings are gated out of lite (lite "online" is derived from `lite_wallet_->walletOpen()`, not RPC).
|
||||
- **Packaging:** `./build.sh --lite-backend --linux-release` (zip + AppImage, **verified**) and `--win-release` (cross-compiled `.exe`, **verified**; first build the Windows backend artifact with `scripts/build-lite-backend-artifact.sh --platform windows`). macOS `--lite-backend --mac-release` is **wired but not yet verified on this Linux box** (needs macOS/osxcross): the `.app`/launcher/rpath/`CFBundleExecutable` follow `ObsidianDragonLite`, full-node assets are skipped, and the lite variant gets its own `CFBundleName` ("DragonX Wallet Lite"), bundle id (`is.hush.dragonx.lite`), and DMG name so it can coexist with the full-node app. All variants correctly exclude full-node assets.
|
||||
- **Rollout / kill-switch (implemented):** `wallet/lite_rollout_policy.{h,cpp}` is a pure, fail-open gate (local-only, no network) feeding `LiteWalletLifecycleService::availability()` (new `RolloutDisabled` reason). Inputs: the emergency env var `DRAGONX_LITE_KILL_SWITCH` (absolute — not even `force_on` bypasses it); a `lite_rollout` setting (`auto`/`force_on`/`force_off`); and an optional **locally-cached** manifest at `<config-dir>/lite_rollout.json` (`global_enabled`, `min_version`/`max_version`, `blocked_versions`, `rollout_permille`, `message`) keyed for staged rollout on a hashed, never-transmitted per-install id. A signed remote fetcher can populate that cache later without touching the policy. Resolved in `App::rebuildLiteWallet()`; the disable message surfaces via the lifecycle status. Unit-tested + runtime-verified (env / manifest / control).
|
||||
- **Remaining (M5b):** verify the wired macOS `--lite` packaging on a Mac/osxcross, CI backend-artifact build + signing.
|
||||
- **To publish:** rename branch → `feat/lite-wallet`, base the PR on `dev` (the full-node UX is already there), and handle the dormant gated-OFF HushChat content bundled in commit `af06b8b`.
|
||||
|
||||
The detailed milestone plan and design history (the v2 plan, backend artifact/ABI/signing design docs, the v1 plan, chat specs, etc.) are kept **untracked** under `docs/_archive/`.
|
||||
|
||||
## Miner updater (xmrig)
|
||||
|
||||
The mining tab's pool section has an **"Update miner…"** button that downloads/verifies/installs the latest DRG-XMRig from the project Gitea (`util/XmrigUpdater` + `ui/windows/xmrig_download_dialog.h`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/drg-xmrig/releases/latest` → pick the asset for this platform (`linux-x64` / `win-x64` / `macos-x86_64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (from the release body) **and** a detached **ed25519 signature** → miniz-extract the binary (flattening the versioned subdir) into `resources::getDaemonDirectory()`. The whole archive is verified, so extracted members are trusted by transitivity (no per-member hash check). The pure, no-I/O core is split into `xmrig_updater_core.cpp` for unit tests; an env-gated (`DRAGONX_TEST_NETWORK=1`) test exercises the worker live. A **"Browse all releases…"** button (the `/releases` list, newest first, pre-releases included) lets users pin an older or pre-release build — same verify/install path via `startInstallRelease()`; the picker UI is shared with the daemon updater (`ui/windows/release_list_view.h`).
|
||||
|
||||
**Signature verification is enforced** (`kXmrigRequireSignature = true` in `src/util/xmrig_updater.h`), checked against the public key pinned in `kXmrigSignaturePublicKeyBase64`. **Consequence for releases:** every `drg-xmrig` release MUST ship a detached signature per archive or the in-app updater refuses it. To cut a release: build the archives, then `scripts/sign-xmrig-release.sh sign <secret.key> <archive.zip>...` (OpenSSL-based, no extra deps) and upload each `<archive>.sig` as a release asset alongside its `.zip`. The signing **secret key must stay offline** (it is gitignored: `*.ed25519.key`); only its base64 public key is pinned in the source. To rotate the key, regenerate (`scripts/sign-xmrig-release.sh keygen`) and update `kXmrigSignaturePublicKeyBase64`. An emergency env override is not provided — disabling verification means setting `kXmrigSignaturePublicKeyBase64` empty (and rebuilding).
|
||||
|
||||
## Daemon updater (dragonxd)
|
||||
|
||||
Settings → **NODE & SECURITY → DAEMON BINARY** has a **"Check for updates…"** button that downloads/verifies/installs the latest **dragonxd full node** from the project Gitea — the full-node sibling of the xmrig updater (`util/DaemonUpdater` + `ui/windows/daemon_download_dialog.h`, pure no-I/O core in `daemon_updater_core.cpp`; gated full-node-only via `supportsFullNodeLifecycleActions()`). Flow: query `git.dragonx.is/api/v1/repos/DragonX/dragonx/releases/latest` → pick the archive for this platform (`linux-amd64` / `macos` / `win64`; no match → "Unavailable") → libcurl download (TLS verified) → verify the archive **SHA-256** (parsed from the release body's markdown **checksum table**, not xmrig's `<hash> <name>` lines) **and** a detached **ed25519 signature** → miniz-extract the three executables (`dragonxd`/`dragonx-cli`/`dragonx-tx`, flattening the versioned subdir) into `resources::getDaemonDirectory()`. The archive also bundles Sapling params/asmap, which the updater deliberately leaves to the wallet's own resource extraction. Install is **atomic and safe while the node runs** (POSIX `rename()` replaces the in-use binary; Windows moves the locked `.exe` aside to `.old`); the new binary takes effect on the **next daemon start**, so the Done screen offers **"Restart daemon now"** (`App::restartDaemon()`). A **"Browse all releases…"** button (shared `release_list_view.h` picker) lets users pin a specific/older/pre-release node build via `startInstallRelease()` — with a downgrade caution, since an older binary may not match current chain data.
|
||||
|
||||
**Signature verification is enforced** (`kDaemonRequireSignature = true` in `src/util/daemon_updater.h`), checked against `kDaemonSignaturePublicKeyBase64`. **Consequence for releases:** every `dragonx` release MUST ship a detached `<archive>.sig` per platform archive or the in-app updater refuses it (as of v1.0.2 the releases publish SHA-256 but **no** signatures yet — sign + upload them to enable in-app updates). To cut a release: `scripts/sign-daemon-release.sh sign <secret.key> dragonx-<ver>-{linux-amd64,macos,win64}.zip` (OpenSSL-based) and upload each `.sig` next to its `.zip`. The signing **secret key stays offline** (gitignored `*.ed25519.key`; this repo's is `dragonx-daemon.ed25519.key`); only the base64 public key is pinned. To rotate: `scripts/sign-daemon-release.sh keygen` and update `kDaemonSignaturePublicKeyBase64`. The generic SHA-256 / ed25519 primitives are shared with the miner updater (`util::sha256Hex` / `util::verifyXmrigSignature`).
|
||||
|
||||
## Versioning
|
||||
|
||||
The version has a **single source of truth**: `project(... VERSION 1.2.0 ...)` plus `DRAGONX_VERSION_SUFFIX` in `CMakeLists.txt`. CMake generates `build/.../generated/dragonx_generated_version.h` from `src/config/version.h.in`. Do not hand-edit generated version output or hardcode version strings — bump the `project()` version in `CMakeLists.txt`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **C++17.** Match the surrounding code's style per file.
|
||||
- **Icons:** use the Material Design icon font defines (`ICON_MD_*`); never raw Unicode glyphs.
|
||||
- **UI layout values** belong in `res/themes/ui.toml`, read via `schema::UI()` — do not hardcode pixel sizes/offsets in code.
|
||||
- **i18n:** user-facing strings are translated via `src/util/i18n`; translation JSON lives in `res/lang/` (`de`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, `zh`, English fallback in code). Translation/font helper scripts are in `scripts/` (`gen_*.py`, CJK subset tooling).
|
||||
- **Commits:** the history uses Conventional Commits (`feat(scope): …`, `fix(scope): …`). PRs target `master`.
|
||||
471
CMakeLists.txt
471
CMakeLists.txt
@@ -14,14 +14,20 @@ if(APPLE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
project(ObsidianDragon
|
||||
VERSION 1.2.0
|
||||
project(ObsidianDragon
|
||||
VERSION 2.0.0
|
||||
LANGUAGES C CXX
|
||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||
)
|
||||
|
||||
# Pre-release suffix (e.g. "-rc1", "-beta2"). Leave empty for stable releases.
|
||||
set(DRAGONX_VERSION_SUFFIX "-rc1")
|
||||
set(DRAGONX_VERSION_SUFFIX "")
|
||||
|
||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||
set(DRAGONX_LITE_VERSION "1.0.0")
|
||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||
|
||||
# C++17 standard
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
@@ -36,6 +42,132 @@ endif()
|
||||
# Options
|
||||
option(DRAGONX_USE_SYSTEM_SDL3 "Use system SDL3 instead of fetching" ON)
|
||||
option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON)
|
||||
option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node features" OFF)
|
||||
option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF)
|
||||
option(DRAGONX_ENABLE_CHAT "Enable experimental HushChat protocol/UI integration" OFF)
|
||||
set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library")
|
||||
set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers")
|
||||
set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend")
|
||||
set(DRAGONX_LITE_BACKEND_LINK_MODE "imported" CACHE STRING "Lite backend link mode; Phase 1 supports imported only")
|
||||
set_property(CACHE DRAGONX_LITE_BACKEND_LINK_MODE PROPERTY STRINGS imported)
|
||||
set(DRAGONX_LITE_BACKEND_ABI "sdxl-c-v1" CACHE STRING "Expected lite backend C ABI version")
|
||||
set(DRAGONX_LITE_BACKEND_SYMBOLS_FILE "" CACHE FILEPATH "Path to generated lite backend exported-symbol inventory")
|
||||
set(DRAGONX_LITE_BACKEND_MANIFEST "" CACHE FILEPATH "Optional path to generated lite backend artifact manifest")
|
||||
option(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE "Require verified signature metadata in the lite backend artifact manifest" OFF)
|
||||
set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
|
||||
litelib_wallet_exists
|
||||
litelib_initialize_new
|
||||
litelib_initialize_new_from_phrase
|
||||
litelib_initialize_existing
|
||||
litelib_execute
|
||||
litelib_rust_free_string
|
||||
litelib_check_server_online
|
||||
litelib_shutdown
|
||||
)
|
||||
|
||||
if(DRAGONX_BUILD_LITE)
|
||||
set(DRAGONX_APP_NAME "ObsidianDragonLite")
|
||||
set(DRAGONX_BINARY_NAME "ObsidianDragonLite")
|
||||
# NOTE: do NOT FORCE-write DRAGONX_ENABLE_EMBEDDED_DAEMON=OFF into the cache here. A forced
|
||||
# cache write persists into a later full-node reconfigure of the same build dir and silently
|
||||
# disables the embedded daemon — the binary still embeds/extracts, but isUsingEmbeddedDaemon()
|
||||
# returns false, so it "unpacks dragonxd but never starts" (the 1.3.0 regression). It is also
|
||||
# redundant: makeWalletCapabilities() already forces the embedded-daemon capability off for any
|
||||
# lite build via `fullNodeBuild && embeddedDaemonCompiled`, so lite never launches a daemon
|
||||
# regardless of this flag. build.sh sets the flag explicitly per variant to defeat stale caches.
|
||||
set(DRAGONX_APP_VERSION "${DRAGONX_LITE_VERSION}")
|
||||
set(DRAGONX_APP_VERSION_SUFFIX "${DRAGONX_LITE_VERSION_SUFFIX}")
|
||||
else()
|
||||
set(DRAGONX_APP_NAME "ObsidianDragon")
|
||||
set(DRAGONX_BINARY_NAME "ObsidianDragon")
|
||||
set(DRAGONX_APP_VERSION "${PROJECT_VERSION}")
|
||||
set(DRAGONX_APP_VERSION_SUFFIX "${DRAGONX_VERSION_SUFFIX}")
|
||||
endif()
|
||||
|
||||
# Split the active version into numeric components for the generated header + Windows VERSIONINFO.
|
||||
string(REPLACE "." ";" _dragonx_ver_parts "${DRAGONX_APP_VERSION}")
|
||||
list(GET _dragonx_ver_parts 0 DRAGONX_APP_VERSION_MAJOR)
|
||||
list(GET _dragonx_ver_parts 1 DRAGONX_APP_VERSION_MINOR)
|
||||
list(GET _dragonx_ver_parts 2 DRAGONX_APP_VERSION_PATCH)
|
||||
|
||||
set(DRAGONX_LITE_BACKEND_READY OFF)
|
||||
if(DRAGONX_ENABLE_LITE_BACKEND)
|
||||
if(NOT DRAGONX_BUILD_LITE)
|
||||
message(FATAL_ERROR "DRAGONX_ENABLE_LITE_BACKEND is only supported with DRAGONX_BUILD_LITE=ON")
|
||||
endif()
|
||||
if(NOT DRAGONX_LITE_BACKEND_LINK_MODE STREQUAL "imported")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_LINK_MODE currently supports only 'imported'; runtime dynamic loading is a later bridge-runtime phase")
|
||||
endif()
|
||||
if(NOT DRAGONX_LITE_BACKEND_ABI STREQUAL "sdxl-c-v1")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_ABI must be sdxl-c-v1")
|
||||
endif()
|
||||
if(NOT DRAGONX_LITE_BACKEND_LIBRARY)
|
||||
message(FATAL_ERROR "DRAGONX_ENABLE_LITE_BACKEND requires DRAGONX_LITE_BACKEND_LIBRARY to point at an SDXL-compatible artifact")
|
||||
endif()
|
||||
if(NOT EXISTS "${DRAGONX_LITE_BACKEND_LIBRARY}")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_LIBRARY does not exist: ${DRAGONX_LITE_BACKEND_LIBRARY}")
|
||||
endif()
|
||||
if(NOT DRAGONX_LITE_BACKEND_SYMBOLS_FILE)
|
||||
message(FATAL_ERROR "DRAGONX_ENABLE_LITE_BACKEND requires DRAGONX_LITE_BACKEND_SYMBOLS_FILE generated by scripts/build-lite-backend-artifact.sh")
|
||||
endif()
|
||||
if(NOT EXISTS "${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_SYMBOLS_FILE does not exist: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
|
||||
endif()
|
||||
file(STRINGS "${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}" DRAGONX_LITE_BACKEND_SYMBOL_LINES)
|
||||
if(NOT DRAGONX_LITE_BACKEND_SYMBOL_LINES)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_SYMBOLS_FILE is empty: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
|
||||
endif()
|
||||
foreach(DRAGONX_LITE_REQUIRED_SYMBOL IN LISTS DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS)
|
||||
list(FIND DRAGONX_LITE_BACKEND_SYMBOL_LINES "${DRAGONX_LITE_REQUIRED_SYMBOL}" DRAGONX_LITE_SYMBOL_INDEX)
|
||||
if(DRAGONX_LITE_SYMBOL_INDEX EQUAL -1)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_SYMBOLS_FILE is missing required symbol: ${DRAGONX_LITE_REQUIRED_SYMBOL}")
|
||||
endif()
|
||||
endforeach()
|
||||
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||
endif()
|
||||
if(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE)
|
||||
if(NOT DRAGONX_LITE_BACKEND_MANIFEST)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires DRAGONX_LITE_BACKEND_MANIFEST")
|
||||
endif()
|
||||
file(READ "${DRAGONX_LITE_BACKEND_MANIFEST}" DRAGONX_LITE_BACKEND_MANIFEST_JSON)
|
||||
string(JSON DRAGONX_LITE_SIGNATURE_STATUS ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_STATUS_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_status)
|
||||
if(DRAGONX_LITE_SIGNATURE_STATUS_ERROR)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing signature verification status")
|
||||
endif()
|
||||
if(NOT DRAGONX_LITE_SIGNATURE_STATUS STREQUAL "verified")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verified signature metadata")
|
||||
endif()
|
||||
string(JSON DRAGONX_LITE_SIGNATURE_VERIFIED_SHA ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verified_artifact_sha256)
|
||||
string(JSON DRAGONX_LITE_ARTIFACT_SHA ERROR_VARIABLE DRAGONX_LITE_ARTIFACT_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" artifact sha256)
|
||||
if(DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR OR DRAGONX_LITE_ARTIFACT_SHA_ERROR)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing artifact/signature SHA-256 metadata")
|
||||
endif()
|
||||
if(NOT DRAGONX_LITE_SIGNATURE_VERIFIED_SHA STREQUAL DRAGONX_LITE_ARTIFACT_SHA)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST signature metadata does not verify the artifact SHA-256")
|
||||
endif()
|
||||
string(JSON DRAGONX_LITE_SIGNATURE_PERFORMED ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_performed)
|
||||
if(DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR OR NOT DRAGONX_LITE_SIGNATURE_PERFORMED)
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verification_performed=true")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
add_library(dragonx_lite_backend UNKNOWN IMPORTED)
|
||||
set_target_properties(dragonx_lite_backend PROPERTIES
|
||||
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
|
||||
)
|
||||
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
|
||||
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
||||
endif()
|
||||
set_target_properties(dragonx_lite_backend PROPERTIES
|
||||
INTERFACE_INCLUDE_DIRECTORIES "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}"
|
||||
)
|
||||
endif()
|
||||
set(DRAGONX_LITE_BACKEND_READY ON)
|
||||
endif()
|
||||
|
||||
include(CTest)
|
||||
|
||||
# Output directories
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||
@@ -108,6 +240,32 @@ FetchContent_Declare(
|
||||
)
|
||||
FetchContent_MakeAvailable(tomlplusplus)
|
||||
|
||||
# SQLite amalgamation - local Explorer block-summary cache
|
||||
FetchContent_Declare(
|
||||
sqlite3
|
||||
URL https://www.sqlite.org/2024/sqlite-amalgamation-3450300.zip
|
||||
URL_HASH SHA256=ea170e73e447703e8359308ca2e4366a3ae0c4304a8665896f068c736781c651
|
||||
)
|
||||
FetchContent_GetProperties(sqlite3)
|
||||
if(NOT sqlite3_POPULATED)
|
||||
FetchContent_Populate(sqlite3)
|
||||
endif()
|
||||
file(GLOB SQLITE3_AMALGAMATION_C CONFIGURE_DEPENDS
|
||||
${sqlite3_SOURCE_DIR}/sqlite3.c
|
||||
${sqlite3_SOURCE_DIR}/*/sqlite3.c
|
||||
)
|
||||
if(NOT SQLITE3_AMALGAMATION_C)
|
||||
message(FATAL_ERROR "SQLite amalgamation source not found")
|
||||
endif()
|
||||
list(GET SQLITE3_AMALGAMATION_C 0 SQLITE3_SOURCE_FILE)
|
||||
get_filename_component(SQLITE3_INCLUDE_DIR ${SQLITE3_SOURCE_FILE} DIRECTORY)
|
||||
add_library(sqlite3_amalgamation STATIC ${SQLITE3_SOURCE_FILE})
|
||||
target_include_directories(sqlite3_amalgamation PUBLIC ${SQLITE3_INCLUDE_DIR})
|
||||
target_compile_definitions(sqlite3_amalgamation PRIVATE
|
||||
SQLITE_THREADSAFE=1
|
||||
SQLITE_OMIT_LOAD_EXTENSION
|
||||
)
|
||||
|
||||
# libcurl for HTTPS RPC connections (more reliable than cpp-httplib with OpenSSL 3.x)
|
||||
if(WIN32)
|
||||
# For Windows cross-compilation, fetch and build libcurl statically
|
||||
@@ -247,22 +405,61 @@ set(APP_SOURCES
|
||||
src/app_network.cpp
|
||||
src/app_security.cpp
|
||||
src/app_wizard.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
src/wallet/lite_connection_service.cpp
|
||||
src/wallet/lite_diagnostics.cpp
|
||||
src/wallet/lite_wallet_controller.cpp
|
||||
src/wallet/lite_result_parsers.cpp
|
||||
src/wallet/lite_sync_service.cpp
|
||||
src/wallet/lite_wallet_gateway.cpp
|
||||
src/wallet/lite_wallet_state_mapper.cpp
|
||||
src/wallet/lite_wallet_lifecycle_ui_adapter.cpp
|
||||
src/wallet/lite_wallet_server_selection_adapter.cpp
|
||||
src/wallet/lite_wallet_server_lifecycle_readiness.cpp
|
||||
src/wallet/lite_wallet_lifecycle_service.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/ui/theme.cpp
|
||||
src/ui/theme_loader.cpp
|
||||
src/ui/explorer/explorer_block_cache.cpp
|
||||
src/ui/material/color_theme.cpp
|
||||
src/ui/material/typography.cpp
|
||||
src/ui/notifications.cpp
|
||||
src/ui/windows/main_window.cpp
|
||||
src/ui/windows/balance_tab.cpp
|
||||
src/ui/windows/balance_components.cpp
|
||||
src/ui/windows/balance_address_list.cpp
|
||||
src/ui/windows/balance_recent_tx.cpp
|
||||
src/ui/windows/balance_tab_helpers.cpp
|
||||
src/ui/windows/send_tab.cpp
|
||||
src/ui/windows/receive_tab.cpp
|
||||
src/ui/windows/transactions_tab.cpp
|
||||
src/ui/windows/mining_tab.cpp
|
||||
src/ui/windows/mining_earnings.cpp
|
||||
src/ui/windows/mining_stats.cpp
|
||||
src/ui/windows/mining_controls.cpp
|
||||
src/ui/windows/mining_mode_toggle.cpp
|
||||
src/ui/windows/mining_benchmark.cpp
|
||||
src/ui/windows/mining_pool_panel.cpp
|
||||
src/ui/windows/mining_tab_helpers.cpp
|
||||
src/ui/windows/peers_tab.cpp
|
||||
src/ui/windows/network_tab.cpp
|
||||
src/ui/windows/lite_console_tab.cpp
|
||||
src/ui/windows/explorer_tab.cpp
|
||||
src/ui/windows/market_tab.cpp
|
||||
src/ui/windows/console_tab.cpp
|
||||
src/ui/windows/console_command_reference.cpp
|
||||
src/ui/windows/console_input_model.cpp
|
||||
src/ui/windows/console_output_model.cpp
|
||||
src/ui/windows/console_tab_helpers.cpp
|
||||
src/ui/windows/settings_window.cpp
|
||||
src/ui/pages/settings_page.cpp
|
||||
src/ui/windows/about_dialog.cpp
|
||||
@@ -286,16 +483,27 @@ set(APP_SOURCES
|
||||
src/data/address_book.cpp
|
||||
src/data/exchange_info.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/async_task_manager.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/base64.cpp
|
||||
src/util/single_instance.cpp
|
||||
src/util/i18n.cpp
|
||||
src/util/text_format.cpp
|
||||
src/util/platform.cpp
|
||||
src/util/payment_uri.cpp
|
||||
src/util/texture_loader.cpp
|
||||
src/util/noise_texture.cpp
|
||||
src/daemon/embedded_daemon.cpp
|
||||
src/daemon/daemon_controller.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
src/daemon/xmrig_manager.cpp
|
||||
src/util/bootstrap.cpp
|
||||
src/util/lite_server_probe.cpp
|
||||
src/util/xmrig_updater.cpp
|
||||
src/util/xmrig_updater_core.cpp
|
||||
src/util/daemon_updater.cpp
|
||||
src/util/daemon_updater_core.cpp
|
||||
src/util/secure_vault.cpp
|
||||
src/ui/effects/framebuffer.cpp
|
||||
src/ui/effects/blur_shader.cpp
|
||||
@@ -326,20 +534,53 @@ endif()
|
||||
|
||||
set(APP_HEADERS
|
||||
src/app.h
|
||||
src/services/network_refresh_service.h
|
||||
src/services/refresh_scheduler.h
|
||||
src/services/wallet_security_controller.h
|
||||
src/services/wallet_security_workflow.h
|
||||
src/services/wallet_security_workflow_executor.h
|
||||
src/wallet/wallet_capabilities.h
|
||||
src/wallet/wallet_backend.h
|
||||
src/wallet/lite_owned_string.h
|
||||
src/wallet/lite_rollout_policy.h
|
||||
src/wallet/lite_client_bridge.h
|
||||
src/wallet/lite_connection_service.h
|
||||
src/wallet/lite_result_parsers.h
|
||||
src/wallet/lite_sync_service.h
|
||||
src/wallet/lite_wallet_gateway.h
|
||||
src/wallet/lite_wallet_state_mapper.h
|
||||
src/wallet/lite_wallet_lifecycle_ui_adapter.h
|
||||
src/wallet/lite_wallet_server_selection_adapter.h
|
||||
src/wallet/lite_wallet_server_lifecycle_readiness.h
|
||||
src/wallet/lite_wallet_lifecycle_service.h
|
||||
src/chat/chat_protocol.h
|
||||
src/config/version.h
|
||||
src/data/wallet_state.h
|
||||
src/data/transaction_history_cache.h
|
||||
src/ui/theme.h
|
||||
src/ui/theme_loader.h
|
||||
src/ui/explorer/explorer_block_cache.h
|
||||
src/ui/notifications.h
|
||||
src/ui/windows/main_window.h
|
||||
src/ui/windows/balance_tab.h
|
||||
src/ui/windows/balance_address_list.h
|
||||
src/ui/windows/balance_recent_tx.h
|
||||
src/ui/windows/balance_tab_helpers.h
|
||||
src/ui/windows/send_tab.h
|
||||
src/ui/windows/receive_tab.h
|
||||
src/ui/windows/transactions_tab.h
|
||||
src/ui/windows/mining_tab.h
|
||||
src/ui/windows/mining_benchmark.h
|
||||
src/ui/windows/mining_pool_panel.h
|
||||
src/ui/windows/mining_tab_helpers.h
|
||||
src/ui/windows/peers_tab.h
|
||||
src/ui/windows/explorer_tab.h
|
||||
src/ui/windows/market_tab.h
|
||||
src/ui/windows/console_command_reference.h
|
||||
src/ui/windows/console_input_model.h
|
||||
src/ui/windows/console_output_model.h
|
||||
src/ui/windows/console_tab.h
|
||||
src/ui/windows/console_tab_helpers.h
|
||||
src/ui/windows/settings_window.h
|
||||
src/ui/windows/about_dialog.h
|
||||
src/ui/windows/key_export_dialog.h
|
||||
@@ -363,6 +604,8 @@ set(APP_HEADERS
|
||||
src/data/address_book.h
|
||||
src/data/exchange_info.h
|
||||
src/util/logger.h
|
||||
src/util/async_task_manager.h
|
||||
src/util/amount_format.h
|
||||
src/util/base64.h
|
||||
src/util/single_instance.h
|
||||
src/util/i18n.h
|
||||
@@ -370,6 +613,8 @@ set(APP_HEADERS
|
||||
src/util/payment_uri.h
|
||||
src/util/secure_vault.h
|
||||
src/daemon/embedded_daemon.h
|
||||
src/daemon/daemon_controller.h
|
||||
src/daemon/lifecycle_adapters.h
|
||||
src/daemon/xmrig_manager.h
|
||||
src/ui/effects/framebuffer.h
|
||||
src/ui/effects/blur_shader.h
|
||||
@@ -408,10 +653,12 @@ if(WIN32)
|
||||
set(WIN_RC_FILE ${CMAKE_BINARY_DIR}/generated/ObsidianDragon.rc)
|
||||
endif()
|
||||
|
||||
# Generate version.h from the single project(VERSION ...) declaration
|
||||
# Generate version values from the single project(VERSION ...) declaration.
|
||||
# Keep the build-specific app name in the build tree so full/lite configures do
|
||||
# not rewrite a tracked source header.
|
||||
configure_file(
|
||||
${CMAKE_SOURCE_DIR}/src/config/version.h.in
|
||||
${CMAKE_SOURCE_DIR}/src/config/version.h
|
||||
${CMAKE_BINARY_DIR}/generated/dragonx_generated_version.h
|
||||
@ONLY
|
||||
)
|
||||
|
||||
@@ -433,6 +680,7 @@ set_source_files_properties(
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Light.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Medium.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/MaterialIcons-Regular.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf;\
|
||||
${CMAKE_SOURCE_DIR}/res/fonts/NotoSansCJK-Subset.ttf"
|
||||
)
|
||||
|
||||
@@ -446,6 +694,8 @@ add_executable(ObsidianDragon
|
||||
${WIN_RC_FILE}
|
||||
)
|
||||
|
||||
set_target_properties(ObsidianDragon PROPERTIES OUTPUT_NAME "${DRAGONX_BINARY_NAME}")
|
||||
|
||||
target_include_directories(ObsidianDragon PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/src/embedded
|
||||
@@ -465,10 +715,63 @@ target_link_libraries(ObsidianDragon PRIVATE
|
||||
SDL3::SDL3
|
||||
nlohmann_json::nlohmann_json
|
||||
tomlplusplus::tomlplusplus
|
||||
sqlite3_amalgamation
|
||||
${CURL_LIBRARIES}
|
||||
${SODIUM_LIBRARY}
|
||||
)
|
||||
|
||||
if(DRAGONX_LITE_BACKEND_READY)
|
||||
target_link_libraries(ObsidianDragon PRIVATE dragonx_lite_backend ${DRAGONX_LITE_BACKEND_EXTRA_LIBS})
|
||||
|
||||
# Real-backend smoke tool (only built when a real lite backend is linked).
|
||||
add_executable(lite_smoke
|
||||
tools/lite_smoke.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_connection_service.cpp
|
||||
src/wallet/lite_result_parsers.cpp
|
||||
)
|
||||
target_include_directories(lite_smoke PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_BINARY_DIR}/generated
|
||||
${SODIUM_INCLUDE_DIR}
|
||||
)
|
||||
target_compile_definitions(lite_smoke PRIVATE DRAGONX_ENABLE_LITE_BACKEND=1)
|
||||
target_link_libraries(lite_smoke PRIVATE
|
||||
dragonx_lite_backend ${DRAGONX_LITE_BACKEND_EXTRA_LIBS}
|
||||
nlohmann_json::nlohmann_json
|
||||
${SODIUM_LIBRARY}
|
||||
)
|
||||
if(UNIX)
|
||||
target_link_libraries(lite_smoke PRIVATE ${CMAKE_DL_LIBS} pthread)
|
||||
endif()
|
||||
|
||||
# Real-backend SEND smoke tool — drives the exact GUI send path (bridge.execute("send", ...)).
|
||||
add_executable(lite_send_smoke
|
||||
tools/lite_send_smoke.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_connection_service.cpp
|
||||
src/wallet/lite_result_parsers.cpp
|
||||
)
|
||||
target_include_directories(lite_send_smoke PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_BINARY_DIR}/generated
|
||||
${SODIUM_INCLUDE_DIR}
|
||||
)
|
||||
target_compile_definitions(lite_send_smoke PRIVATE DRAGONX_ENABLE_LITE_BACKEND=1)
|
||||
target_link_libraries(lite_send_smoke PRIVATE
|
||||
dragonx_lite_backend ${DRAGONX_LITE_BACKEND_EXTRA_LIBS}
|
||||
nlohmann_json::nlohmann_json
|
||||
${SODIUM_LIBRARY}
|
||||
)
|
||||
if(UNIX)
|
||||
target_link_libraries(lite_send_smoke PRIVATE ${CMAKE_DL_LIBS} pthread)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Platform-specific settings
|
||||
if(WIN32)
|
||||
target_link_libraries(ObsidianDragon PRIVATE ws2_32 winmm imm32 version setupapi dwmapi crypt32 wldap32 psapi iphlpapi d3d11 dxgi d3dcompiler dcomp)
|
||||
@@ -497,6 +800,10 @@ endif()
|
||||
# Compile definitions
|
||||
target_compile_definitions(ObsidianDragon PRIVATE
|
||||
DRAGONX_DEBUG
|
||||
DRAGONX_LITE_BUILD=$<BOOL:${DRAGONX_BUILD_LITE}>
|
||||
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
|
||||
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
|
||||
DRAGONX_ENABLE_CHAT=$<BOOL:${DRAGONX_ENABLE_CHAT}>
|
||||
)
|
||||
if(WIN32)
|
||||
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_USE_DX11)
|
||||
@@ -504,6 +811,25 @@ else()
|
||||
target_compile_definitions(ObsidianDragon PRIVATE DRAGONX_HAS_GLAD)
|
||||
endif()
|
||||
|
||||
add_executable(HushChatFixtureCheck
|
||||
tools/hushchat_fixture_check.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
)
|
||||
|
||||
target_include_directories(HushChatFixtureCheck PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${SODIUM_INCLUDE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(HushChatFixtureCheck PRIVATE
|
||||
nlohmann_json::nlohmann_json
|
||||
${SODIUM_LIBRARY}
|
||||
)
|
||||
|
||||
target_compile_definitions(HushChatFixtureCheck PRIVATE
|
||||
DRAGONX_ENABLE_CHAT=0
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Copy resources
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -527,6 +853,12 @@ endif()
|
||||
# so edits to res/lang/*.json are picked up by 'make' without re-running cmake.
|
||||
file(GLOB LANG_FILES ${CMAKE_SOURCE_DIR}/res/lang/*.json)
|
||||
if(LANG_FILES)
|
||||
find_program(XXD_EXECUTABLE NAMES xxd)
|
||||
if(NOT XXD_EXECUTABLE)
|
||||
message(WARNING "xxd not found; runtime language JSON files will be copied, but embedded build/generated/embedded/lang_*.h files will not be regenerated")
|
||||
endif()
|
||||
file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/generated/embedded)
|
||||
|
||||
foreach(LANG_FILE ${LANG_FILES})
|
||||
get_filename_component(LANG_FILENAME ${LANG_FILE} NAME)
|
||||
add_custom_command(
|
||||
@@ -540,16 +872,18 @@ if(LANG_FILES)
|
||||
list(APPEND LANG_OUTPUTS ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res/lang/${LANG_FILENAME})
|
||||
|
||||
# Also regenerate the embedded header so the binary always has fresh translations
|
||||
get_filename_component(LANG_CODE ${LANG_FILENAME} NAME_WE)
|
||||
set(LANG_HEADER ${CMAKE_SOURCE_DIR}/src/embedded/lang_${LANG_CODE}.h)
|
||||
add_custom_command(
|
||||
OUTPUT ${LANG_HEADER}
|
||||
COMMAND xxd -i "res/lang/${LANG_FILENAME}" > "src/embedded/lang_${LANG_CODE}.h"
|
||||
DEPENDS ${LANG_FILE}
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
COMMENT "Embedding lang_${LANG_CODE}.h"
|
||||
)
|
||||
list(APPEND LANG_OUTPUTS ${LANG_HEADER})
|
||||
if(XXD_EXECUTABLE)
|
||||
get_filename_component(LANG_CODE ${LANG_FILENAME} NAME_WE)
|
||||
set(LANG_HEADER ${CMAKE_BINARY_DIR}/generated/embedded/lang_${LANG_CODE}.h)
|
||||
add_custom_command(
|
||||
OUTPUT ${LANG_HEADER}
|
||||
COMMAND ${XXD_EXECUTABLE} -i "res/lang/${LANG_FILENAME}" > "${LANG_HEADER}"
|
||||
DEPENDS ${LANG_FILE}
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
COMMENT "Embedding lang_${LANG_CODE}.h"
|
||||
)
|
||||
list(APPEND LANG_OUTPUTS ${LANG_HEADER})
|
||||
endif()
|
||||
endforeach()
|
||||
add_custom_target(copy_langs ALL DEPENDS ${LANG_OUTPUTS})
|
||||
add_dependencies(ObsidianDragon copy_langs)
|
||||
@@ -596,6 +930,7 @@ if(THEME_FILES AND Python3_EXECUTABLE)
|
||||
message(STATUS " Theme files: ${THEME_FILES} (build-time expansion via Python)")
|
||||
elseif(THEME_FILES)
|
||||
# Fallback: plain copy if Python is not available
|
||||
message(WARNING "Python3 not found; copying theme files without expand_themes.py layout merge")
|
||||
foreach(THEME_FILE ${THEME_FILES})
|
||||
get_filename_component(THEME_FILENAME ${THEME_FILE} NAME)
|
||||
add_custom_command(
|
||||
@@ -645,20 +980,122 @@ install(TARGETS ObsidianDragon
|
||||
)
|
||||
|
||||
install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
|
||||
DESTINATION share/ObsidianDragon
|
||||
DESTINATION share/${DRAGONX_BINARY_NAME}
|
||||
OPTIONAL
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
if(BUILD_TESTING)
|
||||
add_executable(ObsidianDragonTests
|
||||
tests/test_phase4.cpp
|
||||
src/services/network_refresh_service.cpp
|
||||
src/services/refresh_scheduler.cpp
|
||||
src/services/wallet_security_controller.cpp
|
||||
src/services/wallet_security_workflow.cpp
|
||||
src/services/wallet_security_workflow_executor.cpp
|
||||
src/chat/chat_protocol.cpp
|
||||
src/wallet/lite_owned_string.cpp
|
||||
src/wallet/lite_rollout_policy.cpp
|
||||
src/wallet/lite_client_bridge.cpp
|
||||
src/wallet/lite_connection_service.cpp
|
||||
src/wallet/lite_diagnostics.cpp
|
||||
src/wallet/lite_wallet_controller.cpp
|
||||
src/wallet/lite_result_parsers.cpp
|
||||
src/wallet/lite_sync_service.cpp
|
||||
src/wallet/lite_wallet_gateway.cpp
|
||||
src/wallet/lite_wallet_state_mapper.cpp
|
||||
src/wallet/lite_wallet_lifecycle_ui_adapter.cpp
|
||||
src/wallet/lite_wallet_server_selection_adapter.cpp
|
||||
src/wallet/lite_wallet_server_lifecycle_readiness.cpp
|
||||
src/wallet/lite_wallet_lifecycle_service.cpp
|
||||
src/ui/explorer/explorer_block_cache.cpp
|
||||
src/ui/windows/balance_address_list.cpp
|
||||
src/ui/windows/balance_recent_tx.cpp
|
||||
src/ui/windows/console_input_model.cpp
|
||||
src/ui/windows/console_output_model.cpp
|
||||
src/ui/windows/console_tab_helpers.cpp
|
||||
src/ui/windows/mining_benchmark.cpp
|
||||
src/ui/windows/mining_pool_panel.cpp
|
||||
src/ui/windows/mining_tab_helpers.cpp
|
||||
src/util/payment_uri.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/i18n.cpp
|
||||
src/util/text_format.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
src/rpc/connection.cpp
|
||||
src/config/settings.cpp
|
||||
src/resources/embedded_resources.cpp
|
||||
src/util/secure_vault.cpp
|
||||
src/util/platform.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/lite_server_probe.cpp
|
||||
src/util/xmrig_updater.cpp
|
||||
src/util/xmrig_updater_core.cpp
|
||||
src/util/daemon_updater.cpp
|
||||
src/util/daemon_updater_core.cpp
|
||||
${MINIZ_SOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(ObsidianDragonTests PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
${CMAKE_SOURCE_DIR}/src/resources
|
||||
${CMAKE_SOURCE_DIR}/libs
|
||||
${CMAKE_BINARY_DIR}/generated
|
||||
${IMGUI_DIR}
|
||||
${SODIUM_INCLUDE_DIR}
|
||||
${CURL_INCLUDE_DIRS}
|
||||
${MINIZ_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE
|
||||
nlohmann_json::nlohmann_json
|
||||
sqlite3_amalgamation
|
||||
${SODIUM_LIBRARY}
|
||||
${CURL_LIBRARIES}
|
||||
)
|
||||
|
||||
target_compile_definitions(ObsidianDragonTests PRIVATE
|
||||
DRAGONX_ENABLE_CHAT=$<BOOL:${DRAGONX_ENABLE_CHAT}>
|
||||
DRAGONX_LITE_BUILD=$<BOOL:${DRAGONX_BUILD_LITE}>
|
||||
DRAGONX_ENABLE_EMBEDDED_DAEMON=$<BOOL:${DRAGONX_ENABLE_EMBEDDED_DAEMON}>
|
||||
DRAGONX_ENABLE_LITE_BACKEND=$<BOOL:${DRAGONX_LITE_BACKEND_READY}>
|
||||
DRAGONX_TEST_FIXTURE_DIR="${CMAKE_SOURCE_DIR}/tests/fixtures"
|
||||
)
|
||||
|
||||
if(DRAGONX_LITE_BACKEND_READY)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE dragonx_lite_backend ${DRAGONX_LITE_BACKEND_EXTRA_LIBS})
|
||||
endif()
|
||||
|
||||
if(UNIX)
|
||||
target_link_libraries(ObsidianDragonTests PRIVATE ${CMAKE_DL_LIBS})
|
||||
endif()
|
||||
|
||||
add_test(NAME ObsidianDragonPhase4Tests COMMAND ObsidianDragonTests)
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Summary
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
message(STATUS "")
|
||||
message(STATUS "DragonX ImGui Wallet Configuration:")
|
||||
message(STATUS " Version: ${PROJECT_VERSION}")
|
||||
message(STATUS " Version: ${DRAGONX_APP_VERSION}${DRAGONX_APP_VERSION_SUFFIX} (${DRAGONX_APP_NAME})")
|
||||
message(STATUS " Build type: ${CMAKE_BUILD_TYPE}")
|
||||
message(STATUS " C++ Standard: ${CMAKE_CXX_STANDARD}")
|
||||
message(STATUS " ImGui dir: ${IMGUI_DIR}")
|
||||
message(STATUS " SDL3 found: ${SDL3_FOUND}")
|
||||
message(STATUS " Sodium lib: ${SODIUM_LIBRARY}")
|
||||
message(STATUS " Lite build: ${DRAGONX_BUILD_LITE}")
|
||||
message(STATUS " Lite requested: ${DRAGONX_ENABLE_LITE_BACKEND}")
|
||||
message(STATUS " Lite backend: ${DRAGONX_LITE_BACKEND_READY}")
|
||||
message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
|
||||
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
|
||||
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||
message(STATUS " Lite signature: ${DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE}")
|
||||
message(STATUS "")
|
||||
|
||||
49
README.md
49
README.md
@@ -1,6 +1,8 @@
|
||||
# DragonX Wallet - ImGui Edition
|
||||
# ObsidianDragon - DragonX Wallet
|
||||
|
||||
A lightweight, portable cryptocurrency wallet for DragonX (DRGX), built with Dear ImGui.
|
||||
A lightweight, portable full-node cryptocurrency wallet for DragonX (DRGX), built with Dear ImGui.
|
||||
|
||||
Current pre-release: **1.2.0-rc1**.
|
||||
|
||||

|
||||

|
||||
@@ -9,10 +11,13 @@ A lightweight, portable cryptocurrency wallet for DragonX (DRGX), built with Dea
|
||||
|
||||
- **Full Node Support**: Connects to dragonxd for complete blockchain verification
|
||||
- **Shielded Transactions**: Full z-address support with encrypted memos
|
||||
- **Integrated Mining**: CPU mining controls with hashrate monitoring
|
||||
- **Address Management**: Labels, icons, favorites, hidden addresses, and address-to-address transfers
|
||||
- **Integrated Mining**: Solo CPU mining plus pool mining through xmrig, with idle-mining controls
|
||||
- **Explorer Tools**: Block/transaction lookup and bootstrap snapshot download
|
||||
- **Market Data**: Real-time price charts from CoinGecko
|
||||
- **QR Codes**: Generate and display QR codes for receiving addresses
|
||||
- **Multi-language**: i18n support (English, Spanish, more coming)
|
||||
- **Multi-language**: i18n support for English, German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese
|
||||
- **CJK Fonts**: Bundled CJK subset font for translated interfaces
|
||||
- **Lightweight**: ~5-10MB binary vs ~50MB+ for Qt version
|
||||
- **Fast Builds**: Compiles in seconds, not minutes
|
||||
|
||||
@@ -116,7 +121,8 @@ cd ObsidianDragon/
|
||||
./ObsidianDragon
|
||||
```
|
||||
|
||||
The wallet will automatically connect to the daemon using credentials from \`~/.hush/DRAGONX/DRAGONX.conf\`.
|
||||
The wallet will automatically connect to the daemon using credentials from `~/.hush/DRAGONX/DRAGONX.conf`.
|
||||
|
||||
### Using Custom Node Binaries
|
||||
|
||||
The wallet checks its **own directory first** when looking for DragonX node binaries. This means you can test new or different branch builds of `hush-arrakis-chain`/`hushd` without waiting for a new wallet release:
|
||||
@@ -131,9 +137,10 @@ The wallet checks its **own directory first** when looking for DragonX node bina
|
||||
3. System-wide locations (`/usr/local/bin`, `~/dragonx/src`, etc.)
|
||||
|
||||
This is useful for testing new branches or hotfixes to the node software before they are bundled into a wallet release.
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in \`~/.hush/DRAGONX/DRAGONX.conf\`:
|
||||
Configuration is stored in `~/.hush/DRAGONX/DRAGONX.conf`:
|
||||
|
||||
```
|
||||
rpcuser=your_rpc_user
|
||||
@@ -148,44 +155,46 @@ ObsidianDragon/
|
||||
├── src/
|
||||
│ ├── main.cpp # Entry point, SDL/ImGui setup
|
||||
│ ├── app.cpp/h # Main application class
|
||||
│ ├── wallet_state.h # Wallet data structures
|
||||
│ ├── version.h # Version definitions
|
||||
│ ├── data/ # WalletState, address book, exchange info
|
||||
│ ├── config/ # Settings persistence and committed/generated version.h
|
||||
│ ├── ui/
|
||||
│ │ ├── theme.cpp/h # DragonX theme
|
||||
│ │ └── windows/ # UI tabs and dialogs
|
||||
│ │ ├── schema/ # TOML UI schema and skin manager
|
||||
│ │ ├── material/ # Material components, typography, layout
|
||||
│ │ ├── windows/ # Tabs and dialogs
|
||||
│ │ └── pages/ # Multi-page screens such as Settings
|
||||
│ ├── rpc/
|
||||
│ │ ├── rpc_client.cpp # JSON-RPC client
|
||||
│ │ └── connection.cpp # Daemon connection
|
||||
│ ├── config/
|
||||
│ │ └── settings.cpp # Settings persistence
|
||||
│ ├── resources/ # Embedded resource extraction
|
||||
│ ├── platform/ # Windows DX11/backdrop helpers
|
||||
│ ├── util/
|
||||
│ │ ├── i18n.cpp # Internationalization
|
||||
│ │ └── ...
|
||||
│ └── daemon/
|
||||
│ └── embedded_daemon.cpp
|
||||
├── res/
|
||||
│ ├── fonts/ # Ubuntu font
|
||||
│ ├── fonts/ # Ubuntu, icon, and CJK fonts
|
||||
│ └── lang/ # Translation files
|
||||
├── libs/
|
||||
│ └── qrcode/ # QR code generation
|
||||
├── CMakeLists.txt
|
||||
├── build-release.sh # Build script
|
||||
└── create-appimage.sh # AppImage packaging
|
||||
├── build.sh # Release/cross-platform build script
|
||||
└── scripts/create-appimage.sh # AppImage packaging
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
Fetched automatically by CMake (no manual install needed):
|
||||
Fetched or discovered by CMake:
|
||||
|
||||
- **[SDL3](https://github.com/libsdl-org/SDL)** — Cross-platform windowing/input
|
||||
- **[nlohmann/json](https://github.com/nlohmann/json)** — JSON parsing
|
||||
- **[toml++](https://github.com/marzer/tomlplusplus)** — TOML parsing (UI schema/themes)
|
||||
- **[libcurl](https://curl.se/libcurl/)** — HTTPS RPC transport (system on Linux, fetched on Windows)
|
||||
- **[libcurl](https://curl.se/libcurl/)** — HTTP/HTTPS transport for daemon RPC and network calls (system on Linux/macOS, fetched on Windows)
|
||||
|
||||
Bundled in `libs/`:
|
||||
|
||||
- **[Dear ImGui](https://github.com/ocornut/imgui)** — Immediate mode GUI
|
||||
- **[libsodium](https://libsodium.org)** — Cryptographic operations (fetched by `scripts/fetch-libsodium.sh`)
|
||||
- **[libsodium](https://libsodium.org)** — Cryptographic operations (system on Linux or fetched by `scripts/fetch-libsodium.sh`)
|
||||
- **[QR-Code-generator](https://github.com/nayuki/QR-Code-generator)** — QR code rendering
|
||||
- **[miniz](https://github.com/richgel999/miniz)** — ZIP compression
|
||||
- **[GLAD](https://glad.dav1d.de/)** — OpenGL loader (Linux/macOS)
|
||||
@@ -202,9 +211,11 @@ Bundled in `libs/`:
|
||||
|
||||
## Translation
|
||||
|
||||
Current language files live in `res/lang/` as `de`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, and `zh` JSON files, with built-in English fallbacks.
|
||||
|
||||
To add a new language:
|
||||
|
||||
1. Copy \`res/lang/es.json\` to \`res/lang/<code>.json\`
|
||||
1. Copy `res/lang/es.json` to `res/lang/<code>.json`
|
||||
2. Translate all strings
|
||||
3. The language will appear in Settings automatically
|
||||
|
||||
|
||||
@@ -361,7 +361,22 @@ https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
---
|
||||
|
||||
## 13. IconFontCppHeaders
|
||||
## 13. Material Design Icons Pickaxe Subset Font
|
||||
|
||||
- **Location:** `res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf`
|
||||
- **Source:** https://github.com/Templarian/MaterialDesign-Webfont
|
||||
- **Derived from:** Pictogrammers Material Design Icons webfont (`materialdesignicons-webfont.ttf`)
|
||||
- **Copyright:** Pictogrammers contributors
|
||||
- **License:** Apache License 2.0
|
||||
|
||||
This bundled font is a local one-glyph subset containing only the MDI pickaxe
|
||||
icon, remapped onto a BMP private-use codepoint for Dear ImGui compatibility.
|
||||
The full text of the Apache License 2.0 is available at:
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
---
|
||||
|
||||
## 14. IconFontCppHeaders
|
||||
|
||||
- **Location:** `src/embedded/IconsMaterialDesign.h`
|
||||
- **Source:** https://github.com/juliettef/IconFontCppHeaders
|
||||
@@ -390,7 +405,7 @@ freely, subject to the following restrictions:
|
||||
|
||||
---
|
||||
|
||||
## 14. Ubuntu Font Family
|
||||
## 15. Ubuntu Font Family
|
||||
|
||||
- **Location:** `res/fonts/Ubuntu-Light.ttf`, `Ubuntu-Medium.ttf`, `Ubuntu-R.ttf`
|
||||
- **Source:** https://design.ubuntu.com/font
|
||||
|
||||
437
build.sh
437
build.sh
@@ -20,7 +20,9 @@
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VERSION="1.2.0-rc1"
|
||||
# VERSION is resolved per-variant from CMakeLists.txt (the single source of truth) after arg
|
||||
# parsing — see the APP_BASENAME block below. Placeholder until then.
|
||||
VERSION=""
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'
|
||||
@@ -41,6 +43,8 @@ DO_DEV=false
|
||||
DO_LINUX=false
|
||||
DO_WIN=false
|
||||
DO_MAC=false
|
||||
DO_LITE=false
|
||||
DO_LITE_BACKEND=false
|
||||
CLEAN=false
|
||||
BUILD_TYPE="Release"
|
||||
|
||||
@@ -54,6 +58,10 @@ Targets (at least one required, or none for dev build):
|
||||
--linux-release Linux release (zip + AppImage) -> release/linux/
|
||||
--win-release Windows cross-compile (mingw-w64) -> release/windows/
|
||||
--mac-release macOS .app bundle + DMG -> release/mac/
|
||||
--lite Build ObsidianDragonLite variant (no embedded daemon/full-node features)
|
||||
--lite-backend Like --lite, and link the real SDXL litelib backend artifact
|
||||
(auto-discovers build/lite-backend/<platform>/; build it with
|
||||
scripts/build-lite-backend-artifact.sh, or set DRAGONX_LITE_BACKEND_DIR)
|
||||
|
||||
Build trees are stored under build/{linux,windows,mac}/
|
||||
|
||||
@@ -74,6 +82,7 @@ Examples:
|
||||
$0 --linux-release # Linux release (zip + AppImage)
|
||||
$0 --win-release # Windows cross-compile
|
||||
$0 --mac-release # macOS bundle + DMG (native or osxcross)
|
||||
$0 --lite-backend --mac-release # macOS ObsidianDragonLite.app + DMG (lite backend)
|
||||
$0 --clean --linux-release --win-release # Clean + both
|
||||
EOF
|
||||
exit 0
|
||||
@@ -85,6 +94,8 @@ while [[ $# -gt 0 ]]; do
|
||||
--linux-release) DO_LINUX=true; shift ;;
|
||||
--win-release) DO_WIN=true; shift ;;
|
||||
--mac-release) DO_MAC=true; shift ;;
|
||||
--lite) DO_LITE=true; shift ;;
|
||||
--lite-backend) DO_LITE=true; DO_LITE_BACKEND=true; shift ;;
|
||||
-c|--clean) CLEAN=true; shift ;;
|
||||
-d|--debug) BUILD_TYPE="Debug"; shift ;;
|
||||
-j) JOBS="$2"; shift 2 ;;
|
||||
@@ -98,6 +109,92 @@ if ! $DO_LINUX && ! $DO_WIN && ! $DO_MAC; then
|
||||
DO_DEV=true
|
||||
fi
|
||||
|
||||
APP_BASENAME="ObsidianDragon"
|
||||
CMAKE_LITE_ARGS=()
|
||||
# Always set the variant flag EXPLICITLY (ON and OFF) so switching variants in a shared build dir
|
||||
# can't reuse a stale cached value (e.g. a prior --lite build leaving DRAGONX_BUILD_LITE=ON).
|
||||
if $DO_LITE; then
|
||||
APP_BASENAME="ObsidianDragonLite"
|
||||
CMAKE_LITE_ARGS+=("-DDRAGONX_BUILD_LITE=ON")
|
||||
# Lite never embeds/launches a daemon; set it explicitly too for cache hygiene.
|
||||
CMAKE_LITE_ARGS+=("-DDRAGONX_ENABLE_EMBEDDED_DAEMON=OFF")
|
||||
info "Lite mode enabled: building ${APP_BASENAME}"
|
||||
else
|
||||
CMAKE_LITE_ARGS+=("-DDRAGONX_BUILD_LITE=OFF")
|
||||
# Re-assert the embedded daemon ON for full-node builds, EXPLICITLY, so a build dir whose cache
|
||||
# was poisoned OFF by a prior --lite configure (or any stale value) is healed — otherwise the
|
||||
# full-node app extracts dragonxd but never launches it (isUsingEmbeddedDaemon() == false).
|
||||
CMAKE_LITE_ARGS+=("-DDRAGONX_ENABLE_EMBEDDED_DAEMON=ON")
|
||||
fi
|
||||
|
||||
# Resolve the release version string for the active variant from CMakeLists.txt (single source of
|
||||
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
|
||||
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
|
||||
_cml="$SCRIPT_DIR/CMakeLists.txt"
|
||||
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
|
||||
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||
if $DO_LITE; then
|
||||
VERSION="${_lite_ver}${_lite_suffix}"
|
||||
else
|
||||
VERSION="${_full_ver}${_full_suffix}"
|
||||
fi
|
||||
[ -n "$_full_ver" ] && [ -n "$VERSION" ] || { err "Could not parse version from CMakeLists.txt"; exit 1; }
|
||||
info "Release version: ${VERSION} (${APP_BASENAME})"
|
||||
|
||||
# ── Lite backend (real SDXL litelib) linking ─────────────────────────────────
|
||||
# Enables DRAGONX_ENABLE_LITE_BACKEND with an imported artifact produced by
|
||||
# scripts/build-lite-backend-artifact.sh. Auto-discovers build/lite-backend/<platform>/;
|
||||
# override the directory with DRAGONX_LITE_BACKEND_DIR.
|
||||
if $DO_LITE_BACKEND; then
|
||||
# Artifact platform follows the cross target when exactly one non-host release is requested,
|
||||
# so `--lite-backend --win-release` links the Windows backend (not the host's) automatically.
|
||||
case "$(uname -s)" in
|
||||
Linux) lb_platform="linux" ;;
|
||||
Darwin) lb_platform="macos" ;;
|
||||
*) lb_platform="linux" ;;
|
||||
esac
|
||||
if $DO_WIN && ! $DO_LINUX && ! $DO_MAC; then lb_platform="windows"; fi
|
||||
if $DO_MAC && ! $DO_LINUX && ! $DO_WIN; then lb_platform="macos"; fi
|
||||
lb_dir="${DRAGONX_LITE_BACKEND_DIR:-$SCRIPT_DIR/build/lite-backend/$lb_platform}"
|
||||
lb_lib=""
|
||||
for cand in "$lb_dir"/libsilentdragonxlite.a "$lb_dir"/libsilentdragonxlite.so "$lb_dir"/silentdragonxlite.lib; do
|
||||
[[ -f "$cand" ]] && { lb_lib="$cand"; break; }
|
||||
done
|
||||
lb_symbols="$lb_dir/lite-backend-symbols.txt"
|
||||
lb_manifest="$lb_dir/lite-backend-artifact-manifest.json"
|
||||
if [[ -z "$lb_lib" || ! -f "$lb_symbols" ]]; then
|
||||
err "Lite backend artifact not found under: $lb_dir"
|
||||
err "Build it first: ./scripts/build-lite-backend-artifact.sh --platform $lb_platform"
|
||||
err "Or set DRAGONX_LITE_BACKEND_DIR to an existing artifact directory."
|
||||
exit 1
|
||||
fi
|
||||
CMAKE_LITE_ARGS+=(
|
||||
"-DDRAGONX_ENABLE_LITE_BACKEND=ON"
|
||||
"-DDRAGONX_LITE_BACKEND_LIBRARY=$lb_lib"
|
||||
"-DDRAGONX_LITE_BACKEND_SYMBOLS_FILE=$lb_symbols"
|
||||
"-DDRAGONX_LITE_BACKEND_LINK_MODE=imported"
|
||||
"-DDRAGONX_LITE_BACKEND_ABI=sdxl-c-v1"
|
||||
)
|
||||
[[ -f "$lb_manifest" ]] && CMAKE_LITE_ARGS+=("-DDRAGONX_LITE_BACKEND_MANIFEST=$lb_manifest")
|
||||
# A Rust x86_64-pc-windows-gnu staticlib pulls in Win32 system libs (rustls/schannel, ring,
|
||||
# dirs, std) that the app doesn't already link. The set is rustc's `--print native-static-libs`
|
||||
# for the backend (winapi_* shims mapped to the real mingw import libs); all exist in mingw-w64.
|
||||
if [[ "$lb_platform" == "windows" ]]; then
|
||||
CMAKE_LITE_ARGS+=("-DDRAGONX_LITE_BACKEND_EXTRA_LIBS=advapi32;ws2_32;kernel32;bcrypt;cfgmgr32;credui;crypt32;cryptnet;fwpuclnt;gdi32;msimg32;ncrypt;ntdll;ole32;opengl32;secur32;shell32;synchronization;user32;winspool;userenv")
|
||||
fi
|
||||
info "Lite backend enabled ($lb_platform): $lb_lib"
|
||||
else
|
||||
# Explicit OFF so a prior --lite-backend configure in a shared build dir can't leave it ON
|
||||
# (which would then fail the BUILD_LITE=OFF guard in CMake).
|
||||
CMAKE_LITE_ARGS+=("-DDRAGONX_ENABLE_LITE_BACKEND=OFF")
|
||||
fi
|
||||
|
||||
should_bundle_full_node_assets() {
|
||||
! $DO_LITE
|
||||
}
|
||||
|
||||
# ── Helper: find resource files ──────────────────────────────────────────────
|
||||
find_sapling_params() {
|
||||
local dirs=(
|
||||
@@ -215,13 +312,14 @@ build_dev() {
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_BUILD_TYPE="$BUILD_TYPE" \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
|
||||
[[ -f "bin/ObsidianDragon" ]] || { err "Build failed"; exit 1; }
|
||||
info "Dev binary: $bd/bin/ObsidianDragon ($(du -h bin/ObsidianDragon | cut -f1))"
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "Build failed"; exit 1; }
|
||||
info "Dev binary: $bd/bin/${APP_BASENAME} ($(du -h "bin/${APP_BASENAME}" | cut -f1))"
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -242,42 +340,51 @@ build_release_linux() {
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
|
||||
[[ -f "bin/ObsidianDragon" ]] || { err "Linux build failed"; exit 1; }
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
|
||||
|
||||
info "Stripping ..."
|
||||
strip bin/ObsidianDragon
|
||||
info "Binary: $(du -h bin/ObsidianDragon | cut -f1)"
|
||||
strip "bin/${APP_BASENAME}"
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||
|
||||
# ── Bundle daemon ────────────────────────────────────────────────────────
|
||||
bundle_linux_daemon "bin" || warn "Daemon not bundled — wallet-only build"
|
||||
if should_bundle_full_node_assets; then
|
||||
# ── Bundle daemon ────────────────────────────────────────────────────
|
||||
bundle_linux_daemon "bin" || warn "Daemon not bundled — wallet-only build"
|
||||
|
||||
# ── Bundle Sapling params ────────────────────────────────────────────────
|
||||
SAPLING_SPEND="" SAPLING_OUTPUT=""
|
||||
find_sapling_params && {
|
||||
cp -f "$SAPLING_SPEND" "bin/sapling-spend.params"
|
||||
cp -f "$SAPLING_OUTPUT" "bin/sapling-output.params"
|
||||
info "Bundled Sapling params"
|
||||
} || warn "Sapling params not found — not bundled"
|
||||
# ── Bundle Sapling params ────────────────────────────────────────────
|
||||
SAPLING_SPEND="" SAPLING_OUTPUT=""
|
||||
find_sapling_params && {
|
||||
cp -f "$SAPLING_SPEND" "bin/sapling-spend.params"
|
||||
cp -f "$SAPLING_OUTPUT" "bin/sapling-output.params"
|
||||
info "Bundled Sapling params"
|
||||
} || warn "Sapling params not found — not bundled"
|
||||
else
|
||||
info "Lite mode: skipping daemon and Sapling/asmap bundling"
|
||||
fi
|
||||
|
||||
# ── Package: release/linux/ ──────────────────────────────────────────────
|
||||
rm -rf "$out"
|
||||
# Remove only THIS variant's prior artifacts so full-node and lite releases can coexist in the
|
||||
# same output dir (both ObsidianDragon* and ObsidianDragonLite* end up under release/linux/).
|
||||
mkdir -p "$out"
|
||||
rm -rf "$out/${APP_BASENAME}-"* "$out/${APP_BASENAME}.AppImage"
|
||||
|
||||
local DIST="ObsidianDragon-${VERSION}-Linux-x64"
|
||||
local DIST="${APP_BASENAME}-${VERSION}-Linux-x64"
|
||||
local dist_dir="$out/$DIST"
|
||||
mkdir -p "$dist_dir"
|
||||
|
||||
cp bin/ObsidianDragon "$dist_dir/"
|
||||
[[ -f bin/dragonxd ]] && cp bin/dragonxd "$dist_dir/"
|
||||
[[ -f bin/dragonx-cli ]] && cp bin/dragonx-cli "$dist_dir/"
|
||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
|
||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
|
||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
|
||||
cp "bin/${APP_BASENAME}" "$dist_dir/"
|
||||
if should_bundle_full_node_assets; then
|
||||
[[ -f bin/dragonxd ]] && cp bin/dragonxd "$dist_dir/"
|
||||
[[ -f bin/dragonx-cli ]] && cp bin/dragonx-cli "$dist_dir/"
|
||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
|
||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
|
||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
|
||||
fi
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/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"
|
||||
@@ -299,27 +406,29 @@ build_release_linux() {
|
||||
"$APPDIR/usr/share/icons/hicolor/256x256/apps" \
|
||||
"$APPDIR/usr/share/ObsidianDragon/res"
|
||||
|
||||
cp bin/ObsidianDragon "$APPDIR/usr/bin/"
|
||||
cp "bin/${APP_BASENAME}" "$APPDIR/usr/bin/"
|
||||
cp -r bin/res/* "$APPDIR/usr/share/ObsidianDragon/res/" 2>/dev/null || true
|
||||
|
||||
[[ -f bin/dragonxd ]] && cp bin/dragonxd "$APPDIR/usr/bin/"
|
||||
[[ -f bin/dragonx-cli ]] && cp bin/dragonx-cli "$APPDIR/usr/bin/"
|
||||
# Daemon data files must be alongside the daemon binary (usr/bin/)
|
||||
# because dragonxd searches relative to its own directory.
|
||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
|
||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
|
||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
|
||||
if should_bundle_full_node_assets; then
|
||||
[[ -f bin/dragonxd ]] && cp bin/dragonxd "$APPDIR/usr/bin/"
|
||||
[[ -f bin/dragonx-cli ]] && cp bin/dragonx-cli "$APPDIR/usr/bin/"
|
||||
# Daemon data files must be alongside the daemon binary (usr/bin/)
|
||||
# because dragonxd searches relative to its own directory.
|
||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
|
||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
|
||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
|
||||
fi
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
||||
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
|
||||
|
||||
# Desktop entry
|
||||
cat > "$APPDIR/usr/share/applications/ObsidianDragon.desktop" <<'DESK'
|
||||
cat > "$APPDIR/usr/share/applications/ObsidianDragon.desktop" <<DESK
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=DragonX Wallet
|
||||
Comment=DragonX Cryptocurrency Wallet
|
||||
Exec=ObsidianDragon
|
||||
Exec=${APP_BASENAME}
|
||||
Icon=ObsidianDragon
|
||||
Categories=Finance;Network;
|
||||
Terminal=false
|
||||
@@ -350,14 +459,14 @@ SVG
|
||||
cp "$APPDIR/ObsidianDragon.svg" "$APPDIR/ObsidianDragon.png" 2>/dev/null || true
|
||||
|
||||
# AppRun
|
||||
cat > "$APPDIR/AppRun" <<'APPRUN'
|
||||
cat > "$APPDIR/AppRun" <<APPRUN
|
||||
#!/bin/bash
|
||||
SELF=$(readlink -f "$0")
|
||||
HERE=${SELF%/*}
|
||||
export DRAGONX_RES_PATH="${HERE}/usr/share/ObsidianDragon/res"
|
||||
export LD_LIBRARY_PATH="${HERE}/usr/lib:${LD_LIBRARY_PATH}"
|
||||
cd "${HERE}/usr/share/ObsidianDragon"
|
||||
exec "${HERE}/usr/bin/ObsidianDragon" "$@"
|
||||
SELF=\$(readlink -f "\$0")
|
||||
HERE=\${SELF%/*}
|
||||
export DRAGONX_RES_PATH="\${HERE}/usr/share/ObsidianDragon/res"
|
||||
export LD_LIBRARY_PATH="\${HERE}/usr/lib:\${LD_LIBRARY_PATH}"
|
||||
cd "\${HERE}/usr/share/ObsidianDragon"
|
||||
exec "\${HERE}/usr/bin/${APP_BASENAME}" "\$@"
|
||||
APPRUN
|
||||
chmod +x "$APPDIR/AppRun"
|
||||
|
||||
@@ -386,9 +495,9 @@ APPRUN
|
||||
local ARCH
|
||||
ARCH=$(uname -m)
|
||||
cd "$bd"
|
||||
ARCH="$ARCH" "$APPIMAGETOOL" "$APPDIR" "ObsidianDragon-${VERSION}-${ARCH}.AppImage" 2>/dev/null && {
|
||||
cp "ObsidianDragon-${VERSION}-${ARCH}.AppImage" "$out/ObsidianDragon-${VERSION}.AppImage"
|
||||
info "AppImage: $out/ObsidianDragon-${VERSION}.AppImage ($(du -h "$out/ObsidianDragon-${VERSION}.AppImage" | cut -f1))"
|
||||
ARCH="$ARCH" "$APPIMAGETOOL" "$APPDIR" "${APP_BASENAME}-${VERSION}-${ARCH}.AppImage" 2>/dev/null && {
|
||||
cp "${APP_BASENAME}-${VERSION}-${ARCH}.AppImage" "$out/${APP_BASENAME}-${VERSION}.AppImage"
|
||||
info "AppImage: $out/${APP_BASENAME}-${VERSION}.AppImage ($(du -h "$out/${APP_BASENAME}-${VERSION}.AppImage" | cut -f1))"
|
||||
} || warn "AppImage creation failed — binaries zip still in release/linux/"
|
||||
|
||||
info "Linux release artifacts: $out/"
|
||||
@@ -497,26 +606,48 @@ HDR
|
||||
|
||||
# ── Daemon binaries ──────────────────────────────────────────────
|
||||
local DD="$SCRIPT_DIR/prebuilt-binaries/dragonxd-win"
|
||||
if [[ -d "$DD" && -f "$DD/dragonxd.exe" ]]; then
|
||||
info "Embedding daemon binaries ..."
|
||||
echo -e "\n#define HAS_EMBEDDED_DAEMON 1\n" >> "$GEN/embedded_data.h"
|
||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||
local sym=$(echo "$f" | sed 's/[^a-zA-Z0-9]/_/g')
|
||||
if [[ -f "$DD/$f" ]]; then
|
||||
cp -f "$DD/$f" "$RES/$f"
|
||||
info " Staged $f ($(du -h "$DD/$f" | cut -f1))"
|
||||
echo "INCBIN(${sym}, \"$RES/$f\");" >> "$GEN/embedded_data.h"
|
||||
else
|
||||
echo "extern \"C\" { static const uint8_t* g_${sym}_data = nullptr; }" >> "$GEN/embedded_data.h"
|
||||
echo "static const unsigned int g_${sym}_size = 0;" >> "$GEN/embedded_data.h"
|
||||
fi
|
||||
done
|
||||
if should_bundle_full_node_assets; then
|
||||
if [[ -d "$DD" && -f "$DD/dragonxd.exe" ]]; then
|
||||
info "Embedding daemon binaries ..."
|
||||
echo -e "\n#define HAS_EMBEDDED_DAEMON 1\n" >> "$GEN/embedded_data.h"
|
||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||
local sym=$(echo "$f" | sed 's/[^a-zA-Z0-9]/_/g')
|
||||
if [[ -f "$DD/$f" ]]; then
|
||||
cp -f "$DD/$f" "$RES/$f"
|
||||
info " Staged $f ($(du -h "$DD/$f" | cut -f1))"
|
||||
echo "INCBIN(${sym}, \"$RES/$f\");" >> "$GEN/embedded_data.h"
|
||||
else
|
||||
echo "extern \"C\" { static const uint8_t* g_${sym}_data = nullptr; }" >> "$GEN/embedded_data.h"
|
||||
echo "static const unsigned int g_${sym}_size = 0;" >> "$GEN/embedded_data.h"
|
||||
fi
|
||||
done
|
||||
else
|
||||
warn "prebuilt-binaries/dragonxd-win/ not found — wallet-only build"
|
||||
fi
|
||||
else
|
||||
warn "prebuilt-binaries/dragonxd-win/ not found — wallet-only build"
|
||||
info "Lite mode: skipping embedded daemon binaries"
|
||||
fi
|
||||
|
||||
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
|
||||
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
|
||||
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
|
||||
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
|
||||
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
|
||||
# no miner ("xmrig binary not found" at runtime).
|
||||
if [[ ! -f "$XMRIG_DIR/xmrig.exe" ]]; then
|
||||
local _xz; _xz=$(ls "$XMRIG_DIR"/drg-xmrig-*-win-x64.zip 2>/dev/null | head -1)
|
||||
if [[ -n "$_xz" ]] && command -v unzip >/dev/null 2>&1; then
|
||||
local _xtmp; _xtmp=$(mktemp -d)
|
||||
# -j flattens the versioned subdir; check the file (not unzip's exit code, which is
|
||||
# non-zero if a pattern matches nothing).
|
||||
unzip -j -o "$_xz" '*xmrig.exe' -d "$_xtmp" >/dev/null 2>&1 || true
|
||||
if [[ -f "$_xtmp/xmrig.exe" ]]; then
|
||||
cp -f "$_xtmp/xmrig.exe" "$XMRIG_DIR/xmrig.exe"
|
||||
info " Extracted xmrig.exe from $(basename "$_xz")"
|
||||
fi
|
||||
rm -rf "$_xtmp"
|
||||
fi
|
||||
fi
|
||||
if [[ -f "$XMRIG_DIR/xmrig.exe" ]]; then
|
||||
cp -f "$XMRIG_DIR/xmrig.exe" "$RES/xmrig.exe"
|
||||
info " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"
|
||||
@@ -599,34 +730,40 @@ HDR
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
|
||||
[[ -f "bin/ObsidianDragon.exe" ]] || { err "Windows build failed"; exit 1; }
|
||||
info "Binary: $(du -h bin/ObsidianDragon.exe | cut -f1)"
|
||||
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
|
||||
|
||||
# ── Package: release/windows/ ────────────────────────────────────────────
|
||||
rm -rf "$out"
|
||||
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
|
||||
mkdir -p "$out"
|
||||
rm -rf "$out/${APP_BASENAME}-"* "$out/${APP_BASENAME}.exe"
|
||||
|
||||
local DIST="ObsidianDragon-${VERSION}-Windows-x64"
|
||||
local DIST="${APP_BASENAME}-${VERSION}-Windows-x64"
|
||||
local dist_dir="$out/$DIST"
|
||||
mkdir -p "$dist_dir"
|
||||
cp bin/ObsidianDragon.exe "$dist_dir/"
|
||||
cp "bin/${APP_BASENAME}.exe" "$dist_dir/"
|
||||
|
||||
local DD="$SCRIPT_DIR/prebuilt-binaries/dragonxd-win"
|
||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||
done
|
||||
if should_bundle_full_node_assets; then
|
||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||
done
|
||||
|
||||
# Bundle Sapling params + asmap for the zip distribution
|
||||
# (The single-file exe has these embedded via INCBIN, but the zip
|
||||
# needs them on disk so the daemon can find them in its work dir.)
|
||||
for f in sapling-spend.params sapling-output.params asmap.dat; do
|
||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||
done
|
||||
# Bundle Sapling params + asmap for the zip distribution
|
||||
# (The single-file exe has these embedded via INCBIN, but the zip
|
||||
# needs them on disk so the daemon can find them in its work dir.)
|
||||
for f in sapling-spend.params sapling-output.params asmap.dat; do
|
||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||
done
|
||||
else
|
||||
info "Lite mode: skipping daemon and Sapling/asmap assets in Windows zip"
|
||||
fi
|
||||
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig.exe"
|
||||
@@ -635,8 +772,8 @@ HDR
|
||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||
|
||||
# ── Single-file exe (all resources embedded) ────────────────────────────
|
||||
cp bin/ObsidianDragon.exe "$out/ObsidianDragon-${VERSION}.exe"
|
||||
info "Single-file exe: $out/ObsidianDragon-${VERSION}.exe ($(du -h "$out/ObsidianDragon-${VERSION}.exe" | cut -f1))"
|
||||
cp "bin/${APP_BASENAME}.exe" "$out/${APP_BASENAME}-${VERSION}.exe"
|
||||
info "Single-file exe: $out/${APP_BASENAME}-${VERSION}.exe ($(du -h "$out/${APP_BASENAME}-${VERSION}.exe" | cut -f1))"
|
||||
|
||||
# ── Zip ──────────────────────────────────────────────────────────────────
|
||||
if command -v zip &>/dev/null; then
|
||||
@@ -818,7 +955,8 @@ TOOLCHAIN
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"}
|
||||
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"} \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
else
|
||||
# Build libsodium as universal if needed
|
||||
local need_sodium=false
|
||||
@@ -844,39 +982,40 @@ TOOLCHAIN
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"
|
||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
fi
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
cmake --build . -j "$JOBS"
|
||||
|
||||
[[ -f "bin/ObsidianDragon" ]] || { err "macOS build failed"; exit 1; }
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
|
||||
|
||||
# Strip — use osxcross strip for cross-builds
|
||||
if $IS_CROSS; then
|
||||
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
|
||||
if [[ -x "$STRIP_CMD" ]]; then
|
||||
info "Stripping (osxcross) ..."
|
||||
"$STRIP_CMD" bin/ObsidianDragon
|
||||
"$STRIP_CMD" "bin/${APP_BASENAME}"
|
||||
else
|
||||
warn "osxcross strip not found at $STRIP_CMD — skipping"
|
||||
fi
|
||||
else
|
||||
info "Stripping ..."
|
||||
strip bin/ObsidianDragon
|
||||
strip "bin/${APP_BASENAME}"
|
||||
# Verify universal binary
|
||||
if command -v lipo &>/dev/null; then
|
||||
info "Architecture info:"
|
||||
lipo -info bin/ObsidianDragon
|
||||
lipo -info "bin/${APP_BASENAME}"
|
||||
fi
|
||||
fi
|
||||
info "Binary: $(du -h bin/ObsidianDragon | cut -f1)"
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||
|
||||
# ── Create .app bundle ───────────────────────────────────────────────────
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
|
||||
local APP="$out/ObsidianDragon.app"
|
||||
local APP="$out/${APP_BASENAME}.app"
|
||||
local CONTENTS="$APP/Contents"
|
||||
local MACOS="$CONTENTS/MacOS"
|
||||
local RESOURCES="$CONTENTS/Resources"
|
||||
@@ -885,39 +1024,43 @@ TOOLCHAIN
|
||||
mkdir -p "$MACOS" "$RESOURCES/res" "$FRAMEWORKS"
|
||||
|
||||
# Main binary
|
||||
cp bin/ObsidianDragon "$MACOS/"
|
||||
chmod +x "$MACOS/ObsidianDragon"
|
||||
cp "bin/${APP_BASENAME}" "$MACOS/"
|
||||
chmod +x "$MACOS/${APP_BASENAME}"
|
||||
|
||||
# Resources
|
||||
cp -r bin/res/* "$RESOURCES/res/" 2>/dev/null || true
|
||||
|
||||
# Daemon binaries (macOS native, from dragonxd-mac/)
|
||||
local daemon_dir="$SCRIPT_DIR/prebuilt-binaries/dragonxd-mac"
|
||||
if [[ -d "$daemon_dir" ]]; then
|
||||
for f in dragonxd dragonx-cli dragonx-tx; do
|
||||
[[ -f "$daemon_dir/$f" ]] && { cp "$daemon_dir/$f" "$MACOS/"; chmod +x "$MACOS/$f"; info " Bundled $f"; }
|
||||
done
|
||||
for f in sapling-spend.params sapling-output.params; do
|
||||
[[ -f "$daemon_dir/$f" ]] && { cp "$daemon_dir/$f" "$MACOS/"; info " Bundled $f"; }
|
||||
done
|
||||
elif ! $IS_CROSS; then
|
||||
# Native macOS: try standard paths
|
||||
local daemon_paths=(
|
||||
"$SCRIPT_DIR/../dragonxd"
|
||||
"$HOME/dragonx/src/dragonxd"
|
||||
)
|
||||
for p in "${daemon_paths[@]}"; do
|
||||
[[ -f "$p" ]] && { cp "$p" "$MACOS/dragonxd"; chmod +x "$MACOS/dragonxd"; info " Bundled dragonxd"; break; }
|
||||
done
|
||||
local cli_paths=(
|
||||
"$SCRIPT_DIR/../dragonx-cli"
|
||||
"$HOME/dragonx/src/dragonx-cli"
|
||||
)
|
||||
for p in "${cli_paths[@]}"; do
|
||||
[[ -f "$p" ]] && { cp "$p" "$MACOS/dragonx-cli"; chmod +x "$MACOS/dragonx-cli"; info " Bundled dragonx-cli"; break; }
|
||||
done
|
||||
if should_bundle_full_node_assets; then
|
||||
# Daemon binaries (macOS native, from dragonxd-mac/)
|
||||
local daemon_dir="$SCRIPT_DIR/prebuilt-binaries/dragonxd-mac"
|
||||
if [[ -d "$daemon_dir" ]]; then
|
||||
for f in dragonxd dragonx-cli dragonx-tx; do
|
||||
[[ -f "$daemon_dir/$f" ]] && { cp "$daemon_dir/$f" "$MACOS/"; chmod +x "$MACOS/$f"; info " Bundled $f"; }
|
||||
done
|
||||
for f in sapling-spend.params sapling-output.params; do
|
||||
[[ -f "$daemon_dir/$f" ]] && { cp "$daemon_dir/$f" "$MACOS/"; info " Bundled $f"; }
|
||||
done
|
||||
elif ! $IS_CROSS; then
|
||||
# Native macOS: try standard paths
|
||||
local daemon_paths=(
|
||||
"$SCRIPT_DIR/../dragonxd"
|
||||
"$HOME/dragonx/src/dragonxd"
|
||||
)
|
||||
for p in "${daemon_paths[@]}"; do
|
||||
[[ -f "$p" ]] && { cp "$p" "$MACOS/dragonxd"; chmod +x "$MACOS/dragonxd"; info " Bundled dragonxd"; break; }
|
||||
done
|
||||
local cli_paths=(
|
||||
"$SCRIPT_DIR/../dragonx-cli"
|
||||
"$HOME/dragonx/src/dragonx-cli"
|
||||
)
|
||||
for p in "${cli_paths[@]}"; do
|
||||
[[ -f "$p" ]] && { cp "$p" "$MACOS/dragonx-cli"; chmod +x "$MACOS/dragonx-cli"; info " Bundled dragonx-cli"; break; }
|
||||
done
|
||||
else
|
||||
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
|
||||
fi
|
||||
else
|
||||
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
|
||||
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
|
||||
fi
|
||||
|
||||
# xmrig binary (from prebuilt-binaries/xmrig-hac/)
|
||||
@@ -930,11 +1073,13 @@ TOOLCHAIN
|
||||
warn "xmrig not found — mining unavailable in .app"
|
||||
fi
|
||||
|
||||
# asmap.dat — placed in MacOS/ so the daemon finds it next to its binary
|
||||
find_asmap 2>/dev/null && {
|
||||
cp "$ASMAP_DAT" "$MACOS/asmap.dat"
|
||||
info " Bundled asmap.dat"
|
||||
}
|
||||
if should_bundle_full_node_assets; then
|
||||
# asmap.dat — placed in MacOS/ so the daemon finds it next to its binary
|
||||
find_asmap 2>/dev/null && {
|
||||
cp "$ASMAP_DAT" "$MACOS/asmap.dat"
|
||||
info " Bundled asmap.dat"
|
||||
}
|
||||
fi
|
||||
|
||||
# Bundle SDL3 dylib
|
||||
local sdl_dylib=""
|
||||
@@ -954,26 +1099,34 @@ TOOLCHAIN
|
||||
# Fix the rpath so the binary finds SDL3 in Frameworks/
|
||||
if $IS_CROSS; then
|
||||
local INSTALL_NAME_TOOL="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-install_name_tool"
|
||||
[[ -x "$INSTALL_NAME_TOOL" ]] && "$INSTALL_NAME_TOOL" -change "@rpath/$sdl_name" "@executable_path/../Frameworks/$sdl_name" "$MACOS/ObsidianDragon" 2>/dev/null || true
|
||||
[[ -x "$INSTALL_NAME_TOOL" ]] && "$INSTALL_NAME_TOOL" -change "@rpath/$sdl_name" "@executable_path/../Frameworks/$sdl_name" "$MACOS/${APP_BASENAME}" 2>/dev/null || true
|
||||
else
|
||||
install_name_tool -change "@rpath/$sdl_name" "@executable_path/../Frameworks/$sdl_name" "$MACOS/ObsidianDragon" 2>/dev/null || true
|
||||
install_name_tool -change "@rpath/$sdl_name" "@executable_path/../Frameworks/$sdl_name" "$MACOS/${APP_BASENAME}" 2>/dev/null || true
|
||||
fi
|
||||
info " Bundled $sdl_name"
|
||||
fi
|
||||
|
||||
# Launcher script (ensures working dir + dylib path)
|
||||
mv "$MACOS/ObsidianDragon" "$MACOS/ObsidianDragon.bin"
|
||||
cat > "$MACOS/ObsidianDragon" <<'LAUNCH'
|
||||
# Launcher script (ensures working dir + dylib path). Uses ${APP_BASENAME} so the lite
|
||||
# variant (ObsidianDragonLite) gets a correctly-named launcher + .bin pair.
|
||||
mv "$MACOS/${APP_BASENAME}" "$MACOS/${APP_BASENAME}.bin"
|
||||
cat > "$MACOS/${APP_BASENAME}" <<LAUNCH
|
||||
#!/bin/bash
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
export DYLD_LIBRARY_PATH="$DIR/../Frameworks:$DYLD_LIBRARY_PATH"
|
||||
export DRAGONX_RES_PATH="$DIR/../Resources/res"
|
||||
cd "$DIR/../Resources"
|
||||
exec "$DIR/ObsidianDragon.bin" "$@"
|
||||
DIR="\$(cd "\$(dirname "\$0")" && pwd)"
|
||||
export DYLD_LIBRARY_PATH="\$DIR/../Frameworks:\$DYLD_LIBRARY_PATH"
|
||||
export DRAGONX_RES_PATH="\$DIR/../Resources/res"
|
||||
cd "\$DIR/../Resources"
|
||||
exec "\$DIR/${APP_BASENAME}.bin" "\$@"
|
||||
LAUNCH
|
||||
chmod +x "$MACOS/ObsidianDragon"
|
||||
chmod +x "$MACOS/${APP_BASENAME}"
|
||||
|
||||
# Info.plist
|
||||
# Info.plist — display name + bundle id differ per variant so lite and full-node .apps
|
||||
# can coexist; the executable matches the launcher (${APP_BASENAME}); the icon is shared.
|
||||
local APP_DISPLAY_NAME="DragonX Wallet"
|
||||
local APP_BUNDLE_ID="is.hush.dragonx"
|
||||
if $DO_LITE; then
|
||||
APP_DISPLAY_NAME="DragonX Wallet Lite"
|
||||
APP_BUNDLE_ID="is.hush.dragonx.lite"
|
||||
fi
|
||||
cat > "$CONTENTS/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
@@ -981,17 +1134,17 @@ LAUNCH
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>DragonX Wallet</string>
|
||||
<string>${APP_DISPLAY_NAME}</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>DragonX Wallet</string>
|
||||
<string>${APP_DISPLAY_NAME}</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>is.hush.dragonx</string>
|
||||
<string>${APP_BUNDLE_ID}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${VERSION}</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>ObsidianDragon</string>
|
||||
<string>${APP_BASENAME}</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>ObsidianDragon</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
@@ -1053,25 +1206,27 @@ PLIST
|
||||
info ".app bundle created: $APP"
|
||||
|
||||
# ── Zip the .app bundle ──────────────────────────────────────────────────
|
||||
local APP_ZIP="ObsidianDragon-${VERSION}-macOS-${MAC_ARCH}.app.zip"
|
||||
local APP_ZIP="${APP_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.app.zip"
|
||||
if command -v zip &>/dev/null; then
|
||||
(cd "$out" && zip -r "$APP_ZIP" "ObsidianDragon.app")
|
||||
(cd "$out" && zip -r "$APP_ZIP" "${APP_BASENAME}.app")
|
||||
info "App zip: $out/$APP_ZIP ($(du -h "$out/$APP_ZIP" | cut -f1))"
|
||||
fi
|
||||
|
||||
# ── Create DMG ───────────────────────────────────────────────────────────
|
||||
local DMG_NAME="DragonX_Wallet-${VERSION}-macOS-${MAC_ARCH}.dmg"
|
||||
local DMG_BASENAME="DragonX_Wallet"
|
||||
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite"
|
||||
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
|
||||
|
||||
if command -v create-dmg &>/dev/null; then
|
||||
# create-dmg (works on macOS; also available on Linux via npm)
|
||||
info "Creating DMG with create-dmg ..."
|
||||
create-dmg \
|
||||
--volname "DragonX Wallet" \
|
||||
--volname "${APP_DISPLAY_NAME}" \
|
||||
--volicon "$RESOURCES/ObsidianDragon.icns" \
|
||||
--window-pos 200 120 \
|
||||
--window-size 600 400 \
|
||||
--icon-size 100 \
|
||||
--icon "ObsidianDragon.app" 150 190 \
|
||||
--icon "${APP_BASENAME}.app" 150 190 \
|
||||
--app-drop-link 450 190 \
|
||||
--no-internet-enable \
|
||||
"$out/$DMG_NAME" \
|
||||
@@ -1086,7 +1241,7 @@ PLIST
|
||||
mkdir -p "$staging"
|
||||
cp -a "$APP" "$staging/"
|
||||
ln -s /Applications "$staging/Applications"
|
||||
hdiutil create -volname "DragonX Wallet" \
|
||||
hdiutil create -volname "${APP_DISPLAY_NAME}" \
|
||||
-srcfolder "$staging" \
|
||||
-ov -format UDZO \
|
||||
"$out/$DMG_NAME" 2>/dev/null && {
|
||||
@@ -1102,7 +1257,7 @@ PLIST
|
||||
cp -a "$APP" "$staging/"
|
||||
# Can't create a real symlink to /Applications in an ISO, but the .app
|
||||
# is the important part — users drag it to Applications manually.
|
||||
genisoimage -V "DragonX Wallet" \
|
||||
genisoimage -V "${APP_DISPLAY_NAME}" \
|
||||
-D -R -apple -no-pad \
|
||||
-o "$out/$DMG_NAME" \
|
||||
"$staging" 2>/dev/null && {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
|
||||
<!-- Application identity —————————————————————————————— -->
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="DragonX.ObsidianDragon.Wallet"
|
||||
version="1.2.0.0"
|
||||
processorArchitecture="amd64"
|
||||
/>
|
||||
|
||||
<description>ObsidianDragon Wallet</description>
|
||||
|
||||
<!-- Common Controls v6 (themed buttons, etc.) ————————— -->
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
|
||||
<!-- DPI awareness (Per-Monitor V2) ————————————————————— -->
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2,PerMonitor</dpiAwareness>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<!-- Supported OS declarations (Windows 7 → 11) ———————— -->
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
</assembly>
|
||||
@@ -5,7 +5,7 @@
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="DragonX.ObsidianDragon.Wallet"
|
||||
version="@PROJECT_VERSION_MAJOR@.@PROJECT_VERSION_MINOR@.@PROJECT_VERSION_PATCH@.0"
|
||||
version="@DRAGONX_APP_VERSION_MAJOR@.@DRAGONX_APP_VERSION_MINOR@.@DRAGONX_APP_VERSION_PATCH@.0"
|
||||
processorArchitecture="amd64"
|
||||
/>
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
#include <winver.h>
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0
|
||||
PRODUCTVERSION @PROJECT_VERSION_MAJOR@,@PROJECT_VERSION_MINOR@,@PROJECT_VERSION_PATCH@,0
|
||||
FILEVERSION @DRAGONX_APP_VERSION_MAJOR@,@DRAGONX_APP_VERSION_MINOR@,@DRAGONX_APP_VERSION_PATCH@,0
|
||||
PRODUCTVERSION @DRAGONX_APP_VERSION_MAJOR@,@DRAGONX_APP_VERSION_MINOR@,@DRAGONX_APP_VERSION_PATCH@,0
|
||||
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
@@ -32,13 +32,13 @@ BEGIN
|
||||
BLOCK "040904B0" // US-English, Unicode
|
||||
BEGIN
|
||||
VALUE "CompanyName", "DragonX Developers\0"
|
||||
VALUE "FileDescription", "ObsidianDragon Wallet\0"
|
||||
VALUE "FileVersion", "@PROJECT_VERSION@\0"
|
||||
VALUE "InternalName", "ObsidianDragon\0"
|
||||
VALUE "FileDescription", "@DRAGONX_APP_NAME@ Wallet\0"
|
||||
VALUE "FileVersion", "@DRAGONX_APP_VERSION@@DRAGONX_APP_VERSION_SUFFIX@\0"
|
||||
VALUE "InternalName", "@DRAGONX_APP_NAME@\0"
|
||||
VALUE "LegalCopyright", "Copyright 2024-2026 DragonX Developers. GPLv3.\0"
|
||||
VALUE "OriginalFilename", "ObsidianDragon.exe\0"
|
||||
VALUE "ProductName", "ObsidianDragon\0"
|
||||
VALUE "ProductVersion", "@PROJECT_VERSION@\0"
|
||||
VALUE "OriginalFilename", "@DRAGONX_APP_NAME@.exe\0"
|
||||
VALUE "ProductName", "@DRAGONX_APP_NAME@\0"
|
||||
VALUE "ProductVersion", "@DRAGONX_APP_VERSION@@DRAGONX_APP_VERSION_SUFFIX@\0"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
||||
BIN
res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf
Normal file
BIN
res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf
Normal file
Binary file not shown.
@@ -346,6 +346,7 @@
|
||||
"market_now": "Jetzt",
|
||||
"market_pct_shielded": "%.0f%% Abgeschirmt",
|
||||
"market_portfolio": "PORTFOLIO",
|
||||
"market_price_loading": "Preisdaten werden geladen...",
|
||||
"market_price_unavailable": "Preisdaten nicht verfügbar",
|
||||
"market_refresh_price": "Preisdaten aktualisieren",
|
||||
"market_trade_on": "Handeln auf %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "Ahora",
|
||||
"market_pct_shielded": "%.0f%% Protegido",
|
||||
"market_portfolio": "PORTAFOLIO",
|
||||
"market_price_loading": "Cargando datos de precio...",
|
||||
"market_price_unavailable": "Datos de precio no disponibles",
|
||||
"market_refresh_price": "Actualizar datos de precio",
|
||||
"market_trade_on": "Operar en %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "Maintenant",
|
||||
"market_pct_shielded": "%.0f%% Blindé",
|
||||
"market_portfolio": "PORTEFEUILLE",
|
||||
"market_price_loading": "Chargement des données de prix...",
|
||||
"market_price_unavailable": "Données de prix indisponibles",
|
||||
"market_refresh_price": "Actualiser les données de prix",
|
||||
"market_trade_on": "Échanger sur %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "現在",
|
||||
"market_pct_shielded": "%.0f%% シールド済み",
|
||||
"market_portfolio": "ポートフォリオ",
|
||||
"market_price_loading": "価格データを読み込み中...",
|
||||
"market_price_unavailable": "価格データが利用できません",
|
||||
"market_refresh_price": "価格データを更新",
|
||||
"market_trade_on": "%s で取引",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "현재",
|
||||
"market_pct_shielded": "%.0f%% 차폐됨",
|
||||
"market_portfolio": "포트폴리오",
|
||||
"market_price_loading": "가격 데이터를 불러오는 중...",
|
||||
"market_price_unavailable": "가격 데이터를 사용할 수 없습니다",
|
||||
"market_refresh_price": "가격 데이터 새로고침",
|
||||
"market_trade_on": "%s에서 거래",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "Agora",
|
||||
"market_pct_shielded": "%.0f%% Blindado",
|
||||
"market_portfolio": "PORTFÓLIO",
|
||||
"market_price_loading": "Carregando dados de preço...",
|
||||
"market_price_unavailable": "Dados de preço indisponíveis",
|
||||
"market_refresh_price": "Atualizar dados de preço",
|
||||
"market_trade_on": "Negociar no %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "Сейчас",
|
||||
"market_pct_shielded": "%.0f%% Экранировано",
|
||||
"market_portfolio": "ПОРТФЕЛЬ",
|
||||
"market_price_loading": "Загрузка данных о ценах...",
|
||||
"market_price_unavailable": "Данные о ценах недоступны",
|
||||
"market_refresh_price": "Обновить данные о ценах",
|
||||
"market_trade_on": "Торговать на %s",
|
||||
|
||||
@@ -346,6 +346,7 @@
|
||||
"market_now": "现在",
|
||||
"market_pct_shielded": "%.0f%% 屏蔽",
|
||||
"market_portfolio": "投资组合",
|
||||
"market_price_loading": "正在加载价格数据...",
|
||||
"market_price_unavailable": "价格数据不可用",
|
||||
"market_refresh_price": "刷新价格数据",
|
||||
"market_trade_on": "在 %s 交易",
|
||||
|
||||
@@ -485,12 +485,12 @@ suggestion-trunc-len = { size = 50 }
|
||||
fee-rounding = { size = 10.0 }
|
||||
amount-bar-max-btn-width = { size = 80.0 }
|
||||
amount-bar-height = { size = 22.0 }
|
||||
confirm-popup-max-width = { size = 420.0 }
|
||||
confirm-popup-max-width = { size = 560.0 }
|
||||
confirm-addr-card-height = { size = 28.0 }
|
||||
confirm-amount-card-height = { size = 70.0 }
|
||||
confirm-row-step = { size = 16.0 }
|
||||
confirm-val-col-x = { size = 90.0 }
|
||||
confirm-usd-col-x = { size = 80.0 }
|
||||
confirm-amount-card-height = { size = 96.0 }
|
||||
confirm-row-step = { size = 24.0 }
|
||||
confirm-val-col-x = { size = 150.0 }
|
||||
confirm-usd-col-x = { size = 116.0 }
|
||||
progress-card-height = { size = 36.0 }
|
||||
progress-card-height-txid = { size = 52.0 }
|
||||
progress-card-pad-x = { size = 12.0 }
|
||||
@@ -516,10 +516,10 @@ error-icon-inset = { size = 20.0 }
|
||||
error-btn-rounding = { size = 4.0 }
|
||||
progress-glass-rounding-ratio = { size = 0.75 }
|
||||
confirm-addr-card-min-height = { size = 24.0 }
|
||||
confirm-val-col-min-x = { size = 70.0 }
|
||||
confirm-usd-col-min-x = { size = 60.0 }
|
||||
confirm-amount-card-min-height = { size = 54.0 }
|
||||
confirm-row-step-min = { size = 12.0 }
|
||||
confirm-val-col-min-x = { size = 112.0 }
|
||||
confirm-usd-col-min-x = { size = 92.0 }
|
||||
confirm-amount-card-min-height = { size = 82.0 }
|
||||
confirm-row-step-min = { size = 20.0 }
|
||||
action-btn-min-height = { size = 26.0 }
|
||||
recent-icon-min-size = { size = 3.5 }
|
||||
recent-icon-size = { size = 5.0 }
|
||||
@@ -566,7 +566,7 @@ txid-trunc-len = { size = 14 }
|
||||
txid-label-x-offset = { size = 20.0 }
|
||||
txid-copy-btn-right-offset = { size = 50.0 }
|
||||
txid-copy-btn-y-offset = { size = 2.0 }
|
||||
confirm-popup-width-ratio = { size = 0.85 }
|
||||
confirm-popup-width-ratio = { size = 0.92 }
|
||||
confirm-glass-rounding-ratio = { size = 0.75 }
|
||||
confirm-addr-trunc-len = { size = 48 }
|
||||
confirm-divider-thickness = { size = 1.0 }
|
||||
@@ -1279,7 +1279,7 @@ page-fade-speed = { size = 8.0 }
|
||||
collapse-hysteresis = { size = 60.0 }
|
||||
header-icon = { icon-dark = "logos/logo_ObsidianDragon_dark.png", icon-light = "logos/logo_ObsidianDragon_light.png" }
|
||||
coin-icon = { icon = "logos/logo_dragonx_128.png" }
|
||||
header-title = { font = "subtitle1", size = 14.0, pad-x = 22.0, pad-y = 6.0, logo-gap = 4.0, opacity = 0.7, offset-y = 4.0 }
|
||||
header-title = { font = "subtitle1", size = 12.0, pad-x = 8.0, pad-y = 10.0, logo-gap = 4.0, opacity = 0.7, offset-y = -2.0 }
|
||||
|
||||
[components.main-window.window]
|
||||
padding = [12, 36]
|
||||
@@ -1378,6 +1378,17 @@ summary = { min-width = 280.0, max-width = 400.0, width-ratio = 0.32, min-height
|
||||
side-panel = { min-width = 280.0, max-width = 450.0, width-ratio = 0.4, min-height = 200.0, max-height = 350.0, height-ratio = 0.8 }
|
||||
table = { min-height = 150.0, height-ratio = 0.45, min-remaining = 100.0, default-reserve = 30.0 }
|
||||
|
||||
[dialog]
|
||||
width-default = 480.0
|
||||
width-lg = 600.0
|
||||
width-xl = 660.0
|
||||
min-width = 280.0
|
||||
form-width = 400.0
|
||||
action-width = 100.0
|
||||
action-gap = 8.0
|
||||
max-height-ratio = 0.94
|
||||
compact-bottom-ratio = 0.64
|
||||
|
||||
[button]
|
||||
min-width = 180.0
|
||||
width = 140.0
|
||||
|
||||
801
scripts/build-lite-backend-artifact.sh
Executable file
801
scripts/build-lite-backend-artifact.sh
Executable file
@@ -0,0 +1,801 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
ABI_VERSION="sdxl-c-v1"
|
||||
LINK_MODE="imported"
|
||||
BACKEND_DIR="$PROJECT_ROOT/third_party/silentdragonxlite/lib"
|
||||
BACKEND_SOURCE_DIR=""
|
||||
BUILD_BACKEND_DIR=""
|
||||
BACKEND_DEPENDENCY_DIR=""
|
||||
BACKEND_DEPENDENCY_OVERRIDE_REQUESTED=false
|
||||
OUT_DIR="$PROJECT_ROOT/build/lite-backend"
|
||||
PLATFORM=""
|
||||
RUST_TARGET=""
|
||||
CARGO_TARGET_DIR_VALUE="${CARGO_TARGET_DIR:-}"
|
||||
ARTIFACT_PATH=""
|
||||
BUILD_ARTIFACT=true
|
||||
BUILDER="${DRAGONX_LITE_BACKEND_BUILDER:-local}"
|
||||
JOBS="${JOBS:-}"
|
||||
SOURCE_DATE_EPOCH_VALUE="${SOURCE_DATE_EPOCH:-}"
|
||||
REPRODUCIBLE=false
|
||||
SIGNATURE_REQUIRED=false
|
||||
SIGNATURE_FILE=""
|
||||
SIGNATURE_FORMAT=""
|
||||
SIGNATURE_VERIFICATION_TOOL=""
|
||||
SIGNATURE_VERIFICATION_COMMAND=""
|
||||
SIGNATURE_KEY_FINGERPRINT=""
|
||||
SIGNATURE_CERTIFICATE_IDENTITY=""
|
||||
SIGNATURE_CERTIFICATE_ISSUER=""
|
||||
SIGNATURE_TRANSPARENCY_LOG_URL=""
|
||||
SIGNATURE_VERIFIED_SHA256=""
|
||||
SIGNATURE_POLICY_NAME="dragonx-lite-backend-signature-policy-v1"
|
||||
SIGNATURE_POLICY_DEFINED_MANIFEST_VALUE=true
|
||||
SIGNATURE_REQUIRED_MANIFEST_VALUE=false
|
||||
SIGNATURE_METADATA_PROVIDED=false
|
||||
SIGNATURE_VERIFICATION_PERFORMED=false
|
||||
SIGNATURE_VERIFICATION_STATUS="not-provided"
|
||||
SIGNATURE_FILE_SHA256=""
|
||||
|
||||
REQUIRED_SYMBOLS=(
|
||||
litelib_wallet_exists
|
||||
litelib_initialize_new
|
||||
litelib_initialize_new_from_phrase
|
||||
litelib_initialize_existing
|
||||
litelib_execute
|
||||
litelib_rust_free_string
|
||||
litelib_check_server_online
|
||||
litelib_shutdown
|
||||
)
|
||||
|
||||
EXTRA_CARGO_ARGS=()
|
||||
EXTRA_REMAP_PATH_PREFIXES=()
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Build or inventory the SDXL-compatible lite backend artifact.
|
||||
|
||||
Usage: $0 [options]
|
||||
|
||||
Options:
|
||||
--platform linux|windows|macos Artifact platform. Defaults to host platform.
|
||||
--rust-target TRIPLE Cargo target triple for cross builds.
|
||||
--cargo-target-dir PATH Isolated Cargo target directory for clean builds.
|
||||
--backend-dir PATH SilentDragonXLite/lib source directory.
|
||||
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
|
||||
--out-dir PATH Output directory for copied artifact and metadata.
|
||||
--artifact PATH Inventory an existing artifact instead of building.
|
||||
--no-build Do not run cargo; requires --artifact.
|
||||
--reproducible Add deterministic Rust path remaps for clean builds.
|
||||
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
|
||||
--builder NAME Redacted builder/provenance label. Default: local.
|
||||
--signature-required Fail if verified signature metadata is not supplied.
|
||||
--signature-file PATH Existing sidecar signature file to record.
|
||||
--signature-format FORMAT Signature format: minisign, gpg, sigstore, external, or other.
|
||||
--signature-verification-tool T Verification tool and version used by the release builder.
|
||||
--signature-verification-command C
|
||||
Verification command already run by the release builder.
|
||||
--signature-key-fingerprint F Reviewed public-key fingerprint, when applicable.
|
||||
--signature-certificate-identity ID
|
||||
Reviewed certificate identity, when applicable.
|
||||
--signature-certificate-issuer I
|
||||
Reviewed certificate issuer, when applicable.
|
||||
--signature-transparency-log-url URL
|
||||
Transparency log entry, when applicable.
|
||||
--signature-verified-sha256 SHA Artifact SHA-256 verified by the signature check.
|
||||
-j, --jobs N Cargo parallel jobs.
|
||||
--cargo-arg ARG Extra argument forwarded to cargo build.
|
||||
-h, --help Show this help.
|
||||
|
||||
Outputs:
|
||||
<out>/<platform>/<artifact>
|
||||
<out>/<platform>/lite-backend-symbols.txt
|
||||
<out>/<platform>/lite-backend-artifact-manifest.json
|
||||
|
||||
The script captures symbols, checksums, and optional read-only signature
|
||||
verification metadata only. It does not load the library, resolve function
|
||||
pointers, call SDXL, sign, upload, or publish artifacts.
|
||||
EOF
|
||||
}
|
||||
|
||||
info() { printf '[lite-backend] %s\n' "$*"; }
|
||||
warn() { printf '[lite-backend] warning: %s\n' "$*" >&2; }
|
||||
die() { printf '[lite-backend] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
absolute_path() {
|
||||
local path="$1"
|
||||
if [[ "$path" = /* ]]; then
|
||||
printf '%s\n' "$path"
|
||||
else
|
||||
printf '%s/%s\n' "$PWD" "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
host_platform() {
|
||||
case "$(uname -s)" in
|
||||
Linux) printf 'linux\n' ;;
|
||||
Darwin) printf 'macos\n' ;;
|
||||
MINGW*|MSYS*|CYGWIN*) printf 'windows\n' ;;
|
||||
*) die "unsupported host platform: $(uname -s)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
normalize_platform() {
|
||||
case "$1" in
|
||||
linux|Linux) printf 'linux\n' ;;
|
||||
windows|win|Win|Windows) printf 'windows\n' ;;
|
||||
macos|mac|darwin|Darwin) printf 'macos\n' ;;
|
||||
"") host_platform ;;
|
||||
*) die "unsupported platform: $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--platform)
|
||||
[[ $# -ge 2 ]] || die "--platform requires a value"
|
||||
PLATFORM="$(normalize_platform "$2")"
|
||||
shift 2
|
||||
;;
|
||||
--rust-target)
|
||||
[[ $# -ge 2 ]] || die "--rust-target requires a value"
|
||||
RUST_TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--cargo-target-dir)
|
||||
[[ $# -ge 2 ]] || die "--cargo-target-dir requires a value"
|
||||
CARGO_TARGET_DIR_VALUE="$(absolute_path "$2")"
|
||||
shift 2
|
||||
;;
|
||||
--backend-dir)
|
||||
[[ $# -ge 2 ]] || die "--backend-dir requires a value"
|
||||
BACKEND_DIR="$(absolute_path "$2")"
|
||||
shift 2
|
||||
;;
|
||||
--silentdragonxlitelib-dir)
|
||||
[[ $# -ge 2 ]] || die "--silentdragonxlitelib-dir requires a value"
|
||||
BACKEND_DEPENDENCY_DIR="$(absolute_path "$2")"
|
||||
BACKEND_DEPENDENCY_OVERRIDE_REQUESTED=true
|
||||
shift 2
|
||||
;;
|
||||
--out-dir)
|
||||
[[ $# -ge 2 ]] || die "--out-dir requires a value"
|
||||
OUT_DIR="$(absolute_path "$2")"
|
||||
shift 2
|
||||
;;
|
||||
--artifact)
|
||||
[[ $# -ge 2 ]] || die "--artifact requires a value"
|
||||
ARTIFACT_PATH="$(absolute_path "$2")"
|
||||
BUILD_ARTIFACT=false
|
||||
shift 2
|
||||
;;
|
||||
--no-build)
|
||||
BUILD_ARTIFACT=false
|
||||
shift
|
||||
;;
|
||||
--reproducible)
|
||||
REPRODUCIBLE=true
|
||||
shift
|
||||
;;
|
||||
--remap-path-prefix)
|
||||
[[ $# -ge 2 ]] || die "--remap-path-prefix requires FROM=TO"
|
||||
[[ "$2" == *=* ]] || die "--remap-path-prefix requires FROM=TO"
|
||||
EXTRA_REMAP_PATH_PREFIXES+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--builder)
|
||||
[[ $# -ge 2 ]] || die "--builder requires a value"
|
||||
BUILDER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-required)
|
||||
SIGNATURE_REQUIRED=true
|
||||
shift
|
||||
;;
|
||||
--signature-file|--signature-path)
|
||||
[[ $# -ge 2 ]] || die "$1 requires a value"
|
||||
SIGNATURE_FILE="$(absolute_path "$2")"
|
||||
shift 2
|
||||
;;
|
||||
--signature-format)
|
||||
[[ $# -ge 2 ]] || die "--signature-format requires a value"
|
||||
SIGNATURE_FORMAT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-verification-tool|--signature-tool)
|
||||
[[ $# -ge 2 ]] || die "$1 requires a value"
|
||||
SIGNATURE_VERIFICATION_TOOL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-verification-command)
|
||||
[[ $# -ge 2 ]] || die "--signature-verification-command requires a value"
|
||||
SIGNATURE_VERIFICATION_COMMAND="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-key-fingerprint)
|
||||
[[ $# -ge 2 ]] || die "--signature-key-fingerprint requires a value"
|
||||
SIGNATURE_KEY_FINGERPRINT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-certificate-identity)
|
||||
[[ $# -ge 2 ]] || die "--signature-certificate-identity requires a value"
|
||||
SIGNATURE_CERTIFICATE_IDENTITY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-certificate-issuer)
|
||||
[[ $# -ge 2 ]] || die "--signature-certificate-issuer requires a value"
|
||||
SIGNATURE_CERTIFICATE_ISSUER="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-transparency-log-url)
|
||||
[[ $# -ge 2 ]] || die "--signature-transparency-log-url requires a value"
|
||||
SIGNATURE_TRANSPARENCY_LOG_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--signature-verified-sha256)
|
||||
[[ $# -ge 2 ]] || die "--signature-verified-sha256 requires a value"
|
||||
SIGNATURE_VERIFIED_SHA256="$2"
|
||||
shift 2
|
||||
;;
|
||||
-j|--jobs)
|
||||
[[ $# -ge 2 ]] || die "--jobs requires a value"
|
||||
JOBS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--cargo-arg)
|
||||
[[ $# -ge 2 ]] || die "--cargo-arg requires a value"
|
||||
EXTRA_CARGO_ARGS+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PLATFORM="$(normalize_platform "$PLATFORM")"
|
||||
BACKEND_SOURCE_DIR="$BACKEND_DIR"
|
||||
BUILD_BACKEND_DIR="$BACKEND_SOURCE_DIR"
|
||||
|
||||
if [[ "$PLATFORM" == "windows" && -z "$RUST_TARGET" ]]; then
|
||||
RUST_TARGET="x86_64-pc-windows-gnu"
|
||||
fi
|
||||
if [[ "$PLATFORM" == "macos" && -z "$RUST_TARGET" && "$(host_platform)" != "macos" ]]; then
|
||||
die "macOS artifacts require --rust-target when not running on macOS"
|
||||
fi
|
||||
if [[ "$BUILD_ARTIFACT" == false && -z "$ARTIFACT_PATH" ]]; then
|
||||
die "--no-build requires --artifact"
|
||||
fi
|
||||
|
||||
backend_dependency_path_from_cargo() {
|
||||
local cargo_toml="$1"
|
||||
awk '
|
||||
/^[[:space:]]*silentdragonxlitelib[[:space:]]*=/ {
|
||||
original = $0
|
||||
path = $0
|
||||
sub(/.*path[[:space:]]*=[[:space:]]*"/, "", path)
|
||||
sub(/".*/, "", path)
|
||||
if (path != original) print path
|
||||
exit
|
||||
}
|
||||
' "$cargo_toml"
|
||||
}
|
||||
|
||||
canonical_dependency_path() {
|
||||
local path="$1"
|
||||
if [[ -d "$path" ]]; then
|
||||
(cd "$path" && pwd -P)
|
||||
else
|
||||
absolute_path "$path"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_backend_dependency_source() {
|
||||
[[ -n "$BACKEND_DEPENDENCY_DIR" ]] || return
|
||||
|
||||
if [[ ! -f "$BACKEND_DEPENDENCY_DIR/Cargo.toml" ]]; then
|
||||
if [[ "$BUILD_ARTIFACT" == true || "$BACKEND_DEPENDENCY_OVERRIDE_REQUESTED" == true ]]; then
|
||||
die "Cargo.toml not found in $BACKEND_DEPENDENCY_DIR"
|
||||
fi
|
||||
warn "Cargo.toml not found in silentdragonxlitelib source: $BACKEND_DEPENDENCY_DIR"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! grep -Eq '^[[:space:]]*name[[:space:]]*=[[:space:]]*"silentdragonxlitelib"' "$BACKEND_DEPENDENCY_DIR/Cargo.toml"; then
|
||||
if [[ "$BUILD_ARTIFACT" == true || "$BACKEND_DEPENDENCY_OVERRIDE_REQUESTED" == true ]]; then
|
||||
die "dependency path does not look like silentdragonxlitelib: $BACKEND_DEPENDENCY_DIR"
|
||||
fi
|
||||
warn "dependency path does not look like silentdragonxlitelib: $BACKEND_DEPENDENCY_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
# Ensure the Sapling proving params are present in the core crate (rust-embed bakes them in at build
|
||||
# time). They are the fixed Zcash trusted-setup output — not buildable — so fetch + verify them from
|
||||
# git.dragonx.is when absent. Override the source with SAPLING_PARAMS_BASE_URL.
|
||||
SAPLING_PARAMS_BASE_URL="${SAPLING_PARAMS_BASE_URL:-https://git.dragonx.is/DragonX/zcash-params/releases/download/sapling-v1}"
|
||||
ensure_sapling_params() {
|
||||
local dir="$1"
|
||||
[[ -n "$dir" ]] || return 0
|
||||
mkdir -p "$dir"
|
||||
local specs=(
|
||||
"sapling-spend.params:8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"
|
||||
"sapling-output.params:2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"
|
||||
)
|
||||
local spec name want path got
|
||||
for spec in "${specs[@]}"; do
|
||||
name="${spec%%:*}"; want="${spec##*:}"; path="$dir/$name"
|
||||
if [[ -f "$path" ]] && [[ "$(compute_sha256 "$path")" == "$want" ]]; then
|
||||
info "sapling param present and verified: $name"
|
||||
continue
|
||||
fi
|
||||
info "fetching $name from $SAPLING_PARAMS_BASE_URL"
|
||||
curl -fsSL "$SAPLING_PARAMS_BASE_URL/$name" -o "$path" || die "failed to download sapling param: $name"
|
||||
got="$(compute_sha256 "$path")"
|
||||
[[ "$got" == "$want" ]] || { rm -f "$path"; die "sapling param $name sha256 mismatch (got $got, want $want)"; }
|
||||
info "downloaded and verified $name"
|
||||
done
|
||||
}
|
||||
|
||||
prepare_backend_source() {
|
||||
BUILD_BACKEND_DIR="$BACKEND_SOURCE_DIR"
|
||||
|
||||
if [[ "$BACKEND_DEPENDENCY_OVERRIDE_REQUESTED" == false ]]; then
|
||||
if [[ -f "$BACKEND_SOURCE_DIR/Cargo.toml" ]]; then
|
||||
local configured_dependency_path
|
||||
configured_dependency_path="$(backend_dependency_path_from_cargo "$BACKEND_SOURCE_DIR/Cargo.toml")"
|
||||
if [[ -n "$configured_dependency_path" ]]; then
|
||||
if [[ "$configured_dependency_path" = /* ]]; then
|
||||
BACKEND_DEPENDENCY_DIR="$(canonical_dependency_path "$configured_dependency_path")"
|
||||
warn "backend Cargo.toml uses an absolute silentdragonxlitelib path; use --silentdragonxlitelib-dir for portable builders"
|
||||
else
|
||||
BACKEND_DEPENDENCY_DIR="$(canonical_dependency_path "$BACKEND_SOURCE_DIR/$configured_dependency_path")"
|
||||
info "using relative silentdragonxlitelib dependency at $BACKEND_DEPENDENCY_DIR"
|
||||
fi
|
||||
validate_backend_dependency_source
|
||||
fi
|
||||
fi
|
||||
return
|
||||
fi
|
||||
|
||||
[[ -f "$BACKEND_SOURCE_DIR/Cargo.toml" ]] || die "Cargo.toml not found in $BACKEND_SOURCE_DIR"
|
||||
validate_backend_dependency_source
|
||||
[[ "$BACKEND_DEPENDENCY_DIR" != *\"* ]] || die "--silentdragonxlitelib-dir path cannot contain a double quote"
|
||||
|
||||
local prepared_root="$OUT_DIR/.prepared-backend/$PLATFORM"
|
||||
[[ "$prepared_root" == */.prepared-backend/* ]] || die "refusing unsafe prepared backend path: $prepared_root"
|
||||
rm -rf "$prepared_root"
|
||||
mkdir -p "$prepared_root"
|
||||
|
||||
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
|
||||
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
|
||||
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
|
||||
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
|
||||
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
|
||||
# "vendor" relative to the build root, so expose it inside the prepared root too.
|
||||
[[ -d "$BACKEND_SOURCE_DIR/vendor" ]] && ln -s "$BACKEND_SOURCE_DIR/vendor" "$prepared_root/vendor"
|
||||
[[ -f "$BACKEND_SOURCE_DIR/silentdragonxlitelib.h" ]] && ln -s "$BACKEND_SOURCE_DIR/silentdragonxlitelib.h" "$prepared_root/silentdragonxlitelib.h"
|
||||
|
||||
local replacement="silentdragonxlitelib = { path = \"$BACKEND_DEPENDENCY_DIR\" }"
|
||||
awk -v replacement="$replacement" '
|
||||
BEGIN { replaced = 0 }
|
||||
/^[[:space:]]*silentdragonxlitelib[[:space:]]*=/ {
|
||||
print replacement
|
||||
replaced = 1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
END { if (replaced != 1) exit 42 }
|
||||
' "$BACKEND_SOURCE_DIR/Cargo.toml" > "$prepared_root/Cargo.toml" \
|
||||
|| die "failed to prepare backend Cargo.toml with portable silentdragonxlitelib path"
|
||||
|
||||
BUILD_BACKEND_DIR="$prepared_root"
|
||||
info "prepared backend source at $BUILD_BACKEND_DIR with silentdragonxlitelib from $BACKEND_DEPENDENCY_DIR"
|
||||
}
|
||||
|
||||
prepare_backend_source
|
||||
|
||||
artifact_kind() {
|
||||
local name="${1##*/}"
|
||||
case "$name" in
|
||||
*.a|*.lib) printf 'static-library\n' ;;
|
||||
*.so|*.dylib|*.dll) printf 'shared-library\n' ;;
|
||||
*) printf 'unknown\n' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cargo_output_candidates() {
|
||||
local cargo_target_root="$BUILD_BACKEND_DIR/target"
|
||||
if [[ -n "$CARGO_TARGET_DIR_VALUE" ]]; then
|
||||
cargo_target_root="$CARGO_TARGET_DIR_VALUE"
|
||||
fi
|
||||
|
||||
local base="$cargo_target_root/release"
|
||||
if [[ -n "$RUST_TARGET" ]]; then
|
||||
base="$cargo_target_root/$RUST_TARGET/release"
|
||||
fi
|
||||
|
||||
case "$PLATFORM" in
|
||||
linux)
|
||||
printf '%s\n' "$base/libsilentdragonxlite.a" "$base/silentdragonxlite.a" "$base/libsilentdragonxlite.so"
|
||||
;;
|
||||
windows)
|
||||
printf '%s\n' "$base/silentdragonxlite.lib" "$base/libsilentdragonxlite.a" "$base/silentdragonxlite.dll"
|
||||
;;
|
||||
macos)
|
||||
printf '%s\n' "$base/libsilentdragonxlite.a" "$base/silentdragonxlite.a" "$base/libsilentdragonxlite.dylib" "$base/silentdragonxlite.dylib"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
source_revision_for() {
|
||||
local dir="$1"
|
||||
local revision_file
|
||||
for revision_file in "$dir/DRAGONX_SOURCE_REVISION" "$dir/../DRAGONX_SOURCE_REVISION"; do
|
||||
if [[ -f "$revision_file" ]]; then
|
||||
sed -n '1p' "$revision_file"
|
||||
return
|
||||
fi
|
||||
done
|
||||
|
||||
if git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$dir" rev-parse HEAD 2>/dev/null || printf 'unknown'
|
||||
else
|
||||
printf 'unknown'
|
||||
fi
|
||||
}
|
||||
|
||||
default_source_date_epoch() {
|
||||
if git -C "$PROJECT_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
git -C "$PROJECT_ROOT" log -1 --format=%ct 2>/dev/null || printf '0'
|
||||
else
|
||||
printf '0'
|
||||
fi
|
||||
}
|
||||
|
||||
append_rustflag() {
|
||||
local rustflag="$1"
|
||||
if [[ -n "${RUSTFLAGS:-}" ]]; then
|
||||
export RUSTFLAGS="${RUSTFLAGS} ${rustflag}"
|
||||
else
|
||||
export RUSTFLAGS="$rustflag"
|
||||
fi
|
||||
}
|
||||
|
||||
append_rust_path_remap() {
|
||||
local from_path="$1"
|
||||
local to_path="$2"
|
||||
[[ -n "$from_path" && -n "$to_path" ]] || return
|
||||
append_rustflag "--remap-path-prefix=${from_path}=${to_path}"
|
||||
}
|
||||
|
||||
apply_reproducible_rustflags() {
|
||||
local cargo_target_root="$BUILD_BACKEND_DIR/target"
|
||||
if [[ -n "$CARGO_TARGET_DIR_VALUE" ]]; then
|
||||
cargo_target_root="$CARGO_TARGET_DIR_VALUE"
|
||||
fi
|
||||
|
||||
append_rust_path_remap "$PROJECT_ROOT" "/dragonx-project"
|
||||
append_rust_path_remap "$BACKEND_SOURCE_DIR" "/dragonx-lite-backend"
|
||||
if [[ "$BUILD_BACKEND_DIR" != "$BACKEND_SOURCE_DIR" ]]; then
|
||||
append_rust_path_remap "$BUILD_BACKEND_DIR" "/dragonx-lite-backend"
|
||||
fi
|
||||
append_rust_path_remap "$BACKEND_DEPENDENCY_DIR" "/dragonx-lite-backend-dependency"
|
||||
for path_remap in "${EXTRA_REMAP_PATH_PREFIXES[@]}"; do
|
||||
append_rustflag "--remap-path-prefix=${path_remap}"
|
||||
done
|
||||
|
||||
local cargo_home="${CARGO_HOME:-}"
|
||||
if [[ -z "$cargo_home" && -n "${HOME:-}" ]]; then
|
||||
cargo_home="$HOME/.cargo"
|
||||
fi
|
||||
if [[ -n "$cargo_home" && -d "$cargo_home" ]]; then
|
||||
append_rust_path_remap "$cargo_home" "/cargo-home"
|
||||
fi
|
||||
append_rust_path_remap "$cargo_target_root" "/dragonx-lite-cargo-target"
|
||||
}
|
||||
|
||||
build_with_cargo() {
|
||||
command -v cargo >/dev/null 2>&1 || die "cargo was not found"
|
||||
[[ -f "$BUILD_BACKEND_DIR/Cargo.toml" ]] || die "Cargo.toml not found in $BUILD_BACKEND_DIR"
|
||||
|
||||
if [[ -z "$SOURCE_DATE_EPOCH_VALUE" ]]; then
|
||||
SOURCE_DATE_EPOCH_VALUE="$(default_source_date_epoch)"
|
||||
fi
|
||||
|
||||
export CARGO_INCREMENTAL=0
|
||||
export SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH_VALUE"
|
||||
if [[ -n "$CARGO_TARGET_DIR_VALUE" ]]; then
|
||||
export CARGO_TARGET_DIR="$CARGO_TARGET_DIR_VALUE"
|
||||
fi
|
||||
if [[ "$REPRODUCIBLE" == true ]]; then
|
||||
apply_reproducible_rustflags
|
||||
fi
|
||||
if [[ "$PLATFORM" == "windows" && -d "$BUILD_BACKEND_DIR/libsodium-mingw" ]]; then
|
||||
export SODIUM_LIB_DIR="$BUILD_BACKEND_DIR/libsodium-mingw"
|
||||
fi
|
||||
|
||||
[[ -n "$BACKEND_DEPENDENCY_DIR" ]] && ensure_sapling_params "$BACKEND_DEPENDENCY_DIR/zcash-params"
|
||||
|
||||
local cargo_cmd=(cargo build --locked --lib --release)
|
||||
if [[ -n "$RUST_TARGET" ]]; then
|
||||
cargo_cmd+=(--target "$RUST_TARGET")
|
||||
fi
|
||||
if [[ -n "$JOBS" ]]; then
|
||||
cargo_cmd+=(-j "$JOBS")
|
||||
fi
|
||||
cargo_cmd+=("${EXTRA_CARGO_ARGS[@]}")
|
||||
|
||||
info "building backend in $BUILD_BACKEND_DIR"
|
||||
(cd "$BUILD_BACKEND_DIR" && "${cargo_cmd[@]}")
|
||||
|
||||
while IFS= read -r candidate; do
|
||||
if [[ -f "$candidate" ]]; then
|
||||
ARTIFACT_PATH="$candidate"
|
||||
return
|
||||
fi
|
||||
done < <(cargo_output_candidates)
|
||||
|
||||
die "cargo finished, but no expected backend artifact was found under $BUILD_BACKEND_DIR/target"
|
||||
}
|
||||
|
||||
select_nm_tool() {
|
||||
if [[ "$PLATFORM" == "windows" ]] && command -v x86_64-w64-mingw32-nm >/dev/null 2>&1; then
|
||||
printf 'x86_64-w64-mingw32-nm\n'
|
||||
return
|
||||
fi
|
||||
if command -v llvm-nm >/dev/null 2>&1; then
|
||||
printf 'llvm-nm\n'
|
||||
return
|
||||
fi
|
||||
if command -v nm >/dev/null 2>&1; then
|
||||
printf 'nm\n'
|
||||
return
|
||||
fi
|
||||
die "no symbol inventory tool found; install nm, llvm-nm, or x86_64-w64-mingw32-nm"
|
||||
}
|
||||
|
||||
compute_sha256() {
|
||||
local file="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$file" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$file" | awk '{print $1}'
|
||||
else
|
||||
die "sha256sum or shasum is required"
|
||||
fi
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
value="${value//$'\n'/\\n}"
|
||||
value="${value//$'\r'/}"
|
||||
value="${value//$'\t'/\\t}"
|
||||
printf '"%s"' "$value"
|
||||
}
|
||||
|
||||
json_array() {
|
||||
local first=true
|
||||
printf '['
|
||||
for value in "$@"; do
|
||||
if [[ "$first" == true ]]; then
|
||||
first=false
|
||||
else
|
||||
printf ','
|
||||
fi
|
||||
json_escape "$value"
|
||||
done
|
||||
printf ']'
|
||||
}
|
||||
|
||||
json_array_from_file() {
|
||||
local file="$1"
|
||||
local values=()
|
||||
if [[ -f "$file" ]]; then
|
||||
mapfile -t values < "$file"
|
||||
fi
|
||||
json_array "${values[@]}"
|
||||
}
|
||||
|
||||
signature_metadata_requested() {
|
||||
[[ "$SIGNATURE_REQUIRED" == true || \
|
||||
-n "$SIGNATURE_FILE" || \
|
||||
-n "$SIGNATURE_FORMAT" || \
|
||||
-n "$SIGNATURE_VERIFICATION_TOOL" || \
|
||||
-n "$SIGNATURE_VERIFICATION_COMMAND" || \
|
||||
-n "$SIGNATURE_KEY_FINGERPRINT" || \
|
||||
-n "$SIGNATURE_CERTIFICATE_IDENTITY" || \
|
||||
-n "$SIGNATURE_CERTIFICATE_ISSUER" || \
|
||||
-n "$SIGNATURE_TRANSPARENCY_LOG_URL" || \
|
||||
-n "$SIGNATURE_VERIFIED_SHA256" ]]
|
||||
}
|
||||
|
||||
validate_signature_metadata() {
|
||||
SIGNATURE_REQUIRED_MANIFEST_VALUE=false
|
||||
if [[ "$SIGNATURE_REQUIRED" == true ]]; then
|
||||
SIGNATURE_REQUIRED_MANIFEST_VALUE=true
|
||||
fi
|
||||
|
||||
if ! signature_metadata_requested; then
|
||||
return
|
||||
fi
|
||||
|
||||
[[ -n "$SIGNATURE_FILE" ]] || die "signature metadata requires --signature-file"
|
||||
[[ -f "$SIGNATURE_FILE" ]] || die "signature file does not exist: $SIGNATURE_FILE"
|
||||
[[ -n "$SIGNATURE_FORMAT" ]] || die "signature metadata requires --signature-format"
|
||||
case "$SIGNATURE_FORMAT" in
|
||||
minisign|gpg|sigstore|external|other) ;;
|
||||
*) die "unsupported --signature-format: $SIGNATURE_FORMAT" ;;
|
||||
esac
|
||||
[[ -n "$SIGNATURE_VERIFICATION_TOOL" ]] || die "signature metadata requires --signature-verification-tool"
|
||||
[[ -n "$SIGNATURE_VERIFIED_SHA256" ]] || die "signature metadata requires --signature-verified-sha256"
|
||||
[[ "$SIGNATURE_VERIFIED_SHA256" == "$SHA256_DIGEST" ]] || die "signature verified SHA-256 does not match artifact SHA-256"
|
||||
if [[ -z "$SIGNATURE_KEY_FINGERPRINT" && -z "$SIGNATURE_CERTIFICATE_IDENTITY" ]]; then
|
||||
die "signature metadata requires --signature-key-fingerprint or --signature-certificate-identity"
|
||||
fi
|
||||
|
||||
SIGNATURE_METADATA_PROVIDED=true
|
||||
SIGNATURE_VERIFICATION_PERFORMED=true
|
||||
SIGNATURE_VERIFICATION_STATUS="verified"
|
||||
SIGNATURE_FILE_SHA256="$(compute_sha256 "$SIGNATURE_FILE")"
|
||||
}
|
||||
|
||||
if [[ "$BUILD_ARTIFACT" == true ]]; then
|
||||
build_with_cargo
|
||||
fi
|
||||
if [[ -z "$SOURCE_DATE_EPOCH_VALUE" ]]; then
|
||||
SOURCE_DATE_EPOCH_VALUE="$(default_source_date_epoch)"
|
||||
fi
|
||||
|
||||
[[ -f "$ARTIFACT_PATH" ]] || die "artifact not found: $ARTIFACT_PATH"
|
||||
|
||||
KIND="$(artifact_kind "$ARTIFACT_PATH")"
|
||||
[[ "$KIND" != "unknown" ]] || die "artifact kind is unsupported: $ARTIFACT_PATH"
|
||||
|
||||
PLATFORM_OUT_DIR="$OUT_DIR/$PLATFORM"
|
||||
mkdir -p "$PLATFORM_OUT_DIR"
|
||||
|
||||
ARTIFACT_NAME="$(basename "$ARTIFACT_PATH")"
|
||||
ARTIFACT_OUTPUT="$PLATFORM_OUT_DIR/$ARTIFACT_NAME"
|
||||
if [[ "$(absolute_path "$ARTIFACT_PATH")" != "$(absolute_path "$ARTIFACT_OUTPUT")" ]]; then
|
||||
cp -p "$ARTIFACT_PATH" "$ARTIFACT_OUTPUT"
|
||||
fi
|
||||
|
||||
SYMBOLS_FILE="$PLATFORM_OUT_DIR/lite-backend-symbols.txt"
|
||||
RAW_SYMBOLS_FILE="$PLATFORM_OUT_DIR/lite-backend-symbols.raw.txt"
|
||||
NM_TOOL="$(select_nm_tool)"
|
||||
|
||||
info "capturing exported symbols with $NM_TOOL"
|
||||
if ! "$NM_TOOL" -g --defined-only "$ARTIFACT_OUTPUT" > "$RAW_SYMBOLS_FILE" 2> "$PLATFORM_OUT_DIR/lite-backend-symbols.err.txt"; then
|
||||
die "symbol inventory failed; see $PLATFORM_OUT_DIR/lite-backend-symbols.err.txt"
|
||||
fi
|
||||
awk '{print $NF}' "$RAW_SYMBOLS_FILE" \
|
||||
| sed 's/^_//' \
|
||||
| grep -E '^(litelib_[A-Za-z0-9_]*|blake3_PW)$' \
|
||||
| sort -u > "$SYMBOLS_FILE" || true
|
||||
|
||||
[[ -s "$SYMBOLS_FILE" ]] || die "no SDXL C ABI symbols were found in $ARTIFACT_OUTPUT"
|
||||
|
||||
MISSING_SYMBOLS=()
|
||||
for required in "${REQUIRED_SYMBOLS[@]}"; do
|
||||
if ! grep -Fxq "$required" "$SYMBOLS_FILE"; then
|
||||
MISSING_SYMBOLS+=("$required")
|
||||
fi
|
||||
done
|
||||
if [[ ${#MISSING_SYMBOLS[@]} -ne 0 ]]; then
|
||||
printf '%s\n' "${MISSING_SYMBOLS[@]}" > "$PLATFORM_OUT_DIR/lite-backend-missing-symbols.txt"
|
||||
die "artifact is missing required symbols; see $PLATFORM_OUT_DIR/lite-backend-missing-symbols.txt"
|
||||
fi
|
||||
|
||||
SHA256_DIGEST="$(compute_sha256 "$ARTIFACT_OUTPUT")"
|
||||
validate_signature_metadata
|
||||
ARTIFACT_SIZE_BYTES="$(wc -c < "$ARTIFACT_OUTPUT" | tr -d ' ')"
|
||||
PROJECT_REVISION="$(source_revision_for "$PROJECT_ROOT")"
|
||||
BACKEND_REVISION="$(source_revision_for "$BACKEND_SOURCE_DIR")"
|
||||
BACKEND_DEPENDENCY_REVISION=""
|
||||
if [[ -n "$BACKEND_DEPENDENCY_DIR" ]]; then
|
||||
BACKEND_DEPENDENCY_REVISION="$(source_revision_for "$BACKEND_DEPENDENCY_DIR")"
|
||||
fi
|
||||
ARTIFACT_SET_ID="$PLATFORM-${SHA256_DIGEST:0:16}"
|
||||
REPRODUCIBLE_MANIFEST_VALUE=false
|
||||
if [[ "$BUILD_ARTIFACT" == true && "$REPRODUCIBLE" == true ]]; then
|
||||
REPRODUCIBLE_MANIFEST_VALUE=true
|
||||
fi
|
||||
PORTABLE_DEPENDENCY_OVERRIDE_MANIFEST_VALUE=false
|
||||
if [[ "$BACKEND_DEPENDENCY_OVERRIDE_REQUESTED" == true ]]; then
|
||||
PORTABLE_DEPENDENCY_OVERRIDE_MANIFEST_VALUE=true
|
||||
fi
|
||||
FILE_DESCRIPTION="unknown"
|
||||
if command -v file >/dev/null 2>&1; then
|
||||
FILE_DESCRIPTION="$(file -b "$ARTIFACT_OUTPUT")"
|
||||
fi
|
||||
|
||||
MANIFEST_FILE="$PLATFORM_OUT_DIR/lite-backend-artifact-manifest.json"
|
||||
{
|
||||
printf '{\n'
|
||||
printf ' "schema": "dragonx.lite.backend-artifact.v1",\n'
|
||||
printf ' "generated_by": "scripts/build-lite-backend-artifact.sh",\n'
|
||||
printf ' "read_only_inventory": true,\n'
|
||||
printf ' "artifact_mutation_requested": false,\n'
|
||||
printf ' "upload_requested": false,\n'
|
||||
printf ' "signing_requested": false,\n'
|
||||
printf ' "publication_requested": false,\n'
|
||||
printf ' "signature_verification": {\n'
|
||||
printf ' "policy_name": '; json_escape "$SIGNATURE_POLICY_NAME"; printf ',\n'
|
||||
printf ' "policy_defined": %s,\n' "$SIGNATURE_POLICY_DEFINED_MANIFEST_VALUE"
|
||||
printf ' "required_for_release": %s,\n' "$SIGNATURE_REQUIRED_MANIFEST_VALUE"
|
||||
printf ' "metadata_read_only": true,\n'
|
||||
printf ' "metadata_provided": %s,\n' "$SIGNATURE_METADATA_PROVIDED"
|
||||
printf ' "verification_performed": %s,\n' "$SIGNATURE_VERIFICATION_PERFORMED"
|
||||
printf ' "verification_status": '; json_escape "$SIGNATURE_VERIFICATION_STATUS"; printf ',\n'
|
||||
printf ' "signature_format": '; json_escape "$SIGNATURE_FORMAT"; printf ',\n'
|
||||
printf ' "signature_path": '; json_escape "$SIGNATURE_FILE"; printf ',\n'
|
||||
printf ' "signature_file_sha256": '; json_escape "$SIGNATURE_FILE_SHA256"; printf ',\n'
|
||||
printf ' "verification_tool": '; json_escape "$SIGNATURE_VERIFICATION_TOOL"; printf ',\n'
|
||||
printf ' "verification_command": '; json_escape "$SIGNATURE_VERIFICATION_COMMAND"; printf ',\n'
|
||||
printf ' "key_fingerprint": '; json_escape "$SIGNATURE_KEY_FINGERPRINT"; printf ',\n'
|
||||
printf ' "certificate_identity": '; json_escape "$SIGNATURE_CERTIFICATE_IDENTITY"; printf ',\n'
|
||||
printf ' "certificate_issuer": '; json_escape "$SIGNATURE_CERTIFICATE_ISSUER"; printf ',\n'
|
||||
printf ' "transparency_log_url": '; json_escape "$SIGNATURE_TRANSPARENCY_LOG_URL"; printf ',\n'
|
||||
printf ' "verified_artifact_sha256": '; json_escape "$SIGNATURE_VERIFIED_SHA256"; printf '\n'
|
||||
printf ' },\n'
|
||||
printf ' "abi_version": '; json_escape "$ABI_VERSION"; printf ',\n'
|
||||
printf ' "link_mode": '; json_escape "$LINK_MODE"; printf ',\n'
|
||||
printf ' "platform": '; json_escape "$PLATFORM"; printf ',\n'
|
||||
printf ' "rust_target": '; json_escape "$RUST_TARGET"; printf ',\n'
|
||||
printf ' "artifact": {\n'
|
||||
printf ' "path": '; json_escape "$ARTIFACT_OUTPUT"; printf ',\n'
|
||||
printf ' "kind": '; json_escape "$KIND"; printf ',\n'
|
||||
printf ' "size_bytes": %s,\n' "$ARTIFACT_SIZE_BYTES"
|
||||
printf ' "sha256": '; json_escape "$SHA256_DIGEST"; printf ',\n'
|
||||
printf ' "file_description": '; json_escape "$FILE_DESCRIPTION"; printf '\n'
|
||||
printf ' },\n'
|
||||
printf ' "symbol_inventory": {\n'
|
||||
printf ' "tool": '; json_escape "$NM_TOOL"; printf ',\n'
|
||||
printf ' "symbols_path": '; json_escape "$SYMBOLS_FILE"; printf ',\n'
|
||||
printf ' "raw_symbols_path": '; json_escape "$RAW_SYMBOLS_FILE"; printf ',\n'
|
||||
printf ' "required_symbols": '; json_array "${REQUIRED_SYMBOLS[@]}"; printf ',\n'
|
||||
printf ' "exported_symbols": '; json_array_from_file "$SYMBOLS_FILE"; printf ',\n'
|
||||
printf ' "missing_required_symbols": []\n'
|
||||
printf ' },\n'
|
||||
printf ' "provenance": {\n'
|
||||
printf ' "owner_ready": true,\n'
|
||||
printf ' "metadata_provided": true,\n'
|
||||
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
|
||||
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n'
|
||||
printf ' "portable_dependency_override": %s,\n' "$PORTABLE_DEPENDENCY_OVERRIDE_MANIFEST_VALUE"
|
||||
printf ' "silentdragonxlitelib_source": '; json_escape "$BACKEND_DEPENDENCY_DIR"; printf ',\n'
|
||||
printf ' "builder": '; json_escape "$BUILDER"; printf ',\n'
|
||||
printf ' "source_revision": '; json_escape "$BACKEND_REVISION"; printf ',\n'
|
||||
printf ' "silentdragonxlitelib_revision": '; json_escape "$BACKEND_DEPENDENCY_REVISION"; printf ',\n'
|
||||
printf ' "project_revision": '; json_escape "$PROJECT_REVISION"; printf ',\n'
|
||||
printf ' "artifact_set_id": '; json_escape "$ARTIFACT_SET_ID"; printf ',\n'
|
||||
printf ' "source_date_epoch": '; json_escape "$SOURCE_DATE_EPOCH_VALUE"; printf ',\n'
|
||||
printf ' "reproducible": %s,\n' "$REPRODUCIBLE_MANIFEST_VALUE"
|
||||
printf ' "redacted": true\n'
|
||||
printf ' }\n'
|
||||
printf '}\n'
|
||||
} > "$MANIFEST_FILE"
|
||||
|
||||
info "artifact: $ARTIFACT_OUTPUT"
|
||||
info "symbols: $SYMBOLS_FILE"
|
||||
info "manifest: $MANIFEST_FILE"
|
||||
info "sha256: $SHA256_DIGEST"
|
||||
cat <<EOF
|
||||
|
||||
CMake configure example:
|
||||
cmake -S "$PROJECT_ROOT" -B "$PROJECT_ROOT/build/lite" \\
|
||||
-DDRAGONX_BUILD_LITE=ON \\
|
||||
-DDRAGONX_ENABLE_LITE_BACKEND=ON \\
|
||||
-DDRAGONX_LITE_BACKEND_LIBRARY="$ARTIFACT_OUTPUT" \\
|
||||
-DDRAGONX_LITE_BACKEND_SYMBOLS_FILE="$SYMBOLS_FILE" \\
|
||||
-DDRAGONX_LITE_BACKEND_MANIFEST="$MANIFEST_FILE" \\
|
||||
-DDRAGONX_LITE_BACKEND_LINK_MODE=$LINK_MODE \\
|
||||
-DDRAGONX_LITE_BACKEND_ABI=$ABI_VERSION
|
||||
EOF
|
||||
56
scripts/check-source-hygiene.sh
Executable file
56
scripts/check-source-hygiene.sh
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/bin/bash
|
||||
# Source-tree hygiene guard.
|
||||
#
|
||||
# Blocks two failure modes that an AI coding session previously introduced in
|
||||
# src/wallet/ (the lite-wallet "_plan"/"_batch" churn): pathologically long
|
||||
# filenames (which also break the Windows MAX_PATH 260-char limit during the
|
||||
# cross-build) and the runaway "receipt/custody/handoff/stewardship" naming
|
||||
# explosion where each session wrapped the previous artifact in one more layer.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/check-source-hygiene.sh # check working-tree src/
|
||||
# scripts/check-source-hygiene.sh --staged # check staged files (pre-commit)
|
||||
#
|
||||
# Install as a git pre-commit hook:
|
||||
# ln -sf ../../scripts/check-source-hygiene.sh .git/hooks/pre-commit
|
||||
# # (the hook invokes it with --staged automatically when named pre-commit)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MAX_LEN=80
|
||||
# Naming-explosion tokens. Two or more chained in one basename is the smell.
|
||||
CHURN_RE='receipt|custody|handoff|stewardship|promotion_activation|acceptance_confirmation|archive_handoff|post_closure'
|
||||
|
||||
mode="${1:-}"
|
||||
if [[ "$mode" == "--staged" || "$(basename "$0")" == "pre-commit" ]]; then
|
||||
mapfile -t files < <(git diff --cached --name-only --diff-filter=AR | grep -E '\.(cpp|h|hpp|cc)$' || true)
|
||||
else
|
||||
mapfile -t files < <(git ls-files 'src/**/*.cpp' 'src/**/*.h' 2>/dev/null; \
|
||||
find src -type f \( -name '*.cpp' -o -name '*.h' \) 2>/dev/null)
|
||||
# de-dup
|
||||
mapfile -t files < <(printf '%s\n' "${files[@]}" | sort -u)
|
||||
fi
|
||||
|
||||
fail=0
|
||||
for f in "${files[@]}"; do
|
||||
[[ -z "$f" ]] && continue
|
||||
base="$(basename "$f")"
|
||||
len=${#base}
|
||||
if (( len > MAX_LEN )); then
|
||||
echo "✗ filename too long ($len > $MAX_LEN chars): $f" >&2
|
||||
fail=1
|
||||
fi
|
||||
# count distinct churn tokens in the basename ( || true: grep exits 1 on no match)
|
||||
n=$(printf '%s' "$base" | grep -oE "$CHURN_RE" | sort -u | wc -l || true)
|
||||
if (( n >= 2 )); then
|
||||
echo "✗ runaway naming pattern ($n churn tokens) — refactor in place, don't add a layer: $f" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if (( fail )); then
|
||||
echo "" >&2
|
||||
echo "Source hygiene check failed. See docs in scripts/check-source-hygiene.sh." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "source hygiene OK (${#files[@]} files checked)"
|
||||
35
scripts/gen-lite-checkpoints.sh
Executable file
35
scripts/gen-lite-checkpoints.sh
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generate SDXL lite-wallet mainnet checkpoint entries from a fully-synced dragonxd.
|
||||
# Each entry is (height,"blockhash","serialized_sapling_tree") in checkpoints.rs format.
|
||||
# Fills the 1,770,000 -> tip gap so wallets reseed close to their birthday on rescan,
|
||||
# bounding the (divergence-prone) compact-block replay span. Usage:
|
||||
# scripts/gen-lite-checkpoints.sh [start] [step] > /tmp/new_checkpoints.txt
|
||||
set -euo pipefail
|
||||
|
||||
CLI=${DRAGONX_CLI:-/home/d/dragonx/src/dragonx-cli}
|
||||
START=${1:-1770000}
|
||||
STEP=${2:-10000}
|
||||
|
||||
tip=$("$CLI" getblockcount)
|
||||
end=$(( (tip / STEP) * STEP ))
|
||||
|
||||
# Sanity: confirm the method reproduces a KNOWN checkpoint tree before trusting it.
|
||||
ref_hash=$("$CLI" getblockhash 1760000 | tr -d '"[:space:]')
|
||||
ref_tree=$("$CLI" getblockmerkletree 1760000 | tr -d '"[:space:]')
|
||||
expect_hash="0000545a45b8d4ee4e4b423cb1ea74d67e3a04c320c6ea2f59ee06c08f91a117"
|
||||
if [ "$ref_hash" != "$expect_hash" ]; then
|
||||
echo "ABORT: getblockhash 1760000 = $ref_hash != known $expect_hash" >&2; exit 1
|
||||
fi
|
||||
echo "# self-check: 1760000 hash matches; tree len=${#ref_tree}" >&2
|
||||
|
||||
n=0
|
||||
h=$START
|
||||
while [ "$h" -le "$end" ]; do
|
||||
hash=$("$CLI" getblockhash "$h" | tr -d '"[:space:]')
|
||||
tree=$("$CLI" getblockmerkletree "$h" | tr -d '"[:space:]')
|
||||
if [ -z "$hash" ] || [ -z "$tree" ]; then echo "ABORT: empty hash/tree at $h" >&2; exit 1; fi
|
||||
printf '\t(%s,"%s",\n\t\t"%s"\n\t),\n' "$h" "$hash" "$tree"
|
||||
n=$((n+1))
|
||||
h=$((h+STEP))
|
||||
done
|
||||
echo "# generated $n checkpoints from $START to $end (tip=$tip)" >&2
|
||||
67
scripts/sign-daemon-release.sh
Executable file
67
scripts/sign-daemon-release.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
|
||||
#
|
||||
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
|
||||
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
|
||||
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
|
||||
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
|
||||
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
|
||||
#
|
||||
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
|
||||
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
|
||||
# (the same flow the wallet's unit tests verify for the miner updater).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
|
||||
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
|
||||
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
|
||||
#
|
||||
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
|
||||
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
|
||||
|
||||
set -euo pipefail
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
|
||||
|
||||
# Raw 32-byte ed25519 public key (base64) from a private key file. The DER SubjectPublicKeyInfo for
|
||||
# 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; }
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
keygen)
|
||||
prefix="${1:-dragonx-daemon}"
|
||||
[ -e "$prefix.ed25519.key" ] && die "$prefix.ed25519.key already exists — refusing to overwrite"
|
||||
openssl genpkey -algorithm ed25519 -out "$prefix.ed25519.key"
|
||||
chmod 600 "$prefix.ed25519.key"
|
||||
pub="$(pubkey_b64 "$prefix.ed25519.key")"
|
||||
printf '%s\n' "$pub" > "$prefix.ed25519.pub.b64"
|
||||
echo "secret key : $prefix.ed25519.key (KEEP OFFLINE, mode 600)"
|
||||
echo "public key : $prefix.ed25519.pub.b64"
|
||||
echo
|
||||
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
|
||||
echo " $pub"
|
||||
;;
|
||||
pubkey)
|
||||
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
|
||||
pubkey_b64 "$1"
|
||||
;;
|
||||
sign)
|
||||
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
|
||||
key="$1"; shift
|
||||
[ -f "$key" ] || die "no such key: $key"
|
||||
for f in "$@"; do
|
||||
[ -f "$f" ] || die "no such file: $f"
|
||||
raw="$(mktemp)"
|
||||
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
|
||||
openssl base64 -A -in "$raw" > "$f.sig"
|
||||
printf '\n' >> "$f.sig"
|
||||
rm -f "$raw"
|
||||
echo "signed: $f -> $f.sig"
|
||||
done
|
||||
echo "Upload each .sig as a release asset next to its archive."
|
||||
;;
|
||||
*)
|
||||
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
|
||||
;;
|
||||
esac
|
||||
66
scripts/sign-xmrig-release.sh
Executable file
66
scripts/sign-xmrig-release.sh
Executable file
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env bash
|
||||
# Sign DRG-XMRig release archives for the wallet's in-app updater (opt-in ed25519 signatures).
|
||||
#
|
||||
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
|
||||
# key pinned in src/util/xmrig_updater.h (kXmrigSignaturePublicKeyBase64). For each archive
|
||||
# <name>.zip this produces <name>.zip.sig holding the base64 of the raw 64-byte ed25519 signature —
|
||||
# upload that .sig next to the .zip as a release asset.
|
||||
#
|
||||
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
|
||||
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
|
||||
# (verified by the wallet's unit tests + an interop check).
|
||||
#
|
||||
# Usage:
|
||||
# scripts/sign-xmrig-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
|
||||
# scripts/sign-xmrig-release.sh pubkey <secret.key> # print the base64 public key to pin
|
||||
# scripts/sign-xmrig-release.sh sign <secret.key> <file>...# -> <file>.sig per file
|
||||
#
|
||||
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
|
||||
# kXmrigSignaturePublicKeyBase64 in src/util/xmrig_updater.h.
|
||||
|
||||
set -euo pipefail
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
|
||||
|
||||
# Raw 32-byte ed25519 public key (base64) from a private key file. The DER SubjectPublicKeyInfo for
|
||||
# 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; }
|
||||
|
||||
cmd="${1:-}"; shift || true
|
||||
case "$cmd" in
|
||||
keygen)
|
||||
prefix="${1:-drg-xmrig}"
|
||||
[ -e "$prefix.ed25519.key" ] && die "$prefix.ed25519.key already exists — refusing to overwrite"
|
||||
openssl genpkey -algorithm ed25519 -out "$prefix.ed25519.key"
|
||||
chmod 600 "$prefix.ed25519.key"
|
||||
pub="$(pubkey_b64 "$prefix.ed25519.key")"
|
||||
printf '%s\n' "$pub" > "$prefix.ed25519.pub.b64"
|
||||
echo "secret key : $prefix.ed25519.key (KEEP OFFLINE, mode 600)"
|
||||
echo "public key : $prefix.ed25519.pub.b64"
|
||||
echo
|
||||
echo "Pin this in src/util/xmrig_updater.h (kXmrigSignaturePublicKeyBase64):"
|
||||
echo " $pub"
|
||||
;;
|
||||
pubkey)
|
||||
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
|
||||
pubkey_b64 "$1"
|
||||
;;
|
||||
sign)
|
||||
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
|
||||
key="$1"; shift
|
||||
[ -f "$key" ] || die "no such key: $key"
|
||||
for f in "$@"; do
|
||||
[ -f "$f" ] || die "no such file: $f"
|
||||
raw="$(mktemp)"
|
||||
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
|
||||
openssl base64 -A -in "$raw" > "$f.sig"
|
||||
printf '\n' >> "$f.sig"
|
||||
rm -f "$raw"
|
||||
echo "signed: $f -> $f.sig"
|
||||
done
|
||||
echo "Upload each .sig as a release asset next to its archive."
|
||||
;;
|
||||
*)
|
||||
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
|
||||
;;
|
||||
esac
|
||||
73
setup.sh
73
setup.sh
@@ -120,20 +120,20 @@ pkgs_core_debian="build-essential cmake git pkg-config
|
||||
libgl1-mesa-dev libx11-dev libxcursor-dev libxrandr-dev
|
||||
libxinerama-dev libxi-dev libxkbcommon-dev libwayland-dev
|
||||
libsodium-dev libcurl4-openssl-dev
|
||||
autoconf automake libtool wget"
|
||||
autoconf automake libtool wget python3 xxd"
|
||||
|
||||
pkgs_core_fedora="gcc gcc-c++ cmake git pkg-config
|
||||
mesa-libGL-devel libX11-devel libXcursor-devel libXrandr-devel
|
||||
libXinerama-devel libXi-devel libxkbcommon-devel wayland-devel
|
||||
libsodium-devel libcurl-devel
|
||||
autoconf automake libtool wget"
|
||||
autoconf automake libtool wget python3 vim-common"
|
||||
|
||||
pkgs_core_arch="base-devel cmake git pkg-config
|
||||
mesa libx11 libxcursor libxrandr libxinerama libxi
|
||||
libxkbcommon wayland libsodium curl
|
||||
autoconf automake libtool wget"
|
||||
autoconf automake libtool wget python xxd"
|
||||
|
||||
pkgs_core_macos="cmake"
|
||||
pkgs_core_macos="cmake python xxd"
|
||||
|
||||
# Windows cross-compile (from Linux)
|
||||
pkgs_win_debian="mingw-w64 zip"
|
||||
@@ -245,7 +245,7 @@ if [[ -z "$core_pkgs" ]]; then
|
||||
else
|
||||
# Check if key tools are already present
|
||||
NEED_CORE=false
|
||||
has_cmd cmake && has_cmd g++ && has_cmd pkg-config || NEED_CORE=true
|
||||
has_cmd cmake && has_cmd g++ && has_cmd pkg-config && has_cmd python3 && has_cmd xxd || NEED_CORE=true
|
||||
|
||||
if $NEED_CORE; then
|
||||
install_pkgs "$core_pkgs" "core build"
|
||||
@@ -258,6 +258,8 @@ check_tool cmake "cmake"
|
||||
check_tool g++ "g++ (C++ compiler)"
|
||||
check_tool git "git"
|
||||
check_tool make "make"
|
||||
check_tool python3 "python3 (theme expansion)"
|
||||
check_tool xxd "xxd (embedded language headers)"
|
||||
|
||||
# ── 2. libsodium ────────────────────────────────────────────────────────────
|
||||
header "libsodium"
|
||||
@@ -440,6 +442,53 @@ copy_daemon_data() {
|
||||
done
|
||||
}
|
||||
|
||||
# ── Stale-daemon guard ───────────────────────────────────────────────────────
|
||||
# A prebuilt daemon binary is only rebuilt on its platform's flag (--win/--mac),
|
||||
# and build.sh merely BUNDLES whatever binary already exists — so a daemon left
|
||||
# over from an old source revision silently ships in the wallet (e.g. the Network
|
||||
# tab once reported v1.0.1 while the source was v1.0.2). These helpers compare the
|
||||
# version baked into a prebuilt binary against the dragonx source and flag drift.
|
||||
STALE_DAEMON=0
|
||||
|
||||
# MAJOR.MINOR.REVISION from the checked-out dragonx source (empty if unavailable).
|
||||
dragonx_source_version() {
|
||||
local hdr="$DRAGONX_SRC/src/clientversion.h"
|
||||
[[ -f "$hdr" ]] || return 1
|
||||
local maj min rev
|
||||
maj=$(awk '/#define[ \t]+CLIENT_VERSION_MAJOR/{print $3; exit}' "$hdr")
|
||||
min=$(awk '/#define[ \t]+CLIENT_VERSION_MINOR/{print $3; exit}' "$hdr")
|
||||
rev=$(awk '/#define[ \t]+CLIENT_VERSION_REVISION/{print $3; exit}' "$hdr")
|
||||
[[ -n "$maj" && -n "$min" && -n "$rev" ]] || return 1
|
||||
printf '%s.%s.%s' "$maj" "$min" "$rev"
|
||||
}
|
||||
|
||||
# vX.Y.Z baked into a built daemon binary (the daemon embeds "vX.Y.Z-<githash>").
|
||||
# Uses grep -a so no `strings`/binutils dependency is required.
|
||||
dragonx_binary_version() {
|
||||
local bin="$1"
|
||||
[[ -f "$bin" ]] || return 1
|
||||
LC_ALL=C grep -aoE 'v[0-9]+\.[0-9]+\.[0-9]+-[0-9a-f]{6,}' "$bin" 2>/dev/null \
|
||||
| head -1 | sed -E 's/^v([0-9]+\.[0-9]+\.[0-9]+).*/\1/'
|
||||
}
|
||||
|
||||
# Compare a prebuilt daemon against the source; warn (and set STALE_DAEMON) on drift.
|
||||
# $1 = label, $2 = binary path, $3 = rebuild flag(s) (e.g. "--win", "" for Linux)
|
||||
daemon_version_guard() {
|
||||
local label="$1" bin="$2" rebuild_hint="$3"
|
||||
[[ -f "$bin" ]] || return 0
|
||||
local src bv
|
||||
src=$(dragonx_source_version) || return 0 # no source checked out → can't compare
|
||||
bv=$(dragonx_binary_version "$bin")
|
||||
[[ -n "$bv" ]] || return 0 # couldn't read the binary's version
|
||||
if [[ "$bv" == "$src" ]]; then
|
||||
ok " $label daemon is v$bv (matches dragonx source)"
|
||||
else
|
||||
warn " $label daemon is v$bv but dragonx source is v$src — STALE"
|
||||
warn " rebuild so the wallet ships the current daemon: ./setup.sh${rebuild_hint:+ $rebuild_hint}"
|
||||
STALE_DAEMON=1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Linux daemon ─────────────────────────────────────────────────────────────
|
||||
|
||||
# Skip Linux daemon build if only cross-compile targets were requested
|
||||
@@ -459,11 +508,13 @@ fi
|
||||
if $CHECK_ONLY; then
|
||||
if [[ -f "$DRAGONXD_LINUX/dragonxd" ]] || [[ -f "$DRAGONXD_LINUX/hushd" ]]; then
|
||||
ok "dragonxd daemon (Linux) present"
|
||||
daemon_version_guard "Linux" "$DRAGONXD_LINUX/dragonxd" ""
|
||||
else
|
||||
miss "dragonxd daemon (Linux) not built"
|
||||
fi
|
||||
elif $SKIP_LINUX_DAEMON; then
|
||||
skip "dragonxd (Linux) — skipped, binaries already present (cross-compile only)"
|
||||
daemon_version_guard "Linux" "$DRAGONXD_LINUX/dragonxd" ""
|
||||
else
|
||||
clone_dragonx_if_needed
|
||||
|
||||
@@ -498,9 +549,11 @@ fi
|
||||
|
||||
if ! $SETUP_WIN; then
|
||||
skip "dragonxd (Windows) — use --win to cross-compile"
|
||||
daemon_version_guard "Windows" "$DRAGONXD_WIN/dragonxd.exe" "--win"
|
||||
elif $CHECK_ONLY; then
|
||||
if [[ -f "$DRAGONXD_WIN/dragonxd.exe" ]] || [[ -f "$DRAGONXD_WIN/hushd.exe" ]]; then
|
||||
ok "dragonxd daemon (Windows) present"
|
||||
daemon_version_guard "Windows" "$DRAGONXD_WIN/dragonxd.exe" "--win"
|
||||
else
|
||||
miss "dragonxd daemon (Windows) not built"
|
||||
fi
|
||||
@@ -556,9 +609,11 @@ fi
|
||||
|
||||
if ! $SETUP_MAC; then
|
||||
skip "dragonxd (macOS) — use --mac to cross-compile"
|
||||
daemon_version_guard "macOS" "$DRAGONXD_MAC/dragonxd" "--mac"
|
||||
elif $CHECK_ONLY; then
|
||||
if [[ -f "$DRAGONXD_MAC/dragonxd" ]] || [[ -f "$DRAGONXD_MAC/hushd" ]]; then
|
||||
ok "dragonxd daemon (macOS) present"
|
||||
daemon_version_guard "macOS" "$DRAGONXD_MAC/dragonxd" "--mac"
|
||||
else
|
||||
miss "dragonxd daemon (macOS) not built"
|
||||
fi
|
||||
@@ -621,6 +676,14 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# Prominent reminder if any prebuilt daemon drifted from the source — these are bundled verbatim
|
||||
# by build.sh, so a stale binary ships in the wallet (and shows an old version in the Network tab).
|
||||
if [[ "$STALE_DAEMON" -eq 1 ]]; then
|
||||
warn "One or more prebuilt daemons are OLDER than the dragonx source (see above)."
|
||||
warn "build.sh bundles them as-is, so rebuild the stale platform(s) before releasing:"
|
||||
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
|
||||
fi
|
||||
|
||||
# ── 7. xmrig-hac (mining binary) ────────────────────────────────────────────
|
||||
header "xmrig-hac Mining Binary"
|
||||
|
||||
|
||||
1716
src/app.cpp
1716
src/app.cpp
File diff suppressed because it is too large
Load Diff
306
src/app.h
306
src/app.h
@@ -12,8 +12,14 @@
|
||||
#include <chrono>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
#include "util/async_task_manager.h"
|
||||
#include "wallet/wallet_capabilities.h"
|
||||
#include "ui/sidebar.h"
|
||||
#include "ui/windows/console_tab.h"
|
||||
#include "imgui.h"
|
||||
@@ -25,8 +31,9 @@ namespace dragonx {
|
||||
class RPCWorker;
|
||||
}
|
||||
namespace config { class Settings; }
|
||||
namespace daemon { class EmbeddedDaemon; class XmrigManager; }
|
||||
namespace daemon { class DaemonController; class EmbeddedDaemon; class XmrigManager; }
|
||||
namespace util { class Bootstrap; class SecureVault; }
|
||||
namespace wallet { class LiteWalletController; }
|
||||
}
|
||||
|
||||
namespace dragonx {
|
||||
@@ -125,6 +132,13 @@ public:
|
||||
* @brief Whether we are in the shutdown phase
|
||||
*/
|
||||
bool isShuttingDown() const { return shutting_down_; }
|
||||
wallet::WalletCapabilities walletCapabilities() const { return wallet::currentWalletCapabilities(); }
|
||||
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
|
||||
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
|
||||
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
|
||||
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
|
||||
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
|
||||
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
|
||||
|
||||
/**
|
||||
* @brief Render the shutdown overlay (called instead of normal UI during shutdown)
|
||||
@@ -141,6 +155,15 @@ public:
|
||||
rpc::RPCClient* rpc() { return rpc_.get(); }
|
||||
rpc::RPCWorker* worker() { return worker_.get(); }
|
||||
config::Settings* settings() { return settings_.get(); }
|
||||
// Lite wallet controller (non-null only in lite builds with a linked backend).
|
||||
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
|
||||
// Reason the lite wallet failed to auto-open this session (empty if none / opened OK).
|
||||
const std::string& liteOpenError() const { return lite_open_error_; }
|
||||
// Show the lite send-time unlock modal (called when a spend is attempted on a locked wallet).
|
||||
void requestLiteUnlock() { lite_unlock_prompt_ = true; }
|
||||
// (Re)build the lite controller from current settings so a changed lite-server selection
|
||||
// takes effect. No-op on non-lite/unlinked builds; preserves a live wallet (see app.cpp).
|
||||
void rebuildLiteWallet(bool force = false);
|
||||
WalletState& state() { return state_; }
|
||||
const WalletState& state() const { return state_; }
|
||||
const WalletState& getWalletState() const { return state_; }
|
||||
@@ -176,6 +199,10 @@ public:
|
||||
int getXmrigRequestedThreads() const {
|
||||
return xmrig_manager_ ? xmrig_manager_->getRequestedThreads() : 0;
|
||||
}
|
||||
// True while the pool miner process is live — used to refuse replacing the binary under it.
|
||||
bool isPoolMinerRunning() const {
|
||||
return xmrig_manager_ && xmrig_manager_->isRunning();
|
||||
}
|
||||
|
||||
// Mine-when-idle state query
|
||||
bool isIdleMiningActive() const { return idle_mining_active_; }
|
||||
@@ -183,7 +210,9 @@ public:
|
||||
// Peers
|
||||
const std::vector<PeerInfo>& getPeers() const { return state_.peers; }
|
||||
const std::vector<BannedPeer>& getBannedPeers() const { return state_.bannedPeers; }
|
||||
bool isPeerRefreshInProgress() const { return peer_refresh_in_progress_.load(std::memory_order_relaxed); }
|
||||
bool isPeerRefreshInProgress() const {
|
||||
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Peers);
|
||||
}
|
||||
void banPeer(const std::string& ip, int duration_seconds = 86400);
|
||||
void unbanPeer(const std::string& ip);
|
||||
void clearBans();
|
||||
@@ -211,6 +240,9 @@ public:
|
||||
void setAddressSortOrder(const std::string& addr, int order);
|
||||
int getNextSortOrder() const;
|
||||
void swapAddressOrder(const std::string& a, const std::string& b);
|
||||
bool isMiningAddress(const std::string& addr) const;
|
||||
void setMiningAddress(const std::string& addr, bool mining);
|
||||
void invalidateAddressValidationCache();
|
||||
|
||||
// Key export/import
|
||||
void exportPrivateKey(const std::string& address, std::function<void(const std::string&)> callback);
|
||||
@@ -220,11 +252,18 @@ public:
|
||||
// Wallet backup
|
||||
void backupWallet(const std::string& destination, std::function<void(bool, const std::string&)> callback);
|
||||
|
||||
// Transaction operations
|
||||
void sendTransaction(const std::string& from, const std::string& to,
|
||||
// Transaction operations
|
||||
void sendTransaction(const std::string& from, const std::string& to,
|
||||
double amount, double fee, const std::string& memo,
|
||||
std::function<void(bool success, const std::string& result)> callback);
|
||||
|
||||
// Register a daemon async operation id (z_shieldcoinbase / z_mergetoaddress /
|
||||
// auto-shield) with the shared opid poller so its eventual success/failure is
|
||||
// surfaced and balances/transactions refresh on completion. z_sendmany uses the
|
||||
// richer pending-send path internally; this is for operations with no optimistic
|
||||
// transaction row of their own.
|
||||
void trackOperation(const std::string& opid);
|
||||
|
||||
// Force refresh
|
||||
void refreshNow();
|
||||
void refreshMiningInfo();
|
||||
@@ -232,12 +271,7 @@ public:
|
||||
void refreshMarketData();
|
||||
|
||||
/// @brief Per-category refresh intervals, adjusted by active tab
|
||||
struct RefreshIntervals {
|
||||
float core; // balance + sync status
|
||||
float transactions; // tx list + enrichment
|
||||
float addresses; // address lists + balances
|
||||
float peers; // peer info (0 = disabled)
|
||||
};
|
||||
using RefreshIntervals = services::NetworkRefreshService::Intervals;
|
||||
|
||||
/// @brief Get recommended refresh intervals for a given page
|
||||
static RefreshIntervals getIntervalsForPage(ui::NavPage page);
|
||||
@@ -269,9 +303,23 @@ public:
|
||||
bool startEmbeddedDaemon();
|
||||
void stopEmbeddedDaemon();
|
||||
bool isEmbeddedDaemonRunning() const;
|
||||
bool isUsingEmbeddedDaemon() const { return use_embedded_daemon_; }
|
||||
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use; }
|
||||
void rescanBlockchain(); // restart daemon with -rescan flag
|
||||
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
|
||||
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
|
||||
void rescanBlockchain(); // restart daemon with -rescan flag (full-history nodes)
|
||||
// Runtime rescanblockchain RPC starting at a snapshot-available height. Unlike the
|
||||
// -rescan restart, this works on bootstrapped/pruned nodes (which lack pre-snapshot
|
||||
// block data), reconciling the wallet's stale spent-state without a daemon restart.
|
||||
void runtimeRescan(int startHeight);
|
||||
// Async binary-search probe for the lowest block height the node still has on disk.
|
||||
// cb(ok, lowestHeight, fullHistory): fullHistory==true when genesis is present (a normal,
|
||||
// non-bootstrapped node). Runs on the UI thread via the RPC worker callbacks.
|
||||
void detectLowestAvailableBlockHeight(std::function<void(bool ok, int lowestHeight, bool fullHistory)> cb);
|
||||
// Flag that a bootstrap just finished so the wallet auto-reconciles spent-state once the
|
||||
// daemon is back up (consumed in update()).
|
||||
void markPostBootstrapRescanPending() { post_bootstrap_rescan_pending_ = true; }
|
||||
bool runtimeRescanActive() const { return runtime_rescan_active_; }
|
||||
void repairWallet(); // restart daemon with -zapwallettxes=2 (wipe & rebuild wallet tx records)
|
||||
void reinstallBundledDaemon(); // stop daemon, overwrite installed binary with the bundled one, restart
|
||||
void deleteBlockchainData(); // stop daemon, delete chain data, restart fresh
|
||||
bool stopDaemonForBootstrap(); // stop daemon + disconnect for bootstrap, returns true if was running
|
||||
bool isBootstrapDownloading() const { return bootstrap_downloading_; }
|
||||
@@ -340,10 +388,7 @@ public:
|
||||
void showChangePassphraseDialog() { show_change_passphrase_ = true; }
|
||||
void showDecryptDialog() {
|
||||
show_decrypt_dialog_ = true;
|
||||
decrypt_phase_ = 0; // passphrase entry
|
||||
decrypt_step_ = 0;
|
||||
decrypt_status_.clear();
|
||||
decrypt_in_progress_ = false;
|
||||
wallet_security_workflow_.reset();
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
}
|
||||
|
||||
@@ -355,8 +400,61 @@ public:
|
||||
|
||||
/// @brief Check if RPC worker has queued results waiting to be processed
|
||||
bool hasPendingRPCResults() const;
|
||||
bool hasTransactionSendProgress() const { return send_progress_active_ || send_submissions_in_flight_ > 0 || !pending_opids_.empty(); }
|
||||
std::string transactionSendProgressText() const;
|
||||
std::string transactionRefreshProgressText() const;
|
||||
|
||||
// Copy a SECRET (seed phrase, private key) to the clipboard and arm an auto-clear: after a
|
||||
// short delay the clipboard is wiped IF it still holds this secret (so we don't clobber
|
||||
// something the user copied afterwards). Only a hash of the secret is retained, never the
|
||||
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
|
||||
void copySecretToClipboard(const std::string& secret);
|
||||
void pumpSecretClipboardClear();
|
||||
bool isTransactionRefreshInProgress() const {
|
||||
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
|
||||
}
|
||||
|
||||
private:
|
||||
friend class AppDaemonLifecycleRuntime;
|
||||
friend class AppDaemonLifecycleTaskContext;
|
||||
|
||||
bool sendStopCommandSafely(rpc::RPCClient& client, const char* context);
|
||||
void maybeFinishTransactionSendProgress();
|
||||
void upsertPendingSendTransaction(const std::string& opid,
|
||||
const std::string& from,
|
||||
const std::string& to,
|
||||
double amount,
|
||||
const std::string& memo,
|
||||
double fee = 0.0);
|
||||
// Work around a dragonxd note-selection bug: its z_sendmany picks notes to cover the recipient
|
||||
// total but not the miner fee, so a shielded send whose largest notes sum exactly to the amount
|
||||
// fails with "Insufficient shielded funds, have H, need H+fee" despite ample balance. When a
|
||||
// failed opid matches that (H >= the requested amount), re-issue the send once with a tiny
|
||||
// self-output that lifts the daemon's selection target past the boundary so it grabs another
|
||||
// note; the recipient still receives the exact amount. Returns true if a retry was issued.
|
||||
bool maybeRetrySendForFeeGap(const std::string& opid, const std::string& rawMsg);
|
||||
void resendWithFeeGapWorkaround(const std::string& from, const std::string& to,
|
||||
double amount, double fee, const std::string& memo,
|
||||
std::function<void(bool, const std::string&)> callback);
|
||||
void markPendingSendTransactionSucceeded(const std::string& opid,
|
||||
const std::string& txid);
|
||||
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
||||
bool restoreBalances);
|
||||
// Deliver a deferred z_sendmany result to its waiting UI callback once the opid
|
||||
// reaches a terminal status. Returns true if a callback was registered (and fired).
|
||||
bool invokeSendResultCallback(const std::string& opid, bool ok,
|
||||
const std::string& result);
|
||||
void applyPendingSendBalanceDeltas(bool includeAggregateBalances);
|
||||
std::string transactionHistoryCacheWalletIdentity() const;
|
||||
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
|
||||
void unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase);
|
||||
void loadTransactionHistoryCacheIfAvailable();
|
||||
void storeTransactionHistoryCacheIfAvailable();
|
||||
void wipePendingTransactionHistoryCachePassphrase();
|
||||
void resetTransactionHistoryCacheSession();
|
||||
void pruneShieldedHistoryScanProgress();
|
||||
void invalidateShieldedHistoryScanProgress(bool persistCache);
|
||||
|
||||
// Subsystems
|
||||
std::unique_ptr<rpc::RPCClient> rpc_;
|
||||
std::unique_ptr<rpc::RPCWorker> worker_;
|
||||
@@ -371,8 +469,26 @@ private:
|
||||
rpc::ConnectionConfig saved_config_;
|
||||
|
||||
std::unique_ptr<config::Settings> settings_;
|
||||
std::unique_ptr<daemon::EmbeddedDaemon> embedded_daemon_;
|
||||
std::unique_ptr<wallet::LiteWalletController> lite_wallet_; // lite builds w/ linked backend
|
||||
// Pending send_tab callback for an in-flight lite send (delivered in update() once the
|
||||
// controller's async broadcast result arrives). Only one lite send runs at a time.
|
||||
std::function<void(bool, const std::string&)> lite_send_callback_;
|
||||
// One-shot guard: auto-open an existing lite wallet on the first update() tick (kept off
|
||||
// init() so a slow initialize_existing network call doesn't freeze startup before the window).
|
||||
bool lite_autoopen_done_ = false;
|
||||
double lite_open_last_attempt_ = 0.0; // ImGui time of the last async open attempt (retry timer)
|
||||
// Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in
|
||||
// the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens.
|
||||
std::string lite_open_error_;
|
||||
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
||||
bool lite_firstrun_dismissed_ = false;
|
||||
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
|
||||
bool lite_unlock_prompt_ = false;
|
||||
// One-shot: prompt to unlock on startup once we learn the auto-opened wallet is encrypted+locked.
|
||||
bool lite_startup_lock_checked_ = false;
|
||||
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
||||
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
||||
util::AsyncTaskManager async_tasks_;
|
||||
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
|
||||
|
||||
// Wallet state
|
||||
@@ -390,7 +506,6 @@ private:
|
||||
|
||||
// Daemon restart (e.g. after changing debug log categories)
|
||||
std::atomic<bool> daemon_restarting_{false};
|
||||
std::thread daemon_restart_thread_;
|
||||
|
||||
// Encryption state check timeout
|
||||
float encryption_check_timer_ = 0.0f;
|
||||
@@ -406,7 +521,7 @@ private:
|
||||
bool show_address_book_ = false;
|
||||
|
||||
// Embedded daemon state
|
||||
bool use_embedded_daemon_ = true;
|
||||
bool use_embedded_daemon_ = wallet::supportsEmbeddedDaemon(wallet::currentWalletCapabilities());
|
||||
std::string daemon_status_;
|
||||
mutable std::string daemon_mem_diag_; // diagnostic info for daemon memory detection
|
||||
size_t daemon_output_offset_ = 0; // for incremental output parsing (rescan detection)
|
||||
@@ -423,6 +538,16 @@ private:
|
||||
// Connection
|
||||
std::string connection_status_ = "Disconnected";
|
||||
bool connection_in_progress_ = false;
|
||||
bool remote_rpc_plaintext_warning_shown_ = false;
|
||||
// Startup daemon-launch diagnostics: bound the "RPC port busy, no config" wait before warning,
|
||||
// and show the embedded-daemon start failure (binary/params/spawn) only once. Reset on connect.
|
||||
int daemon_wait_attempts_ = 0;
|
||||
bool daemon_start_error_shown_ = false;
|
||||
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
|
||||
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
|
||||
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
|
||||
std::uint64_t clipboard_secret_hash_ = 0;
|
||||
double clipboard_clear_deadline_ = 0.0;
|
||||
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
||||
|
||||
// Current page (sidebar navigation)
|
||||
@@ -460,64 +585,31 @@ private:
|
||||
std::string pending_memo_;
|
||||
std::string pending_label_;
|
||||
|
||||
// Per-category timers (in seconds since last refresh)
|
||||
float core_timer_ = 0.0f; // balance + sync status
|
||||
float address_timer_ = 0.0f; // address lists
|
||||
float transaction_timer_ = 0.0f; // transaction list
|
||||
float peer_timer_ = 0.0f; // peer info
|
||||
float price_timer_ = 0.0f;
|
||||
float fast_refresh_timer_ = 0.0f; // For mining stats
|
||||
|
||||
// Default refresh intervals (seconds)
|
||||
static constexpr float CORE_INTERVAL_DEFAULT = 5.0f;
|
||||
static constexpr float ADDRESS_INTERVAL_DEFAULT = 15.0f;
|
||||
static constexpr float TX_INTERVAL_DEFAULT = 10.0f;
|
||||
static constexpr float PEER_INTERVAL_DEFAULT = 10.0f;
|
||||
static constexpr float PRICE_INTERVAL = 60.0f;
|
||||
static constexpr float FAST_REFRESH_INTERVAL = 1.0f;
|
||||
|
||||
// Active intervals — adjusted by tab priority via applyRefreshPolicy()
|
||||
float active_core_interval_ = CORE_INTERVAL_DEFAULT;
|
||||
float active_tx_interval_ = TX_INTERVAL_DEFAULT;
|
||||
float active_addr_interval_ = ADDRESS_INTERVAL_DEFAULT;
|
||||
float active_peer_interval_ = PEER_INTERVAL_DEFAULT;
|
||||
|
||||
// Per-category refresh guards (prevent worker queue pileup)
|
||||
std::atomic<bool> core_refresh_in_progress_{false};
|
||||
std::atomic<bool> address_refresh_in_progress_{false};
|
||||
std::atomic<bool> tx_refresh_in_progress_{false};
|
||||
|
||||
// Mining refresh guard (prevents worker queue pileup)
|
||||
std::atomic<bool> mining_refresh_in_progress_{false};
|
||||
// Per-category refresh timers, policy, and worker queue guards.
|
||||
services::NetworkRefreshService network_refresh_;
|
||||
int mining_slow_counter_ = 0; // counts fast ticks; fires slow refresh every N
|
||||
|
||||
// Mining toggle guard (prevents concurrent setgenerate calls)
|
||||
std::atomic<bool> mining_toggle_in_progress_{false};
|
||||
|
||||
// Peer refresh guard (visual feedback for refresh button)
|
||||
std::atomic<bool> peer_refresh_in_progress_{false};
|
||||
|
||||
// Auto-shield guard (prevents concurrent auto-shield operations)
|
||||
std::atomic<bool> auto_shield_pending_{false};
|
||||
|
||||
// P4: Incremental transaction cache
|
||||
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
||||
float tx_age_timer_ = 0.0f; // seconds since last tx fetch
|
||||
static constexpr float TX_MAX_AGE = 15.0f; // force tx refresh every N seconds even without new blocks
|
||||
static constexpr int MAX_VIEWTX_PER_CYCLE = 25; // cap z_viewtransaction calls per refresh
|
||||
std::size_t shielded_history_scan_cursor_ = 0;
|
||||
bool shielded_history_scan_pending_ = false;
|
||||
// False until the first full shielded-history scan finishes. Drives the History tab's
|
||||
// "Loading older history…" progress so the user knows transactions are still streaming in
|
||||
// after the first batch appears; goes quiet for the routine per-block re-scans afterward.
|
||||
bool initial_history_scan_complete_ = false;
|
||||
std::unordered_map<std::string, int> shielded_history_scan_heights_;
|
||||
|
||||
// P4b: z_viewtransaction result cache — avoids re-calling the RPC for
|
||||
// txids we've already enriched. Keyed by txid.
|
||||
struct ViewTxCacheEntry {
|
||||
std::string from_address; // first spend address
|
||||
struct Output {
|
||||
std::string address;
|
||||
double value = 0.0;
|
||||
std::string memo;
|
||||
};
|
||||
std::vector<Output> outgoing_outputs;
|
||||
};
|
||||
std::unordered_map<std::string, ViewTxCacheEntry> viewtx_cache_;
|
||||
using ViewTxCacheEntry = services::NetworkRefreshService::TransactionViewCacheEntry;
|
||||
services::NetworkRefreshService::TransactionViewCache viewtx_cache_;
|
||||
|
||||
// P4c: Confirmed transaction cache — deeply-confirmed txns (>= 10 confs)
|
||||
// are accumulated here and reused across refresh cycles. Only
|
||||
@@ -528,14 +620,58 @@ private:
|
||||
|
||||
// Dirty flags for demand-driven refresh
|
||||
bool addresses_dirty_ = true; // true → refreshAddresses() will run
|
||||
bool address_validation_cache_dirty_ = true;
|
||||
bool transactions_dirty_ = false; // true → force tx refresh regardless of block height
|
||||
bool encryption_state_prefetched_ = false; // suppress duplicate getwalletinfo on connect
|
||||
bool rescan_status_poll_in_progress_ = false;
|
||||
// True once we've actually observed the rescan running (daemon restarted into -rescan warmup).
|
||||
// Gates the "rescan complete" detection so a getrescaninfo poll that hits the still-running
|
||||
// pre-restart daemon (which reports rescanning=false) can't fire a false "complete" instantly.
|
||||
bool rescan_confirmed_active_ = false;
|
||||
// A runtime rescanblockchain RPC is in flight (vs the -rescan daemon restart). While set,
|
||||
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
|
||||
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
|
||||
bool runtime_rescan_active_ = false;
|
||||
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
|
||||
// that reconciles the preserved wallet.dat against the freshly-imported chain.
|
||||
bool post_bootstrap_rescan_pending_ = false;
|
||||
// Largest "blocks remaining" seen during the current witness-rebuild phase. The daemon's
|
||||
// "Building Witnesses for block" fraction resets every call (it's re-invoked per connected
|
||||
// block, each walking from its own start height to the tip), so we derive a stable, monotonic
|
||||
// overall percentage from how far "remaining" has fallen below this peak. Reset per phase.
|
||||
int witness_rebuild_total_blocks_ = 0;
|
||||
// The daemon's primary witness signal is "Setting Initial Sapling Witness for tx <hash>, <i>
|
||||
// of <N>", logged once per wallet tx as its initial witness is set. The <i> is the tx's slot in
|
||||
// an UNORDERED map, so it bounces wildly (was the cause of the resetting progress). The honest
|
||||
// monotonic metric is how many DISTINCT txs have been witnessed (the set only grows; it also
|
||||
// dedups the daemon's occasional double-prints) over the reported total N.
|
||||
std::unordered_set<std::string> witness_seen_txids_;
|
||||
int witness_total_txs_ = 0;
|
||||
bool opid_poll_in_progress_ = false;
|
||||
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
|
||||
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.
|
||||
int consecutive_core_failures_ = 0;
|
||||
|
||||
// Pending z_sendmany operation tracking
|
||||
bool send_progress_active_ = false;
|
||||
int send_submissions_in_flight_ = 0;
|
||||
std::vector<std::string> pending_opids_; // opids to poll for completion
|
||||
float opid_poll_timer_ = 0.0f;
|
||||
static constexpr float OPID_POLL_INTERVAL = 2.0f;
|
||||
|
||||
struct PendingSendInfo {
|
||||
std::string from;
|
||||
std::string to;
|
||||
std::string memo;
|
||||
double amount = 0.0;
|
||||
double fee = 0.0;
|
||||
std::int64_t timestamp = 0;
|
||||
};
|
||||
std::unordered_map<std::string, PendingSendInfo> pending_send_info_;
|
||||
// Opids issued as a fee-gap auto-retry (see maybeRetrySendForFeeGap). Tracked so a retry that
|
||||
// fails again is reported to the user instead of looping.
|
||||
std::unordered_set<std::string> send_feegap_retried_opids_;
|
||||
// z_sendmany UI callbacks held until the opid reaches a terminal status, so the
|
||||
// user isn't told "sent successfully" before the tx is actually built/broadcast.
|
||||
std::unordered_map<std::string, std::function<void(bool, const std::string&)>>
|
||||
pending_send_callbacks_;
|
||||
// Txids from completed z_sendmany operations.
|
||||
// Ensures shielded sends are discoverable by z_viewtransaction
|
||||
// even when they don't appear in listtransactions or
|
||||
@@ -549,18 +685,19 @@ private:
|
||||
std::string wizard_pending_passphrase_; // held until daemon connects
|
||||
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
|
||||
|
||||
// Deferred encryption (wizard background task)
|
||||
std::string deferred_encrypt_passphrase_;
|
||||
std::string deferred_encrypt_pin_;
|
||||
bool deferred_encrypt_pending_ = false;
|
||||
// Wallet security flow state shared by wizard/settings encryption paths.
|
||||
services::WalletSecurityController wallet_security_;
|
||||
services::WalletSecurityWorkflow wallet_security_workflow_;
|
||||
|
||||
// Wizard: stopping an external daemon before bootstrap
|
||||
bool wizard_stopping_external_ = false;
|
||||
std::string wizard_stop_status_;
|
||||
std::thread wizard_stop_thread_;
|
||||
|
||||
// PIN vault
|
||||
std::unique_ptr<util::SecureVault> vault_;
|
||||
data::TransactionHistoryCache transaction_history_cache_;
|
||||
std::string pending_transaction_history_cache_passphrase_;
|
||||
bool transaction_history_cache_loaded_ = false;
|
||||
|
||||
// Lock screen state
|
||||
bool lock_screen_was_visible_ = false; // tracks lock→unlock transitions for auto-focus
|
||||
@@ -602,14 +739,7 @@ private:
|
||||
|
||||
// Decrypt wallet dialog state
|
||||
bool show_decrypt_dialog_ = false;
|
||||
int decrypt_phase_ = 0; // 0=passphrase, 1=working, 2=done, 3=error
|
||||
int decrypt_step_ = 0; // 0=unlock, 1=export, 2=stop, 3=rename, 4=restart, 5=import
|
||||
char decrypt_pass_buf_[256] = {};
|
||||
std::string decrypt_status_;
|
||||
bool decrypt_in_progress_ = false;
|
||||
std::chrono::steady_clock::time_point decrypt_step_start_time_{};
|
||||
std::chrono::steady_clock::time_point decrypt_overall_start_time_{};
|
||||
std::atomic<bool> decrypt_import_active_{false}; // background z_importwallet running
|
||||
|
||||
// Wizard PIN setup state
|
||||
char wizard_pin_buf_[16] = {};
|
||||
@@ -626,6 +756,8 @@ private:
|
||||
// Private methods - rendering
|
||||
void renderStatusBar();
|
||||
void renderAboutDialog();
|
||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||
void renderImportKeyDialog();
|
||||
void renderExportKeyDialog();
|
||||
void renderBackupDialog();
|
||||
@@ -641,6 +773,16 @@ private:
|
||||
void tryConnect();
|
||||
void onConnected();
|
||||
void onDisconnected(const std::string& reason);
|
||||
// Set the "node is initializing" UI state (status line + overlay description) from the
|
||||
// embedded/external daemon's launch state and its own console output (current phase + block
|
||||
// height), so a connect probe that times out while the daemon loads shows WHAT it's doing.
|
||||
// `reachableButBusy` is true when the probe connected but got no RPC reply (a timeout),
|
||||
// false when the daemon is merely launching (not bound yet). Returns the status title.
|
||||
std::string applyDaemonInitStatus(bool reachableButBusy);
|
||||
// Tear down a connection that died mid-session (daemon crash / restart / dropped
|
||||
// socket) so update()'s reconnect branch re-enters tryConnect(). Unlike onDisconnected
|
||||
// alone, this also rpc_->disconnect()s so rpc_->isConnected() actually flips to false.
|
||||
void handleLostConnection(const std::string& reason);
|
||||
void applyDefaultBanlist();
|
||||
|
||||
// Private methods - data refresh
|
||||
@@ -648,13 +790,17 @@ private:
|
||||
void refreshCoreData(); // Balance + blockchain info (can use fast_worker_)
|
||||
void refreshAddressData(); // Address lists + balances
|
||||
void refreshTransactionData(); // Transaction list + z_viewtransaction enrichment
|
||||
void refreshEncryptionState(); // Wallet encryption/lock state
|
||||
void refreshRecentTransactionData(); // Lightweight recent/unconfirmed tx poll
|
||||
bool refreshEncryptionState(); // Wallet encryption/lock state
|
||||
void refreshBalance(); // Legacy: balance-only refresh (used by specific callers)
|
||||
void refreshAddresses(); // Legacy: standalone address refresh
|
||||
void refreshPrice();
|
||||
void refreshWalletEncryptionState();
|
||||
void applyRefreshPolicy(ui::NavPage page);
|
||||
bool currentPageNeedsWalletDataRefresh() const;
|
||||
bool shouldRunWalletTransactionRefresh() const;
|
||||
bool shouldRefreshTransactions() const;
|
||||
bool shouldRefreshRecentTransactions() const;
|
||||
void checkAutoLock();
|
||||
void checkIdleMining();
|
||||
};
|
||||
|
||||
2318
src/app_network.cpp
2318
src/app_network.cpp
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
||||
#include "rpc/rpc_client.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "services/wallet_security_workflow_executor.h"
|
||||
#include "config/settings.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "ui/notifications.h"
|
||||
@@ -27,13 +28,203 @@
|
||||
|
||||
#include "imgui.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
class WalletSecurityRpcAdapter : public services::WalletSecurityController::RpcGateway {
|
||||
public:
|
||||
explicit WalletSecurityRpcAdapter(rpc::RPCClient* rpc, std::string source = "Security settings")
|
||||
: rpc_(rpc), source_(std::move(source)) {}
|
||||
|
||||
bool encryptWallet(const std::string& passphrase, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("encryptwallet", {passphrase}); }, error);
|
||||
}
|
||||
|
||||
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
|
||||
}
|
||||
|
||||
bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_exportwallet", {fileName}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
bool importWallet(const std::string& filePath, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_importwallet", {filePath}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Fn>
|
||||
bool callWithError(Fn&& fn, std::string& error) {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace(source_);
|
||||
fn();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
rpc::RPCClient* rpc_ = nullptr;
|
||||
std::string source_;
|
||||
};
|
||||
|
||||
class WalletSecurityVaultAdapter : public services::WalletSecurityController::VaultGateway {
|
||||
public:
|
||||
explicit WalletSecurityVaultAdapter(util::SecureVault* vault) : vault_(vault) {}
|
||||
|
||||
bool storePin(const std::string& pin, const std::string& passphrase) override {
|
||||
return vault_ && vault_->store(pin, passphrase);
|
||||
}
|
||||
|
||||
private:
|
||||
util::SecureVault* vault_ = nullptr;
|
||||
};
|
||||
|
||||
class WalletSecurityDecryptRpcAdapter : public services::WalletSecurityWorkflowExecutor::RpcGateway {
|
||||
public:
|
||||
using StopFn = std::function<bool(rpc::RPCClient&, const char*)>;
|
||||
|
||||
WalletSecurityDecryptRpcAdapter(rpc::RPCClient* rpc, StopFn stopFn,
|
||||
std::string source = "Security / Decrypt wallet workflow")
|
||||
: rpc_(rpc), stopFn_(std::move(stopFn)), source_(std::move(source)) {}
|
||||
|
||||
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
|
||||
}
|
||||
|
||||
bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_exportwallet", {fileName}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
bool requestDaemonStop(std::string& error) override {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
bool ok = stopFn_ ? stopFn_(*rpc_, "Decrypt export daemon stop") : false;
|
||||
if (!ok) error = "Stop RPC failed";
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool probeDaemon(std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("getinfo"); }, error);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Fn>
|
||||
bool callWithError(Fn&& fn, std::string& error) {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace(source_);
|
||||
fn();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
rpc::RPCClient* rpc_ = nullptr;
|
||||
StopFn stopFn_;
|
||||
std::string source_;
|
||||
};
|
||||
|
||||
class WalletSecurityImportRpcAdapter : public services::WalletSecurityWorkflowExecutor::ImportGateway {
|
||||
public:
|
||||
WalletSecurityImportRpcAdapter(rpc::RPCClient* fallbackRpc, rpc::ConnectionConfig config)
|
||||
: fallbackRpc_(fallbackRpc), config_(std::move(config)) {}
|
||||
|
||||
bool importWallet(const std::string& exportPath, long timeoutSeconds, std::string& error) override {
|
||||
auto importRpc = std::make_unique<rpc::RPCClient>();
|
||||
bool importRpcOk = importRpc->connect(config_.host, config_.port,
|
||||
config_.rpcuser, config_.rpcpassword,
|
||||
config_.use_tls);
|
||||
if (!importRpcOk) importRpc.reset();
|
||||
|
||||
auto* rpcForImport = importRpc ? importRpc.get() : fallbackRpc_;
|
||||
if (!rpcForImport) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Import wallet workflow");
|
||||
rpcForImport->call("z_importwallet", {exportPath}, timeoutSeconds);
|
||||
if (importRpc) importRpc->disconnect();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
if (importRpc) importRpc->disconnect();
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
rpc::RPCClient* fallbackRpc_ = nullptr;
|
||||
rpc::ConnectionConfig config_;
|
||||
};
|
||||
|
||||
class WalletSecurityFileAdapter : public services::WalletSecurityWorkflowExecutor::FileGateway {
|
||||
public:
|
||||
std::string dataDir() override { return util::Platform::getDragonXDataDir(); }
|
||||
|
||||
bool backupEncryptedWallet(const services::WalletSecurityWorkflowExecutor::WalletFilePlan& filePlan,
|
||||
std::string& error) override {
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(filePlan.walletPath, ec)) return true;
|
||||
|
||||
std::filesystem::remove(filePlan.backupPath, ec);
|
||||
ec.clear();
|
||||
std::filesystem::rename(filePlan.walletPath, filePlan.backupPath, ec);
|
||||
if (ec) {
|
||||
error = ec.message();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
class WalletSecurityDaemonAdapter : public services::WalletSecurityWorkflowExecutor::DaemonGateway {
|
||||
public:
|
||||
WalletSecurityDaemonAdapter(App& app, const util::AsyncTaskManager::Token& token)
|
||||
: app_(app), token_(token) {}
|
||||
|
||||
bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); }
|
||||
void stopEmbeddedDaemon() override { app_.stopEmbeddedDaemon(); }
|
||||
bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); }
|
||||
bool cancelled() const override { return token_.cancelled(); }
|
||||
bool shuttingDown() const override { return app_.isShuttingDown(); }
|
||||
void sleepForMs(int milliseconds) override {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, milliseconds)));
|
||||
}
|
||||
|
||||
private:
|
||||
App& app_;
|
||||
const util::AsyncTaskManager::Token& token_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// Wallet encryption helpers
|
||||
@@ -45,9 +236,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
encrypt_status_ = "Encrypting wallet...";
|
||||
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
auto result = rpc_->call("encryptwallet", {passphrase});
|
||||
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityRpcAdapter rpcAdapter(rpc_.get());
|
||||
auto result = wallet_security_.runDeferredEncryption(
|
||||
{std::move(passphrase), {}}, rpcAdapter, nullptr);
|
||||
if (result.encrypted) {
|
||||
return [this]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Wallet encrypted. Restarting daemon...";
|
||||
@@ -78,22 +271,22 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
connection_status_ = TR("restarting_after_encryption");
|
||||
// Give daemon a moment to shut down, then restart
|
||||
// (do this off the main thread to avoid stalling the UI)
|
||||
std::thread([this]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
async_tasks_.submit("encrypt-daemon-restart", [this](const util::AsyncTaskManager::Token& token) {
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
// tryConnect will be called by the update loop
|
||||
}).detach();
|
||||
});
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
"Please restart your daemon for encryption to take effect.");
|
||||
}
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
} else {
|
||||
std::string err = result.error;
|
||||
return [this, err]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Encryption failed: " + err;
|
||||
@@ -118,15 +311,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
// Called every frame from render() until the task completes.
|
||||
// ---------------------------------------------------------------------------
|
||||
void App::processDeferredEncryption() {
|
||||
if (!deferred_encrypt_pending_) return;
|
||||
if (!wallet_security_.hasDeferredEncryption()) return;
|
||||
|
||||
// Phase 1: wait for daemon connection
|
||||
if (!state_.connected || !rpc_ || !rpc_->isConnected()) {
|
||||
// Throttle connection attempts to every 3 seconds
|
||||
static double s_lastAttempt = -10.0;
|
||||
double now = ImGui::GetTime();
|
||||
if (now - s_lastAttempt >= 3.0) {
|
||||
s_lastAttempt = now;
|
||||
if (wallet_security_.shouldAttemptDeferredConnect(ImGui::GetTime())) {
|
||||
if (!connection_in_progress_) {
|
||||
// Just try to connect — tryConnect is now async
|
||||
tryConnect();
|
||||
@@ -140,31 +329,29 @@ void App::processDeferredEncryption() {
|
||||
|
||||
// Phase 2: connected — launch encryption
|
||||
if (!encrypt_in_progress_) {
|
||||
std::string passphrase = deferred_encrypt_passphrase_;
|
||||
std::string pin = deferred_encrypt_pin_;
|
||||
auto deferredEncryption = wallet_security_.deferredEncryption();
|
||||
std::string passphrase = std::move(deferredEncryption.passphrase);
|
||||
std::string pin = std::move(deferredEncryption.pin);
|
||||
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = "Encrypting wallet...";
|
||||
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
rpc_->call("encryptwallet", {passphrase});
|
||||
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityRpcAdapter rpcAdapter(rpc_.get());
|
||||
WalletSecurityVaultAdapter vaultAdapter(vault_.get());
|
||||
auto result = wallet_security_.runDeferredEncryption(
|
||||
std::move(request), rpcAdapter, vault_ ? &vaultAdapter : nullptr);
|
||||
|
||||
// Store PIN vault on the worker thread (Argon2id is expensive)
|
||||
bool pinStored = false;
|
||||
if (!pin.empty() && vault_) {
|
||||
pinStored = vault_->store(pin, passphrase);
|
||||
}
|
||||
|
||||
return [this, pinStored, pin]() {
|
||||
if (result.encrypted) {
|
||||
return [this, result]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_.clear();
|
||||
DEBUG_LOGF("[App] Wallet encrypted (deferred)\n");
|
||||
|
||||
// Finalize PIN settings on main thread
|
||||
if (!pin.empty()) {
|
||||
if (pinStored) {
|
||||
if (result.pinProvided) {
|
||||
if (result.pinStored) {
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
ui::Notifications::instance().info("Wallet encrypted & PIN set", 5.0f);
|
||||
@@ -176,59 +363,32 @@ void App::processDeferredEncryption() {
|
||||
ui::Notifications::instance().info("Wallet encrypted successfully", 5.0f);
|
||||
}
|
||||
|
||||
// Securely clear deferred state
|
||||
if (!deferred_encrypt_passphrase_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_passphrase_[0],
|
||||
deferred_encrypt_passphrase_.size());
|
||||
deferred_encrypt_passphrase_.clear();
|
||||
}
|
||||
if (!deferred_encrypt_pin_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_pin_[0],
|
||||
deferred_encrypt_pin_.size());
|
||||
deferred_encrypt_pin_.clear();
|
||||
}
|
||||
deferred_encrypt_pending_ = false;
|
||||
wallet_security_.clearDeferredEncryption();
|
||||
|
||||
// Restart daemon (it shuts itself down after encryptwallet)
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
std::thread([this]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
async_tasks_.submit("deferred-encrypt-daemon-restart", [this](const util::AsyncTaskManager::Token& token) {
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
// tryConnect will be called by the update loop
|
||||
}).detach();
|
||||
});
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
"Please restart your daemon for encryption to take effect.");
|
||||
}
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
} else {
|
||||
std::string err = result.error;
|
||||
return [this, err]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Encryption failed: " + err;
|
||||
deferred_encrypt_pending_ = false;
|
||||
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
|
||||
ui::Notifications::instance().error("Encryption failed: " + err);
|
||||
|
||||
// Clean up sensitive data on failure
|
||||
if (!deferred_encrypt_passphrase_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_passphrase_[0],
|
||||
deferred_encrypt_passphrase_.size());
|
||||
deferred_encrypt_passphrase_.clear();
|
||||
}
|
||||
if (!deferred_encrypt_pin_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_pin_[0],
|
||||
deferred_encrypt_pin_.size());
|
||||
deferred_encrypt_pin_.clear();
|
||||
}
|
||||
wallet_security_.clearDeferredEncryption();
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -243,17 +403,14 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
// Use fast-lane worker to bypass head-of-line blocking behind refreshData.
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this, r, passphrase, timeout]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
w->post([this, r, passphrase = std::string(passphrase), timeout]() mutable -> rpc::RPCWorker::MainCb {
|
||||
std::string err_msg;
|
||||
try {
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
}
|
||||
WalletSecurityRpcAdapter rpcAdapter(r);
|
||||
bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg);
|
||||
std::string cachePassphrase = passphrase;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
|
||||
return [this, ok, err_msg, timeout]() {
|
||||
return [this, ok, err_msg, timeout, passphrase = std::move(cachePassphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
if (ok) {
|
||||
lock_error_msg_.clear();
|
||||
@@ -265,6 +422,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
state_.encrypted = true;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = std::time(nullptr) + timeout;
|
||||
unlockTransactionHistoryCacheWithPassphrase(passphrase);
|
||||
} else {
|
||||
lock_attempts_++;
|
||||
lock_error_msg_ = TR("incorrect_passphrase");
|
||||
@@ -278,6 +436,7 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
}
|
||||
DEBUG_LOGF("[App] Wallet unlock failed (attempt %d): %s\n", lock_attempts_, err_msg.c_str());
|
||||
}
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -302,6 +461,7 @@ void App::lockWallet() {
|
||||
if (ok) {
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
resetTransactionHistoryCacheSession();
|
||||
DEBUG_LOGF("[App] Wallet locked\n");
|
||||
}
|
||||
};
|
||||
@@ -315,7 +475,10 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this, r, oldPass, newPass]() -> rpc::RPCWorker::MainCb {
|
||||
w->post([this,
|
||||
r,
|
||||
oldPass = std::string(oldPass),
|
||||
newPass = std::string(newPass)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string err_msg;
|
||||
try {
|
||||
@@ -324,8 +487,14 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
}
|
||||
std::string cacheNewPass = newPass;
|
||||
util::SecureVault::secureZero(oldPass.data(), oldPass.size());
|
||||
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
||||
|
||||
return [this, ok, err_msg]() {
|
||||
return [this,
|
||||
ok,
|
||||
err_msg,
|
||||
newPass = std::move(cacheNewPass)]() mutable {
|
||||
encrypt_in_progress_ = false;
|
||||
if (ok) {
|
||||
encrypt_status_.clear();
|
||||
@@ -333,10 +502,13 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
||||
memset(change_old_pass_buf_, 0, sizeof(change_old_pass_buf_));
|
||||
memset(change_new_pass_buf_, 0, sizeof(change_new_pass_buf_));
|
||||
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
|
||||
unlockTransactionHistoryCacheWithPassphrase(newPass);
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
ui::Notifications::instance().info("Passphrase changed successfully");
|
||||
} else {
|
||||
encrypt_status_ = "Failed: " + err_msg;
|
||||
}
|
||||
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -352,6 +524,7 @@ void App::refreshWalletEncryptionState() {
|
||||
json result;
|
||||
bool ok = false;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / Wallet encryption state");
|
||||
result = rpc_->call("getwalletinfo");
|
||||
ok = true;
|
||||
} catch (...) {}
|
||||
@@ -365,10 +538,24 @@ void App::refreshWalletEncryptionState() {
|
||||
int64_t until = result["unlocked_until"].get<int64_t>();
|
||||
state_.unlocked_until = until;
|
||||
state_.locked = (until == 0);
|
||||
state_.encryption_state_known = true;
|
||||
if (state_.locked) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
} else {
|
||||
state_.encrypted = false;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = 0;
|
||||
state_.encryption_state_known = true;
|
||||
if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
storeTransactionHistoryCacheIfAvailable();
|
||||
}
|
||||
|
||||
// Wallet is no longer encrypted — if a PIN vault exists,
|
||||
// it's stale (passphrase it protects is gone). Reset PIN
|
||||
@@ -382,7 +569,6 @@ void App::refreshWalletEncryptionState() {
|
||||
settings_->save();
|
||||
}
|
||||
}
|
||||
state_.encryption_state_known = true;
|
||||
} catch (...) {}
|
||||
};
|
||||
});
|
||||
@@ -430,7 +616,7 @@ void App::checkIdleMining() {
|
||||
if (idle_mining_active_) {
|
||||
idle_mining_active_ = false;
|
||||
idle_scaled_to_idle_ = false;
|
||||
if (settings_ && settings_->getPoolMode()) {
|
||||
if (settings_ && (settings_->getPoolMode() || !supportsSoloMining())) {
|
||||
if (xmrig_manager_ && xmrig_manager_->isRunning())
|
||||
stopPoolMining();
|
||||
} else {
|
||||
@@ -448,7 +634,8 @@ void App::checkIdleMining() {
|
||||
|
||||
int idleSec = util::Platform::getSystemIdleSeconds();
|
||||
int delay = settings_->getMineIdleDelay();
|
||||
bool isPool = settings_->getPoolMode();
|
||||
bool isPool = settings_->getPoolMode() || !supportsSoloMining();
|
||||
if (isPool && !supportsPoolMining()) return;
|
||||
bool threadScaling = settings_->getIdleThreadScaling();
|
||||
int maxThreads = std::max(1, (int)std::thread::hardware_concurrency());
|
||||
|
||||
@@ -826,11 +1013,8 @@ void App::renderLockScreen() {
|
||||
rpcErr = e.what();
|
||||
}
|
||||
|
||||
// Securely wipe passphrase
|
||||
util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
|
||||
if (rpcOk) {
|
||||
return [this, timeout]() {
|
||||
return [this, timeout, passphrase = std::move(passphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_error_msg_.clear();
|
||||
lock_attempts_ = 0;
|
||||
@@ -841,13 +1025,16 @@ void App::renderLockScreen() {
|
||||
state_.encrypted = true;
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = std::time(nullptr) + timeout;
|
||||
unlockTransactionHistoryCacheWithPassphrase(passphrase);
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
} else {
|
||||
return [this, rpcErr]() {
|
||||
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
||||
lock_unlock_in_progress_ = false;
|
||||
lock_attempts_++;
|
||||
lock_error_msg_ = "Unlock failed: " + rpcErr;
|
||||
lock_error_timer_ = 3.0f;
|
||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1146,14 +1333,18 @@ void App::renderEncryptWalletDialog() {
|
||||
void App::renderDecryptWalletDialog() {
|
||||
if (!show_decrypt_dialog_) return;
|
||||
using namespace ui::material;
|
||||
using DecryptPhase = services::WalletSecurityWorkflow::DecryptPhase;
|
||||
using DecryptStep = services::WalletSecurityWorkflow::DecryptStep;
|
||||
|
||||
bool canClose = (decrypt_phase_ != 1); // don't close while working
|
||||
auto decryptState = wallet_security_workflow_.snapshot();
|
||||
|
||||
bool canClose = wallet_security_workflow_.canClose();
|
||||
bool* pOpen = canClose ? &show_decrypt_dialog_ : nullptr;
|
||||
|
||||
if (BeginOverlayDialog("Remove Wallet Encryption", pOpen, 480.0f, 0.94f)) {
|
||||
|
||||
// ---- Phase 0: Passphrase entry ----
|
||||
if (decrypt_phase_ == 0) {
|
||||
if (decryptState.phase == DecryptPhase::PassphraseEntry) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.7f, 0.3f, 1));
|
||||
ImGui::TextWrapped(ICON_MD_WARNING
|
||||
" This will remove encryption from your wallet. "
|
||||
@@ -1176,143 +1367,110 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
if (!decrypt_status_.empty()) {
|
||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decrypt_status_.c_str());
|
||||
if (!decryptState.status.empty()) {
|
||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decryptState.status.c_str());
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
bool valid = strlen(decrypt_pass_buf_) >= 1;
|
||||
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || decrypt_in_progress_);
|
||||
ImGui::BeginDisabled(!valid || decryptState.inProgress);
|
||||
if (ImGui::Button("Remove Encryption", ImVec2(btnW, 40)) || (enterPressed && valid)) {
|
||||
std::string passphrase(decrypt_pass_buf_);
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
decrypt_phase_ = 1;
|
||||
decrypt_step_ = 0;
|
||||
decrypt_in_progress_ = true;
|
||||
decrypt_status_ = "Unlocking wallet...";
|
||||
decrypt_overall_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_step_start_time_ = decrypt_overall_start_time_;
|
||||
wallet_security_workflow_.start(std::chrono::steady_clock::now());
|
||||
|
||||
// Run entire decrypt flow on worker thread
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
||||
// Step 1: Unlock wallet
|
||||
try {
|
||||
rpc_->call("walletpassphrase", {passphrase, 600});
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Incorrect passphrase";
|
||||
decrypt_phase_ = 0; // back to entry
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
|
||||
if (!unlock.ok) {
|
||||
return [this]() {
|
||||
wallet_security_workflow_.failEntry("Incorrect passphrase");
|
||||
};
|
||||
}
|
||||
|
||||
// Update step on main thread
|
||||
return [this]() {
|
||||
decrypt_step_ = 1;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Exporting wallet keys...";
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::ExportKeys,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::ExportKeys),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 2
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string exportFile = "obsidiandecryptexport" +
|
||||
std::to_string(std::time(nullptr));
|
||||
std::string exportPath = dataDir + exportFile;
|
||||
|
||||
try {
|
||||
rpc_->call("z_exportwallet", {exportFile}, 300L);
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
WalletSecurityFileAdapter files;
|
||||
auto exportOutcome = services::WalletSecurityWorkflowExecutor::exportWallet(
|
||||
decryptRpc, files, static_cast<std::uint64_t>(std::time(nullptr)));
|
||||
if (!exportOutcome.ok) {
|
||||
std::string err = exportOutcome.error;
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Export failed: " + err;
|
||||
decrypt_phase_ = 3;
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
}
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 2;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Stopping daemon...";
|
||||
auto filePlan = exportOutcome.filePlan;
|
||||
|
||||
return [this, filePlan]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::StopDaemon,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::StopDaemon),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 3
|
||||
worker_->post([this, exportPath]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
rpc_->call("stop");
|
||||
} catch (...) {
|
||||
// stop often throws because connection drops
|
||||
}
|
||||
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
services::WalletSecurityWorkflowExecutor::stopDaemon(decryptRpc);
|
||||
|
||||
// Wait for daemon to fully stop
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 3;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Backing up encrypted wallet...";
|
||||
return [this, filePlan]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::BackupWallet),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 4 (rename)
|
||||
worker_->post([this, exportPath]() -> rpc::RPCWorker::MainCb {
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string walletPath = dataDir + "wallet.dat";
|
||||
std::string backupPath = dataDir + "wallet.dat.encrypted.bak";
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(walletPath, ec)) {
|
||||
std::filesystem::remove(backupPath, ec);
|
||||
std::filesystem::rename(walletPath, backupPath, ec);
|
||||
if (ec) {
|
||||
std::string err = ec.message();
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Failed to rename wallet.dat: " + err;
|
||||
decrypt_phase_ = 3;
|
||||
};
|
||||
}
|
||||
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityFileAdapter files;
|
||||
auto backup = services::WalletSecurityWorkflowExecutor::backupEncryptedWallet(files, filePlan);
|
||||
if (!backup.ok) {
|
||||
std::string err = backup.error;
|
||||
return [this, err]() {
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
}
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 4;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Restarting daemon...";
|
||||
return [this, exportPath = filePlan.exportPath]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::RestartDaemon,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::RestartDaemon),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
auto restartAndImport = [this, exportPath]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
for (int i = 0; i < 10 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
}
|
||||
|
||||
// Wait for daemon to become available
|
||||
int maxWait = 60;
|
||||
bool daemonUp = false;
|
||||
for (int i = 0; i < maxWait && !shutting_down_; i++) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
try {
|
||||
rpc_->call("getinfo");
|
||||
daemonUp = true;
|
||||
break;
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
if (!daemonUp) {
|
||||
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
|
||||
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
auto restart = services::WalletSecurityWorkflowExecutor::restartDaemonAndWait(
|
||||
daemonAdapter, decryptRpc);
|
||||
if (!restart.ok) {
|
||||
if (restart.error.empty()) return;
|
||||
if (worker_) {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Daemon failed to restart";
|
||||
decrypt_phase_ = 3;
|
||||
worker_->post([this, err = restart.error]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, err]() {
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1324,9 +1482,8 @@ void App::renderDecryptWalletDialog() {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
// Close the decrypt dialog — user can use the wallet now
|
||||
decrypt_in_progress_ = false;
|
||||
wallet_security_workflow_.closeDialogForImport();
|
||||
show_decrypt_dialog_ = false;
|
||||
decrypt_import_active_ = true;
|
||||
|
||||
// Mark rescanning so status bar picks it up immediately
|
||||
state_.sync.rescanning = true;
|
||||
@@ -1334,13 +1491,15 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
// Clear encryption state early — vault/PIN removed now,
|
||||
// wallet file is already unencrypted
|
||||
if (vault_ && vault_->hasVault()) {
|
||||
vault_->removeVault();
|
||||
}
|
||||
if (settings_ && settings_->getPinEnabled()) {
|
||||
settings_->setPinEnabled(false);
|
||||
settings_->save();
|
||||
}
|
||||
services::WalletSecurityWorkflowExecutor::cleanupVaultAndPin([this]() {
|
||||
if (vault_ && vault_->hasVault()) {
|
||||
vault_->removeVault();
|
||||
}
|
||||
if (settings_ && settings_->getPinEnabled()) {
|
||||
settings_->setPinEnabled(false);
|
||||
settings_->save();
|
||||
}
|
||||
});
|
||||
|
||||
ui::Notifications::instance().info(
|
||||
"Importing keys & rescanning blockchain — wallet is usable while this runs",
|
||||
@@ -1349,32 +1508,17 @@ void App::renderDecryptWalletDialog() {
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Import wallet in background (use full path)
|
||||
// Use a SEPARATE RPC client so the main rpc_'s
|
||||
// curl_mutex isn't held for the entire import duration.
|
||||
// Blocking rpc_ prevents refreshData/refreshPeerInfo
|
||||
// from running, which leaves the UI with no peers.
|
||||
auto importRpc = std::make_unique<rpc::RPCClient>();
|
||||
bool importRpcOk = importRpc->connect(
|
||||
saved_config_.host, saved_config_.port,
|
||||
saved_config_.rpcuser, saved_config_.rpcpassword);
|
||||
if (!importRpcOk) {
|
||||
// Fall back to main client if temp connect fails
|
||||
importRpc.reset();
|
||||
}
|
||||
auto* rpcForImport = importRpc ? importRpc.get() : rpc_.get();
|
||||
|
||||
// Use 20-minute timeout — import + rescan can be very slow
|
||||
try {
|
||||
rpcForImport->call("z_importwallet", {exportPath}, 1200L);
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
||||
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
||||
importAdapter, exportPath);
|
||||
if (!importResult.ok) {
|
||||
std::string err = importResult.error;
|
||||
if (worker_) {
|
||||
worker_->post([this, err]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, err]() {
|
||||
decrypt_import_active_ = false;
|
||||
wallet_security_workflow_.finishImport();
|
||||
ui::Notifications::instance().error(
|
||||
"Key import failed: " + err +
|
||||
err +
|
||||
"\nEncrypted backup: wallet.dat.encrypted.bak",
|
||||
12.0f);
|
||||
};
|
||||
@@ -1383,20 +1527,15 @@ void App::renderDecryptWalletDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect the temporary RPC client
|
||||
if (importRpc) {
|
||||
importRpc->disconnect();
|
||||
importRpc.reset();
|
||||
}
|
||||
|
||||
// Success — force full state refresh so peers,
|
||||
// balances, and addresses are fetched immediately.
|
||||
if (worker_) {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
decrypt_import_active_ = false;
|
||||
wallet_security_workflow_.finishImport();
|
||||
|
||||
// Force address + peer refresh
|
||||
invalidateAddressValidationCache();
|
||||
addresses_dirty_ = true;
|
||||
transactions_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
@@ -1414,7 +1553,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
std::thread(restartAndImport).detach();
|
||||
async_tasks_.submit("decrypt-restart-import", restartAndImport);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1434,7 +1573,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 1: Working ----
|
||||
} else if (decrypt_phase_ == 1) {
|
||||
} else if (decryptState.phase == DecryptPhase::Working) {
|
||||
// Step checklist
|
||||
const char* stepLabels[] = {
|
||||
"Unlocking wallet",
|
||||
@@ -1448,17 +1587,18 @@ void App::renderDecryptWalletDialog() {
|
||||
// Compute elapsed times
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto stepElapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - decrypt_step_start_time_).count();
|
||||
now - decryptState.stepStarted).count();
|
||||
auto totalElapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - decrypt_overall_start_time_).count();
|
||||
now - decryptState.overallStarted).count();
|
||||
|
||||
ImGui::Spacing();
|
||||
for (int i = 0; i < numSteps; i++) {
|
||||
ImGui::PushFont(Type().iconMed());
|
||||
if (i < decrypt_step_) {
|
||||
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||
// Completed
|
||||
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.4f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
||||
} else if (i == decrypt_step_) {
|
||||
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||
// In progress - animate
|
||||
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, alpha), ICON_MD_PENDING);
|
||||
@@ -1469,7 +1609,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine();
|
||||
|
||||
if (i == decrypt_step_) {
|
||||
if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||
// Show step label with elapsed time
|
||||
int mins = (int)(stepElapsed / 60);
|
||||
int secs = (int)(stepElapsed % 60);
|
||||
@@ -1480,7 +1620,8 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.6f, 1.0f),
|
||||
"%s... (%ds)", stepLabels[i], secs);
|
||||
}
|
||||
} else if (i < decrypt_step_) {
|
||||
} else if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "%s", stepLabels[i]);
|
||||
} else {
|
||||
ImGui::TextDisabled("%s", stepLabels[i]);
|
||||
@@ -1515,7 +1656,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::Spacing();
|
||||
|
||||
// Step-specific hints
|
||||
if (decrypt_step_ == 4) {
|
||||
if (decryptState.step == DecryptStep::RestartDaemon) {
|
||||
ImGui::TextWrapped("Waiting for the daemon to finish starting up...");
|
||||
} else {
|
||||
ImGui::TextWrapped("Please wait. The daemon is exporting keys, restarting, "
|
||||
@@ -1531,7 +1672,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 2: Success ----
|
||||
} else if (decrypt_phase_ == 2) {
|
||||
} else if (decryptState.phase == DecryptPhase::Success) {
|
||||
ImGui::PushFont(Type().iconLarge());
|
||||
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.5f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
||||
ImGui::PopFont();
|
||||
@@ -1549,7 +1690,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 3: Error ----
|
||||
} else if (decrypt_phase_ == 3) {
|
||||
} else if (decryptState.phase == DecryptPhase::Error) {
|
||||
ImGui::PushFont(Type().iconLarge());
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), ICON_MD_ERROR);
|
||||
ImGui::PopFont();
|
||||
@@ -1557,14 +1698,12 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Decryption failed");
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("%s", decrypt_status_.c_str());
|
||||
ImGui::TextWrapped("%s", decryptState.status.c_str());
|
||||
|
||||
ImGui::Spacing();
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
if (ImGui::Button("Try Again", ImVec2(btnW, 40))) {
|
||||
decrypt_phase_ = 0;
|
||||
decrypt_step_ = 0;
|
||||
decrypt_status_.clear();
|
||||
wallet_security_workflow_.reset();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close", ImVec2(btnW, 40))) {
|
||||
@@ -1639,6 +1778,7 @@ void App::renderPinDialogs() {
|
||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
||||
// Verify passphrase via RPC (worker thread)
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
||||
} catch (const std::exception& e) {
|
||||
return [this]() {
|
||||
@@ -1652,6 +1792,7 @@ void App::renderPinDialogs() {
|
||||
|
||||
// Lock wallet back
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletlock");
|
||||
} catch (...) {}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "config/settings.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "ui/notifications.h"
|
||||
#include "ui/material/color_theme.h"
|
||||
@@ -39,13 +40,48 @@ namespace dragonx {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
struct WizardLowSpecSnapshot {
|
||||
bool valid = false;
|
||||
float blur = 0.0f;
|
||||
float uiOp = 0.0f;
|
||||
bool fx = false;
|
||||
bool scanline = false;
|
||||
};
|
||||
|
||||
struct WizardUiState {
|
||||
float blur_amount = 1.5f;
|
||||
bool theme_effects = true;
|
||||
float ui_opacity = 1.0f;
|
||||
bool low_spec = false;
|
||||
bool scanline = true;
|
||||
std::string balance_layout = "classic";
|
||||
int language_index = 0;
|
||||
bool appearance_init = false;
|
||||
WizardLowSpecSnapshot low_spec_snapshot;
|
||||
float card0_max_h = 0.0f;
|
||||
float card1_max_h = 0.0f;
|
||||
double external_last_check = -10.0;
|
||||
bool daemon_prestarted = false;
|
||||
};
|
||||
|
||||
WizardUiState s_wizardUi;
|
||||
|
||||
} // namespace
|
||||
|
||||
void App::restartWizard()
|
||||
{
|
||||
if (!supportsFullNodeLifecycleActions()) {
|
||||
ui::Notifications::instance().warning("Lite wallet lifecycle requests are available from Settings as dry-run readiness checks");
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[App] Restarting setup wizard — stopping daemon...\n");
|
||||
|
||||
// Reset crash counter for fresh wizard attempt
|
||||
if (embedded_daemon_) {
|
||||
embedded_daemon_->resetCrashCount();
|
||||
if (daemon_controller_) {
|
||||
daemon_controller_->resetCrashCount();
|
||||
}
|
||||
|
||||
// Disconnect RPC
|
||||
@@ -56,10 +92,11 @@ void App::restartWizard()
|
||||
|
||||
// Stop the embedded daemon in a background thread to avoid
|
||||
// blocking the UI for up to 32 seconds (RPC stop + process wait).
|
||||
if (embedded_daemon_ && isEmbeddedDaemonRunning()) {
|
||||
std::thread([this]() {
|
||||
if (daemon_controller_ && isEmbeddedDaemonRunning()) {
|
||||
async_tasks_.submit("wizard-restart-stop-daemon", [this](const util::AsyncTaskManager::Token& token) {
|
||||
if (token.cancelled()) return;
|
||||
stopEmbeddedDaemon();
|
||||
}).detach();
|
||||
});
|
||||
}
|
||||
|
||||
// Enter wizard — the wizard completion handler already calls
|
||||
@@ -73,6 +110,7 @@ void App::restartWizard()
|
||||
// ===========================================================================
|
||||
|
||||
void App::renderFirstRunWizard() {
|
||||
auto& wizardUi = s_wizardUi;
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->WorkPos);
|
||||
ImGui::SetNextWindowSize(viewport->WorkSize);
|
||||
@@ -243,15 +281,14 @@ void App::renderFirstRunWizard() {
|
||||
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
||||
cy += 14.0f * dp;
|
||||
|
||||
// Statics for appearance settings
|
||||
static float wiz_blur_amount = 1.5f;
|
||||
static bool wiz_theme_effects = true;
|
||||
static float wiz_ui_opacity = 1.0f;
|
||||
static bool wiz_low_spec = false;
|
||||
static bool wiz_scanline = true;
|
||||
static std::string wiz_balance_layout = "classic";
|
||||
static int wiz_language_index = 0;
|
||||
static bool wiz_appearance_init = false;
|
||||
float& wiz_blur_amount = wizardUi.blur_amount;
|
||||
bool& wiz_theme_effects = wizardUi.theme_effects;
|
||||
float& wiz_ui_opacity = wizardUi.ui_opacity;
|
||||
bool& wiz_low_spec = wizardUi.low_spec;
|
||||
bool& wiz_scanline = wizardUi.scanline;
|
||||
std::string& wiz_balance_layout = wizardUi.balance_layout;
|
||||
int& wiz_language_index = wizardUi.language_index;
|
||||
bool& wiz_appearance_init = wizardUi.appearance_init;
|
||||
if (!wiz_appearance_init) {
|
||||
wiz_blur_amount = settings_->getBlurMultiplier();
|
||||
wiz_theme_effects = settings_->getThemeEffectsEnabled();
|
||||
@@ -398,7 +435,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
// --- Low-spec mode checkbox ---
|
||||
// Snapshot for restoring settings when low-spec is turned off
|
||||
static struct { bool valid; float blur; float uiOp; bool fx; bool scanline; } wiz_lsSnap = {};
|
||||
WizardLowSpecSnapshot& wiz_lsSnap = wizardUi.low_spec_snapshot;
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f * dp);
|
||||
@@ -596,7 +633,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen
|
||||
static float card0MaxH = 0.0f;
|
||||
float& card0MaxH = wizardUi.card0_max_h;
|
||||
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
||||
card0Bot = card0Top + card0MaxH;
|
||||
|
||||
@@ -737,6 +774,8 @@ void App::renderFirstRunWizard() {
|
||||
auto finalProg = bootstrap_->getProgress();
|
||||
if (finalProg.state == util::Bootstrap::State::Completed) {
|
||||
bootstrap_.reset();
|
||||
// Reconcile the preserved wallet.dat against the new chain once the daemon is up.
|
||||
markPostBootstrapRescanPending();
|
||||
wizard_phase_ = WizardPhase::EncryptOffer;
|
||||
} else {
|
||||
wizard_phase_ = WizardPhase::BootstrapFailed;
|
||||
@@ -772,21 +811,16 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(ui::material::PrimaryVariant()));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ImGui::Button("Retry##bs", ImVec2(btnW2, btnH2))) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap retry...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap retry");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
bootstrap_->start(dataDir);
|
||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor(3);
|
||||
|
||||
@@ -808,17 +842,23 @@ void App::renderFirstRunWizard() {
|
||||
if (isFocused) {
|
||||
static std::atomic<bool> s_extCached{false};
|
||||
static std::atomic<bool> s_checkInFlight{false};
|
||||
static double s_extLastCheck = -10.0;
|
||||
double& s_extLastCheck = wizardUi.external_last_check;
|
||||
double now = ImGui::GetTime();
|
||||
if (now - s_extLastCheck >= 2.0 && !s_checkInFlight.load()) {
|
||||
s_extLastCheck = now;
|
||||
bool embeddedRunning = isEmbeddedDaemonRunning();
|
||||
s_checkInFlight.store(true);
|
||||
std::thread([embeddedRunning]() {
|
||||
async_tasks_.submit("wizard-external-daemon-check", [embeddedRunning](const util::AsyncTaskManager::Token& token) {
|
||||
if (token.cancelled()) {
|
||||
s_checkInFlight.store(false);
|
||||
return;
|
||||
}
|
||||
bool inUse = daemon::EmbeddedDaemon::isRpcPortInUse();
|
||||
s_extCached.store(inUse && !embeddedRunning);
|
||||
if (!token.cancelled()) {
|
||||
s_extCached.store(inUse && !embeddedRunning);
|
||||
}
|
||||
s_checkInFlight.store(false);
|
||||
}).detach();
|
||||
});
|
||||
}
|
||||
externalRunning = s_extCached.load();
|
||||
}
|
||||
@@ -859,19 +899,19 @@ void App::renderFirstRunWizard() {
|
||||
if (ImGui::Button("Stop Daemon##wiz", ImVec2(stopW, btnH2))) {
|
||||
wizard_stopping_external_ = true;
|
||||
wizard_stop_status_ = "Sending stop command...";
|
||||
if (wizard_stop_thread_.joinable()) wizard_stop_thread_.join();
|
||||
wizard_stop_thread_ = std::thread([this]() {
|
||||
async_tasks_.submit("wizard-stop-external-daemon", [this](const util::AsyncTaskManager::Token& token) {
|
||||
auto config = rpc::Connection::autoDetectConfig();
|
||||
if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
|
||||
auto tmp_rpc = std::make_unique<rpc::RPCClient>();
|
||||
if (tmp_rpc->connect(config.host, config.port,
|
||||
config.rpcuser, config.rpcpassword)) {
|
||||
try { tmp_rpc->call("stop"); } catch (...) {}
|
||||
config.rpcuser, config.rpcpassword,
|
||||
config.use_tls)) {
|
||||
sendStopCommandSafely(*tmp_rpc, "Wizard external daemon stop");
|
||||
tmp_rpc->disconnect();
|
||||
}
|
||||
}
|
||||
wizard_stop_status_ = "Waiting for daemon to shut down...";
|
||||
for (int i = 0; i < 60; i++) {
|
||||
for (int i = 0; i < 60 && !token.cancelled(); i++) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (!daemon::EmbeddedDaemon::isRpcPortInUse()) {
|
||||
wizard_stop_status_ = "Daemon stopped.";
|
||||
@@ -879,6 +919,7 @@ void App::renderFirstRunWizard() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (token.cancelled()) return;
|
||||
wizard_stop_status_ = "Daemon did not stop — try manually.";
|
||||
wizard_stopping_external_ = false;
|
||||
});
|
||||
@@ -953,21 +994,16 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(ui::material::PrimaryVariant()));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ImGui::Button("Download##bs", ImVec2(dlBtnW, btnH2))) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
bootstrap_->start(dataDir);
|
||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor(3);
|
||||
|
||||
@@ -977,23 +1013,18 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(ui::material::PrimaryVariant()));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
ImGui::BeginDisabled(!supportsFullNodeLifecycleActions());
|
||||
if (ImGui::Button("Mirror##bs_mirror", ImVec2(mirrorW, btnH2))) {
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap (mirror)...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||
bootstrap_->start(dataDir, mirrorUrl);
|
||||
wizard_phase_ = WizardPhase::BootstrapInProgress;
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.");
|
||||
ui::material::Tooltip("Download from mirror (bootstrap2.dragonx.is).\nUse this if the main download is slow or failing.");
|
||||
}
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor(3);
|
||||
@@ -1012,7 +1043,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen (but not when collapsed)
|
||||
static float card1MaxH = 0.0f;
|
||||
float& card1MaxH = wizardUi.card1_max_h;
|
||||
if (isCollapsed) {
|
||||
card1Bot = card1Top + (cy - card1Top);
|
||||
} else {
|
||||
@@ -1037,7 +1068,7 @@ void App::renderFirstRunWizard() {
|
||||
// Pre-start daemon when encrypt card becomes focused so it's ready
|
||||
// by the time the user finishes typing their passphrase
|
||||
if (isFocused) {
|
||||
static bool wiz_daemon_prestarted = false;
|
||||
bool& wiz_daemon_prestarted = wizardUi.daemon_prestarted;
|
||||
if (!wiz_daemon_prestarted) {
|
||||
wiz_daemon_prestarted = true;
|
||||
if (!state_.connected && isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
||||
@@ -1281,10 +1312,9 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::BeginDisabled(!canEncrypt);
|
||||
if (ImGui::Button("Encrypt & Continue##wiz", ImVec2(encBtnW, btnH2))) {
|
||||
// Save passphrase + optional PIN for background processing
|
||||
deferred_encrypt_passphrase_ = std::string(encrypt_pass_buf_);
|
||||
if (pinEntered && pinOk)
|
||||
deferred_encrypt_pin_ = pinStr;
|
||||
deferred_encrypt_pending_ = true;
|
||||
wallet_security_.beginDeferredEncryption(
|
||||
std::string(encrypt_pass_buf_),
|
||||
(pinEntered && pinOk) ? pinStr : std::string());
|
||||
|
||||
// Clear sensitive buffers
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
|
||||
1855
src/chat/chat_protocol.cpp
Normal file
1855
src/chat/chat_protocol.cpp
Normal file
File diff suppressed because it is too large
Load Diff
586
src/chat/chat_protocol.h
Normal file
586
src/chat/chat_protocol.h
Normal file
@@ -0,0 +1,586 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifndef DRAGONX_ENABLE_CHAT
|
||||
#define DRAGONX_ENABLE_CHAT 0
|
||||
#endif
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
enum class HushChatHeaderType {
|
||||
Message,
|
||||
ContactRequest
|
||||
};
|
||||
|
||||
struct HushChatHeader {
|
||||
int header_number = 0;
|
||||
int version = 0;
|
||||
std::string reply_zaddr;
|
||||
std::string conversation_id;
|
||||
HushChatHeaderType type = HushChatHeaderType::Message;
|
||||
std::string secretstream_header_hex;
|
||||
std::string public_key_hex;
|
||||
};
|
||||
|
||||
struct HushChatHeaderParseResult {
|
||||
bool ok = false;
|
||||
HushChatHeader header;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
struct HushChatMemoOutput {
|
||||
std::size_t position = 0;
|
||||
std::string memo;
|
||||
};
|
||||
|
||||
struct HushChatMemoPair {
|
||||
HushChatHeader header;
|
||||
std::size_t header_position = 0;
|
||||
std::size_t payload_position = 0;
|
||||
std::string payload_memo;
|
||||
};
|
||||
|
||||
enum class HushChatMemoGroupingIssue {
|
||||
InvalidHeader,
|
||||
MissingPayload,
|
||||
DuplicateHeader,
|
||||
OversizedMemo
|
||||
};
|
||||
|
||||
struct HushChatMemoGroupingIssueInfo {
|
||||
HushChatMemoGroupingIssue issue = HushChatMemoGroupingIssue::InvalidHeader;
|
||||
std::size_t position = 0;
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
struct HushChatMemoGroupingResult {
|
||||
std::vector<HushChatMemoPair> pairs;
|
||||
std::vector<HushChatMemoGroupingIssueInfo> issues;
|
||||
std::size_t ignored_memo_count = 0;
|
||||
};
|
||||
|
||||
struct HushChatTransactionInput {
|
||||
std::string txid;
|
||||
std::vector<HushChatMemoOutput> outputs;
|
||||
};
|
||||
|
||||
struct HushChatTransactionMetadata {
|
||||
std::string txid;
|
||||
HushChatHeaderType type = HushChatHeaderType::Message;
|
||||
std::string conversation_id;
|
||||
std::string reply_zaddr;
|
||||
std::size_t header_position = 0;
|
||||
std::size_t payload_position = 0;
|
||||
std::size_t payload_size = 0;
|
||||
};
|
||||
|
||||
struct HushChatTransactionExtractionResult {
|
||||
bool feature_enabled = false;
|
||||
std::vector<HushChatTransactionMetadata> metadata;
|
||||
std::vector<HushChatMemoGroupingIssueInfo> issues;
|
||||
std::size_t ignored_memo_count = 0;
|
||||
};
|
||||
|
||||
enum class HushChatDecryptPreflightError {
|
||||
None,
|
||||
FeatureDisabled,
|
||||
NonMessageHeader,
|
||||
InvalidHeaderNumber,
|
||||
UnsupportedVersion,
|
||||
MissingReplyAddress,
|
||||
MissingConversationId,
|
||||
InvalidSecretstreamHeader,
|
||||
InvalidPublicKey,
|
||||
EmptyCiphertext,
|
||||
OversizedCiphertext,
|
||||
OddLengthCiphertext,
|
||||
InvalidCiphertextHex,
|
||||
TruncatedCiphertext
|
||||
};
|
||||
|
||||
struct HushChatDecryptPreflightInput {
|
||||
HushChatHeader header;
|
||||
std::string ciphertext_hex;
|
||||
};
|
||||
|
||||
struct HushChatDecryptPreflightResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
HushChatDecryptPreflightError error = HushChatDecryptPreflightError::None;
|
||||
const char* error_name = "None";
|
||||
std::size_t ciphertext_size = 0;
|
||||
};
|
||||
|
||||
enum class HushChatHexDecodeError {
|
||||
None,
|
||||
Empty,
|
||||
OddLength,
|
||||
InvalidHex,
|
||||
UnexpectedByteLength
|
||||
};
|
||||
|
||||
struct HushChatHexDecodeResult {
|
||||
bool ok = false;
|
||||
HushChatHexDecodeError error = HushChatHexDecodeError::None;
|
||||
const char* error_name = "None";
|
||||
std::vector<unsigned char> bytes;
|
||||
};
|
||||
|
||||
enum class HushChatDecryptDirection {
|
||||
Incoming,
|
||||
Outgoing
|
||||
};
|
||||
|
||||
enum class HushChatSessionKeySelection {
|
||||
ClientRx,
|
||||
ServerTx
|
||||
};
|
||||
|
||||
enum class HushChatDecryptInputError {
|
||||
None,
|
||||
FeatureDisabled,
|
||||
InvalidStoredChatKey,
|
||||
DecryptPreflightFailed,
|
||||
InvalidPeerPublicKey,
|
||||
InvalidStreamHeader,
|
||||
InvalidCiphertext
|
||||
};
|
||||
|
||||
struct HushChatDecryptInputMaterial {
|
||||
std::string stored_chat_key_hex;
|
||||
HushChatHeader header;
|
||||
std::string ciphertext_hex;
|
||||
HushChatDecryptDirection direction = HushChatDecryptDirection::Incoming;
|
||||
std::string peer_public_key_hex;
|
||||
};
|
||||
|
||||
struct HushChatPreparedDecryptInput {
|
||||
std::vector<unsigned char> stored_chat_key_bytes;
|
||||
std::vector<unsigned char> seed_bytes;
|
||||
std::vector<unsigned char> peer_public_key_bytes;
|
||||
std::vector<unsigned char> stream_header_bytes;
|
||||
std::vector<unsigned char> ciphertext_bytes;
|
||||
HushChatDecryptDirection direction = HushChatDecryptDirection::Incoming;
|
||||
HushChatSessionKeySelection session_key_selection = HushChatSessionKeySelection::ClientRx;
|
||||
std::size_t plaintext_capacity = 0;
|
||||
};
|
||||
|
||||
struct HushChatDecryptInputPreparationResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
HushChatDecryptInputError error = HushChatDecryptInputError::None;
|
||||
const char* error_name = "None";
|
||||
HushChatHexDecodeError hex_error = HushChatHexDecodeError::None;
|
||||
HushChatDecryptPreflightError preflight_error = HushChatDecryptPreflightError::None;
|
||||
HushChatPreparedDecryptInput prepared;
|
||||
};
|
||||
|
||||
struct HushChatDecryptFixtureReadinessResult {
|
||||
bool ready = false;
|
||||
std::size_t stored_chat_key_size = 0;
|
||||
std::size_t seed_size = 0;
|
||||
std::size_t peer_public_key_size = 0;
|
||||
std::size_t stream_header_size = 0;
|
||||
std::size_t ciphertext_size = 0;
|
||||
std::size_t plaintext_capacity = 0;
|
||||
HushChatSessionKeySelection session_key_selection = HushChatSessionKeySelection::ClientRx;
|
||||
};
|
||||
|
||||
enum class HushChatCompatibilityFixtureError {
|
||||
None,
|
||||
FeatureDisabled,
|
||||
MissingFixtureId,
|
||||
InvalidLocalPublicKey,
|
||||
InvalidPeerPublicKey,
|
||||
InvalidHeaderMemo,
|
||||
InvalidMemoPair,
|
||||
NonMemoHeader,
|
||||
HeaderPublicKeyMismatch,
|
||||
DecryptInputFailed,
|
||||
NotFixtureReady,
|
||||
ExpectedStoredChatKeyLengthMismatch,
|
||||
ExpectedSeedLengthMismatch,
|
||||
ExpectedLocalPublicKeyLengthMismatch,
|
||||
ExpectedPeerPublicKeyLengthMismatch,
|
||||
ExpectedStreamHeaderLengthMismatch,
|
||||
ExpectedCiphertextLengthMismatch,
|
||||
ExpectedPlaintextLengthMismatch,
|
||||
ExpectedRoleMismatch,
|
||||
InvalidPlaintextHash
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixture {
|
||||
std::string fixture_id;
|
||||
std::string stored_chat_key_hex;
|
||||
std::string local_public_key_hex;
|
||||
std::string peer_public_key_hex;
|
||||
std::string header_memo;
|
||||
std::string ciphertext_memo;
|
||||
HushChatDecryptDirection direction = HushChatDecryptDirection::Incoming;
|
||||
HushChatSessionKeySelection expected_session_key_selection = HushChatSessionKeySelection::ClientRx;
|
||||
std::size_t expected_stored_chat_key_size = 32;
|
||||
std::size_t expected_seed_size = 32;
|
||||
std::size_t expected_local_public_key_size = 32;
|
||||
std::size_t expected_peer_public_key_size = 32;
|
||||
std::size_t expected_stream_header_size = 24;
|
||||
std::size_t expected_ciphertext_size = 0;
|
||||
std::size_t expected_plaintext_size = 0;
|
||||
std::string expected_plaintext_hash_hex;
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixtureVerificationResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
HushChatCompatibilityFixtureError error = HushChatCompatibilityFixtureError::None;
|
||||
const char* error_name = "None";
|
||||
HushChatHexDecodeError hex_error = HushChatHexDecodeError::None;
|
||||
HushChatDecryptInputError decrypt_input_error = HushChatDecryptInputError::None;
|
||||
HushChatDecryptPreflightError preflight_error = HushChatDecryptPreflightError::None;
|
||||
HushChatHeader header;
|
||||
HushChatDecryptInputPreparationResult preparation;
|
||||
HushChatDecryptFixtureReadinessResult readiness;
|
||||
std::size_t local_public_key_size = 0;
|
||||
std::size_t peer_public_key_size = 0;
|
||||
std::size_t plaintext_hash_size = 0;
|
||||
};
|
||||
|
||||
enum class HushChatCompatibilityFixtureKind {
|
||||
IncomingMemo,
|
||||
OutgoingMemo,
|
||||
SeedPublicKeyProjection,
|
||||
CorruptedAuthFailure,
|
||||
ContactExclusion
|
||||
};
|
||||
|
||||
enum class HushChatCompatibilityFixtureFileStatus {
|
||||
Pending,
|
||||
Ready
|
||||
};
|
||||
|
||||
enum class HushChatCompatibilityFixtureFileError {
|
||||
None,
|
||||
FeatureDisabled,
|
||||
InvalidJson,
|
||||
JsonNotObject,
|
||||
InvalidSchema,
|
||||
MissingKind,
|
||||
UnknownKind,
|
||||
MissingStatus,
|
||||
UnknownStatus,
|
||||
MissingFixtureId,
|
||||
MissingPendingReason,
|
||||
MissingFixtureObject,
|
||||
InvalidFixtureField,
|
||||
FixtureVerificationFailed,
|
||||
ContactFixtureNotExcluded,
|
||||
FileReadFailed
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixtureFile {
|
||||
std::string schema;
|
||||
HushChatCompatibilityFixtureKind kind = HushChatCompatibilityFixtureKind::IncomingMemo;
|
||||
HushChatCompatibilityFixtureFileStatus status = HushChatCompatibilityFixtureFileStatus::Pending;
|
||||
std::string fixture_id;
|
||||
std::string pending_reason;
|
||||
HushChatCompatibilityFixture fixture;
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixtureFileParseResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
bool pending = false;
|
||||
bool verified = false;
|
||||
bool excluded_from_decrypt = false;
|
||||
HushChatCompatibilityFixtureFileError error = HushChatCompatibilityFixtureFileError::None;
|
||||
const char* error_name = "None";
|
||||
HushChatCompatibilityFixtureFile file;
|
||||
HushChatCompatibilityFixtureVerificationResult verification;
|
||||
};
|
||||
|
||||
enum class HushChatSeedPublicKeyProjectionError {
|
||||
None,
|
||||
FeatureDisabled,
|
||||
MissingFixtureId,
|
||||
InvalidStoredChatKey,
|
||||
InvalidLocalPublicKey,
|
||||
ExpectedStoredChatKeyLengthMismatch,
|
||||
ExpectedSeedLengthMismatch,
|
||||
ExpectedLocalPublicKeyLengthMismatch,
|
||||
SodiumInitializationFailed,
|
||||
KeypairProjectionFailed,
|
||||
ProjectedPublicKeyMismatch
|
||||
};
|
||||
|
||||
struct HushChatSeedPublicKeyProjectionResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
HushChatSeedPublicKeyProjectionError error = HushChatSeedPublicKeyProjectionError::None;
|
||||
const char* error_name = "None";
|
||||
HushChatHexDecodeError hex_error = HushChatHexDecodeError::None;
|
||||
std::size_t stored_chat_key_size = 0;
|
||||
std::size_t seed_size = 0;
|
||||
std::size_t local_public_key_size = 0;
|
||||
std::size_t projected_public_key_size = 0;
|
||||
};
|
||||
|
||||
enum class HushChatCorruptedAuthFailureReadinessError {
|
||||
None,
|
||||
FeatureDisabled,
|
||||
FixturePending,
|
||||
WrongFixtureKind,
|
||||
FixtureNotVerified,
|
||||
SeedProjectionNotVerified
|
||||
};
|
||||
|
||||
struct HushChatCorruptedAuthFailureReadinessResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
bool structurally_ready_for_future_auth_check = false;
|
||||
bool requires_future_secretstream_auth_failure = false;
|
||||
bool decrypted = false;
|
||||
bool authenticated = false;
|
||||
HushChatCorruptedAuthFailureReadinessError error = HushChatCorruptedAuthFailureReadinessError::None;
|
||||
const char* error_name = "None";
|
||||
};
|
||||
|
||||
enum class HushChatCompatibilityFixtureImportError {
|
||||
None,
|
||||
FeatureDisabled,
|
||||
MissingRequiredKind,
|
||||
DuplicateKind,
|
||||
FixtureLoadFailed,
|
||||
FixtureKindMismatch,
|
||||
FixturePending,
|
||||
FixtureInvalid,
|
||||
FixtureNotVerified,
|
||||
SeedProjectionFailed,
|
||||
AuthFailureScaffoldFailed,
|
||||
ContactFixtureNotExcluded
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixtureImportCandidate {
|
||||
HushChatCompatibilityFixtureKind expected_kind = HushChatCompatibilityFixtureKind::IncomingMemo;
|
||||
std::string path;
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixtureImportItem {
|
||||
HushChatCompatibilityFixtureKind expected_kind = HushChatCompatibilityFixtureKind::IncomingMemo;
|
||||
HushChatCompatibilityFixtureKind loaded_kind = HushChatCompatibilityFixtureKind::IncomingMemo;
|
||||
std::string path;
|
||||
bool supplied = false;
|
||||
bool pending = false;
|
||||
bool replacement_eligible = false;
|
||||
bool seed_projection_verified = false;
|
||||
bool future_auth_failure_required = false;
|
||||
bool structurally_ready_for_future_auth_check = false;
|
||||
HushChatCompatibilityFixtureImportError error = HushChatCompatibilityFixtureImportError::None;
|
||||
const char* error_name = "None";
|
||||
HushChatCompatibilityFixtureFileParseResult parsed;
|
||||
HushChatSeedPublicKeyProjectionResult seed_projection;
|
||||
HushChatCorruptedAuthFailureReadinessResult auth_failure_readiness;
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixtureImportChecklistResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
bool replacement_ready = false;
|
||||
HushChatCompatibilityFixtureImportError error = HushChatCompatibilityFixtureImportError::None;
|
||||
const char* error_name = "None";
|
||||
std::size_t required_count = 0;
|
||||
std::size_t supplied_count = 0;
|
||||
std::size_t missing_count = 0;
|
||||
std::size_t pending_count = 0;
|
||||
std::size_t verified_count = 0;
|
||||
std::size_t seed_projection_verified_count = 0;
|
||||
std::size_t future_auth_failure_required_count = 0;
|
||||
std::size_t auth_failure_structural_ready_count = 0;
|
||||
std::size_t excluded_count = 0;
|
||||
std::size_t rejected_count = 0;
|
||||
std::vector<HushChatCompatibilityFixtureImportItem> items;
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixtureReplacementReportItem {
|
||||
HushChatCompatibilityFixtureKind expected_kind = HushChatCompatibilityFixtureKind::IncomingMemo;
|
||||
HushChatCompatibilityFixtureKind loaded_kind = HushChatCompatibilityFixtureKind::IncomingMemo;
|
||||
std::string path;
|
||||
bool supplied = false;
|
||||
bool pending = false;
|
||||
bool replacement_eligible = false;
|
||||
bool refused = true;
|
||||
bool seed_projection_verified = false;
|
||||
bool future_auth_failure_required = false;
|
||||
bool structurally_ready_for_future_auth_check = false;
|
||||
bool cont_excluded = false;
|
||||
bool decrypted = false;
|
||||
bool authenticated = false;
|
||||
HushChatCompatibilityFixtureImportError error = HushChatCompatibilityFixtureImportError::None;
|
||||
const char* error_name = "None";
|
||||
};
|
||||
|
||||
struct HushChatCompatibilityFixtureReplacementDryRunResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
bool dry_run_only = true;
|
||||
bool redacted_report = true;
|
||||
bool would_replace = false;
|
||||
bool replacement_refused = true;
|
||||
HushChatCompatibilityFixtureImportError error = HushChatCompatibilityFixtureImportError::None;
|
||||
const char* error_name = "None";
|
||||
std::size_t required_count = 0;
|
||||
std::size_t supplied_count = 0;
|
||||
std::size_t missing_count = 0;
|
||||
std::size_t pending_count = 0;
|
||||
std::size_t verified_count = 0;
|
||||
std::size_t seed_projection_verified_count = 0;
|
||||
std::size_t future_auth_failure_required_count = 0;
|
||||
std::size_t auth_failure_structural_ready_count = 0;
|
||||
std::size_t excluded_count = 0;
|
||||
std::size_t rejected_count = 0;
|
||||
std::vector<HushChatCompatibilityFixtureReplacementReportItem> report_items;
|
||||
};
|
||||
|
||||
enum class HushChatCaptureManifestError {
|
||||
None,
|
||||
FeatureDisabled,
|
||||
FileReadFailed,
|
||||
InvalidJson,
|
||||
JsonNotObject,
|
||||
InvalidSchema,
|
||||
MissingManifestId,
|
||||
MissingStatus,
|
||||
UnknownStatus,
|
||||
MissingFixtureDirectory,
|
||||
MissingDryRunCommand,
|
||||
InvalidDryRunCommand,
|
||||
MissingProvenance,
|
||||
MissingSourceClient,
|
||||
InvalidSourceClient,
|
||||
MissingSourceClientVersion,
|
||||
MissingCaptureDate,
|
||||
MissingNetwork,
|
||||
MissingCaptureMethod,
|
||||
MissingHandling,
|
||||
MissingHandlingFlag,
|
||||
HandlingFlagNotTrue,
|
||||
MissingCategories,
|
||||
InvalidCategoryEntry,
|
||||
UnknownCategory,
|
||||
DuplicateCategory,
|
||||
MissingRequiredCategory,
|
||||
ProhibitedFieldPresent
|
||||
};
|
||||
|
||||
enum class HushChatCaptureManifestStatus {
|
||||
Staged
|
||||
};
|
||||
|
||||
struct HushChatCaptureManifestCategoryReport {
|
||||
HushChatCompatibilityFixtureKind kind = HushChatCompatibilityFixtureKind::IncomingMemo;
|
||||
std::string staged_filename;
|
||||
bool declared = false;
|
||||
};
|
||||
|
||||
struct HushChatCaptureManifestValidationResult {
|
||||
bool ok = false;
|
||||
bool feature_enabled = false;
|
||||
bool redacted_report = true;
|
||||
bool validates_provenance_only = true;
|
||||
bool no_sensitive_material_declared = false;
|
||||
bool has_dry_run_command = false;
|
||||
HushChatCaptureManifestError error = HushChatCaptureManifestError::None;
|
||||
const char* error_name = "None";
|
||||
HushChatCaptureManifestStatus status = HushChatCaptureManifestStatus::Staged;
|
||||
std::string manifest_path;
|
||||
std::string fixture_directory;
|
||||
std::size_t required_count = 0;
|
||||
std::size_t declared_count = 0;
|
||||
std::size_t missing_count = 0;
|
||||
std::size_t duplicate_count = 0;
|
||||
std::size_t prohibited_field_count = 0;
|
||||
std::size_t handling_flag_count = 0;
|
||||
std::vector<HushChatCaptureManifestCategoryReport> categories;
|
||||
};
|
||||
|
||||
constexpr int kHushChatSupportedVersion = 0;
|
||||
constexpr std::size_t kHushChatMemoByteLimit = 512;
|
||||
constexpr std::size_t kHushChatPublicKeyHexLength = 64;
|
||||
constexpr std::size_t kHushChatSecretstreamHeaderHexLength = 48;
|
||||
constexpr std::size_t kHushChatSecretstreamABytes = 17;
|
||||
constexpr std::size_t kHushChatStoredChatKeyByteLength = 32;
|
||||
constexpr std::size_t kHushChatStoredChatKeyHexLength = kHushChatStoredChatKeyByteLength * 2;
|
||||
constexpr std::size_t kHushChatSeedByteLength = 32;
|
||||
constexpr std::size_t kHushChatPublicKeyByteLength = kHushChatPublicKeyHexLength / 2;
|
||||
constexpr std::size_t kHushChatSecretstreamHeaderByteLength = kHushChatSecretstreamHeaderHexLength / 2;
|
||||
constexpr const char* kHushChatCompatibilityFixtureSchema = "dragonx.hushchat.compat-fixture.v1";
|
||||
constexpr const char* kHushChatCaptureManifestSchema = "dragonx.hushchat.capture-manifest.v1";
|
||||
|
||||
constexpr bool hushChatFeatureEnabledAtBuild()
|
||||
{
|
||||
return DRAGONX_ENABLE_CHAT != 0;
|
||||
}
|
||||
|
||||
HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo);
|
||||
HushChatMemoGroupingResult groupHushChatMemoOutputs(const std::vector<HushChatMemoOutput>& outputs);
|
||||
HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
|
||||
const HushChatTransactionInput& transaction,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatDecryptPreflightResult validateHushChatMemoDecryptPreflight(
|
||||
const HushChatDecryptPreflightInput& input,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatHexDecodeResult decodeHushChatHexBytes(const std::string& hex,
|
||||
std::size_t expectedByteLength);
|
||||
HushChatDecryptInputPreparationResult prepareHushChatDecryptInput(
|
||||
const HushChatDecryptInputMaterial& material,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatDecryptFixtureReadinessResult inspectHushChatDecryptFixtureReadiness(
|
||||
const HushChatPreparedDecryptInput& prepared);
|
||||
HushChatSessionKeySelection hushChatSessionKeySelectionForDirection(HushChatDecryptDirection direction);
|
||||
HushChatCompatibilityFixtureVerificationResult verifyHushChatCompatibilityFixture(
|
||||
const HushChatCompatibilityFixture& fixture,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatCompatibilityFixtureFileParseResult parseHushChatCompatibilityFixtureFile(
|
||||
const std::string& jsonText,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatCompatibilityFixtureFileParseResult loadHushChatCompatibilityFixtureFile(
|
||||
const std::string& path,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatSeedPublicKeyProjectionResult verifyHushChatSeedPublicKeyProjection(
|
||||
const HushChatCompatibilityFixture& fixture,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatCorruptedAuthFailureReadinessResult inspectHushChatCorruptedAuthFailureReadiness(
|
||||
const HushChatCompatibilityFixtureFileParseResult& parsed,
|
||||
const HushChatSeedPublicKeyProjectionResult& seedProjection,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
std::vector<HushChatCompatibilityFixtureKind> hushChatRequiredCompatibilityFixtureKinds();
|
||||
HushChatCompatibilityFixtureImportChecklistResult inspectHushChatCompatibilityFixtureImportChecklist(
|
||||
const std::vector<HushChatCompatibilityFixtureImportCandidate>& candidates,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatCompatibilityFixtureReplacementDryRunResult inspectHushChatCompatibilityFixtureReplacementDryRun(
|
||||
const std::vector<HushChatCompatibilityFixtureImportCandidate>& candidates,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatCaptureManifestValidationResult validateHushChatCaptureManifest(
|
||||
const std::string& jsonText,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
HushChatCaptureManifestValidationResult loadHushChatCaptureManifestFile(
|
||||
const std::string& path,
|
||||
bool featureEnabled = hushChatFeatureEnabledAtBuild());
|
||||
const char* hushChatHeaderTypeName(HushChatHeaderType type);
|
||||
const char* hushChatMemoGroupingIssueName(HushChatMemoGroupingIssue issue);
|
||||
const char* hushChatDecryptPreflightErrorName(HushChatDecryptPreflightError error);
|
||||
const char* hushChatHexDecodeErrorName(HushChatHexDecodeError error);
|
||||
const char* hushChatDecryptDirectionName(HushChatDecryptDirection direction);
|
||||
const char* hushChatSessionKeySelectionName(HushChatSessionKeySelection selection);
|
||||
const char* hushChatDecryptInputErrorName(HushChatDecryptInputError error);
|
||||
const char* hushChatCompatibilityFixtureErrorName(HushChatCompatibilityFixtureError error);
|
||||
const char* hushChatCompatibilityFixtureKindName(HushChatCompatibilityFixtureKind kind);
|
||||
const char* hushChatCompatibilityFixtureFileStatusName(HushChatCompatibilityFixtureFileStatus status);
|
||||
const char* hushChatCompatibilityFixtureFileErrorName(HushChatCompatibilityFixtureFileError error);
|
||||
const char* hushChatSeedPublicKeyProjectionErrorName(HushChatSeedPublicKeyProjectionError error);
|
||||
const char* hushChatCorruptedAuthFailureReadinessErrorName(HushChatCorruptedAuthFailureReadinessError error);
|
||||
const char* hushChatCompatibilityFixtureImportErrorName(HushChatCompatibilityFixtureImportError error);
|
||||
const char* hushChatCaptureManifestErrorName(HushChatCaptureManifestError error);
|
||||
|
||||
} // namespace dragonx::chat
|
||||
@@ -11,8 +11,10 @@
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <ctime>
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <shlobj.h>
|
||||
@@ -30,12 +32,39 @@ namespace config {
|
||||
Settings::Settings() = default;
|
||||
Settings::~Settings() = default;
|
||||
|
||||
namespace {
|
||||
|
||||
Settings::LiteServerSelectionPreferenceMode parseLiteServerSelectionPreferenceMode(
|
||||
const json& value)
|
||||
{
|
||||
if (!value.is_string()) return Settings::LiteServerSelectionPreferenceMode::Sticky;
|
||||
const std::string mode = value.get<std::string>();
|
||||
if (mode == "random" || mode == "Random") {
|
||||
return Settings::LiteServerSelectionPreferenceMode::Random;
|
||||
}
|
||||
return Settings::LiteServerSelectionPreferenceMode::Sticky;
|
||||
}
|
||||
|
||||
const char* liteServerSelectionPreferenceModeName(
|
||||
Settings::LiteServerSelectionPreferenceMode mode)
|
||||
{
|
||||
switch (mode) {
|
||||
case Settings::LiteServerSelectionPreferenceMode::Sticky:
|
||||
return "sticky";
|
||||
case Settings::LiteServerSelectionPreferenceMode::Random:
|
||||
return "random";
|
||||
}
|
||||
return "sticky";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string Settings::getDefaultPath()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
char path[MAX_PATH];
|
||||
if (SUCCEEDED(SHGetFolderPathA(NULL, CSIDL_APPDATA, NULL, 0, path))) {
|
||||
std::string dir = std::string(path) + "\\ObsidianDragon";
|
||||
std::string dir = std::string(path) + "\\" DRAGONX_APP_NAME;
|
||||
fs::create_directories(dir);
|
||||
return dir + "\\settings.json";
|
||||
}
|
||||
@@ -46,7 +75,7 @@ std::string Settings::getDefaultPath()
|
||||
struct passwd* pw = getpwuid(getuid());
|
||||
home = pw->pw_dir;
|
||||
}
|
||||
std::string dir = std::string(home) + "/Library/Application Support/ObsidianDragon";
|
||||
std::string dir = std::string(home) + "/Library/Application Support/" DRAGONX_APP_NAME;
|
||||
fs::create_directories(dir);
|
||||
return dir + "/settings.json";
|
||||
#else
|
||||
@@ -55,7 +84,7 @@ std::string Settings::getDefaultPath()
|
||||
struct passwd* pw = getpwuid(getuid());
|
||||
home = pw->pw_dir;
|
||||
}
|
||||
std::string dir = std::string(home) + "/.config/ObsidianDragon";
|
||||
std::string dir = std::string(home) + "/.config/" DRAGONX_APP_NAME;
|
||||
fs::create_directories(dir);
|
||||
return dir + "/settings.json";
|
||||
#endif
|
||||
@@ -136,6 +165,8 @@ bool Settings::load(const std::string& path)
|
||||
m.icon = meta["icon"].get<std::string>();
|
||||
if (meta.contains("order") && meta["order"].is_number_integer())
|
||||
m.sortOrder = meta["order"].get<int>();
|
||||
if (meta.contains("mining") && meta["mining"].is_boolean())
|
||||
m.mining = meta["mining"].get<bool>();
|
||||
address_meta_[addr] = m;
|
||||
}
|
||||
}
|
||||
@@ -146,6 +177,66 @@ bool Settings::load(const std::string& path)
|
||||
if (j.contains("keep_daemon_running")) keep_daemon_running_ = j["keep_daemon_running"].get<bool>();
|
||||
if (j.contains("stop_external_daemon")) stop_external_daemon_ = j["stop_external_daemon"].get<bool>();
|
||||
if (j.contains("max_connections")) max_connections_ = j["max_connections"].get<int>();
|
||||
if (j.contains("lite_wallet") && j["lite_wallet"].is_object()) {
|
||||
const auto& lite = j["lite_wallet"];
|
||||
if (lite.contains("server_selection_mode")) {
|
||||
lite_server_selection_mode_ = parseLiteServerSelectionPreferenceMode(
|
||||
lite["server_selection_mode"]);
|
||||
}
|
||||
if (lite.contains("sticky_server_url") && lite["sticky_server_url"].is_string()) {
|
||||
lite_sticky_server_url_ = lite["sticky_server_url"].get<std::string>();
|
||||
}
|
||||
if (lite.contains("chain_name") && lite["chain_name"].is_string()) {
|
||||
lite_chain_name_ = lite["chain_name"].get<std::string>();
|
||||
}
|
||||
// Migration: the SDXL backend only accepts main/test/regtest and hard-panics
|
||||
// (process abort) on any other chain name. Older builds persisted the "DRAGONX"
|
||||
// ticker here, which crashed the lite backend on launch. Rewrite any invalid
|
||||
// value to "main" and flag a re-save so the corrected setting persists.
|
||||
if (lite_chain_name_ != "main" && lite_chain_name_ != "test" &&
|
||||
lite_chain_name_ != "regtest") {
|
||||
lite_chain_name_ = "main";
|
||||
needs_upgrade_save_ = true;
|
||||
}
|
||||
if (lite.contains("random_selection_seed") && lite["random_selection_seed"].is_number_unsigned()) {
|
||||
lite_random_selection_seed_ = lite["random_selection_seed"].get<std::size_t>();
|
||||
} else if (lite.contains("random_selection_seed") && lite["random_selection_seed"].is_number_integer()) {
|
||||
const auto seed = lite["random_selection_seed"].get<long long>();
|
||||
lite_random_selection_seed_ = seed > 0 ? static_cast<std::size_t>(seed) : 0;
|
||||
}
|
||||
if (lite.contains("persist_selected_server") && lite["persist_selected_server"].is_boolean()) {
|
||||
lite_persist_selected_server_ = lite["persist_selected_server"].get<bool>();
|
||||
}
|
||||
if (lite.contains("servers") && lite["servers"].is_array()) {
|
||||
lite_servers_.clear();
|
||||
for (const auto& server : lite["servers"]) {
|
||||
if (!server.is_object()) continue;
|
||||
LiteServerPreference preference;
|
||||
if (server.contains("url") && server["url"].is_string()) {
|
||||
preference.url = server["url"].get<std::string>();
|
||||
}
|
||||
if (server.contains("label") && server["label"].is_string()) {
|
||||
preference.label = server["label"].get<std::string>();
|
||||
}
|
||||
if (server.contains("enabled") && server["enabled"].is_boolean()) {
|
||||
preference.enabled = server["enabled"].get<bool>();
|
||||
}
|
||||
lite_servers_.push_back(preference);
|
||||
}
|
||||
}
|
||||
if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) {
|
||||
const auto v = lite["rollout_override"].get<std::string>();
|
||||
lite_rollout_override_ = (v == "force_on" || v == "force_off") ? v : "auto";
|
||||
}
|
||||
if (lite.contains("install_id") && lite["install_id"].is_string()) {
|
||||
lite_install_id_ = lite["install_id"].get<std::string>();
|
||||
}
|
||||
if (lite.contains("hidden_servers") && lite["hidden_servers"].is_array()) {
|
||||
lite_hidden_servers_.clear();
|
||||
for (const auto& u : lite["hidden_servers"])
|
||||
if (u.is_string()) lite_hidden_servers_.insert(u.get<std::string>());
|
||||
}
|
||||
}
|
||||
if (j.contains("verbose_logging")) verbose_logging_ = j["verbose_logging"].get<bool>();
|
||||
if (j.contains("debug_categories") && j["debug_categories"].is_array()) {
|
||||
debug_categories_.clear();
|
||||
@@ -167,6 +258,7 @@ bool Settings::load(const std::string& path)
|
||||
if (j.contains("pool_hugepages")) pool_hugepages_ = j["pool_hugepages"].get<bool>();
|
||||
if (j.contains("pool_mode")) pool_mode_ = j["pool_mode"].get<bool>();
|
||||
if (j.contains("mine_when_idle")) mine_when_idle_ = j["mine_when_idle"].get<bool>();
|
||||
if (j.contains("xmrig_version")) xmrig_version_ = j["xmrig_version"].get<std::string>();
|
||||
if (j.contains("mine_idle_delay")) mine_idle_delay_= std::max(30, j["mine_idle_delay"].get<int>());
|
||||
if (j.contains("idle_thread_scaling")) idle_thread_scaling_ = j["idle_thread_scaling"].get<bool>();
|
||||
if (j.contains("idle_threads_active")) idle_threads_active_ = j["idle_threads_active"].get<int>();
|
||||
@@ -201,6 +293,17 @@ bool Settings::load(const std::string& path)
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to parse settings: %s\n", e.what());
|
||||
// The file exists but is unparseable (truncated/corrupt). Quarantine it so the
|
||||
// next save() doesn't silently overwrite it with defaults — the user's data stays
|
||||
// recoverable. Proceed with in-memory defaults.
|
||||
file.close();
|
||||
std::error_code ec;
|
||||
const std::string quarantine =
|
||||
path + ".corrupt-" + std::to_string(static_cast<long long>(std::time(nullptr)));
|
||||
fs::rename(path, quarantine, ec);
|
||||
if (!ec) {
|
||||
DEBUG_LOGF("Quarantined corrupt settings to %s\n", quarantine.c_str());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -246,11 +349,12 @@ bool Settings::save(const std::string& path)
|
||||
{
|
||||
json meta_obj = json::object();
|
||||
for (const auto& [addr, m] : address_meta_) {
|
||||
if (m.label.empty() && m.icon.empty() && m.sortOrder < 0) continue;
|
||||
if (m.label.empty() && m.icon.empty() && m.sortOrder < 0 && !m.mining) continue;
|
||||
json entry = json::object();
|
||||
if (!m.label.empty()) entry["label"] = m.label;
|
||||
if (!m.icon.empty()) entry["icon"] = m.icon;
|
||||
if (m.sortOrder >= 0) entry["order"] = m.sortOrder;
|
||||
if (m.mining) entry["mining"] = true;
|
||||
meta_obj[addr] = entry;
|
||||
}
|
||||
j["address_meta"] = meta_obj;
|
||||
@@ -262,6 +366,27 @@ bool Settings::save(const std::string& path)
|
||||
j["keep_daemon_running"] = keep_daemon_running_;
|
||||
j["stop_external_daemon"] = stop_external_daemon_;
|
||||
j["max_connections"] = max_connections_;
|
||||
{
|
||||
json lite = json::object();
|
||||
lite["server_selection_mode"] = liteServerSelectionPreferenceModeName(lite_server_selection_mode_);
|
||||
lite["sticky_server_url"] = lite_sticky_server_url_;
|
||||
lite["chain_name"] = lite_chain_name_;
|
||||
lite["random_selection_seed"] = lite_random_selection_seed_;
|
||||
lite["persist_selected_server"] = lite_persist_selected_server_;
|
||||
lite["servers"] = json::array();
|
||||
for (const auto& server : lite_servers_) {
|
||||
json entry = json::object();
|
||||
entry["url"] = server.url;
|
||||
entry["label"] = server.label;
|
||||
entry["enabled"] = server.enabled;
|
||||
lite["servers"].push_back(entry);
|
||||
}
|
||||
lite["rollout_override"] = lite_rollout_override_;
|
||||
lite["install_id"] = lite_install_id_;
|
||||
lite["hidden_servers"] = json::array();
|
||||
for (const auto& u : lite_hidden_servers_) lite["hidden_servers"].push_back(u);
|
||||
j["lite_wallet"] = lite;
|
||||
}
|
||||
j["verbose_logging"] = verbose_logging_;
|
||||
j["debug_categories"] = json::array();
|
||||
for (const auto& cat : debug_categories_)
|
||||
@@ -279,6 +404,7 @@ bool Settings::save(const std::string& path)
|
||||
j["pool_hugepages"] = pool_hugepages_;
|
||||
j["pool_mode"] = pool_mode_;
|
||||
j["mine_when_idle"] = mine_when_idle_;
|
||||
j["xmrig_version"] = xmrig_version_;
|
||||
j["mine_idle_delay"]= mine_idle_delay_;
|
||||
j["idle_thread_scaling"] = idle_thread_scaling_;
|
||||
j["idle_threads_active"] = idle_threads_active_;
|
||||
@@ -298,17 +424,11 @@ bool Settings::save(const std::string& path)
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure directory exists
|
||||
fs::path p(path);
|
||||
fs::create_directories(p.parent_path());
|
||||
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file << j.dump(4);
|
||||
return true;
|
||||
// Atomic + durable: write to a temp file, fsync, then rename over the real file.
|
||||
// A crash mid-write can no longer truncate settings.json (which would silently
|
||||
// reset every preference on the next launch). Owner-only (0600) — it carries the
|
||||
// lite-server list and address metadata.
|
||||
return util::Platform::writeFileAtomically(path, j.dump(4), /*restrictPermissions=*/true);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to save settings: %s\n", e.what());
|
||||
return false;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <set>
|
||||
@@ -54,6 +55,17 @@ public:
|
||||
*/
|
||||
static std::string getDefaultPath();
|
||||
|
||||
enum class LiteServerSelectionPreferenceMode {
|
||||
Sticky,
|
||||
Random
|
||||
};
|
||||
|
||||
struct LiteServerPreference {
|
||||
std::string url;
|
||||
std::string label;
|
||||
bool enabled = true;
|
||||
};
|
||||
|
||||
// Theme
|
||||
std::string getTheme() const { return theme_; }
|
||||
void setTheme(const std::string& theme) { theme_ = theme; }
|
||||
@@ -147,6 +159,7 @@ public:
|
||||
std::string label;
|
||||
std::string icon; // material icon name, e.g. "savings"
|
||||
int sortOrder = -1; // -1 = auto (use default sort)
|
||||
bool mining = false;
|
||||
};
|
||||
const AddressMeta& getAddressMeta(const std::string& addr) const {
|
||||
static const AddressMeta empty{};
|
||||
@@ -162,6 +175,20 @@ public:
|
||||
void setAddressSortOrder(const std::string& addr, int order) {
|
||||
address_meta_[addr].sortOrder = order;
|
||||
}
|
||||
bool isMiningAddress(const std::string& addr) const {
|
||||
auto it = address_meta_.find(addr);
|
||||
return it != address_meta_.end() && it->second.mining;
|
||||
}
|
||||
void setMiningAddress(const std::string& addr, bool mining) {
|
||||
address_meta_[addr].mining = mining;
|
||||
}
|
||||
std::set<std::string> getMiningAddresses() const {
|
||||
std::set<std::string> addresses;
|
||||
for (const auto& [addr, meta] : address_meta_) {
|
||||
if (meta.mining) addresses.insert(addr);
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
int getNextSortOrder() const {
|
||||
int mx = -1;
|
||||
for (const auto& [k, v] : address_meta_)
|
||||
@@ -203,6 +230,35 @@ public:
|
||||
int getMaxConnections() const { return max_connections_; }
|
||||
void setMaxConnections(int v) { max_connections_ = std::max(0, v); }
|
||||
|
||||
// Lite wallet server selection
|
||||
LiteServerSelectionPreferenceMode getLiteServerSelectionMode() const { return lite_server_selection_mode_; }
|
||||
void setLiteServerSelectionMode(LiteServerSelectionPreferenceMode mode) { lite_server_selection_mode_ = mode; }
|
||||
std::string getLiteStickyServerUrl() const { return lite_sticky_server_url_; }
|
||||
void setLiteStickyServerUrl(const std::string& url) { lite_sticky_server_url_ = url; }
|
||||
std::string getLiteChainName() const { return lite_chain_name_; }
|
||||
void setLiteChainName(const std::string& chainName) { lite_chain_name_ = chainName; }
|
||||
std::size_t getLiteRandomSelectionSeed() const { return lite_random_selection_seed_; }
|
||||
void setLiteRandomSelectionSeed(std::size_t seed) { lite_random_selection_seed_ = seed; }
|
||||
bool getLitePersistSelectedServer() const { return lite_persist_selected_server_; }
|
||||
void setLitePersistSelectedServer(bool persist) { lite_persist_selected_server_ = persist; }
|
||||
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
|
||||
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
|
||||
|
||||
// Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle).
|
||||
const std::set<std::string>& getLiteHiddenServers() const { return lite_hidden_servers_; }
|
||||
bool isLiteServerHidden(const std::string& url) const { return lite_hidden_servers_.count(url) > 0; }
|
||||
void hideLiteServer(const std::string& url) { lite_hidden_servers_.insert(url); }
|
||||
void unhideLiteServer(const std::string& url) { lite_hidden_servers_.erase(url); }
|
||||
|
||||
// Lite wallet rollout / kill-switch (see wallet/lite_rollout_policy.h).
|
||||
// Override: "auto" (honor rollout manifest), "force_on", or "force_off".
|
||||
std::string getLiteRolloutOverride() const { return lite_rollout_override_; }
|
||||
void setLiteRolloutOverride(const std::string& v) { lite_rollout_override_ = v; }
|
||||
// Stable, locally-generated install id used only to derive the staged-rollout bucket.
|
||||
// Never transmitted; carries no PII. Generated on first use if empty.
|
||||
std::string getLiteInstallId() const { return lite_install_id_; }
|
||||
void setLiteInstallId(const std::string& v) { lite_install_id_ = v; }
|
||||
|
||||
// Verbose diagnostic logging (connection attempts, daemon state, port owner, etc.)
|
||||
bool getVerboseLogging() const { return verbose_logging_; }
|
||||
void setVerboseLogging(bool v) { verbose_logging_ = v; }
|
||||
@@ -250,6 +306,10 @@ public:
|
||||
bool getPoolMode() const { return pool_mode_; }
|
||||
void setPoolMode(bool v) { pool_mode_ = v; }
|
||||
|
||||
// Installed DRG-XMRig release tag (for in-app miner update detection); empty if unknown/bundled.
|
||||
std::string getXmrigVersion() const { return xmrig_version_; }
|
||||
void setXmrigVersion(const std::string& v) { xmrig_version_ = v; }
|
||||
|
||||
// Mine when idle (auto-start mining when system is idle)
|
||||
bool getMineWhenIdle() const { return mine_when_idle_; }
|
||||
void setMineWhenIdle(bool v) { mine_when_idle_ = v; }
|
||||
@@ -343,6 +403,26 @@ private:
|
||||
bool keep_daemon_running_ = false;
|
||||
bool stop_external_daemon_ = false;
|
||||
int max_connections_ = 0; // 0 = daemon default
|
||||
|
||||
// Lite wallet server preferences. These are user/server settings only;
|
||||
// wallet secrets, wallet files, and lifecycle state are never stored here.
|
||||
LiteServerSelectionPreferenceMode lite_server_selection_mode_ = LiteServerSelectionPreferenceMode::Sticky;
|
||||
std::string lite_sticky_server_url_ = "https://lite.dragonx.is";
|
||||
std::string lite_chain_name_ = "main"; // SDXL backend chain id; must be main/test/regtest
|
||||
std::size_t lite_random_selection_seed_ = 0;
|
||||
bool lite_persist_selected_server_ = true;
|
||||
std::string lite_rollout_override_ = "auto"; // auto|force_on|force_off
|
||||
std::string lite_install_id_; // random local-only id; rollout-bucket source
|
||||
std::vector<LiteServerPreference> lite_servers_ = {
|
||||
{"https://lite.dragonx.is", "DragonX Lite", true},
|
||||
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
||||
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
|
||||
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
|
||||
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
|
||||
{"https://lite5.dragonx.is", "DragonX Lite 5", true}
|
||||
};
|
||||
std::set<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
|
||||
|
||||
bool verbose_logging_ = false;
|
||||
std::set<std::string> debug_categories_;
|
||||
bool theme_effects_enabled_ = true;
|
||||
@@ -359,6 +439,7 @@ private:
|
||||
bool pool_tls_ = false;
|
||||
bool pool_hugepages_ = true;
|
||||
bool pool_mode_ = false; // false=solo, true=pool
|
||||
std::string xmrig_version_; // installed DRG-XMRig release tag (update detection)
|
||||
bool mine_when_idle_ = false; // auto-start mining when system idle
|
||||
int mine_idle_delay_= 120; // seconds of idle before mining starts
|
||||
bool idle_thread_scaling_ = false; // scale threads instead of start/stop
|
||||
|
||||
@@ -1,31 +1,3 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// !! DO NOT EDIT version.h — it is generated from version.h.in by CMake.
|
||||
// !! Change the version in CMakeLists.txt: project(... VERSION x.y.z ...)
|
||||
|
||||
#define DRAGONX_VERSION "1.2.0-rc1"
|
||||
#define DRAGONX_VERSION_MAJOR 1
|
||||
#define DRAGONX_VERSION_MINOR 2
|
||||
#define DRAGONX_VERSION_PATCH 0
|
||||
|
||||
#define DRAGONX_APP_NAME "ObsidianDragon"
|
||||
#define DRAGONX_ORG_NAME "Hush"
|
||||
|
||||
// Default RPC settings
|
||||
#define DRAGONX_DEFAULT_RPC_HOST "127.0.0.1"
|
||||
#define DRAGONX_DEFAULT_RPC_PORT "21769"
|
||||
|
||||
// Coin parameters
|
||||
#define DRAGONX_TICKER "DRGX"
|
||||
#define DRAGONX_COIN_NAME "DragonX"
|
||||
#define DRAGONX_URI_SCHEME "drgx"
|
||||
#define DRAGONX_ZATOSHI_PER_COIN 100000000
|
||||
#define DRAGONX_DEFAULT_FEE 0.0001
|
||||
|
||||
// Config file names
|
||||
#define DRAGONX_CONF_FILENAME "DRAGONX.conf"
|
||||
#define DRAGONX_WALLET_FILENAME "wallet.dat"
|
||||
#include "dragonx_generated_version.h"
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
// !! DO NOT EDIT version.h — it is generated from version.h.in by CMake.
|
||||
// !! Change the version in CMakeLists.txt: project(... VERSION x.y.z ...)
|
||||
// !! DO NOT EDIT generated version output — it is generated from version.h.in by CMake.
|
||||
// !! Change the version in CMakeLists.txt: project(... VERSION x.y.z ...) for the full-node app,
|
||||
// !! or DRAGONX_LITE_VERSION for ObsidianDragonLite. DRAGONX_APP_VERSION is the active variant.
|
||||
|
||||
#define DRAGONX_VERSION "@PROJECT_VERSION@@DRAGONX_VERSION_SUFFIX@"
|
||||
#define DRAGONX_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
|
||||
#define DRAGONX_VERSION_MINOR @PROJECT_VERSION_MINOR@
|
||||
#define DRAGONX_VERSION_PATCH @PROJECT_VERSION_PATCH@
|
||||
#define DRAGONX_VERSION "@DRAGONX_APP_VERSION@@DRAGONX_APP_VERSION_SUFFIX@"
|
||||
#define DRAGONX_VERSION_MAJOR @DRAGONX_APP_VERSION_MAJOR@
|
||||
#define DRAGONX_VERSION_MINOR @DRAGONX_APP_VERSION_MINOR@
|
||||
#define DRAGONX_VERSION_PATCH @DRAGONX_APP_VERSION_PATCH@
|
||||
|
||||
#define DRAGONX_APP_NAME "ObsidianDragon"
|
||||
#define DRAGONX_APP_NAME "@DRAGONX_APP_NAME@"
|
||||
#define DRAGONX_ORG_NAME "Hush"
|
||||
|
||||
// Default RPC settings
|
||||
|
||||
128
src/daemon/daemon_controller.cpp
Normal file
128
src/daemon/daemon_controller.cpp
Normal file
@@ -0,0 +1,128 @@
|
||||
#include "daemon_controller.h"
|
||||
#include "../config/settings.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
DaemonController::DaemonController()
|
||||
: daemon_(std::make_unique<EmbeddedDaemon>())
|
||||
{
|
||||
}
|
||||
|
||||
DaemonController::~DaemonController() = default;
|
||||
|
||||
void DaemonController::setStateCallback(StateCallback callback)
|
||||
{
|
||||
daemon_->setStateCallback(std::move(callback));
|
||||
}
|
||||
|
||||
void DaemonController::syncSettings(const config::Settings* settings)
|
||||
{
|
||||
if (!settings) return;
|
||||
daemon_->setDebugCategories(settings->getDebugCategories());
|
||||
daemon_->setMaxConnections(settings->getMaxConnections());
|
||||
}
|
||||
|
||||
bool DaemonController::start(const config::Settings* settings)
|
||||
{
|
||||
syncSettings(settings);
|
||||
return daemon_->start();
|
||||
}
|
||||
|
||||
void DaemonController::stop(int waitMs)
|
||||
{
|
||||
daemon_->stop(waitMs);
|
||||
}
|
||||
|
||||
bool DaemonController::isRunning() const
|
||||
{
|
||||
return daemon_->isRunning();
|
||||
}
|
||||
|
||||
bool DaemonController::externalDaemonDetected() const
|
||||
{
|
||||
return daemon_->externalDaemonDetected();
|
||||
}
|
||||
|
||||
DaemonController::State DaemonController::state() const
|
||||
{
|
||||
return daemon_->getState();
|
||||
}
|
||||
|
||||
const std::string& DaemonController::lastError() const
|
||||
{
|
||||
return daemon_->getLastError();
|
||||
}
|
||||
|
||||
int DaemonController::crashCount() const
|
||||
{
|
||||
return daemon_->getCrashCount();
|
||||
}
|
||||
|
||||
int DaemonController::lastBlockHeight() const
|
||||
{
|
||||
return daemon_ ? daemon_->getLastBlockHeight() : 0;
|
||||
}
|
||||
|
||||
double DaemonController::memoryUsageMB() const
|
||||
{
|
||||
return daemon_ ? daemon_->getMemoryUsageMB() : 0.0;
|
||||
}
|
||||
|
||||
std::vector<std::string> DaemonController::recentLines(std::size_t count) const
|
||||
{
|
||||
return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{};
|
||||
}
|
||||
|
||||
std::string DaemonController::outputSince(std::size_t& offset) const
|
||||
{
|
||||
return daemon_ ? daemon_->getOutputSince(offset) : std::string{};
|
||||
}
|
||||
|
||||
void DaemonController::resetCrashCount()
|
||||
{
|
||||
daemon_->resetCrashCount();
|
||||
}
|
||||
|
||||
void DaemonController::setRescanOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setRescanOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::rescanOnNextStart() const
|
||||
{
|
||||
return daemon_->rescanOnNextStart();
|
||||
}
|
||||
|
||||
void DaemonController::setZapOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setZapOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::zapOnNextStart() const
|
||||
{
|
||||
return daemon_->zapOnNextStart();
|
||||
}
|
||||
|
||||
void DaemonController::prepareLifecycleOperation(const LifecycleDecision& decision,
|
||||
const config::Settings* settings)
|
||||
{
|
||||
if (settings) syncSettings(settings);
|
||||
if (decision.resetCrashCount) resetCrashCount();
|
||||
if (decision.setRescanOnNextStart) setRescanOnNextStart(true);
|
||||
if (decision.setZapOnNextStart) setZapOnNextStart(true);
|
||||
}
|
||||
|
||||
DaemonController::ShutdownDecision DaemonController::shutdownDecision(
|
||||
bool keepDaemonRunning, bool stopExternalDaemon) const
|
||||
{
|
||||
return evaluateShutdownPolicy(static_cast<bool>(daemon_),
|
||||
daemon_ && daemon_->externalDaemonDetected(),
|
||||
keepDaemonRunning,
|
||||
stopExternalDaemon);
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
238
src/daemon/daemon_controller.h
Normal file
238
src/daemon/daemon_controller.h
Normal file
@@ -0,0 +1,238 @@
|
||||
#pragma once
|
||||
|
||||
#include "embedded_daemon.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace config { class Settings; }
|
||||
namespace daemon {
|
||||
|
||||
class DaemonController {
|
||||
public:
|
||||
using State = EmbeddedDaemon::State;
|
||||
using StateCallback = EmbeddedDaemon::StateCallback;
|
||||
|
||||
enum class ShutdownAction {
|
||||
DisconnectOnly,
|
||||
StopDaemon
|
||||
};
|
||||
|
||||
struct ShutdownDecision {
|
||||
ShutdownAction action = ShutdownAction::DisconnectOnly;
|
||||
const char* logReason = "no embedded daemon";
|
||||
const char* status = "Disconnecting...";
|
||||
};
|
||||
|
||||
enum class LifecycleOperation {
|
||||
ManualRestart,
|
||||
Rescan,
|
||||
RepairWallet, // restart with -zapwallettxes=2 (wipe & rebuild wallet tx records)
|
||||
DeleteBlockchainData,
|
||||
BootstrapStop
|
||||
};
|
||||
|
||||
struct LifecycleDecision {
|
||||
LifecycleOperation operation = LifecycleOperation::ManualRestart;
|
||||
bool allowed = false;
|
||||
bool wasRunning = false;
|
||||
const char* taskName = "";
|
||||
const char* status = "";
|
||||
const char* warning = "";
|
||||
bool resetCrashCount = false;
|
||||
bool setRescanOnNextStart = false;
|
||||
bool disconnectRpc = false;
|
||||
int restartDelayMs = 0;
|
||||
bool setZapOnNextStart = false;
|
||||
};
|
||||
|
||||
class LifecycleTaskContext {
|
||||
public:
|
||||
virtual ~LifecycleTaskContext() = default;
|
||||
virtual bool cancelled() const = 0;
|
||||
virtual bool shuttingDown() const = 0;
|
||||
virtual void sleepForMs(int milliseconds) = 0;
|
||||
};
|
||||
|
||||
class LifecycleRuntime {
|
||||
public:
|
||||
virtual ~LifecycleRuntime() = default;
|
||||
virtual void stopDaemonWithPolicy() = 0;
|
||||
virtual bool startDaemon() = 0;
|
||||
virtual int deleteBlockchainData() = 0;
|
||||
virtual void resetOutputOffset() = 0;
|
||||
virtual void requestRpcStopAndDisconnect(const char* context, const char* reason) = 0;
|
||||
};
|
||||
|
||||
struct LifecycleExecutionResult {
|
||||
bool completed = false;
|
||||
bool cancelled = false;
|
||||
bool stopped = false;
|
||||
bool started = false;
|
||||
int deletedItems = 0;
|
||||
};
|
||||
|
||||
DaemonController();
|
||||
~DaemonController();
|
||||
|
||||
DaemonController(const DaemonController&) = delete;
|
||||
DaemonController& operator=(const DaemonController&) = delete;
|
||||
|
||||
EmbeddedDaemon* daemon() { return daemon_.get(); }
|
||||
const EmbeddedDaemon* daemon() const { return daemon_.get(); }
|
||||
|
||||
void setStateCallback(StateCallback callback);
|
||||
void syncSettings(const config::Settings* settings);
|
||||
|
||||
bool start(const config::Settings* settings);
|
||||
void stop(int waitMs);
|
||||
|
||||
bool isRunning() const;
|
||||
bool externalDaemonDetected() const;
|
||||
State state() const;
|
||||
const std::string& lastError() const;
|
||||
int crashCount() const;
|
||||
int lastBlockHeight() const;
|
||||
double memoryUsageMB() const;
|
||||
std::vector<std::string> recentLines(std::size_t count) const;
|
||||
std::string outputSince(std::size_t& offset) const;
|
||||
|
||||
void resetCrashCount();
|
||||
void setRescanOnNextStart(bool enabled);
|
||||
bool rescanOnNextStart() const;
|
||||
void setZapOnNextStart(bool enabled);
|
||||
bool zapOnNextStart() const;
|
||||
|
||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||
bool externalDaemonDetected,
|
||||
bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) {
|
||||
if (!hasDaemon) {
|
||||
return {};
|
||||
}
|
||||
if (keepDaemonRunning) {
|
||||
return {ShutdownAction::DisconnectOnly,
|
||||
"keep_daemon_running enabled",
|
||||
"Disconnecting (daemon stays running)..."};
|
||||
}
|
||||
if (externalDaemonDetected && !stopExternalDaemon) {
|
||||
return {ShutdownAction::DisconnectOnly,
|
||||
"external daemon (not ours to stop)",
|
||||
"Disconnecting (daemon stays running)..."};
|
||||
}
|
||||
return {ShutdownAction::StopDaemon,
|
||||
"stopping managed daemon",
|
||||
"Sending stop command to daemon..."};
|
||||
}
|
||||
static LifecycleDecision evaluateLifecycleOperation(LifecycleOperation operation,
|
||||
bool usingEmbeddedDaemon,
|
||||
bool hasDaemon,
|
||||
bool daemonRunning,
|
||||
bool restartInProgress = false) {
|
||||
switch (operation) {
|
||||
case LifecycleOperation::ManualRestart:
|
||||
if (!usingEmbeddedDaemon || restartInProgress) return {};
|
||||
return {operation, true, daemonRunning, "daemon-restart", "Restarting daemon...", "",
|
||||
true, false, true, 500};
|
||||
case LifecycleOperation::Rescan:
|
||||
if (!usingEmbeddedDaemon || !hasDaemon) {
|
||||
return {operation, false, daemonRunning, "", "",
|
||||
"Rescan requires embedded daemon. Restart your daemon with -rescan manually."};
|
||||
}
|
||||
return {operation, true, daemonRunning, "rescan-blockchain", "Starting rescan...", "",
|
||||
false, true, false, 3000};
|
||||
case LifecycleOperation::RepairWallet:
|
||||
if (!usingEmbeddedDaemon || !hasDaemon) {
|
||||
return {operation, false, daemonRunning, "", "",
|
||||
"Wallet repair requires embedded daemon. Restart your daemon with -zapwallettxes=2 manually."};
|
||||
}
|
||||
return {operation, true, daemonRunning, "repair-wallet", "Repairing wallet...", "",
|
||||
false, false, false, 3000, true};
|
||||
case LifecycleOperation::DeleteBlockchainData:
|
||||
if (!usingEmbeddedDaemon || !hasDaemon) {
|
||||
return {operation, false, daemonRunning, "", "",
|
||||
"Delete blockchain requires embedded daemon. Stop your daemon manually and delete the data directory."};
|
||||
}
|
||||
return {operation, true, daemonRunning, "delete-blockchain-data", "Deleting blockchain data...", "",
|
||||
false, false, false, 3000};
|
||||
case LifecycleOperation::BootstrapStop:
|
||||
return {operation, true, daemonRunning, "bootstrap-stop-daemon", "Stopping daemon for bootstrap...", "",
|
||||
false, false, true, 0};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
void prepareLifecycleOperation(const LifecycleDecision& decision,
|
||||
const config::Settings* settings = nullptr);
|
||||
static inline LifecycleExecutionResult executeLifecycleOperation(const LifecycleDecision& decision,
|
||||
LifecycleRuntime& runtime,
|
||||
LifecycleTaskContext& task)
|
||||
{
|
||||
LifecycleExecutionResult result;
|
||||
if (!decision.allowed) return result;
|
||||
|
||||
auto waitForDelay = [&]() {
|
||||
int waitTicks = std::max(0, decision.restartDelayMs / 100);
|
||||
for (int i = 0; i < waitTicks && !task.cancelled() && !task.shuttingDown(); ++i) {
|
||||
task.sleepForMs(100);
|
||||
}
|
||||
};
|
||||
|
||||
auto cancelled = [&]() {
|
||||
result.cancelled = task.cancelled() || task.shuttingDown();
|
||||
return result.cancelled;
|
||||
};
|
||||
|
||||
switch (decision.operation) {
|
||||
case LifecycleOperation::BootstrapStop:
|
||||
if (decision.wasRunning) {
|
||||
runtime.requestRpcStopAndDisconnect("Bootstrap daemon stop", "Bootstrap");
|
||||
result.stopped = true;
|
||||
}
|
||||
result.completed = true;
|
||||
return result;
|
||||
case LifecycleOperation::ManualRestart:
|
||||
if (decision.wasRunning) {
|
||||
runtime.stopDaemonWithPolicy();
|
||||
result.stopped = true;
|
||||
}
|
||||
break;
|
||||
case LifecycleOperation::Rescan:
|
||||
case LifecycleOperation::RepairWallet:
|
||||
case LifecycleOperation::DeleteBlockchainData:
|
||||
runtime.stopDaemonWithPolicy();
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (cancelled()) return result;
|
||||
waitForDelay();
|
||||
if (cancelled()) return result;
|
||||
|
||||
if (decision.operation == LifecycleOperation::DeleteBlockchainData) {
|
||||
result.deletedItems = runtime.deleteBlockchainData();
|
||||
if (cancelled()) return result;
|
||||
}
|
||||
|
||||
if (decision.operation == LifecycleOperation::Rescan ||
|
||||
decision.operation == LifecycleOperation::RepairWallet ||
|
||||
decision.operation == LifecycleOperation::DeleteBlockchainData) {
|
||||
runtime.resetOutputOffset();
|
||||
}
|
||||
|
||||
result.started = runtime.startDaemon();
|
||||
result.completed = !cancelled();
|
||||
return result;
|
||||
}
|
||||
ShutdownDecision shutdownDecision(bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) const;
|
||||
|
||||
private:
|
||||
std::unique_ptr<EmbeddedDaemon> daemon_;
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -482,8 +482,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
args.push_back("-maxconnections=" + std::to_string(max_connections_));
|
||||
}
|
||||
|
||||
// Add -rescan flag if requested (one-shot)
|
||||
if (rescan_on_next_start_.exchange(false)) {
|
||||
// Add wallet-repair flag if requested (one-shot). -zapwallettxes=2 wipes all wallet tx/note
|
||||
// records and rebuilds them from the chain; it implies -rescan, so don't also pass -rescan.
|
||||
if (zap_on_next_start_.exchange(false)) {
|
||||
DEBUG_LOGF("[INFO] Adding -zapwallettxes=2 flag for wallet repair (zap & rebuild)\n");
|
||||
args.push_back("-zapwallettxes=2");
|
||||
rescan_on_next_start_.store(false); // implied by zap; avoid redundant -rescan
|
||||
} else if (rescan_on_next_start_.exchange(false)) {
|
||||
// Add -rescan flag if requested (one-shot)
|
||||
DEBUG_LOGF("[INFO] Adding -rescan flag for blockchain rescan\n");
|
||||
args.push_back("-rescan");
|
||||
}
|
||||
|
||||
@@ -183,6 +183,14 @@ public:
|
||||
void setRescanOnNextStart(bool v) { rescan_on_next_start_ = v; }
|
||||
bool rescanOnNextStart() const { return rescan_on_next_start_.load(); }
|
||||
|
||||
/**
|
||||
* @brief Request a wallet repair (-zapwallettxes=2) on the next daemon start. This deletes all
|
||||
* wallet transaction/note records and rebuilds them from the chain (keys are kept); the
|
||||
* daemon implicitly rescans afterwards. One-shot, like the rescan flag.
|
||||
*/
|
||||
void setZapOnNextStart(bool v) { zap_on_next_start_ = v; }
|
||||
bool zapOnNextStart() const { return zap_on_next_start_.load(); }
|
||||
|
||||
/** Get number of consecutive daemon crashes (resets on successful start or manual reset) */
|
||||
int getCrashCount() const { return crash_count_.load(); }
|
||||
/** Reset crash counter (call on successful connection or manual restart) */
|
||||
@@ -222,6 +230,7 @@ private:
|
||||
int max_connections_ = 0; // 0 = daemon default
|
||||
std::atomic<int> crash_count_{0}; // consecutive crash counter
|
||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
|
||||
93
src/daemon/lifecycle_adapters.cpp
Normal file
93
src/daemon/lifecycle_adapters.cpp
Normal file
@@ -0,0 +1,93 @@
|
||||
#include "lifecycle_adapters.h"
|
||||
#include "../util/logger.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
AsyncLifecycleTaskContext::AsyncLifecycleTaskContext(
|
||||
const util::AsyncTaskManager::Token& token,
|
||||
const std::atomic<bool>& shuttingDown)
|
||||
: token_(token), shuttingDown_(shuttingDown)
|
||||
{
|
||||
}
|
||||
|
||||
bool AsyncLifecycleTaskContext::cancelled() const
|
||||
{
|
||||
return token_.cancelled();
|
||||
}
|
||||
|
||||
bool AsyncLifecycleTaskContext::shuttingDown() const
|
||||
{
|
||||
return shuttingDown_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void AsyncLifecycleTaskContext::sleepForMs(int milliseconds)
|
||||
{
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, milliseconds)));
|
||||
}
|
||||
|
||||
bool ImmediateLifecycleTaskContext::cancelled() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ImmediateLifecycleTaskContext::shuttingDown() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
void ImmediateLifecycleTaskContext::sleepForMs(int)
|
||||
{
|
||||
}
|
||||
|
||||
int BlockchainDataCleaner::removeBlockchainData(const std::filesystem::path& dataDir)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static constexpr std::array<const char*, 4> directories = {
|
||||
"blocks", "chainstate", "database", "notarizations"
|
||||
};
|
||||
static constexpr std::array<const char*, 5> files = {
|
||||
"peers.dat", "fee_estimates.dat", "banlist.dat", "db.log", ".lock"
|
||||
};
|
||||
|
||||
int removed = 0;
|
||||
for (const char* directoryName : directories) {
|
||||
fs::path path = dataDir / directoryName;
|
||||
std::error_code existsError;
|
||||
if (!fs::exists(path, existsError)) continue;
|
||||
|
||||
std::error_code removeError;
|
||||
auto count = fs::remove_all(path, removeError);
|
||||
if (!removeError) {
|
||||
removed += static_cast<int>(count);
|
||||
DEBUG_LOGF("[DaemonLifecycle] Removed %s (%d entries)\n",
|
||||
directoryName, static_cast<int>(count));
|
||||
} else {
|
||||
DEBUG_LOGF("[DaemonLifecycle] Failed to remove %s: %s\n",
|
||||
directoryName, removeError.message().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
for (const char* fileName : files) {
|
||||
fs::path path = dataDir / fileName;
|
||||
std::error_code removeError;
|
||||
if (fs::remove(path, removeError)) {
|
||||
++removed;
|
||||
DEBUG_LOGF("[DaemonLifecycle] Removed %s\n", fileName);
|
||||
} else if (removeError) {
|
||||
DEBUG_LOGF("[DaemonLifecycle] Failed to remove %s: %s\n",
|
||||
fileName, removeError.message().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
39
src/daemon/lifecycle_adapters.h
Normal file
39
src/daemon/lifecycle_adapters.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include "daemon_controller.h"
|
||||
#include "../util/async_task_manager.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
class AsyncLifecycleTaskContext final : public DaemonController::LifecycleTaskContext {
|
||||
public:
|
||||
AsyncLifecycleTaskContext(const util::AsyncTaskManager::Token& token,
|
||||
const std::atomic<bool>& shuttingDown);
|
||||
|
||||
bool cancelled() const override;
|
||||
bool shuttingDown() const override;
|
||||
void sleepForMs(int milliseconds) override;
|
||||
|
||||
private:
|
||||
const util::AsyncTaskManager::Token& token_;
|
||||
const std::atomic<bool>& shuttingDown_;
|
||||
};
|
||||
|
||||
class ImmediateLifecycleTaskContext final : public DaemonController::LifecycleTaskContext {
|
||||
public:
|
||||
bool cancelled() const override;
|
||||
bool shuttingDown() const override;
|
||||
void sleepForMs(int milliseconds) override;
|
||||
};
|
||||
|
||||
class BlockchainDataCleaner final {
|
||||
public:
|
||||
static int removeBlockchainData(const std::filesystem::path& dataDir);
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <filesystem>
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <shlobj.h>
|
||||
@@ -113,20 +114,16 @@ bool AddressBook::save()
|
||||
j["entries"].push_back(e);
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
fs::path p(file_path_);
|
||||
fs::create_directories(p.parent_path());
|
||||
|
||||
std::ofstream file(file_path_);
|
||||
if (!file.is_open()) {
|
||||
DEBUG_LOGF("Could not open address book for writing: %s\n", file_path_.c_str());
|
||||
// Atomic + durable: temp file + fsync + rename, so a crash mid-write can't
|
||||
// truncate addressbook.json (which is fully rewritten on every entry change).
|
||||
// Owner-only (0600) — it holds the user's saved contacts.
|
||||
if (!util::Platform::writeFileAtomically(file_path_, j.dump(2), /*restrictPermissions=*/true)) {
|
||||
DEBUG_LOGF("Could not write address book: %s\n", file_path_.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
file << j.dump(2);
|
||||
DEBUG_LOGF("Address book saved: %zu entries\n", entries_.size());
|
||||
return true;
|
||||
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Error saving address book: %s\n", e.what());
|
||||
return false;
|
||||
|
||||
538
src/data/transaction_history_cache.cpp
Normal file
538
src/data/transaction_history_cache.cpp
Normal file
@@ -0,0 +1,538 @@
|
||||
#include "transaction_history_cache.h"
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
#include <sodium.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
#ifndef _WIN32
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kSchemaVersion = 1;
|
||||
constexpr std::size_t kKeyBytes = 32;
|
||||
|
||||
struct Statement {
|
||||
sqlite3_stmt* handle = nullptr;
|
||||
|
||||
Statement(sqlite3* db, const char* sql)
|
||||
{
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
|
||||
handle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
~Statement()
|
||||
{
|
||||
if (handle) sqlite3_finalize(handle);
|
||||
}
|
||||
|
||||
Statement(const Statement&) = delete;
|
||||
Statement& operator=(const Statement&) = delete;
|
||||
};
|
||||
|
||||
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
|
||||
{
|
||||
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool bindBlob(sqlite3_stmt* statement, int index, const std::vector<unsigned char>& value)
|
||||
{
|
||||
return sqlite3_bind_blob(statement, index, value.data(), static_cast<int>(value.size()), SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
std::vector<unsigned char> readBlob(sqlite3_stmt* statement, int index)
|
||||
{
|
||||
const void* data = sqlite3_column_blob(statement, index);
|
||||
int bytes = sqlite3_column_bytes(statement, index);
|
||||
if (!data || bytes <= 0) return {};
|
||||
const auto* begin = static_cast<const unsigned char*>(data);
|
||||
return std::vector<unsigned char>(begin, begin + bytes);
|
||||
}
|
||||
|
||||
std::string hexEncode(const unsigned char* bytes, std::size_t length)
|
||||
{
|
||||
static constexpr char kHex[] = "0123456789abcdef";
|
||||
std::string output;
|
||||
output.resize(length * 2);
|
||||
for (std::size_t index = 0; index < length; ++index) {
|
||||
output[index * 2] = kHex[(bytes[index] >> 4) & 0x0F];
|
||||
output[index * 2 + 1] = kHex[bytes[index] & 0x0F];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
json transactionToJson(const TransactionInfo& transaction)
|
||||
{
|
||||
return json{
|
||||
{"txid", transaction.txid},
|
||||
{"type", transaction.type},
|
||||
{"amount", transaction.amount},
|
||||
{"timestamp", transaction.timestamp},
|
||||
{"confirmations", transaction.confirmations},
|
||||
{"address", transaction.address},
|
||||
{"from_address", transaction.from_address},
|
||||
{"memo", transaction.memo}
|
||||
};
|
||||
}
|
||||
|
||||
TransactionInfo transactionFromJson(const json& source)
|
||||
{
|
||||
TransactionInfo transaction;
|
||||
transaction.txid = source.value("txid", std::string());
|
||||
transaction.type = source.value("type", std::string());
|
||||
transaction.amount = source.value("amount", 0.0);
|
||||
transaction.timestamp = source.value("timestamp", static_cast<std::int64_t>(0));
|
||||
transaction.confirmations = source.value("confirmations", 0);
|
||||
transaction.address = source.value("address", std::string());
|
||||
transaction.from_address = source.value("from_address", std::string());
|
||||
transaction.memo = source.value("memo", std::string());
|
||||
return transaction;
|
||||
}
|
||||
|
||||
std::string associatedDataForWallet(const std::string& walletHash)
|
||||
{
|
||||
return std::string("obsidian-dragon-tx-history-v1:") + walletHash;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TransactionHistoryCache::TransactionHistoryCache()
|
||||
: TransactionHistoryCache(defaultDatabasePath())
|
||||
{
|
||||
}
|
||||
|
||||
TransactionHistoryCache::TransactionHistoryCache(std::string databasePath)
|
||||
: database_path_(std::move(databasePath))
|
||||
{
|
||||
if (sodium_init() < 0) {
|
||||
DEBUG_LOGF("Failed to initialize libsodium for transaction history cache\n");
|
||||
}
|
||||
}
|
||||
|
||||
TransactionHistoryCache::~TransactionHistoryCache()
|
||||
{
|
||||
lockKey();
|
||||
close();
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "transaction_history.sqlite").string();
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::walletIdentityFromAddresses(
|
||||
const std::vector<std::string>& shieldedAddresses,
|
||||
const std::vector<std::string>& transparentAddresses)
|
||||
{
|
||||
std::vector<std::string> addresses;
|
||||
addresses.reserve(shieldedAddresses.size() + transparentAddresses.size());
|
||||
for (const auto& address : shieldedAddresses) {
|
||||
if (!address.empty()) addresses.push_back("z:" + address);
|
||||
}
|
||||
for (const auto& address : transparentAddresses) {
|
||||
if (!address.empty()) addresses.push_back("t:" + address);
|
||||
}
|
||||
if (addresses.empty()) return {};
|
||||
|
||||
std::sort(addresses.begin(), addresses.end());
|
||||
std::string identity = "wallet-addresses-v1\n";
|
||||
for (const auto& address : addresses) {
|
||||
identity += address;
|
||||
identity += '\n';
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
std::string TransactionHistoryCache::walletIdentityHash(const std::string& walletIdentity)
|
||||
{
|
||||
unsigned char digest[crypto_generichash_BYTES];
|
||||
crypto_generichash(digest, sizeof(digest),
|
||||
reinterpret_cast<const unsigned char*>(walletIdentity.data()),
|
||||
walletIdentity.size(), nullptr, 0);
|
||||
return hexEncode(digest, sizeof(digest));
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& exception) {
|
||||
DEBUG_LOGF("Failed to create transaction history cache directory: %s\n", exception.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open transaction history cache: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
// Owner-only (0600): although the payload is encrypted, don't leave the cache (or its
|
||||
// WAL/SHM sidecars) world-readable. Best-effort; sidecars may not exist until first write.
|
||||
::chmod(database_path_.c_str(), 0600);
|
||||
::chmod((database_path_ + "-wal").c_str(), 0600);
|
||||
::chmod((database_path_ + "-shm").c_str(), 0600);
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity,
|
||||
const std::string& passphrase)
|
||||
{
|
||||
if (walletIdentity.empty() || passphrase.empty() || !ensureOpen()) return false;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
std::vector<unsigned char> salt = getOrCreateSalt(walletHash);
|
||||
if (salt.empty()) return false;
|
||||
|
||||
if (!deriveKey(passphrase, salt)) return false;
|
||||
unlocked_wallet_hash_ = std::move(walletHash);
|
||||
key_ready_ = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::lockKey()
|
||||
{
|
||||
if (key_ready_) sodium_memzero(key_.data(), key_.size());
|
||||
key_ready_ = false;
|
||||
unlocked_wallet_hash_.clear();
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const
|
||||
{
|
||||
return key_ready_ && !walletIdentity.empty() &&
|
||||
unlocked_wallet_hash_ == walletIdentityHash(walletIdentity);
|
||||
}
|
||||
|
||||
TransactionHistoryCache::LoadResult TransactionHistoryCache::load(
|
||||
const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
const std::string& currentTipHash)
|
||||
{
|
||||
LoadResult result;
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::time_t updatedAt = 0;
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipherText;
|
||||
if (!readSnapshot(walletHash, tipHeight, tipHash, updatedAt, nonce, cipherText)) return result;
|
||||
|
||||
if ((currentTipHeight > 0 && tipHeight > currentTipHeight) ||
|
||||
(currentTipHeight > 0 && tipHeight == currentTipHeight &&
|
||||
!currentTipHash.empty() && !tipHash.empty() && tipHash != currentTipHash)) {
|
||||
clearWalletByHash(walletHash);
|
||||
result.invalidated = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string plainText;
|
||||
if (!decryptPayload(walletHash, nonce, cipherText, plainText)) return result;
|
||||
|
||||
try {
|
||||
json payload = json::parse(plainText);
|
||||
if (payload.value("schema_version", 0) != kSchemaVersion) return result;
|
||||
if (payload.value("wallet_hash", std::string()) != walletHash) return result;
|
||||
if (!payload.contains("transactions") || !payload["transactions"].is_array()) return result;
|
||||
|
||||
result.transactions.reserve(payload["transactions"].size());
|
||||
for (const auto& transactionJson : payload["transactions"]) {
|
||||
if (transactionJson.is_object()) {
|
||||
result.transactions.push_back(transactionFromJson(transactionJson));
|
||||
}
|
||||
}
|
||||
if (payload.contains("shielded_scan_heights") && payload["shielded_scan_heights"].is_object()) {
|
||||
for (auto it = payload["shielded_scan_heights"].begin();
|
||||
it != payload["shielded_scan_heights"].end(); ++it) {
|
||||
if (!it.key().empty() && it.value().is_number_integer()) {
|
||||
result.shieldedScanHeights[it.key()] = it.value().get<int>();
|
||||
}
|
||||
}
|
||||
}
|
||||
result.tipHeight = tipHeight;
|
||||
result.tipHash = tipHash;
|
||||
result.updatedAt = updatedAt;
|
||||
result.loaded = true;
|
||||
} catch (...) {
|
||||
result.transactions.clear();
|
||||
}
|
||||
|
||||
sodium_memzero(plainText.data(), plainText.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::replace(const std::string& walletIdentity,
|
||||
int tipHeight,
|
||||
const std::string& tipHash,
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights)
|
||||
{
|
||||
if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return false;
|
||||
|
||||
std::string walletHash = walletIdentityHash(walletIdentity);
|
||||
json payload;
|
||||
payload["schema_version"] = kSchemaVersion;
|
||||
payload["wallet_hash"] = walletHash;
|
||||
payload["tip_height"] = tipHeight;
|
||||
payload["tip_hash"] = tipHash;
|
||||
payload["updated_at"] = static_cast<std::int64_t>(updatedAt);
|
||||
payload["transactions"] = json::array();
|
||||
for (const auto& transaction : transactions) {
|
||||
payload["transactions"].push_back(transactionToJson(transaction));
|
||||
}
|
||||
payload["shielded_scan_heights"] = json::object();
|
||||
for (const auto& [address, height] : shieldedScanHeights) {
|
||||
if (!address.empty() && height >= 0) {
|
||||
payload["shielded_scan_heights"][address] = height;
|
||||
}
|
||||
}
|
||||
|
||||
std::string plainText = payload.dump();
|
||||
std::vector<unsigned char> nonce;
|
||||
std::vector<unsigned char> cipherText;
|
||||
bool encrypted = encryptPayload(walletHash, plainText, nonce, cipherText);
|
||||
sodium_memzero(plainText.data(), plainText.size());
|
||||
if (!encrypted) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO transaction_history_snapshots "
|
||||
"(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)");
|
||||
if (!statement.handle) return false;
|
||||
|
||||
if (!bindText(statement.handle, 1, walletHash)) return false;
|
||||
sqlite3_bind_int(statement.handle, 2, kSchemaVersion);
|
||||
sqlite3_bind_int(statement.handle, 3, std::max(0, tipHeight));
|
||||
if (!bindText(statement.handle, 4, tipHash)) return false;
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(updatedAt));
|
||||
if (!bindBlob(statement.handle, 6, nonce)) return false;
|
||||
if (!bindBlob(statement.handle, 7, cipherText)) return false;
|
||||
|
||||
if (sqlite3_step(statement.handle) != SQLITE_DONE) return false;
|
||||
pruneOtherWallets(walletHash); // bound DB growth — drop stale-hash snapshots/salts
|
||||
return true;
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::clearWallet(const std::string& walletIdentity)
|
||||
{
|
||||
if (walletIdentity.empty()) return;
|
||||
clearWalletByHash(walletIdentityHash(walletIdentity));
|
||||
}
|
||||
|
||||
int TransactionHistoryCache::snapshotCount()
|
||||
{
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
return sqlite3_column_int(statement.handle, 0);
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
|
||||
if (result != SQLITE_OK) {
|
||||
DEBUG_LOGF("Transaction history cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS transaction_history_keys ("
|
||||
"wallet_hash TEXT PRIMARY KEY,"
|
||||
"salt BLOB NOT NULL)") &&
|
||||
exec("CREATE TABLE IF NOT EXISTS transaction_history_snapshots ("
|
||||
"wallet_hash TEXT PRIMARY KEY,"
|
||||
"schema_version INTEGER NOT NULL,"
|
||||
"tip_height INTEGER NOT NULL,"
|
||||
"tip_hash TEXT NOT NULL,"
|
||||
"updated_at INTEGER NOT NULL,"
|
||||
"nonce BLOB NOT NULL,"
|
||||
"ciphertext BLOB NOT NULL)");
|
||||
}
|
||||
|
||||
std::vector<unsigned char> TransactionHistoryCache::getOrCreateSalt(const std::string& walletHash)
|
||||
{
|
||||
if (!ensureOpen()) return {};
|
||||
|
||||
{
|
||||
Statement statement(db_, "SELECT salt FROM transaction_history_keys WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return {};
|
||||
if (!bindText(statement.handle, 1, walletHash)) return {};
|
||||
if (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
auto salt = readBlob(statement.handle, 0);
|
||||
if (salt.size() == crypto_pwhash_SALTBYTES) return salt;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<unsigned char> salt(crypto_pwhash_SALTBYTES);
|
||||
randombytes_buf(salt.data(), salt.size());
|
||||
|
||||
Statement insert(db_,
|
||||
"INSERT OR REPLACE INTO transaction_history_keys (wallet_hash, salt) VALUES (?, ?)");
|
||||
if (!insert.handle) return {};
|
||||
if (!bindText(insert.handle, 1, walletHash)) return {};
|
||||
if (!bindBlob(insert.handle, 2, salt)) return {};
|
||||
if (sqlite3_step(insert.handle) != SQLITE_DONE) return {};
|
||||
return salt;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt)
|
||||
{
|
||||
if (salt.size() != crypto_pwhash_SALTBYTES) return false;
|
||||
unsigned char derived[kKeyBytes];
|
||||
int result = crypto_pwhash(derived, sizeof(derived),
|
||||
passphrase.c_str(), passphrase.size(),
|
||||
salt.data(),
|
||||
crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
crypto_pwhash_ALG_ARGON2ID13);
|
||||
if (result != 0) return false;
|
||||
std::copy(derived, derived + sizeof(derived), key_.begin());
|
||||
sodium_memzero(derived, sizeof(derived));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::encryptPayload(const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const
|
||||
{
|
||||
if (!key_ready_) return false;
|
||||
nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
|
||||
randombytes_buf(nonce.data(), nonce.size());
|
||||
|
||||
std::string associatedData = associatedDataForWallet(walletHash);
|
||||
cipherText.resize(plainText.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
unsigned long long cipherLength = 0;
|
||||
int result = crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
cipherText.data(), &cipherLength,
|
||||
reinterpret_cast<const unsigned char*>(plainText.data()), plainText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nullptr, nonce.data(), key_.data());
|
||||
if (result != 0) return false;
|
||||
cipherText.resize(static_cast<std::size_t>(cipherLength));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
std::string& plainText) const
|
||||
{
|
||||
if (!key_ready_ || nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES ||
|
||||
cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string associatedData = associatedDataForWallet(walletHash);
|
||||
std::vector<unsigned char> plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES);
|
||||
unsigned long long plainLength = 0;
|
||||
int result = crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
plain.data(), &plainLength, nullptr,
|
||||
cipherText.data(), cipherText.size(),
|
||||
reinterpret_cast<const unsigned char*>(associatedData.data()), associatedData.size(),
|
||||
nonce.data(), key_.data());
|
||||
if (result != 0) return false;
|
||||
|
||||
plainText.assign(reinterpret_cast<const char*>(plain.data()), static_cast<std::size_t>(plainLength));
|
||||
sodium_memzero(plain.data(), plain.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TransactionHistoryCache::readSnapshot(const std::string& walletHash,
|
||||
int& tipHeight,
|
||||
std::string& tipHash,
|
||||
std::time_t& updatedAt,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText)
|
||||
{
|
||||
Statement statement(db_,
|
||||
"SELECT tip_height, tip_hash, updated_at, nonce, ciphertext "
|
||||
"FROM transaction_history_snapshots WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return false;
|
||||
if (!bindText(statement.handle, 1, walletHash)) return false;
|
||||
if (sqlite3_step(statement.handle) != SQLITE_ROW) return false;
|
||||
|
||||
tipHeight = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* tipHashText = sqlite3_column_text(statement.handle, 1);
|
||||
tipHash = tipHashText ? reinterpret_cast<const char*>(tipHashText) : std::string();
|
||||
updatedAt = static_cast<std::time_t>(sqlite3_column_int64(statement.handle, 2));
|
||||
nonce = readBlob(statement.handle, 3);
|
||||
cipherText = readBlob(statement.handle, 4);
|
||||
return !nonce.empty() && !cipherText.empty();
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?");
|
||||
if (!statement.handle) return;
|
||||
if (!bindText(statement.handle, 1, walletHash)) return;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash)
|
||||
{
|
||||
if (!ensureOpen() || keepWalletHash.empty()) return;
|
||||
// Table names are hardcoded literals (no injection surface). Prune both the snapshot
|
||||
// blobs and the now-orphaned salt rows so a stale salt can't outlive its ciphertext.
|
||||
for (const char* table : {"transaction_history_snapshots", "transaction_history_keys"}) {
|
||||
const std::string sql = std::string("DELETE FROM ") + table + " WHERE wallet_hash <> ?";
|
||||
Statement statement(db_, sql.c_str());
|
||||
if (!statement.handle) continue;
|
||||
if (!bindText(statement.handle, 1, keepWalletHash)) continue;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
}
|
||||
|
||||
void TransactionHistoryCache::close()
|
||||
{
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
93
src/data/transaction_history_cache.h
Normal file
93
src/data/transaction_history_cache.h
Normal file
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_state.h"
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
class TransactionHistoryCache {
|
||||
public:
|
||||
struct LoadResult {
|
||||
bool loaded = false;
|
||||
bool invalidated = false;
|
||||
int tipHeight = 0;
|
||||
std::string tipHash;
|
||||
std::time_t updatedAt = 0;
|
||||
std::vector<TransactionInfo> transactions;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
};
|
||||
|
||||
TransactionHistoryCache();
|
||||
explicit TransactionHistoryCache(std::string databasePath);
|
||||
~TransactionHistoryCache();
|
||||
|
||||
TransactionHistoryCache(const TransactionHistoryCache&) = delete;
|
||||
TransactionHistoryCache& operator=(const TransactionHistoryCache&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
static std::string walletIdentityFromAddresses(const std::vector<std::string>& shieldedAddresses,
|
||||
const std::vector<std::string>& transparentAddresses);
|
||||
static std::string walletIdentityHash(const std::string& walletIdentity);
|
||||
|
||||
bool ensureOpen();
|
||||
bool unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase);
|
||||
void lockKey();
|
||||
bool hasKey() const { return key_ready_; }
|
||||
bool isUnlockedFor(const std::string& walletIdentity) const;
|
||||
|
||||
LoadResult load(const std::string& walletIdentity,
|
||||
int currentTipHeight,
|
||||
const std::string& currentTipHash);
|
||||
bool replace(const std::string& walletIdentity,
|
||||
int tipHeight,
|
||||
const std::string& tipHash,
|
||||
const std::vector<TransactionInfo>& transactions,
|
||||
std::time_t updatedAt,
|
||||
const std::unordered_map<std::string, int>& shieldedScanHeights = {});
|
||||
void clearWallet(const std::string& walletIdentity);
|
||||
int snapshotCount();
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
bool createSchema();
|
||||
std::vector<unsigned char> getOrCreateSalt(const std::string& walletHash);
|
||||
bool deriveKey(const std::string& passphrase,
|
||||
const std::vector<unsigned char>& salt);
|
||||
bool encryptPayload(const std::string& walletHash,
|
||||
const std::string& plainText,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText) const;
|
||||
bool decryptPayload(const std::string& walletHash,
|
||||
const std::vector<unsigned char>& nonce,
|
||||
const std::vector<unsigned char>& cipherText,
|
||||
std::string& plainText) const;
|
||||
bool readSnapshot(const std::string& walletHash,
|
||||
int& tipHeight,
|
||||
std::string& tipHash,
|
||||
std::time_t& updatedAt,
|
||||
std::vector<unsigned char>& nonce,
|
||||
std::vector<unsigned char>& cipherText);
|
||||
void clearWalletByHash(const std::string& walletHash);
|
||||
// Delete snapshot + salt rows for every wallet hash except the live one, bounding the
|
||||
// DB so generating a new address (which changes the hash) doesn't orphan history forever.
|
||||
void pruneOtherWallets(const std::string& keepWalletHash);
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::array<unsigned char, 32> key_{};
|
||||
bool key_ready_ = false;
|
||||
std::string unlocked_wallet_hash_;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
@@ -3,12 +3,44 @@
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "wallet_state.h"
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
||||
bool requirePositiveBalance)
|
||||
{
|
||||
std::vector<size_t> indices;
|
||||
indices.reserve(addresses.size());
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
const auto& address = addresses[i];
|
||||
if (!address.isSpendable()) continue;
|
||||
if (requirePositiveBalance && address.balance <= 0.0) continue;
|
||||
indices.push_back(i);
|
||||
}
|
||||
|
||||
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
|
||||
return addresses[lhs].balance > addresses[rhs].balance;
|
||||
});
|
||||
return indices;
|
||||
}
|
||||
|
||||
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
||||
{
|
||||
int bestIndex = -1;
|
||||
double bestBalance = 0.0;
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
|
||||
bestBalance = addresses[i].balance;
|
||||
bestIndex = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
std::string TransactionInfo::getTimeString() const
|
||||
{
|
||||
if (timestamp == 0) return "Unknown";
|
||||
|
||||
@@ -18,6 +18,7 @@ struct AddressInfo {
|
||||
std::string address;
|
||||
double balance = 0.0;
|
||||
std::string type; // "shielded" or "transparent"
|
||||
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
|
||||
|
||||
// For display
|
||||
std::string label;
|
||||
@@ -25,8 +26,13 @@ struct AddressInfo {
|
||||
// Derived
|
||||
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
|
||||
bool isShielded() const { return type == "shielded"; }
|
||||
bool isSpendable() const { return has_spending_key; }
|
||||
};
|
||||
|
||||
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
||||
bool requirePositiveBalance = true);
|
||||
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses);
|
||||
|
||||
/**
|
||||
* @brief Represents a wallet transaction
|
||||
*/
|
||||
@@ -120,7 +126,20 @@ struct SyncInfo {
|
||||
bool rescanning = false;
|
||||
float rescan_progress = 0.0f; // 0.0 - 1.0
|
||||
std::string rescan_status; // e.g. "Rescanning... 25%"
|
||||
|
||||
|
||||
// Sapling note witness rebuild — a distinct, often-long phase after a rescan/zap. The daemon
|
||||
// reports it in TWO sub-phases with different signals, so we track which is active:
|
||||
// 1 = initial pass ("Setting Initial Sapling Witness for tx <hash>, <i> of <N>") — progress
|
||||
// is distinct-txs-witnessed / N (the <i> bounces, so it can't be used directly).
|
||||
// 2 = witness-cache walk ("Building Witnesses for block <h> <frac> complete, <n> remaining")
|
||||
// — progress derived from how far "remaining" has fallen from its per-phase peak.
|
||||
// The two are sequential with different scales, so progress is NOT carried across the boundary
|
||||
// (that would pin the bar at the initial pass's ~100% through the whole cache walk).
|
||||
bool building_witnesses = false;
|
||||
int witness_phase = 0; // 0 none, 1 initial-witness pass, 2 witness-cache walk
|
||||
float witness_progress = 0.0f; // 0.0 - 1.0, within the current sub-phase
|
||||
int witness_remaining = 0; // blocks left in the cache walk (0 if unknown / phase 1)
|
||||
|
||||
bool isSynced() const { return !syncing && blocks > 0 && blocks >= headers - 2; }
|
||||
};
|
||||
|
||||
@@ -135,6 +154,8 @@ struct MarketInfo {
|
||||
double market_cap = 0.0;
|
||||
std::string last_updated;
|
||||
std::chrono::steady_clock::time_point last_fetch_time{};
|
||||
bool price_loading = false;
|
||||
std::string price_error;
|
||||
|
||||
// Price history for chart
|
||||
std::vector<double> price_history;
|
||||
@@ -181,6 +202,12 @@ struct WalletState {
|
||||
// Connection
|
||||
bool connected = false;
|
||||
bool warming_up = false; // daemon reachable but in RPC warmup (error -28)
|
||||
// True when the daemon is up/launching but not yet answering RPC (e.g. the connect probe
|
||||
// times out because the node is loading the block index). Distinct from warming_up, which
|
||||
// needs a JSON-RPC -28 reply; here getinfo never returns, so we infer the state from the
|
||||
// daemon's launch state + its own console output. Drives the same loading overlay so the
|
||||
// user sees WHAT the node is doing instead of a bare "Connection failed".
|
||||
bool daemon_initializing = false;
|
||||
std::string warmup_status; // user-friendly title, e.g. "Processing blocks..."
|
||||
std::string warmup_description; // subtitle explaining the stage
|
||||
int daemon_version = 0;
|
||||
@@ -254,6 +281,7 @@ struct WalletState {
|
||||
void clear() {
|
||||
connected = false;
|
||||
warming_up = false;
|
||||
daemon_initializing = false;
|
||||
warmup_status.clear();
|
||||
warmup_description.clear();
|
||||
daemon_version = 0;
|
||||
|
||||
@@ -12,4 +12,5 @@ INCBIN(ubuntu_regular, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-R.ttf");
|
||||
INCBIN(ubuntu_light, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Light.ttf");
|
||||
INCBIN(ubuntu_medium, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Medium.ttf");
|
||||
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
|
||||
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
|
||||
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");
|
||||
|
||||
@@ -27,6 +27,9 @@ extern "C" {
|
||||
extern const unsigned char g_material_icons_data[];
|
||||
extern const unsigned int g_material_icons_size;
|
||||
|
||||
extern const unsigned char g_mdi_pickaxe_subset_data[];
|
||||
extern const unsigned int g_mdi_pickaxe_subset_size;
|
||||
|
||||
extern const unsigned char g_noto_cjk_subset_data[];
|
||||
extern const unsigned int g_noto_cjk_subset_size;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
// Embedded Resources Header
|
||||
// This provides access to resources embedded in the binary
|
||||
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace embedded {
|
||||
|
||||
// Forward declarations for embedded data (generated at build time)
|
||||
struct EmbeddedResource {
|
||||
const unsigned char* data;
|
||||
size_t size;
|
||||
};
|
||||
|
||||
// Resource registry
|
||||
class Resources {
|
||||
public:
|
||||
static Resources& instance() {
|
||||
static Resources inst;
|
||||
return inst;
|
||||
}
|
||||
|
||||
// Get embedded resource by name
|
||||
// Returns nullptr if not found
|
||||
const EmbeddedResource* get(const std::string& name) const {
|
||||
auto it = resources_.find(name);
|
||||
if (it != resources_.end()) {
|
||||
return &it->second;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Check if resource exists
|
||||
bool has(const std::string& name) const {
|
||||
return resources_.find(name) != resources_.end();
|
||||
}
|
||||
|
||||
// Register a resource (called during static init)
|
||||
void registerResource(const std::string& name, const unsigned char* data, size_t size) {
|
||||
resources_[name] = {data, size};
|
||||
}
|
||||
|
||||
private:
|
||||
Resources() = default;
|
||||
std::unordered_map<std::string, EmbeddedResource> resources_;
|
||||
};
|
||||
|
||||
// Helper macro for registering resources
|
||||
#define REGISTER_EMBEDDED_RESOURCE(name, data, size) \
|
||||
static struct _EmbeddedResourceRegister_##name { \
|
||||
_EmbeddedResourceRegister_##name() { \
|
||||
dragonx::embedded::Resources::instance().registerResource(#name, data, size); \
|
||||
} \
|
||||
} _embedded_resource_register_##name
|
||||
|
||||
} // namespace embedded
|
||||
} // namespace dragonx
|
||||
@@ -1224,6 +1224,8 @@ int main(int argc, char* argv[])
|
||||
|
||||
// Immediate triggers: async RPC results or visible notifications
|
||||
bool hasImmediateWork = app.hasPendingRPCResults()
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|| dragonx::ui::Notifications::instance().hasActive();
|
||||
|
||||
// Periodic maintenance: fire refresh timers in app.update()
|
||||
@@ -1801,6 +1803,8 @@ int main(int argc, char* argv[])
|
||||
&& !opaqueBackground;
|
||||
bool animating = app.isShuttingDown()
|
||||
|| backdropNeedsFrames
|
||||
|| app.hasTransactionSendProgress()
|
||||
|| app.isTransactionRefreshInProgress()
|
||||
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|
||||
|| dragonx::ui::Notifications::instance().hasActive()
|
||||
|| dragonx::ui::material::SmoothScrollAnimating();
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <filesystem>
|
||||
#include <vector>
|
||||
#include <cstdio>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
@@ -225,11 +227,13 @@ bool needsParamsExtraction()
|
||||
if (spendRes && resourceNeedsUpdate(spendRes, spendPath)) return true;
|
||||
if (outputRes && resourceNeedsUpdate(outputRes, outputPath)) return true;
|
||||
|
||||
// Also check if daemon binaries need updating
|
||||
// Daemon binaries are only auto-placed when MISSING (never auto-overwritten on a size
|
||||
// mismatch) — the user may be running a specific dragonxd. Replacing the bundled daemon is
|
||||
// an explicit action via Settings → daemon binary. So only trigger extraction if it's absent.
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
const auto* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
|
||||
std::string daemonPath = daemonDir + pathSep + RESOURCE_DRAGONXD;
|
||||
if (daemonRes && resourceNeedsUpdate(daemonRes, daemonPath)) return true;
|
||||
if (daemonRes && !std::filesystem::exists(daemonPath)) return true;
|
||||
#endif
|
||||
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
@@ -274,8 +278,30 @@ static bool extractResource(const EmbeddedResource* res, const std::string& dest
|
||||
// Write file
|
||||
std::ofstream file(destPath, std::ios::binary);
|
||||
if (!file) {
|
||||
DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", destPath.c_str());
|
||||
return false;
|
||||
// The destination may be locked because the previous daemon is still using the binary:
|
||||
// Windows locks a running .exe against truncation, Linux returns ETXTBSY. Both platforms
|
||||
// DO allow renaming/moving such a file — the running process keeps the moved copy — so move
|
||||
// the stale binary aside and write a fresh one at the original path.
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(destPath)) {
|
||||
std::string sidelined = destPath + ".old";
|
||||
std::filesystem::remove(sidelined, ec); // clear any leftover from a prior swap
|
||||
ec.clear();
|
||||
std::filesystem::rename(destPath, sidelined, ec);
|
||||
if (!ec) {
|
||||
file.clear();
|
||||
file.open(destPath, std::ios::binary);
|
||||
if (file)
|
||||
DEBUG_LOGF("[INFO] Replaced in-use %s (old copy moved to .old)\n", destPath.c_str());
|
||||
} else {
|
||||
DEBUG_LOGF("[WARN] Could not move stale %s aside: %s\n",
|
||||
destPath.c_str(), ec.message().c_str());
|
||||
}
|
||||
}
|
||||
if (!file) {
|
||||
DEBUG_LOGF("[ERROR] Failed to open %s for writing\n", destPath.c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
file.write(reinterpret_cast<const char*>(res->data), res->size);
|
||||
@@ -347,12 +373,12 @@ bool extractEmbeddedResources()
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
DEBUG_LOGF("[INFO] Daemon extraction directory: %s\n", daemonDir.c_str());
|
||||
|
||||
// Daemon binaries are placed ONLY when missing — never auto-overwritten on a size mismatch
|
||||
// (the user may run a specific dragonxd; replacing it is an explicit Settings action).
|
||||
const EmbeddedResource* daemonRes = getEmbeddedResource(RESOURCE_DRAGONXD);
|
||||
if (daemonRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONXD;
|
||||
if (resourceNeedsUpdate(daemonRes, dest)) {
|
||||
if (std::filesystem::exists(dest))
|
||||
DEBUG_LOGF("[INFO] Updating stale dragonxd (size mismatch)...\n");
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonxd (%zu MB)...\n", daemonRes->size / (1024*1024));
|
||||
if (!extractResource(daemonRes, dest)) {
|
||||
success = false;
|
||||
@@ -362,13 +388,11 @@ bool extractEmbeddedResources()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const EmbeddedResource* cliRes = getEmbeddedResource(RESOURCE_DRAGONX_CLI);
|
||||
if (cliRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_CLI;
|
||||
if (resourceNeedsUpdate(cliRes, dest)) {
|
||||
if (std::filesystem::exists(dest))
|
||||
DEBUG_LOGF("[INFO] Updating stale dragonx-cli (size mismatch)...\n");
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-cli (%zu MB)...\n", cliRes->size / (1024*1024));
|
||||
if (!extractResource(cliRes, dest)) {
|
||||
success = false;
|
||||
@@ -378,13 +402,11 @@ bool extractEmbeddedResources()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const EmbeddedResource* txRes = getEmbeddedResource(RESOURCE_DRAGONX_TX);
|
||||
if (txRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_TX;
|
||||
if (resourceNeedsUpdate(txRes, dest)) {
|
||||
if (std::filesystem::exists(dest))
|
||||
DEBUG_LOGF("[INFO] Updating stale dragonx-tx (size mismatch)...\n");
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-tx (%zu MB)...\n", txRes->size / (1024*1024));
|
||||
if (!extractResource(txRes, dest)) {
|
||||
success = false;
|
||||
@@ -413,7 +435,18 @@ bool extractEmbeddedResources()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
|
||||
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
|
||||
// if it's still running, the remove fails harmlessly and we retry on the next startup.
|
||||
{
|
||||
std::error_code ec;
|
||||
for (const char* name : { RESOURCE_DRAGONXD, RESOURCE_XMRIG }) {
|
||||
std::filesystem::remove(daemonDir + pathSep + name + std::string(".old"), ec);
|
||||
ec.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
@@ -557,6 +590,120 @@ bool forceExtractXmrig()
|
||||
#endif
|
||||
}
|
||||
|
||||
// Scan a binary blob for the daemon's version stamp: 'v' <maj>.<min>.<rev> optionally followed by
|
||||
// '-' <commit hash (>=6 hex)>, e.g. "v1.0.2-ddd851dc1". Returns the first match, or "" if none.
|
||||
static std::string scanBinaryVersion(const uint8_t* data, std::size_t size)
|
||||
{
|
||||
if (!data || size < 6) return "";
|
||||
auto isdig = [](uint8_t c) { return std::isdigit(static_cast<unsigned char>(c)) != 0; };
|
||||
auto isxd = [](uint8_t c) { return std::isxdigit(static_cast<unsigned char>(c)) != 0; };
|
||||
for (std::size_t i = 0; i + 5 < size; ++i) {
|
||||
if (data[i] != 'v') continue;
|
||||
std::size_t k = i + 1, s;
|
||||
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // major
|
||||
if (k >= size || data[k] != '.') continue; ++k;
|
||||
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // minor
|
||||
if (k >= size || data[k] != '.') continue; ++k;
|
||||
s = k; while (k < size && isdig(data[k])) ++k; if (k == s) continue; // revision
|
||||
std::size_t end = k;
|
||||
if (k < size && data[k] == '-') { // optional -<commit>
|
||||
std::size_t h = k + 1, hs = h;
|
||||
while (h < size && isxd(data[h])) ++h;
|
||||
if (h - hs >= 6) end = h;
|
||||
}
|
||||
return std::string(reinterpret_cast<const char*>(data) + i, end - i);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
DaemonBinaryInfo getInstalledDaemonInfo()
|
||||
{
|
||||
DaemonBinaryInfo info;
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
#ifdef _WIN32
|
||||
info.path = daemonDir + "\\" + RESOURCE_DRAGONXD;
|
||||
#else
|
||||
info.path = daemonDir + "/" + RESOURCE_DRAGONXD;
|
||||
#endif
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(info.path, ec)) return info; // exists stays false
|
||||
info.exists = true;
|
||||
info.size = std::filesystem::file_size(info.path, ec);
|
||||
if (ec) info.size = 0;
|
||||
|
||||
auto ftime = std::filesystem::last_write_time(info.path, ec);
|
||||
if (!ec) {
|
||||
// Convert filesystem clock → system_clock epoch (pre-C++20 portable approximation).
|
||||
auto sysTime = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
|
||||
ftime - decltype(ftime)::clock::now() + std::chrono::system_clock::now());
|
||||
info.modifiedEpoch =
|
||||
static_cast<std::int64_t>(std::chrono::system_clock::to_time_t(sysTime));
|
||||
}
|
||||
|
||||
// Read the binary and scan for its version stamp (one-off; caller caches the result).
|
||||
std::ifstream f(info.path, std::ios::binary);
|
||||
if (f) {
|
||||
f.seekg(0, std::ios::end);
|
||||
std::streamoff len = f.tellg();
|
||||
f.seekg(0, std::ios::beg);
|
||||
if (len > 0) {
|
||||
std::vector<uint8_t> buf(static_cast<std::size_t>(len));
|
||||
f.read(reinterpret_cast<char*>(buf.data()), len);
|
||||
info.version = scanBinaryVersion(buf.data(), static_cast<std::size_t>(f.gcount()));
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
BundledDaemonInfo getBundledDaemonInfo()
|
||||
{
|
||||
BundledDaemonInfo info;
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONXD);
|
||||
if (res && res->data && res->size > 0) {
|
||||
info.available = true;
|
||||
info.size = res->size;
|
||||
// The embedded bytes are constant for this build — scan once.
|
||||
static const std::string cachedVersion = scanBinaryVersion(res->data, res->size);
|
||||
info.version = cachedVersion;
|
||||
}
|
||||
#endif
|
||||
return info;
|
||||
}
|
||||
|
||||
bool reextractBundledDaemon()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_DAEMON
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
#ifdef _WIN32
|
||||
const char pathSep = '\\';
|
||||
#else
|
||||
const char pathSep = '/';
|
||||
#endif
|
||||
bool ok = true;
|
||||
bool wroteAny = false;
|
||||
const char* names[] = { RESOURCE_DRAGONXD, RESOURCE_DRAGONX_CLI, RESOURCE_DRAGONX_TX };
|
||||
for (const char* name : names) {
|
||||
const EmbeddedResource* res = getEmbeddedResource(name);
|
||||
if (!res) continue;
|
||||
std::string dest = daemonDir + pathSep + name;
|
||||
DEBUG_LOGF("[INFO] reextractBundledDaemon: writing %s (%zu MB)\n", name, res->size / (1024*1024));
|
||||
if (!extractResource(res, dest)) {
|
||||
DEBUG_LOGF("[ERROR] reextractBundledDaemon: failed to write %s\n", name);
|
||||
ok = false;
|
||||
continue;
|
||||
}
|
||||
wroteAny = true;
|
||||
#ifndef _WIN32
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
}
|
||||
return ok && wroteAny;
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string getXmrigPath()
|
||||
{
|
||||
std::string daemonDir = getDaemonDirectory();
|
||||
|
||||
@@ -30,6 +30,31 @@ bool needsParamsExtraction();
|
||||
// Get the params directory path
|
||||
std::string getParamsDirectory();
|
||||
|
||||
// --- Daemon binary management (Settings → daemon binary panel) ------------------------------
|
||||
// Info about the dragonxd binary currently installed in the dragonx/ extraction directory.
|
||||
struct DaemonBinaryInfo {
|
||||
bool exists = false;
|
||||
std::string path;
|
||||
std::uintmax_t size = 0;
|
||||
std::string version; // scanned from the binary ("vX.Y.Z-<commit>"), empty if not found
|
||||
std::int64_t modifiedEpoch = 0; // last-write time as unix epoch seconds, 0 if unknown
|
||||
};
|
||||
|
||||
// Info about the dragonxd binary bundled inside this wallet build.
|
||||
struct BundledDaemonInfo {
|
||||
bool available = false; // a daemon resource is embedded in this build
|
||||
std::uintmax_t size = 0;
|
||||
std::string version;
|
||||
};
|
||||
|
||||
// Read + scan the installed dragonxd (reads the file; call off the UI thread or cache the result).
|
||||
DaemonBinaryInfo getInstalledDaemonInfo();
|
||||
// Info about the bundled daemon (scans the embedded bytes once, cached).
|
||||
BundledDaemonInfo getBundledDaemonInfo();
|
||||
// Force-overwrite the installed dragonx binaries (dragonxd/cli/tx) with the bundled ones. The
|
||||
// caller should stop the daemon first. Returns true if all present resources were written.
|
||||
bool reextractBundledDaemon();
|
||||
|
||||
// Resource names
|
||||
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
|
||||
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
#include "../config/version.h"
|
||||
#include "../resources/embedded_resources.h"
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
#include "../util/logger.h"
|
||||
|
||||
@@ -26,6 +29,56 @@ namespace fs = std::filesystem;
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string generateSecureRandomString(size_t length)
|
||||
{
|
||||
static constexpr char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
static constexpr uint32_t charsetSize = static_cast<uint32_t>(sizeof(charset) - 1);
|
||||
|
||||
if (sodium_init() < 0) {
|
||||
DEBUG_LOGF("Failed to initialize libsodium for RPC credential generation\n");
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string result;
|
||||
result.reserve(length);
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
result.push_back(charset[randombytes_uniform(charsetSize)]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string lowercase(std::string value)
|
||||
{
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
bool parseBoolValue(const std::string& value)
|
||||
{
|
||||
std::string lowered = lowercase(value);
|
||||
return lowered == "1" || lowered == "true" || lowered == "yes" || lowered == "on";
|
||||
}
|
||||
|
||||
bool applyCookieAuth(ConnectionConfig& config, const std::string& dataDir)
|
||||
{
|
||||
std::string cookieUser, cookiePass;
|
||||
if (!Connection::readAuthCookie(dataDir, cookieUser, cookiePass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config.rpcuser = cookieUser;
|
||||
config.rpcpassword = cookiePass;
|
||||
config.auth_source = AuthSource::Cookie;
|
||||
if (config.hush_dir.empty()) config.hush_dir = dataDir;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Connection::Connection() = default;
|
||||
Connection::~Connection() = default;
|
||||
|
||||
@@ -140,8 +193,14 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
config.host = value;
|
||||
} else if (key == "proxy") {
|
||||
config.proxy = value;
|
||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||
config.use_tls = parseBoolValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.rpcuser.empty() || !config.rpcpassword.empty()) {
|
||||
config.auth_source = AuthSource::ConfigFile;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -177,10 +236,7 @@ ConnectionConfig Connection::autoDetectConfig()
|
||||
|
||||
// If rpcpassword is empty, the daemon may be using .cookie auth
|
||||
if (config.rpcpassword.empty()) {
|
||||
std::string cookieUser, cookiePass;
|
||||
if (readAuthCookie(data_dir, cookieUser, cookiePass)) {
|
||||
config.rpcuser = cookieUser;
|
||||
config.rpcpassword = cookiePass;
|
||||
if (applyCookieAuth(config, data_dir)) {
|
||||
DEBUG_LOGF("Using .cookie authentication (no rpcpassword in config)\n");
|
||||
}
|
||||
}
|
||||
@@ -196,23 +252,57 @@ ConnectionConfig Connection::autoDetectConfig()
|
||||
return config;
|
||||
}
|
||||
|
||||
bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionConfig& cookieConfig)
|
||||
{
|
||||
if (base.auth_source == AuthSource::Cookie) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string dataDir = base.hush_dir.empty() ? getDefaultDataDir() : base.hush_dir;
|
||||
ConnectionConfig fallback = base;
|
||||
if (!applyCookieAuth(fallback, dataDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cookieConfig = std::move(fallback);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Connection::isLocalHost(const std::string& host)
|
||||
{
|
||||
std::string lowered = lowercase(host);
|
||||
if (!lowered.empty() && lowered.front() == '[' && lowered.back() == ']') {
|
||||
lowered = lowered.substr(1, lowered.size() - 2);
|
||||
}
|
||||
|
||||
return lowered == "localhost" || lowered == "localhost." ||
|
||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
||||
}
|
||||
|
||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
{
|
||||
return !config.use_tls && !isLocalHost(config.host);
|
||||
}
|
||||
|
||||
const char* Connection::authSourceName(AuthSource source)
|
||||
{
|
||||
switch (source) {
|
||||
case AuthSource::ConfigFile: return "config";
|
||||
case AuthSource::Cookie: return "cookie";
|
||||
case AuthSource::Missing: return "missing";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bool Connection::createDefaultConfig(const std::string& path)
|
||||
{
|
||||
// Generate random rpcuser/rpcpassword
|
||||
auto generateRandomString = [](int length) -> std::string {
|
||||
const char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
std::string result;
|
||||
result.reserve(length);
|
||||
|
||||
std::srand(static_cast<unsigned>(std::time(nullptr)));
|
||||
for (int i = 0; i < length; i++) {
|
||||
result += charset[std::rand() % (sizeof(charset) - 1)];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
std::string rpcuser = generateRandomString(16);
|
||||
std::string rpcpassword = generateRandomString(32);
|
||||
std::string rpcuser = generateSecureRandomString(16);
|
||||
std::string rpcpassword = generateSecureRandomString(32);
|
||||
if (rpcuser.empty() || rpcpassword.empty()) {
|
||||
DEBUG_LOGF("Failed to generate secure RPC credentials for config file: %s\n", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) {
|
||||
@@ -241,7 +331,18 @@ bool Connection::createDefaultConfig(const std::string& path)
|
||||
file << "addnode=node4.dragonx.is\n";
|
||||
|
||||
file.close();
|
||||
|
||||
|
||||
// The file holds the freshly-generated rpcuser/rpcpassword in plaintext. ofstream creates it
|
||||
// with the process umask (typically world-readable 0644), so restrict it to owner read/write
|
||||
// before another local user can read the credentials.
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
fs::permissions(path, fs::perms::owner_read | fs::perms::owner_write,
|
||||
fs::perm_options::replace, ec);
|
||||
if (ec) DEBUG_LOGF("Could not restrict config permissions on %s: %s\n", path.c_str(), ec.message().c_str());
|
||||
}
|
||||
|
||||
DEBUG_LOGF("Created default config file: %s\n", path.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ namespace rpc {
|
||||
/**
|
||||
* @brief Connection configuration
|
||||
*/
|
||||
enum class AuthSource {
|
||||
Missing,
|
||||
ConfigFile,
|
||||
Cookie
|
||||
};
|
||||
|
||||
struct ConnectionConfig {
|
||||
std::string host = "127.0.0.1";
|
||||
std::string port = "21769";
|
||||
@@ -20,6 +26,8 @@ struct ConnectionConfig {
|
||||
std::string hush_dir;
|
||||
std::string proxy; // SOCKS5 proxy for Tor
|
||||
bool use_embedded = true;
|
||||
bool use_tls = false;
|
||||
AuthSource auth_source = AuthSource::Missing;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -96,6 +104,23 @@ public:
|
||||
*/
|
||||
static bool readAuthCookie(const std::string& dataDir, std::string& user, std::string& password);
|
||||
|
||||
/**
|
||||
* @brief Build a cookie-auth retry config from a failed config-auth attempt
|
||||
*/
|
||||
static bool buildCookieAuthConfig(const ConnectionConfig& base, ConnectionConfig& cookieConfig);
|
||||
|
||||
/**
|
||||
* @brief Whether a host is local enough for plaintext HTTP RPC
|
||||
*/
|
||||
static bool isLocalHost(const std::string& host);
|
||||
|
||||
/**
|
||||
* @brief Whether this config would send RPC credentials over plaintext to a remote host
|
||||
*/
|
||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
static const char* authSourceName(AuthSource source);
|
||||
|
||||
private:
|
||||
};
|
||||
|
||||
|
||||
@@ -6,17 +6,82 @@
|
||||
// All calls are blocking; run on RPCWorker threads, never on main thread.
|
||||
|
||||
#include "rpc_client.h"
|
||||
#include "connection.h"
|
||||
#include "../config/version.h"
|
||||
#include "../util/base64.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
#include <sodium.h>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <utility>
|
||||
#include "../util/logger.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
std::mutex g_trace_mutex;
|
||||
RPCClient::TraceCallback g_trace_callback;
|
||||
std::atomic_bool g_trace_enabled{false};
|
||||
thread_local std::string g_trace_source;
|
||||
|
||||
void emitRpcTrace(const std::string& method)
|
||||
{
|
||||
if (!g_trace_enabled.load(std::memory_order_relaxed)) return;
|
||||
|
||||
RPCClient::TraceCallback callback;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_trace_mutex);
|
||||
callback = g_trace_callback;
|
||||
}
|
||||
if (!callback) return;
|
||||
|
||||
std::string source = g_trace_source.empty() ? std::string("App") : g_trace_source;
|
||||
callback(source, method);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RPCClient::TraceScope::TraceScope(std::string source)
|
||||
: previous_(RPCClient::currentTraceSource())
|
||||
{
|
||||
RPCClient::setTraceSource(std::move(source));
|
||||
}
|
||||
|
||||
RPCClient::TraceScope::~TraceScope()
|
||||
{
|
||||
RPCClient::setTraceSource(std::move(previous_));
|
||||
}
|
||||
|
||||
void RPCClient::setTraceCallback(TraceCallback callback)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_trace_mutex);
|
||||
g_trace_callback = std::move(callback);
|
||||
}
|
||||
|
||||
void RPCClient::setTraceEnabled(bool enabled)
|
||||
{
|
||||
g_trace_enabled.store(enabled, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool RPCClient::isTraceEnabled()
|
||||
{
|
||||
return g_trace_enabled.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::string RPCClient::currentTraceSource()
|
||||
{
|
||||
return g_trace_source;
|
||||
}
|
||||
|
||||
void RPCClient::setTraceSource(std::string source)
|
||||
{
|
||||
g_trace_source = std::move(source);
|
||||
}
|
||||
|
||||
// Callback for libcurl to write response data
|
||||
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
||||
size_t totalSize = size * nmemb;
|
||||
@@ -24,6 +89,14 @@ static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::stri
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
// curl progress callback: a non-zero return aborts the in-flight transfer. This lets a
|
||||
// requestAbort() from another thread (disconnect/shutdown) unblock curl_easy_perform so the
|
||||
// UI thread's worker join() returns promptly instead of waiting out the request timeout.
|
||||
static int xferInfoCallback(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t) {
|
||||
const auto* self = static_cast<const RPCClient*>(clientp);
|
||||
return (self != nullptr && self->abortRequested()) ? 1 : 0;
|
||||
}
|
||||
|
||||
// Private implementation using libcurl
|
||||
class RPCClient::Impl {
|
||||
public:
|
||||
@@ -60,17 +133,26 @@ RPCClient::~RPCClient() = default;
|
||||
|
||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password)
|
||||
{
|
||||
return connect(host, port, user, password, false);
|
||||
}
|
||||
|
||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
host_ = host;
|
||||
port_ = port;
|
||||
last_connect_info_ = json();
|
||||
|
||||
// Create Basic auth header with proper base64 encoding
|
||||
// Create Basic auth header with proper base64 encoding, then wipe the plaintext
|
||||
// "user:password" temporary (std::string does not zero its buffer on destruction).
|
||||
std::string credentials = user + ":" + password;
|
||||
auth_ = util::base64_encode(credentials);
|
||||
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size());
|
||||
|
||||
// Build URL - use HTTP for localhost RPC (TLS not always enabled)
|
||||
impl_->url = "http://" + host + ":" + port + "/";
|
||||
impl_->url = std::string(useTls ? "https://" : "http://") + host + ":" + port + "/";
|
||||
VERBOSE_LOGF("Connecting to dragonxd at %s\n", impl_->url.c_str());
|
||||
|
||||
// Clean up previous curl handle/headers to avoid leaks on retries
|
||||
@@ -99,17 +181,31 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str());
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_HTTPHEADER, impl_->headers);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_WRITEFUNCTION, WriteCallback);
|
||||
// Progress callback so requestAbort() can unblock an in-flight curl_easy_perform.
|
||||
clearAbort(); // a fresh connection must not start in the aborted state
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_NOPROGRESS, 0L);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_XFERINFODATA, this);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, 30L);
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, 1L); // localhost — fails fast if not listening
|
||||
// Localhost fails fast if nothing is listening; a remote/TLS daemon needs a larger
|
||||
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
|
||||
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
|
||||
|
||||
// Test connection with getinfo
|
||||
// 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
|
||||
// hang means a wedged/loading occupant — no point blocking the full 30s before we retry and
|
||||
// update the UI. (call(timeoutSec) restores the persistent 30s afterwards, so normal RPC calls
|
||||
// that legitimately take longer are unaffected.) Remote/TLS daemons keep the full budget.
|
||||
const long probeTimeout = Connection::isLocalHost(host) ? 8L : 30L;
|
||||
try {
|
||||
json result = call("getinfo");
|
||||
json result = call("getinfo", json::array(), probeTimeout);
|
||||
if (result.contains("version")) {
|
||||
connected_ = true;
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_error_.clear();
|
||||
last_connect_info_ = result;
|
||||
DEBUG_LOGF("Connected to dragonxd v%d\n", result["version"].get<int>());
|
||||
return true;
|
||||
}
|
||||
@@ -120,7 +216,12 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
// it just hasn't finished initializing yet. Mark as connected+warmup
|
||||
// so the wallet can show the UI instead of a blocking overlay.
|
||||
std::string msg = e.what();
|
||||
bool isWarmup = (msg.find("Loading") != std::string::npos ||
|
||||
// Warmup is JSON-RPC error code -28 (RPC_IN_WARMUP) — the robust signal. Fall back
|
||||
// to message substrings for any path that didn't carry the numeric code.
|
||||
int code = 0;
|
||||
if (const auto* re = dynamic_cast<const RpcError*>(&e)) code = re->code;
|
||||
bool isWarmup = (code == -28) ||
|
||||
(msg.find("Loading") != std::string::npos ||
|
||||
msg.find("Verifying") != std::string::npos ||
|
||||
msg.find("Activating") != std::string::npos ||
|
||||
msg.find("Rewinding") != std::string::npos ||
|
||||
@@ -140,15 +241,35 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
connected_ = false;
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_info_ = json();
|
||||
return false;
|
||||
}
|
||||
|
||||
json RPCClient::getLastConnectInfo() const
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
return last_connect_info_;
|
||||
}
|
||||
|
||||
void RPCClient::requestAbort()
|
||||
{
|
||||
// Deliberately NOT taking curl_mutex_ — the whole point is to interrupt a call() that is
|
||||
// currently holding it inside curl_easy_perform. The atomic is read by xferInfoCallback.
|
||||
abort_.store(true, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void RPCClient::clearAbort()
|
||||
{
|
||||
abort_.store(false, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void RPCClient::disconnect()
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
connected_ = false;
|
||||
warming_up_ = false;
|
||||
warmup_status_.clear();
|
||||
last_connect_info_ = json();
|
||||
if (impl_->curl) {
|
||||
curl_easy_cleanup(impl_->curl);
|
||||
impl_->curl = nullptr;
|
||||
@@ -176,6 +297,8 @@ json RPCClient::call(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
@@ -200,23 +323,36 @@ json RPCClient::call(const std::string& method, const json& params)
|
||||
// (insufficient funds, bad params, etc.) with a valid JSON body.
|
||||
// Parse the body first to extract the real error message.
|
||||
if (http_code != 200) {
|
||||
int errCode = 0;
|
||||
try {
|
||||
json response = json::parse(response_data);
|
||||
if (response.contains("error") && !response["error"].is_null()) {
|
||||
std::string err_msg = response["error"]["message"].get<std::string>();
|
||||
throw std::runtime_error(err_msg);
|
||||
if (response.contains("error") && response["error"].is_object()) {
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
throw RpcError(errCode, response["error"]["message"].get<std::string>());
|
||||
// message missing/non-string — keep the detail instead of a bare HTTP code
|
||||
throw RpcError(errCode, "RPC error: " + response["error"].dump());
|
||||
}
|
||||
} catch (const json::exception&) {
|
||||
// Body wasn't valid JSON — fall through to generic HTTP error
|
||||
}
|
||||
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code));
|
||||
throw RpcError(errCode, "RPC error: HTTP " + std::to_string(http_code));
|
||||
}
|
||||
|
||||
json response = json::parse(response_data);
|
||||
|
||||
|
||||
if (response.contains("error") && !response["error"].is_null()) {
|
||||
std::string err_msg = response["error"]["message"].get<std::string>();
|
||||
throw std::runtime_error("RPC error: " + err_msg);
|
||||
int errCode = 0;
|
||||
std::string err_msg;
|
||||
if (response["error"].is_object()) {
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
err_msg = response["error"]["message"].get<std::string>();
|
||||
}
|
||||
if (err_msg.empty()) err_msg = response["error"].dump();
|
||||
throw RpcError(errCode, "RPC error: " + err_msg);
|
||||
}
|
||||
|
||||
return response["result"];
|
||||
@@ -229,6 +365,8 @@ json RPCClient::call(const std::string& method, const json& params, long timeout
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
// Temporarily override timeout
|
||||
long prevTimeout = 30L;
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_TIMEOUT, timeoutSec);
|
||||
@@ -257,20 +395,32 @@ json RPCClient::call(const std::string& method, const json& params, long timeout
|
||||
curl_easy_getinfo(impl_->curl, CURLINFO_RESPONSE_CODE, &http_code);
|
||||
|
||||
if (http_code != 200) {
|
||||
int errCode = 0;
|
||||
try {
|
||||
json response = json::parse(response_data);
|
||||
if (response.contains("error") && !response["error"].is_null()) {
|
||||
std::string err_msg = response["error"]["message"].get<std::string>();
|
||||
throw std::runtime_error(err_msg);
|
||||
if (response.contains("error") && response["error"].is_object()) {
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
throw RpcError(errCode, response["error"]["message"].get<std::string>());
|
||||
throw RpcError(errCode, "RPC error: " + response["error"].dump());
|
||||
}
|
||||
} catch (const json::exception&) {}
|
||||
throw std::runtime_error("RPC error: HTTP " + std::to_string(http_code));
|
||||
throw RpcError(errCode, "RPC error: HTTP " + std::to_string(http_code));
|
||||
}
|
||||
|
||||
json response = json::parse(response_data);
|
||||
if (response.contains("error") && !response["error"].is_null()) {
|
||||
std::string err_msg = response["error"]["message"].get<std::string>();
|
||||
throw std::runtime_error("RPC error: " + err_msg);
|
||||
int errCode = 0;
|
||||
std::string err_msg;
|
||||
if (response["error"].is_object()) {
|
||||
if (response["error"].contains("code") && response["error"]["code"].is_number_integer())
|
||||
errCode = response["error"]["code"].get<int>();
|
||||
if (response["error"].contains("message") && response["error"]["message"].is_string())
|
||||
err_msg = response["error"]["message"].get<std::string>();
|
||||
}
|
||||
if (err_msg.empty()) err_msg = response["error"].dump();
|
||||
throw RpcError(errCode, "RPC error: " + err_msg);
|
||||
}
|
||||
|
||||
return response["result"];
|
||||
@@ -288,6 +438,8 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
throw std::runtime_error("Not connected");
|
||||
}
|
||||
|
||||
emitRpcTrace(method);
|
||||
|
||||
json payload = makePayload(method, params);
|
||||
std::string body = payload.dump();
|
||||
std::string response_data;
|
||||
@@ -318,7 +470,14 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
||||
// Parse with ordered_json to preserve the daemon's original key order
|
||||
nlohmann::ordered_json oj = nlohmann::ordered_json::parse(response_data);
|
||||
if (oj.contains("error") && !oj["error"].is_null()) {
|
||||
std::string err_msg = oj["error"]["message"].get<std::string>();
|
||||
// A daemon error object normally has a string "message", but don't assume it — a malformed
|
||||
// error (missing/non-string message) must yield a clean RPC error, not a json type-exception.
|
||||
const auto& err = oj["error"];
|
||||
std::string err_msg;
|
||||
if (err.is_object() && err.contains("message") && err["message"].is_string())
|
||||
err_msg = err["message"].get<std::string>();
|
||||
else
|
||||
err_msg = err.dump();
|
||||
throw std::runtime_error("RPC error: " + err_msg);
|
||||
}
|
||||
|
||||
@@ -509,7 +668,8 @@ void RPCClient::stop(Callback cb, ErrorCallback err)
|
||||
|
||||
void RPCClient::rescanBlockchain(int startHeight, Callback cb, ErrorCallback err)
|
||||
{
|
||||
doRPC("rescanblockchain", {startHeight}, cb, err);
|
||||
// hush/komodo daemons expose this as "rescan <height>", not bitcoin's "rescanblockchain".
|
||||
doRPC("rescan", {startHeight}, cb, err);
|
||||
}
|
||||
|
||||
void RPCClient::z_validateAddress(const std::string& address, Callback cb, ErrorCallback err)
|
||||
@@ -617,7 +777,8 @@ void RPCClient::getInfo(UnifiedCallback cb)
|
||||
|
||||
void RPCClient::rescanBlockchain(int startHeight, UnifiedCallback cb)
|
||||
{
|
||||
doRPC("rescanblockchain", {startHeight},
|
||||
// hush/komodo daemons expose this as "rescan <height>", not bitcoin's "rescanblockchain".
|
||||
doRPC("rescan", {startHeight},
|
||||
[cb](const json& result) {
|
||||
if (cb) cb(result, "");
|
||||
},
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
#pragma once
|
||||
|
||||
#include "types.h"
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <stdexcept>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace dragonx {
|
||||
@@ -18,6 +20,21 @@ using json = nlohmann::json;
|
||||
using Callback = std::function<void(const json&)>;
|
||||
using ErrorCallback = std::function<void(const std::string&)>;
|
||||
|
||||
/**
|
||||
* @brief A JSON-RPC error carrying the daemon's numeric error code.
|
||||
*
|
||||
* what() preserves the exact human-readable message (so existing string matching
|
||||
* still works); `code` exposes the JSON-RPC error code — notably -28 (RPC_IN_WARMUP)
|
||||
* for a daemon still starting up. Derives from std::runtime_error, so every existing
|
||||
* `catch (const std::exception&)` continues to handle it unchanged.
|
||||
*/
|
||||
class RpcError : public std::runtime_error {
|
||||
public:
|
||||
RpcError(int errorCode, const std::string& message)
|
||||
: std::runtime_error(message), code(errorCode) {}
|
||||
int code = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief JSON-RPC client for dragonxd
|
||||
*
|
||||
@@ -25,6 +42,20 @@ using ErrorCallback = std::function<void(const std::string&)>;
|
||||
*/
|
||||
class RPCClient {
|
||||
public:
|
||||
using TraceCallback = std::function<void(const std::string& source, const std::string& method)>;
|
||||
|
||||
class TraceScope {
|
||||
public:
|
||||
explicit TraceScope(std::string source);
|
||||
~TraceScope();
|
||||
|
||||
TraceScope(const TraceScope&) = delete;
|
||||
TraceScope& operator=(const TraceScope&) = delete;
|
||||
|
||||
private:
|
||||
std::string previous_;
|
||||
};
|
||||
|
||||
RPCClient();
|
||||
~RPCClient();
|
||||
|
||||
@@ -43,6 +74,10 @@ public:
|
||||
bool connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password);
|
||||
|
||||
bool connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Disconnect from dragonxd
|
||||
*/
|
||||
@@ -53,6 +88,18 @@ public:
|
||||
*/
|
||||
bool isConnected() const { return connected_; }
|
||||
|
||||
/**
|
||||
* @brief Ask an in-flight call() to abort as soon as possible (thread-safe).
|
||||
*
|
||||
* Set from another thread (e.g. before stop()-ing the worker on disconnect/shutdown):
|
||||
* a curl progress callback aborts the transfer, so a blocked curl_easy_perform returns
|
||||
* promptly instead of freezing the UI thread's join() until the request timeout. Cleared
|
||||
* on the next connect(); abortRequested() is read by the progress callback.
|
||||
*/
|
||||
void requestAbort();
|
||||
void clearAbort();
|
||||
bool abortRequested() const noexcept { return abort_.load(std::memory_order_relaxed); }
|
||||
|
||||
/**
|
||||
* @brief True if the last connect() succeeded but daemon returned a warmup error.
|
||||
* The curl handle is valid and auth succeeded — RPC calls will throw warmup errors
|
||||
@@ -70,6 +117,13 @@ public:
|
||||
* @brief Get the error message from the last failed connect() attempt.
|
||||
*/
|
||||
const std::string& getLastConnectError() const { return last_connect_error_; }
|
||||
json getLastConnectInfo() const;
|
||||
|
||||
static void setTraceCallback(TraceCallback callback);
|
||||
static void setTraceEnabled(bool enabled);
|
||||
static bool isTraceEnabled();
|
||||
static std::string currentTraceSource();
|
||||
static void setTraceSource(std::string source);
|
||||
|
||||
/**
|
||||
* @brief Make a raw RPC call
|
||||
@@ -195,9 +249,11 @@ private:
|
||||
std::string port_;
|
||||
std::string auth_; // Base64 encoded "user:password"
|
||||
bool connected_ = false;
|
||||
std::atomic<bool> abort_{false}; // set cross-thread to abort an in-flight transfer
|
||||
bool warming_up_ = false;
|
||||
std::string warmup_status_;
|
||||
std::string last_connect_error_;
|
||||
json last_connect_info_;
|
||||
mutable std::recursive_mutex curl_mutex_; // serializes all curl handle access
|
||||
|
||||
// HTTP client (implementation hidden)
|
||||
|
||||
@@ -97,6 +97,18 @@ bool RPCWorker::hasPendingResults() const
|
||||
return !results_.empty();
|
||||
}
|
||||
|
||||
std::size_t RPCWorker::pendingTaskCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(taskMtx_);
|
||||
return tasks_.size();
|
||||
}
|
||||
|
||||
std::size_t RPCWorker::pendingResultCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(resultMtx_);
|
||||
return results_.size();
|
||||
}
|
||||
|
||||
void RPCWorker::run()
|
||||
{
|
||||
while (true) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
@@ -69,6 +70,8 @@ public:
|
||||
|
||||
/// True when there are completed results waiting for the main thread.
|
||||
bool hasPendingResults() const;
|
||||
std::size_t pendingTaskCount() const;
|
||||
std::size_t pendingResultCount() const;
|
||||
|
||||
/// True when the worker thread is running.
|
||||
bool isRunning() const { return running_.load(std::memory_order_relaxed); }
|
||||
@@ -80,7 +83,7 @@ private:
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
// ---- Task queue (produced by main thread, consumed by worker) ----
|
||||
std::mutex taskMtx_;
|
||||
mutable std::mutex taskMtx_;
|
||||
std::condition_variable taskCv_;
|
||||
std::deque<WorkFn> tasks_;
|
||||
|
||||
|
||||
1432
src/services/network_refresh_service.cpp
Normal file
1432
src/services/network_refresh_service.cpp
Normal file
File diff suppressed because it is too large
Load Diff
397
src/services/network_refresh_service.h
Normal file
397
src/services/network_refresh_service.h
Normal file
@@ -0,0 +1,397 @@
|
||||
#pragma once
|
||||
|
||||
#include "chat/chat_protocol.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "refresh_scheduler.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class NetworkRefreshService {
|
||||
public:
|
||||
using Timer = RefreshScheduler::Timer;
|
||||
using Intervals = RefreshScheduler::Intervals;
|
||||
|
||||
class RefreshRpcGateway {
|
||||
public:
|
||||
virtual ~RefreshRpcGateway() = default;
|
||||
virtual nlohmann::json call(const std::string& method,
|
||||
const nlohmann::json& params) = 0;
|
||||
};
|
||||
|
||||
enum class Job {
|
||||
Core,
|
||||
Addresses,
|
||||
Transactions,
|
||||
Mining,
|
||||
Peers,
|
||||
Price,
|
||||
Encryption,
|
||||
ConnectionInit,
|
||||
Count
|
||||
};
|
||||
|
||||
struct DispatchTicket {
|
||||
Job job = Job::Core;
|
||||
std::uint64_t generation = 0;
|
||||
bool accepted = false;
|
||||
};
|
||||
|
||||
struct JobStats {
|
||||
std::uint64_t started = 0;
|
||||
std::uint64_t finished = 0;
|
||||
std::uint64_t skippedInFlight = 0;
|
||||
std::uint64_t skippedQueuePressure = 0;
|
||||
std::uint64_t staleCallbacks = 0;
|
||||
std::size_t lastQueueDepth = 0;
|
||||
};
|
||||
|
||||
struct EnqueueResult {
|
||||
DispatchTicket ticket;
|
||||
bool enqueued = false;
|
||||
std::size_t queueDepth = 0;
|
||||
};
|
||||
|
||||
struct ConnectionInfoResult {
|
||||
bool ok = false;
|
||||
std::optional<int> daemonVersion;
|
||||
std::optional<int> protocolVersion;
|
||||
std::optional<int> p2pPort;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
std::optional<int> blocks;
|
||||
};
|
||||
|
||||
struct WalletEncryptionResult {
|
||||
bool ok = false;
|
||||
bool encrypted = false;
|
||||
std::int64_t unlockedUntil = 0;
|
||||
};
|
||||
|
||||
struct WarmupPollResult {
|
||||
bool ready = false;
|
||||
ConnectionInfoResult info;
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
struct ConnectionInitResult {
|
||||
ConnectionInfoResult info;
|
||||
WalletEncryptionResult encryption;
|
||||
};
|
||||
|
||||
struct CoreRefreshResult {
|
||||
bool balanceOk = false;
|
||||
std::optional<double> shieldedBalance;
|
||||
std::optional<double> transparentBalance;
|
||||
std::optional<double> totalBalance;
|
||||
bool blockchainOk = false;
|
||||
std::optional<int> blocks;
|
||||
std::optional<int> headers;
|
||||
std::optional<std::string> bestBlockHash;
|
||||
std::optional<double> verificationProgress;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
std::optional<double> localHashrate;
|
||||
bool miningOk = false;
|
||||
std::optional<bool> generate;
|
||||
std::optional<int> genproclimit;
|
||||
std::optional<int> blocks;
|
||||
std::optional<double> difficulty;
|
||||
std::optional<double> networkHashrate;
|
||||
std::optional<std::string> chain;
|
||||
double daemonMemoryMb = 0.0;
|
||||
};
|
||||
|
||||
struct PeerRefreshResult {
|
||||
std::vector<PeerInfo> peers;
|
||||
std::vector<BannedPeer> bannedPeers;
|
||||
};
|
||||
|
||||
struct PriceRefreshResult {
|
||||
MarketInfo market;
|
||||
};
|
||||
|
||||
struct PriceHttpResponse {
|
||||
bool transportOk = false;
|
||||
long httpStatus = 0;
|
||||
std::string body;
|
||||
std::string transportError;
|
||||
};
|
||||
|
||||
struct PriceHttpResult {
|
||||
std::optional<PriceRefreshResult> price;
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
struct AddressRefreshResult {
|
||||
std::vector<AddressInfo> shieldedAddresses;
|
||||
std::vector<AddressInfo> transparentAddresses;
|
||||
};
|
||||
|
||||
struct AddressRefreshSnapshot {
|
||||
std::unordered_map<std::string, bool> shieldedSpendingKeys;
|
||||
};
|
||||
|
||||
struct TransactionViewCacheEntry {
|
||||
std::string from_address;
|
||||
std::int64_t timestamp = 0;
|
||||
int confirmations = 0;
|
||||
struct Output {
|
||||
std::string address;
|
||||
double value = 0.0;
|
||||
std::string memo;
|
||||
std::size_t position = 0;
|
||||
};
|
||||
std::vector<Output> outgoing_outputs;
|
||||
};
|
||||
|
||||
using TransactionViewCache = std::unordered_map<std::string, TransactionViewCacheEntry>;
|
||||
|
||||
struct TransactionRefreshSnapshot {
|
||||
std::vector<std::string> shieldedAddresses;
|
||||
std::unordered_set<std::string> fullyEnrichedTxids;
|
||||
TransactionViewCache viewTxCache;
|
||||
std::unordered_set<std::string> sendTxids;
|
||||
std::unordered_set<std::string> pendingOpids;
|
||||
std::vector<TransactionInfo> previousTransactions;
|
||||
std::set<std::string> miningAddresses;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
std::size_t shieldedScanStartIndex = 0;
|
||||
std::size_t maxShieldedReceiveScans = 0;
|
||||
// How many blocks the tip may advance past an address's last scan before it counts as stale
|
||||
// and needs re-scanning. 0 = strict (must be scanned at the exact current tip). A small
|
||||
// tolerance lets a multi-cycle pass over many shielded addresses COMPLETE even though new
|
||||
// blocks arrive mid-pass — otherwise the "scanned at tip" bar moves every block and the scan
|
||||
// (and the transactions_dirty_ flag it drives) never finishes. It also naturally throttles
|
||||
// full rescans to roughly once per `tolerance` blocks.
|
||||
int shieldedScanTipTolerance = 0;
|
||||
};
|
||||
|
||||
struct TransactionRefreshResult {
|
||||
std::vector<TransactionInfo> transactions;
|
||||
std::vector<chat::HushChatTransactionMetadata> hushChatMetadata;
|
||||
int blockHeight = -1;
|
||||
TransactionViewCache newViewTxEntries;
|
||||
std::size_t nextShieldedScanStartIndex = 0;
|
||||
std::size_t shieldedAddressesScanned = 0;
|
||||
std::size_t shieldedAddressCount = 0;
|
||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||
bool shieldedScanComplete = true;
|
||||
};
|
||||
|
||||
struct OperationStatusPollResult {
|
||||
std::vector<std::string> doneOpids;
|
||||
std::vector<std::string> staleOpids;
|
||||
std::vector<std::string> successTxids;
|
||||
std::unordered_map<std::string, std::string> successTxidsByOpid;
|
||||
std::vector<std::string> failureMessages;
|
||||
std::unordered_map<std::string, std::string> failureByOpid; // opid -> error message
|
||||
bool anySuccess = false;
|
||||
};
|
||||
|
||||
struct TransactionCacheUpdate {
|
||||
TransactionViewCache& viewTxCache;
|
||||
std::unordered_set<std::string>& sendTxids;
|
||||
std::vector<TransactionInfo>& confirmedTxCache;
|
||||
std::unordered_set<std::string>& confirmedTxIds;
|
||||
int& confirmedCacheBlock;
|
||||
int& lastTxBlockHeight;
|
||||
};
|
||||
|
||||
static Intervals intervalsForPage(ui::NavPage page) { return RefreshScheduler::intervalsForPage(page); }
|
||||
|
||||
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
|
||||
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
|
||||
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
|
||||
static ConnectionInitResult collectConnectionInitResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
|
||||
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
||||
bool balanceOk,
|
||||
const nlohmann::json& blockInfo,
|
||||
bool blockOk);
|
||||
// includeBalance=false skips z_gettotalbalance (which takes the wallet lock + cs_main) and only
|
||||
// fetches getblockchaininfo — used while syncing, where the balance is incomplete anyway and the
|
||||
// wallet should minimise lock contention with block connection.
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance = true);
|
||||
static MiningRefreshResult parseMiningRefreshResult(const nlohmann::json& miningInfo,
|
||||
bool miningOk,
|
||||
const nlohmann::json& localHashrate,
|
||||
bool hashrateOk,
|
||||
double daemonMemoryMb);
|
||||
static MiningRefreshResult collectMiningRefreshResult(RefreshRpcGateway& rpc,
|
||||
double daemonMemoryMb,
|
||||
bool includeSlowRefresh,
|
||||
bool includeLocalHashrate = true);
|
||||
static PeerRefreshResult parsePeerRefreshResult(const nlohmann::json& peers,
|
||||
const nlohmann::json& bannedPeers);
|
||||
static PeerRefreshResult collectPeerRefreshResult(RefreshRpcGateway& rpc);
|
||||
static std::optional<PriceRefreshResult> parseCoinGeckoPriceResponse(const std::string& response,
|
||||
std::time_t fetchedAt);
|
||||
static PriceHttpResult parsePriceHttpResponse(const PriceHttpResponse& response,
|
||||
std::time_t fetchedAt);
|
||||
static AddressInfo buildShieldedAddressInfo(const std::string& address,
|
||||
const nlohmann::json& validation,
|
||||
bool validationSucceeded);
|
||||
static AddressInfo buildTransparentAddressInfo(const std::string& address);
|
||||
static std::vector<AddressInfo> parseTransparentAddressList(const nlohmann::json& addressList);
|
||||
static void applyShieldedBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static AddressRefreshSnapshot buildAddressRefreshSnapshot(const WalletState& state);
|
||||
static AddressRefreshResult collectAddressRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const AddressRefreshSnapshot& snapshot = {});
|
||||
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
|
||||
const TransactionViewCache& viewTxCache,
|
||||
const std::unordered_set<std::string>& sendTxids);
|
||||
static void appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const nlohmann::json& result,
|
||||
const std::set<std::string>& miningAddresses = {});
|
||||
static void appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const std::string& address,
|
||||
const nlohmann::json& received,
|
||||
const std::set<std::string>& miningAddresses = {},
|
||||
std::unordered_map<std::string, std::vector<chat::HushChatMemoOutput>>* chatMemoOutputs = nullptr);
|
||||
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const TransactionViewCacheEntry& entry);
|
||||
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
||||
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int maxViewTransactionsPerCycle);
|
||||
static TransactionRefreshResult collectRecentTransactionRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int pageSize = 100);
|
||||
static OperationStatusPollResult parseOperationStatusPoll(const nlohmann::json& result,
|
||||
const std::vector<std::string>& requestedOpids);
|
||||
|
||||
static void applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result);
|
||||
static void applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result);
|
||||
static void applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result);
|
||||
static void applyCoreRefreshResult(WalletState& state,
|
||||
const CoreRefreshResult& result,
|
||||
std::time_t updatedAt);
|
||||
static void applyMiningRefreshResult(WalletState& state,
|
||||
const MiningRefreshResult& result,
|
||||
std::time_t updatedAt);
|
||||
static void applyPeerRefreshResult(WalletState& state,
|
||||
PeerRefreshResult&& result,
|
||||
std::time_t updatedAt);
|
||||
static void markPriceRefreshStarted(WalletState& state);
|
||||
static void applyPriceRefreshResult(WalletState& state,
|
||||
const PriceRefreshResult& result,
|
||||
std::chrono::steady_clock::time_point fetchedAt);
|
||||
static void applyPriceRefreshFailure(WalletState& state,
|
||||
const std::string& errorMessage);
|
||||
static void applyAddressRefreshResult(WalletState& state,
|
||||
AddressRefreshResult&& result);
|
||||
static void applyTransactionRefreshResult(WalletState& state,
|
||||
TransactionCacheUpdate cacheUpdate,
|
||||
TransactionRefreshResult&& result,
|
||||
std::time_t updatedAt);
|
||||
|
||||
void applyPage(ui::NavPage page) { scheduler_.applyPage(page); }
|
||||
void setIntervals(Intervals intervals) { scheduler_.setIntervals(intervals); }
|
||||
const Intervals& intervals() const { return scheduler_.intervals(); }
|
||||
|
||||
void tick(float deltaSeconds) { scheduler_.tick(deltaSeconds); }
|
||||
bool isDue(Timer timer) const { return scheduler_.isDue(timer); }
|
||||
bool consumeDue(Timer timer) { return scheduler_.consumeDue(timer); }
|
||||
void reset(Timer timer) { scheduler_.reset(timer); }
|
||||
void markDue(Timer timer) { scheduler_.markDue(timer); }
|
||||
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
|
||||
float timer(Timer timer) const { return scheduler_.timer(timer); }
|
||||
float interval(Timer timer) const { return scheduler_.interval(timer); }
|
||||
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
|
||||
void markWalletMutationRefresh() { scheduler_.markWalletMutationRefresh(); }
|
||||
void resetTxAge() { scheduler_.resetTxAge(); }
|
||||
bool shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsDirty) const;
|
||||
|
||||
bool beginJob(Job job);
|
||||
bool beginJob(Job job, std::size_t queuedWork, std::size_t maxQueuedWork);
|
||||
void finishJob(Job job);
|
||||
bool jobInProgress(Job job) const;
|
||||
void resetJobs();
|
||||
DispatchTicket beginDispatch(Job job, std::size_t queuedWork = 0, std::size_t maxQueuedWork = 0);
|
||||
bool completeDispatch(const DispatchTicket& ticket);
|
||||
void cancelDispatch(const DispatchTicket& ticket);
|
||||
JobStats stats(Job job) const;
|
||||
|
||||
template <typename Worker, typename WorkFn>
|
||||
EnqueueResult enqueue(Job job, Worker& worker, WorkFn&& work, std::size_t maxQueuedWork = 0)
|
||||
{
|
||||
std::size_t queueDepth = worker.pendingTaskCount();
|
||||
auto ticket = beginDispatch(job, queueDepth, maxQueuedWork);
|
||||
if (!ticket.accepted) return {ticket, false, queueDepth};
|
||||
|
||||
worker.post([this, ticket, work = std::forward<WorkFn>(work)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
rpc::RPCWorker::MainCb mainCallback;
|
||||
try {
|
||||
mainCallback = work();
|
||||
} catch (...) {
|
||||
mainCallback = nullptr;
|
||||
}
|
||||
return [this, ticket, mainCallback = std::move(mainCallback)]() mutable {
|
||||
if (!completeDispatch(ticket)) return;
|
||||
if (mainCallback) mainCallback();
|
||||
};
|
||||
});
|
||||
|
||||
return {ticket, true, queueDepth};
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<bool>& jobFlag(Job job);
|
||||
const std::atomic<bool>& jobFlag(Job job) const;
|
||||
static std::size_t jobIndex(Job job);
|
||||
|
||||
RefreshScheduler scheduler_;
|
||||
std::atomic<bool> coreInProgress_{false};
|
||||
std::atomic<bool> addressesInProgress_{false};
|
||||
std::atomic<bool> transactionsInProgress_{false};
|
||||
std::atomic<bool> miningInProgress_{false};
|
||||
std::atomic<bool> peersInProgress_{false};
|
||||
std::atomic<bool> priceInProgress_{false};
|
||||
std::atomic<bool> encryptionInProgress_{false};
|
||||
std::atomic<bool> connectionInitInProgress_{false};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> generations_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> started_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> finished_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> skippedInFlight_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> skippedQueuePressure_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> staleCallbacks_{};
|
||||
std::array<std::atomic<std::size_t>, static_cast<std::size_t>(Job::Count)> lastQueueDepth_{};
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
161
src/services/refresh_scheduler.cpp
Normal file
161
src/services/refresh_scheduler.cpp
Normal file
@@ -0,0 +1,161 @@
|
||||
#include "refresh_scheduler.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
RefreshScheduler::Intervals RefreshScheduler::intervalsForPage(ui::NavPage page)
|
||||
{
|
||||
using NP = ui::NavPage;
|
||||
// Intervals are {core, transactions, addresses, peers} in seconds (0 = disabled).
|
||||
// The peers value keeps the status-bar peer count current on EVERY tab — previously it was 0
|
||||
// off the Peers tab, so the count never updated until you opened Peers. A slow 20s cadence is
|
||||
// plenty for a status-bar number; the Peers tab itself stays fast (5s) for its live list. During
|
||||
// sync this is overridden by kSyncProfile (peers 0) so it can't contend with block download.
|
||||
switch (page) {
|
||||
case NP::Overview: return {2.0f, 10.0f, 15.0f, 20.0f};
|
||||
case NP::Send: return {3.0f, 10.0f, 5.0f, 20.0f};
|
||||
case NP::Receive: return {5.0f, 15.0f, 5.0f, 20.0f};
|
||||
case NP::History: return {5.0f, 3.0f, 15.0f, 20.0f};
|
||||
case NP::Mining: return {5.0f, 15.0f, 15.0f, 20.0f};
|
||||
case NP::Peers: return {5.0f, 15.0f, 15.0f, 5.0f};
|
||||
case NP::Market: return {5.0f, 15.0f, 15.0f, 20.0f};
|
||||
case NP::Console: return {10.0f, 30.0f, 30.0f, 30.0f};
|
||||
default: return {5.0f, 15.0f, 15.0f, 20.0f};
|
||||
}
|
||||
}
|
||||
|
||||
void RefreshScheduler::applyPage(ui::NavPage page)
|
||||
{
|
||||
setIntervals(intervalsForPage(page));
|
||||
}
|
||||
|
||||
void RefreshScheduler::setIntervals(Intervals intervals)
|
||||
{
|
||||
intervals_ = intervals;
|
||||
}
|
||||
|
||||
void RefreshScheduler::tick(float deltaSeconds)
|
||||
{
|
||||
float delta = std::max(0.0f, deltaSeconds);
|
||||
timers_.core += delta;
|
||||
timers_.transactions += delta;
|
||||
timers_.addresses += delta;
|
||||
timers_.peers += delta;
|
||||
timers_.price += delta;
|
||||
timers_.fast += delta;
|
||||
timers_.txAge += delta;
|
||||
timers_.opid += delta;
|
||||
}
|
||||
|
||||
bool RefreshScheduler::isDue(Timer timer) const
|
||||
{
|
||||
float timerInterval = interval(timer);
|
||||
return timerInterval > 0.0f && timerRef(timer) >= timerInterval;
|
||||
}
|
||||
|
||||
bool RefreshScheduler::consumeDue(Timer timer)
|
||||
{
|
||||
if (!isDue(timer)) return false;
|
||||
reset(timer);
|
||||
return true;
|
||||
}
|
||||
|
||||
void RefreshScheduler::reset(Timer timer)
|
||||
{
|
||||
timerRef(timer) = 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markDue(Timer timer)
|
||||
{
|
||||
float timerInterval = interval(timer);
|
||||
timerRef(timer) = timerInterval > 0.0f ? timerInterval : 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::setTimer(Timer timer, float seconds)
|
||||
{
|
||||
timerRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
float RefreshScheduler::timer(Timer timer) const
|
||||
{
|
||||
return timerRef(timer);
|
||||
}
|
||||
|
||||
float RefreshScheduler::interval(Timer timer) const
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return intervals_.core;
|
||||
case Timer::Transactions: return intervals_.transactions;
|
||||
case Timer::Addresses: return intervals_.addresses;
|
||||
case Timer::Peers: return intervals_.peers;
|
||||
case Timer::Price: return kPrice;
|
||||
case Timer::Fast: return kFast;
|
||||
case Timer::TxAge: return kTxMaxAge;
|
||||
case Timer::Opid: return kOpidPoll;
|
||||
}
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markImmediateRefresh()
|
||||
{
|
||||
markDue(Timer::Core);
|
||||
markDue(Timer::Transactions);
|
||||
markDue(Timer::Addresses);
|
||||
markDue(Timer::Peers);
|
||||
}
|
||||
|
||||
void RefreshScheduler::markWalletMutationRefresh()
|
||||
{
|
||||
markDue(Timer::Core);
|
||||
markDue(Timer::Transactions);
|
||||
markDue(Timer::Addresses);
|
||||
}
|
||||
|
||||
void RefreshScheduler::resetTxAge()
|
||||
{
|
||||
reset(Timer::TxAge);
|
||||
}
|
||||
|
||||
bool RefreshScheduler::shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsDirty) const
|
||||
{
|
||||
return lastTxBlockHeight < 0
|
||||
|| currentBlockHeight != lastTxBlockHeight
|
||||
|| transactionsDirty;
|
||||
}
|
||||
|
||||
float& RefreshScheduler::timerRef(Timer timer)
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return timers_.core;
|
||||
case Timer::Transactions: return timers_.transactions;
|
||||
case Timer::Addresses: return timers_.addresses;
|
||||
case Timer::Peers: return timers_.peers;
|
||||
case Timer::Price: return timers_.price;
|
||||
case Timer::Fast: return timers_.fast;
|
||||
case Timer::TxAge: return timers_.txAge;
|
||||
case Timer::Opid: return timers_.opid;
|
||||
}
|
||||
return timers_.core;
|
||||
}
|
||||
|
||||
const float& RefreshScheduler::timerRef(Timer timer) const
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return timers_.core;
|
||||
case Timer::Transactions: return timers_.transactions;
|
||||
case Timer::Addresses: return timers_.addresses;
|
||||
case Timer::Peers: return timers_.peers;
|
||||
case Timer::Price: return timers_.price;
|
||||
case Timer::Fast: return timers_.fast;
|
||||
case Timer::TxAge: return timers_.txAge;
|
||||
case Timer::Opid: return timers_.opid;
|
||||
}
|
||||
return timers_.core;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
88
src/services/refresh_scheduler.h
Normal file
88
src/services/refresh_scheduler.h
Normal file
@@ -0,0 +1,88 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class RefreshScheduler {
|
||||
public:
|
||||
enum class Timer {
|
||||
Core,
|
||||
Transactions,
|
||||
Addresses,
|
||||
Peers,
|
||||
Price,
|
||||
Fast,
|
||||
TxAge,
|
||||
Opid
|
||||
};
|
||||
|
||||
struct Intervals {
|
||||
float core;
|
||||
float transactions;
|
||||
float addresses;
|
||||
float peers;
|
||||
};
|
||||
|
||||
static constexpr float kCoreDefault = 5.0f;
|
||||
static constexpr float kAddressDefault = 15.0f;
|
||||
static constexpr float kTransactionDefault = 10.0f;
|
||||
static constexpr float kPeerDefault = 10.0f;
|
||||
static constexpr float kPrice = 60.0f;
|
||||
static constexpr float kFast = 1.0f;
|
||||
static constexpr float kTxMaxAge = 15.0f;
|
||||
static constexpr float kOpidPoll = 2.0f;
|
||||
|
||||
// Low-impact polling profile applied while the daemon is SYNCING, regardless of the active tab.
|
||||
// Only a slow progress poll runs (core, 10s); transactions/addresses/peers are disabled (0).
|
||||
// Frequent getpeerinfo, per-block transaction scans, and balance polls all contend for the
|
||||
// daemon's cs_main lock and measurably slow block connection during sync — this is exactly why
|
||||
// the lightweight Console tab syncs faster than the Peers tab. Reverts to the per-tab profile
|
||||
// once sync completes. (Tx/address/balance data is incomplete mid-sync anyway.)
|
||||
static constexpr Intervals kSyncProfile{10.0f, 0.0f, 0.0f, 0.0f};
|
||||
|
||||
static Intervals intervalsForPage(ui::NavPage page);
|
||||
|
||||
void applyPage(ui::NavPage page);
|
||||
void setIntervals(Intervals intervals);
|
||||
const Intervals& intervals() const { return intervals_; }
|
||||
|
||||
void tick(float deltaSeconds);
|
||||
|
||||
bool isDue(Timer timer) const;
|
||||
bool consumeDue(Timer timer);
|
||||
void reset(Timer timer);
|
||||
void markDue(Timer timer);
|
||||
void setTimer(Timer timer, float seconds);
|
||||
float timer(Timer timer) const;
|
||||
float interval(Timer timer) const;
|
||||
|
||||
void markImmediateRefresh();
|
||||
void markWalletMutationRefresh();
|
||||
void resetTxAge();
|
||||
bool shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsDirty) const;
|
||||
|
||||
private:
|
||||
struct Timers {
|
||||
float core = 0.0f;
|
||||
float transactions = 0.0f;
|
||||
float addresses = 0.0f;
|
||||
float peers = 0.0f;
|
||||
float price = 0.0f;
|
||||
float fast = 0.0f;
|
||||
float txAge = 0.0f;
|
||||
float opid = 0.0f;
|
||||
};
|
||||
|
||||
float& timerRef(Timer timer);
|
||||
const float& timerRef(Timer timer) const;
|
||||
|
||||
Intervals intervals_{kCoreDefault, kTransactionDefault, kAddressDefault, kPeerDefault};
|
||||
Timers timers_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
127
src/services/wallet_security_controller.cpp
Normal file
127
src/services/wallet_security_controller.cpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#include "wallet_security_controller.h"
|
||||
#include "../util/secure_vault.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
WalletSecurityController::~WalletSecurityController()
|
||||
{
|
||||
clearDeferredEncryption();
|
||||
}
|
||||
|
||||
void WalletSecurityController::beginDeferredEncryption(std::string passphrase, std::string pin)
|
||||
{
|
||||
clearDeferredEncryption();
|
||||
deferred_.passphrase = std::move(passphrase);
|
||||
deferred_.pin = std::move(pin);
|
||||
deferred_.pending = true;
|
||||
deferred_.lastConnectAttempt = -10.0;
|
||||
}
|
||||
|
||||
WalletSecurityController::DeferredEncryptionSnapshot WalletSecurityController::deferredEncryption() const
|
||||
{
|
||||
return {deferred_.passphrase, deferred_.pin};
|
||||
}
|
||||
|
||||
bool WalletSecurityController::shouldAttemptDeferredConnect(double nowSeconds, double minIntervalSeconds)
|
||||
{
|
||||
if (!deferred_.pending) return false;
|
||||
if (nowSeconds - deferred_.lastConnectAttempt < minIntervalSeconds) return false;
|
||||
deferred_.lastConnectAttempt = nowSeconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WalletSecurityController::clearDeferredEncryption()
|
||||
{
|
||||
secureClear(deferred_.passphrase);
|
||||
secureClear(deferred_.pin);
|
||||
deferred_.pending = false;
|
||||
deferred_.lastConnectAttempt = -10.0;
|
||||
}
|
||||
|
||||
WalletSecurityController::DeferredEncryptionResult WalletSecurityController::runDeferredEncryption(
|
||||
DeferredEncryptionSnapshot request, RpcGateway& rpc, VaultGateway* vault)
|
||||
{
|
||||
DeferredEncryptionResult result;
|
||||
result.pinProvided = !request.pin.empty();
|
||||
|
||||
std::string error;
|
||||
if (!rpc.encryptWallet(request.passphrase, error)) {
|
||||
result.error = error.empty() ? "encryptwallet failed" : error;
|
||||
secureClear(request.passphrase);
|
||||
secureClear(request.pin);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.encrypted = true;
|
||||
result.restartRequired = true;
|
||||
if (result.pinProvided && vault) {
|
||||
result.pinStored = vault->storePin(request.pin, request.passphrase);
|
||||
}
|
||||
|
||||
secureClear(request.passphrase);
|
||||
secureClear(request.pin);
|
||||
return result;
|
||||
}
|
||||
|
||||
WalletSecurityController::PinValidationResult WalletSecurityController::validatePinSetup(
|
||||
const std::string& pin, const std::string& confirmation, bool allowEmpty, std::size_t minLength)
|
||||
{
|
||||
if (pin.empty() && confirmation.empty()) {
|
||||
return allowEmpty
|
||||
? PinValidationResult{true, PinValidationError::None, ""}
|
||||
: PinValidationResult{false, PinValidationError::Empty, "PIN is required"};
|
||||
}
|
||||
if (pin != confirmation) {
|
||||
return {false, PinValidationError::Mismatch, "PINs do not match"};
|
||||
}
|
||||
if (pin.size() < minLength) {
|
||||
return {false, PinValidationError::TooShort, "PIN is too short"};
|
||||
}
|
||||
for (unsigned char c : pin) {
|
||||
if (!std::isdigit(c)) {
|
||||
return {false, PinValidationError::NonDigit, "PIN must contain only digits"};
|
||||
}
|
||||
}
|
||||
return {true, PinValidationError::None, ""};
|
||||
}
|
||||
|
||||
WalletSecurityController::KeyKind WalletSecurityController::classifyAddress(const std::string& address)
|
||||
{
|
||||
return !address.empty() && address[0] == 'z' ? KeyKind::Shielded : KeyKind::Transparent;
|
||||
}
|
||||
|
||||
WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(const std::string& key)
|
||||
{
|
||||
return !key.empty() && key[0] == 's' ? KeyKind::Shielded : KeyKind::Transparent;
|
||||
}
|
||||
|
||||
const char* WalletSecurityController::importSuccessMessage(KeyKind kind)
|
||||
{
|
||||
return kind == KeyKind::Shielded
|
||||
? "Z-address key imported successfully. Wallet is rescanning."
|
||||
: "T-address key imported successfully. Wallet is rescanning.";
|
||||
}
|
||||
|
||||
std::string WalletSecurityController::decryptExportFileName(std::uint64_t timestampSeconds)
|
||||
{
|
||||
char buffer[64];
|
||||
snprintf(buffer, sizeof(buffer), "obsidiandecryptexport%llu",
|
||||
static_cast<unsigned long long>(timestampSeconds));
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
void WalletSecurityController::secureClear(std::string& value)
|
||||
{
|
||||
if (!value.empty()) {
|
||||
util::SecureVault::secureZero(&value[0], value.size());
|
||||
value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
93
src/services/wallet_security_controller.h
Normal file
93
src/services/wallet_security_controller.h
Normal file
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityController {
|
||||
public:
|
||||
enum class PinValidationError {
|
||||
None,
|
||||
Empty,
|
||||
Mismatch,
|
||||
TooShort,
|
||||
NonDigit
|
||||
};
|
||||
|
||||
struct PinValidationResult {
|
||||
bool ok = false;
|
||||
PinValidationError error = PinValidationError::None;
|
||||
const char* message = "";
|
||||
};
|
||||
|
||||
struct DeferredEncryptionSnapshot {
|
||||
std::string passphrase;
|
||||
std::string pin;
|
||||
};
|
||||
|
||||
class RpcGateway {
|
||||
public:
|
||||
virtual ~RpcGateway() = default;
|
||||
virtual bool encryptWallet(const std::string& passphrase, std::string& error) = 0;
|
||||
virtual bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool importWallet(const std::string& filePath, long timeoutSeconds, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class VaultGateway {
|
||||
public:
|
||||
virtual ~VaultGateway() = default;
|
||||
virtual bool storePin(const std::string& pin, const std::string& passphrase) = 0;
|
||||
};
|
||||
|
||||
enum class KeyKind {
|
||||
Transparent,
|
||||
Shielded
|
||||
};
|
||||
|
||||
struct DeferredEncryptionResult {
|
||||
bool encrypted = false;
|
||||
bool pinProvided = false;
|
||||
bool pinStored = false;
|
||||
bool restartRequired = false;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
~WalletSecurityController();
|
||||
|
||||
void beginDeferredEncryption(std::string passphrase, std::string pin = {});
|
||||
bool hasDeferredEncryption() const { return deferred_.pending; }
|
||||
DeferredEncryptionSnapshot deferredEncryption() const;
|
||||
bool shouldAttemptDeferredConnect(double nowSeconds, double minIntervalSeconds = 3.0);
|
||||
void clearDeferredEncryption();
|
||||
|
||||
DeferredEncryptionResult runDeferredEncryption(DeferredEncryptionSnapshot request,
|
||||
RpcGateway& rpc,
|
||||
VaultGateway* vault);
|
||||
|
||||
static PinValidationResult validatePinSetup(const std::string& pin,
|
||||
const std::string& confirmation,
|
||||
bool allowEmpty = false,
|
||||
std::size_t minLength = 4);
|
||||
static KeyKind classifyAddress(const std::string& address);
|
||||
static KeyKind classifyPrivateKey(const std::string& key);
|
||||
static const char* importSuccessMessage(KeyKind kind);
|
||||
static std::string decryptExportFileName(std::uint64_t timestampSeconds);
|
||||
static void secureClear(std::string& value);
|
||||
|
||||
private:
|
||||
struct DeferredEncryptionState {
|
||||
std::string passphrase;
|
||||
std::string pin;
|
||||
bool pending = false;
|
||||
double lastConnectAttempt = -10.0;
|
||||
};
|
||||
|
||||
DeferredEncryptionState deferred_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
114
src/services/wallet_security_workflow.cpp
Normal file
114
src/services/wallet_security_workflow.cpp
Normal file
@@ -0,0 +1,114 @@
|
||||
#include "wallet_security_workflow.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
void WalletSecurityWorkflow::reset()
|
||||
{
|
||||
state_ = {};
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::start(std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
state_.phase = DecryptPhase::Working;
|
||||
state_.step = DecryptStep::Unlock;
|
||||
state_.status = stepStatus(DecryptStep::Unlock);
|
||||
state_.inProgress = true;
|
||||
state_.stepStarted = now;
|
||||
state_.overallStarted = now;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::advanceTo(DecryptStep step, std::string status,
|
||||
std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
state_.phase = DecryptPhase::Working;
|
||||
state_.step = step;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = true;
|
||||
state_.stepStarted = now;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::failEntry(std::string status)
|
||||
{
|
||||
state_.phase = DecryptPhase::PassphraseEntry;
|
||||
state_.step = DecryptStep::Unlock;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = false;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::fail(std::string status)
|
||||
{
|
||||
state_.phase = DecryptPhase::Error;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = false;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::closeDialogForImport()
|
||||
{
|
||||
state_.inProgress = false;
|
||||
state_.importActive = true;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::finishImport()
|
||||
{
|
||||
state_.importActive = false;
|
||||
}
|
||||
|
||||
WalletSecurityWorkflow::WalletFilePlan WalletSecurityWorkflow::planWalletFiles(
|
||||
const std::string& dataDir,
|
||||
std::uint64_t timestampSeconds)
|
||||
{
|
||||
WalletFilePlan plan;
|
||||
plan.dataDir = dataDir;
|
||||
plan.exportFile = WalletSecurityController::decryptExportFileName(timestampSeconds);
|
||||
plan.exportPath = dataDir + plan.exportFile;
|
||||
plan.walletPath = dataDir + "wallet.dat";
|
||||
plan.backupPath = dataDir + "wallet.dat.encrypted.bak";
|
||||
return plan;
|
||||
}
|
||||
|
||||
const char* WalletSecurityWorkflow::stepStatus(DecryptStep step)
|
||||
{
|
||||
switch (step) {
|
||||
case DecryptStep::Unlock: return "Unlocking wallet...";
|
||||
case DecryptStep::ExportKeys: return "Exporting wallet keys...";
|
||||
case DecryptStep::StopDaemon: return "Stopping daemon...";
|
||||
case DecryptStep::BackupWallet: return "Backing up encrypted wallet...";
|
||||
case DecryptStep::RestartDaemon: return "Restarting daemon...";
|
||||
case DecryptStep::ImportKeys: return "Importing wallet keys...";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const char* WalletSecurityWorkflow::stepLabel(DecryptStep step)
|
||||
{
|
||||
switch (step) {
|
||||
case DecryptStep::Unlock: return "Unlocking wallet";
|
||||
case DecryptStep::ExportKeys: return "Exporting wallet keys";
|
||||
case DecryptStep::StopDaemon: return "Stopping daemon";
|
||||
case DecryptStep::BackupWallet: return "Backing up encrypted wallet";
|
||||
case DecryptStep::RestartDaemon: return "Restarting daemon";
|
||||
case DecryptStep::ImportKeys: return "Importing wallet keys";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
WalletSecurityWorkflow::DecryptStep WalletSecurityWorkflow::stepFromIndex(int step)
|
||||
{
|
||||
if (step <= 0) return DecryptStep::Unlock;
|
||||
if (step == 1) return DecryptStep::ExportKeys;
|
||||
if (step == 2) return DecryptStep::StopDaemon;
|
||||
if (step == 3) return DecryptStep::BackupWallet;
|
||||
if (step == 4) return DecryptStep::RestartDaemon;
|
||||
return DecryptStep::ImportKeys;
|
||||
}
|
||||
|
||||
bool WalletSecurityWorkflow::stepIsComplete(DecryptStep current, DecryptStep candidate)
|
||||
{
|
||||
return stepIndex(candidate) < stepIndex(current);
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
78
src/services/wallet_security_workflow.h
Normal file
78
src/services/wallet_security_workflow.h
Normal file
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_security_controller.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityWorkflow {
|
||||
public:
|
||||
enum class DecryptPhase {
|
||||
PassphraseEntry = 0,
|
||||
Working = 1,
|
||||
Success = 2,
|
||||
Error = 3
|
||||
};
|
||||
|
||||
enum class DecryptStep {
|
||||
Unlock = 0,
|
||||
ExportKeys = 1,
|
||||
StopDaemon = 2,
|
||||
BackupWallet = 3,
|
||||
RestartDaemon = 4,
|
||||
ImportKeys = 5
|
||||
};
|
||||
|
||||
struct DecryptSnapshot {
|
||||
DecryptPhase phase = DecryptPhase::PassphraseEntry;
|
||||
DecryptStep step = DecryptStep::Unlock;
|
||||
std::string status;
|
||||
bool inProgress = false;
|
||||
bool importActive = false;
|
||||
std::chrono::steady_clock::time_point stepStarted{};
|
||||
std::chrono::steady_clock::time_point overallStarted{};
|
||||
};
|
||||
|
||||
struct WalletFilePlan {
|
||||
std::string dataDir;
|
||||
std::string exportFile;
|
||||
std::string exportPath;
|
||||
std::string walletPath;
|
||||
std::string backupPath;
|
||||
};
|
||||
|
||||
void reset();
|
||||
void start(std::chrono::steady_clock::time_point now);
|
||||
void advanceTo(DecryptStep step, std::string status,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
void failEntry(std::string status);
|
||||
void fail(std::string status);
|
||||
void closeDialogForImport();
|
||||
void finishImport();
|
||||
|
||||
DecryptSnapshot snapshot() const { return state_; }
|
||||
DecryptPhase phase() const { return state_.phase; }
|
||||
DecryptStep step() const { return state_.step; }
|
||||
const std::string& status() const { return state_.status; }
|
||||
bool inProgress() const { return state_.inProgress; }
|
||||
bool importActive() const { return state_.importActive; }
|
||||
bool canClose() const { return state_.phase != DecryptPhase::Working; }
|
||||
|
||||
static WalletFilePlan planWalletFiles(const std::string& dataDir,
|
||||
std::uint64_t timestampSeconds);
|
||||
static const char* stepStatus(DecryptStep step);
|
||||
static const char* stepLabel(DecryptStep step);
|
||||
static int stepIndex(DecryptStep step) { return static_cast<int>(step); }
|
||||
static DecryptStep stepFromIndex(int step);
|
||||
static bool stepIsComplete(DecryptStep current, DecryptStep candidate);
|
||||
|
||||
private:
|
||||
DecryptSnapshot state_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
104
src/services/wallet_security_workflow_executor.cpp
Normal file
104
src/services/wallet_security_workflow_executor.cpp
Normal file
@@ -0,0 +1,104 @@
|
||||
#include "wallet_security_workflow_executor.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::unlockWallet(
|
||||
const std::string& passphrase, RpcGateway& rpc, int timeoutSeconds)
|
||||
{
|
||||
std::string error;
|
||||
if (!rpc.unlockWallet(passphrase, timeoutSeconds, error)) {
|
||||
return {false, error.empty() ? "Incorrect passphrase" : error, true};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::ExportOutcome WalletSecurityWorkflowExecutor::exportWallet(
|
||||
RpcGateway& rpc, FileGateway& files, std::uint64_t timestampSeconds, long timeoutSeconds)
|
||||
{
|
||||
ExportOutcome outcome;
|
||||
outcome.filePlan = WalletSecurityWorkflow::planWalletFiles(files.dataDir(), timestampSeconds);
|
||||
|
||||
std::string error;
|
||||
if (!rpc.exportWallet(outcome.filePlan.exportFile, timeoutSeconds, error)) {
|
||||
outcome.ok = false;
|
||||
outcome.error = error.empty() ? "Export failed" : "Export failed: " + error;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
outcome.ok = true;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::stopDaemon(RpcGateway& rpc)
|
||||
{
|
||||
std::string error;
|
||||
(void)rpc.requestDaemonStop(error);
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::backupEncryptedWallet(
|
||||
FileGateway& files, const WalletFilePlan& filePlan)
|
||||
{
|
||||
std::string error;
|
||||
if (!files.backupEncryptedWallet(filePlan, error)) {
|
||||
return {false, error.empty() ? "Failed to rename wallet.dat" : "Failed to rename wallet.dat: " + error, false};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::restartDaemonAndWait(
|
||||
DaemonGateway& daemon, RpcGateway& rpc, int preRestartDelayMs,
|
||||
int embeddedRestartSettleMs, int maxProbeSeconds)
|
||||
{
|
||||
auto waitForMs = [&](int milliseconds) -> bool {
|
||||
int remaining = milliseconds;
|
||||
while (remaining > 0 && !daemon.cancelled() && !daemon.shuttingDown()) {
|
||||
int slice = remaining >= 100 ? 100 : remaining;
|
||||
daemon.sleepForMs(slice);
|
||||
remaining -= slice;
|
||||
}
|
||||
return !daemon.cancelled() && !daemon.shuttingDown();
|
||||
};
|
||||
|
||||
if (!waitForMs(preRestartDelayMs)) return {false, "", false};
|
||||
|
||||
if (daemon.isUsingEmbeddedDaemon()) {
|
||||
daemon.stopEmbeddedDaemon();
|
||||
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
|
||||
if (!waitForMs(embeddedRestartSettleMs)) return {false, "", false};
|
||||
daemon.startEmbeddedDaemon();
|
||||
}
|
||||
|
||||
bool daemonUp = false;
|
||||
std::string lastError;
|
||||
for (int i = 0; i < maxProbeSeconds && !daemon.cancelled() && !daemon.shuttingDown(); ++i) {
|
||||
daemon.sleepForMs(1000);
|
||||
if (rpc.probeDaemon(lastError)) {
|
||||
daemonUp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
|
||||
if (!daemonUp) return {false, "Daemon failed to restart", false};
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::importWallet(
|
||||
ImportGateway& importer, const std::string& exportPath, long timeoutSeconds)
|
||||
{
|
||||
std::string error;
|
||||
if (!importer.importWallet(exportPath, timeoutSeconds, error)) {
|
||||
return {false, error.empty() ? "Key import failed" : "Key import failed: " + error, false};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflowExecutor::cleanupVaultAndPin(const VaultCleanupGateway& cleanup)
|
||||
{
|
||||
if (cleanup) cleanup();
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
83
src/services/wallet_security_workflow_executor.h
Normal file
83
src/services/wallet_security_workflow_executor.h
Normal file
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_security_workflow.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityWorkflowExecutor {
|
||||
public:
|
||||
using WalletFilePlan = WalletSecurityWorkflow::WalletFilePlan;
|
||||
|
||||
struct Outcome {
|
||||
bool ok = false;
|
||||
std::string error;
|
||||
bool passphraseRejected = false;
|
||||
};
|
||||
|
||||
struct ExportOutcome : Outcome {
|
||||
WalletFilePlan filePlan;
|
||||
};
|
||||
|
||||
class RpcGateway {
|
||||
public:
|
||||
virtual ~RpcGateway() = default;
|
||||
virtual bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool requestDaemonStop(std::string& error) = 0;
|
||||
virtual bool probeDaemon(std::string& error) = 0;
|
||||
};
|
||||
|
||||
class ImportGateway {
|
||||
public:
|
||||
virtual ~ImportGateway() = default;
|
||||
virtual bool importWallet(const std::string& exportPath, long timeoutSeconds, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class FileGateway {
|
||||
public:
|
||||
virtual ~FileGateway() = default;
|
||||
virtual std::string dataDir() = 0;
|
||||
virtual bool backupEncryptedWallet(const WalletFilePlan& filePlan, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class DaemonGateway {
|
||||
public:
|
||||
virtual ~DaemonGateway() = default;
|
||||
virtual bool isUsingEmbeddedDaemon() const = 0;
|
||||
virtual void stopEmbeddedDaemon() = 0;
|
||||
virtual bool startEmbeddedDaemon() = 0;
|
||||
virtual bool cancelled() const = 0;
|
||||
virtual bool shuttingDown() const = 0;
|
||||
virtual void sleepForMs(int milliseconds) = 0;
|
||||
};
|
||||
|
||||
using VaultCleanupGateway = std::function<void()>;
|
||||
|
||||
static Outcome unlockWallet(const std::string& passphrase,
|
||||
RpcGateway& rpc,
|
||||
int timeoutSeconds = 600);
|
||||
static ExportOutcome exportWallet(RpcGateway& rpc,
|
||||
FileGateway& files,
|
||||
std::uint64_t timestampSeconds,
|
||||
long timeoutSeconds = 300L);
|
||||
static Outcome stopDaemon(RpcGateway& rpc);
|
||||
static Outcome backupEncryptedWallet(FileGateway& files,
|
||||
const WalletFilePlan& filePlan);
|
||||
static Outcome restartDaemonAndWait(DaemonGateway& daemon,
|
||||
RpcGateway& rpc,
|
||||
int preRestartDelayMs = 2000,
|
||||
int embeddedRestartSettleMs = 1000,
|
||||
int maxProbeSeconds = 60);
|
||||
static Outcome importWallet(ImportGateway& importer,
|
||||
const std::string& exportPath,
|
||||
long timeoutSeconds = 1200L);
|
||||
static void cleanupVaultAndPin(const VaultCleanupGateway& cleanup);
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -1,418 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// Offscreen render-target scroll fade — the ImGui equivalent of CSS mask-image.
|
||||
// Renders scrollable content to an offscreen surface, then composites it back
|
||||
// as a textured mesh strip with vertex alpha for edge fading.
|
||||
// This produces a true per-pixel fade that works with any background
|
||||
// (including acrylic/backdrop transparency).
|
||||
//
|
||||
// Supports both OpenGL (DRAGONX_HAS_GLAD) and DX11 (DRAGONX_USE_DX11).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
#include <cstdio>
|
||||
|
||||
// ============================================================================
|
||||
// Platform detection
|
||||
// ============================================================================
|
||||
#if defined(DRAGONX_USE_DX11)
|
||||
#include <d3d11.h>
|
||||
#define SCROLL_FADE_HAS_OFFSCREEN 1
|
||||
#define SCROLL_FADE_DX11 1
|
||||
#elif defined(DRAGONX_HAS_GLAD)
|
||||
#include <glad/gl.h>
|
||||
#include "../../util/logger.h"
|
||||
#ifndef GL_FRAMEBUFFER_BINDING
|
||||
#define GL_FRAMEBUFFER_BINDING 0x8CA6
|
||||
#endif
|
||||
#ifndef GL_VIEWPORT
|
||||
#define GL_VIEWPORT 0x0BA2
|
||||
#endif
|
||||
#ifndef GL_SCISSOR_TEST
|
||||
#define GL_SCISSOR_TEST 0x0C11
|
||||
#endif
|
||||
#define SCROLL_FADE_HAS_OFFSCREEN 1
|
||||
#define SCROLL_FADE_GL 1
|
||||
#endif
|
||||
|
||||
#ifdef SCROLL_FADE_HAS_OFFSCREEN
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace effects {
|
||||
|
||||
// ============================================================================
|
||||
// ScrollFadeRT — manages an offscreen render target for scroll-fade rendering
|
||||
// ============================================================================
|
||||
|
||||
class ScrollFadeRT {
|
||||
public:
|
||||
ScrollFadeRT() = default;
|
||||
~ScrollFadeRT() { destroy(); }
|
||||
|
||||
// Non-copyable
|
||||
ScrollFadeRT(const ScrollFadeRT&) = delete;
|
||||
ScrollFadeRT& operator=(const ScrollFadeRT&) = delete;
|
||||
|
||||
/// Ensure RT matches the required dimensions. Returns true if ready.
|
||||
bool ensure(int w, int h) {
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
if (isValid() && w == width_ && h == height_) return true;
|
||||
return init(w, h);
|
||||
}
|
||||
|
||||
void destroy();
|
||||
bool isValid() const;
|
||||
|
||||
/// Get the texture as an ImTextureID for compositing.
|
||||
ImTextureID textureID() const;
|
||||
|
||||
int width() const { return width_; }
|
||||
int height() const { return height_; }
|
||||
|
||||
#ifdef SCROLL_FADE_DX11
|
||||
ID3D11RenderTargetView* rtv() const { return rtv_; }
|
||||
#endif
|
||||
#ifdef SCROLL_FADE_GL
|
||||
unsigned int fbo() const { return fbo_; }
|
||||
#endif
|
||||
|
||||
private:
|
||||
bool init(int w, int h);
|
||||
|
||||
int width_ = 0;
|
||||
int height_ = 0;
|
||||
|
||||
#ifdef SCROLL_FADE_DX11
|
||||
ID3D11Texture2D* tex_ = nullptr;
|
||||
ID3D11RenderTargetView* rtv_ = nullptr;
|
||||
ID3D11ShaderResourceView* srv_ = nullptr;
|
||||
#endif
|
||||
#ifdef SCROLL_FADE_GL
|
||||
unsigned int fbo_ = 0;
|
||||
unsigned int colorTex_ = 0;
|
||||
#endif
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Implementations
|
||||
// ============================================================================
|
||||
|
||||
#ifdef SCROLL_FADE_DX11
|
||||
|
||||
// --- DX11 helpers to get device/context from ImGui backend ---
|
||||
inline ID3D11Device* GetDX11Device() {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!io.BackendRendererUserData) return nullptr;
|
||||
return *reinterpret_cast<ID3D11Device**>(io.BackendRendererUserData);
|
||||
}
|
||||
inline ID3D11DeviceContext* GetDX11Context() {
|
||||
ID3D11Device* dev = GetDX11Device();
|
||||
if (!dev) return nullptr;
|
||||
ID3D11DeviceContext* ctx = nullptr;
|
||||
dev->GetImmediateContext(&ctx);
|
||||
return ctx; // caller must Release()
|
||||
}
|
||||
|
||||
inline bool ScrollFadeRT::init(int w, int h) {
|
||||
destroy();
|
||||
ID3D11Device* dev = GetDX11Device();
|
||||
if (!dev) return false;
|
||||
|
||||
width_ = w;
|
||||
height_ = h;
|
||||
|
||||
// Create texture
|
||||
D3D11_TEXTURE2D_DESC td = {};
|
||||
td.Width = (UINT)w;
|
||||
td.Height = (UINT)h;
|
||||
td.MipLevels = 1;
|
||||
td.ArraySize = 1;
|
||||
td.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
|
||||
td.SampleDesc.Count = 1;
|
||||
td.Usage = D3D11_USAGE_DEFAULT;
|
||||
td.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE;
|
||||
|
||||
if (FAILED(dev->CreateTexture2D(&td, nullptr, &tex_))) {
|
||||
DEBUG_LOGF("ScrollFadeRT: CreateTexture2D failed\n");
|
||||
destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Render target view
|
||||
if (FAILED(dev->CreateRenderTargetView(tex_, nullptr, &rtv_))) {
|
||||
DEBUG_LOGF("ScrollFadeRT: CreateRenderTargetView failed\n");
|
||||
destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shader resource view (for sampling as texture)
|
||||
if (FAILED(dev->CreateShaderResourceView(tex_, nullptr, &srv_))) {
|
||||
DEBUG_LOGF("ScrollFadeRT: CreateShaderResourceView failed\n");
|
||||
destroy();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void ScrollFadeRT::destroy() {
|
||||
if (srv_) { srv_->Release(); srv_ = nullptr; }
|
||||
if (rtv_) { rtv_->Release(); rtv_ = nullptr; }
|
||||
if (tex_) { tex_->Release(); tex_ = nullptr; }
|
||||
width_ = height_ = 0;
|
||||
}
|
||||
|
||||
inline bool ScrollFadeRT::isValid() const { return rtv_ != nullptr; }
|
||||
|
||||
inline ImTextureID ScrollFadeRT::textureID() const {
|
||||
return (ImTextureID)srv_;
|
||||
}
|
||||
|
||||
#endif // SCROLL_FADE_DX11
|
||||
|
||||
#ifdef SCROLL_FADE_GL
|
||||
|
||||
inline bool ScrollFadeRT::init(int w, int h) {
|
||||
destroy();
|
||||
width_ = w;
|
||||
height_ = h;
|
||||
|
||||
glGenFramebuffers(1, &fbo_);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, fbo_);
|
||||
|
||||
glGenTextures(1, &colorTex_);
|
||||
glBindTexture(GL_TEXTURE_2D, colorTex_);
|
||||
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, w, h, 0,
|
||||
GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
|
||||
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
|
||||
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0,
|
||||
GL_TEXTURE_2D, colorTex_, 0);
|
||||
|
||||
GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER);
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, 0);
|
||||
glBindTexture(GL_TEXTURE_2D, 0);
|
||||
|
||||
if (status != GL_FRAMEBUFFER_COMPLETE) {
|
||||
DEBUG_LOGF("ScrollFadeRT: FBO incomplete (0x%X)\n", status);
|
||||
destroy();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void ScrollFadeRT::destroy() {
|
||||
if (colorTex_) { glDeleteTextures(1, &colorTex_); colorTex_ = 0; }
|
||||
if (fbo_) { glDeleteFramebuffers(1, &fbo_); fbo_ = 0; }
|
||||
width_ = height_ = 0;
|
||||
}
|
||||
|
||||
inline bool ScrollFadeRT::isValid() const { return fbo_ != 0; }
|
||||
|
||||
inline ImTextureID ScrollFadeRT::textureID() const {
|
||||
return (ImTextureID)(intptr_t)colorTex_;
|
||||
}
|
||||
|
||||
#endif // SCROLL_FADE_GL
|
||||
|
||||
// ============================================================================
|
||||
// Callback state — singleton storage for bind/unbind data
|
||||
// ============================================================================
|
||||
|
||||
struct ScrollFadeState {
|
||||
#ifdef SCROLL_FADE_DX11
|
||||
ID3D11RenderTargetView* offscreenRTV = nullptr;
|
||||
ID3D11RenderTargetView* savedRTV = nullptr;
|
||||
ID3D11DepthStencilView* savedDSV = nullptr;
|
||||
D3D11_VIEWPORT savedVP = {};
|
||||
#endif
|
||||
#ifdef SCROLL_FADE_GL
|
||||
unsigned int fbo = 0;
|
||||
int savedFBO = 0;
|
||||
int savedVP[4] = {};
|
||||
bool savedScissorEnabled = true; // ImGui always has scissor enabled
|
||||
#endif
|
||||
int vpW = 0, vpH = 0; // framebuffer pixel dimensions for viewport
|
||||
};
|
||||
|
||||
inline ScrollFadeState& GetScrollFadeState() {
|
||||
static ScrollFadeState s;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Callbacks — inserted into the draw list via AddCallback
|
||||
// ============================================================================
|
||||
|
||||
#ifdef SCROLL_FADE_DX11
|
||||
|
||||
inline void BindRTCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
auto& st = GetScrollFadeState();
|
||||
ID3D11DeviceContext* ctx = GetDX11Context();
|
||||
if (!ctx) return;
|
||||
|
||||
// Save current RT and viewport
|
||||
UINT numVP = 1;
|
||||
ctx->OMGetRenderTargets(1, &st.savedRTV, &st.savedDSV);
|
||||
ctx->RSGetViewports(&numVP, &st.savedVP);
|
||||
|
||||
// Bind offscreen RT
|
||||
ctx->OMSetRenderTargets(1, &st.offscreenRTV, nullptr);
|
||||
|
||||
// Set viewport to match RT size
|
||||
D3D11_VIEWPORT vp = {};
|
||||
vp.Width = (FLOAT)st.vpW;
|
||||
vp.Height = (FLOAT)st.vpH;
|
||||
vp.MaxDepth = 1.0f;
|
||||
ctx->RSSetViewports(1, &vp);
|
||||
|
||||
// Clear to transparent
|
||||
float clearColor[4] = { 0.0f, 0.0f, 0.0f, 0.0f };
|
||||
ctx->ClearRenderTargetView(st.offscreenRTV, clearColor);
|
||||
|
||||
ctx->Release();
|
||||
}
|
||||
|
||||
inline void UnbindRTCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
auto& st = GetScrollFadeState();
|
||||
ID3D11DeviceContext* ctx = GetDX11Context();
|
||||
if (!ctx) return;
|
||||
|
||||
// Restore previous RT and viewport
|
||||
ctx->OMSetRenderTargets(1, &st.savedRTV, st.savedDSV);
|
||||
ctx->RSSetViewports(1, &st.savedVP);
|
||||
|
||||
// Release the refs from OMGetRenderTargets
|
||||
if (st.savedRTV) { st.savedRTV->Release(); st.savedRTV = nullptr; }
|
||||
if (st.savedDSV) { st.savedDSV->Release(); st.savedDSV = nullptr; }
|
||||
|
||||
ctx->Release();
|
||||
}
|
||||
|
||||
#endif // SCROLL_FADE_DX11
|
||||
|
||||
#ifdef SCROLL_FADE_GL
|
||||
|
||||
inline void BindRTCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
auto& st = GetScrollFadeState();
|
||||
|
||||
// Save current FBO and viewport
|
||||
glGetIntegerv(GL_FRAMEBUFFER_BINDING, &st.savedFBO);
|
||||
glGetIntegerv(GL_VIEWPORT, st.savedVP);
|
||||
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, st.fbo);
|
||||
glViewport(0, 0, st.vpW, st.vpH);
|
||||
|
||||
// Disable scissor test inside the FBO. ImGui's renderer computes
|
||||
// scissor rects relative to the main framebuffer dimensions — those
|
||||
// coordinates would be wrong for our offscreen surface. The child
|
||||
// window's content is already bounded by ImGui's layout, and the
|
||||
// composite step applies its own clip rect, so skipping scissor
|
||||
// in the FBO is safe.
|
||||
glDisable(GL_SCISSOR_TEST);
|
||||
|
||||
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
}
|
||||
|
||||
inline void UnbindRTCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
auto& st = GetScrollFadeState();
|
||||
glBindFramebuffer(GL_FRAMEBUFFER, (unsigned int)st.savedFBO);
|
||||
glViewport(st.savedVP[0], st.savedVP[1], st.savedVP[2], st.savedVP[3]);
|
||||
if (st.savedScissorEnabled)
|
||||
glEnable(GL_SCISSOR_TEST);
|
||||
}
|
||||
|
||||
#endif // SCROLL_FADE_GL
|
||||
|
||||
// ============================================================================
|
||||
// Composite helper — draw the RT texture as a mesh strip with alpha fade
|
||||
// ============================================================================
|
||||
|
||||
/// Draw the offscreen texture onto `dl` as a vertical strip with alpha=0 at
|
||||
/// the faded edges and alpha=1 in the middle. Produces a true CSS-like
|
||||
/// mask-image: linear-gradient() result.
|
||||
///
|
||||
/// @param logicalW, logicalH Logical display dimensions (DisplaySize) for
|
||||
/// UV calculation — NOT the RT pixel dimensions. ImGui screen coords
|
||||
/// are in logical units, and the FBO projection maps them 1:1 to the
|
||||
/// logical coordinate space, so UVs must divide by logical size.
|
||||
inline void CompositeWithFade(ImDrawList* dl,
|
||||
ImTextureID texID,
|
||||
const ImVec2& screenMin,
|
||||
const ImVec2& screenMax,
|
||||
int logicalW, int logicalH,
|
||||
float fadeTop, float fadeBot,
|
||||
bool needTop, bool needBot)
|
||||
{
|
||||
float left = screenMin.x;
|
||||
float right = screenMax.x;
|
||||
float y0 = screenMin.y;
|
||||
float y1 = screenMin.y + (needTop ? fadeTop : 0.0f);
|
||||
float y2 = screenMax.y - (needBot ? fadeBot : 0.0f);
|
||||
float y3 = screenMax.y;
|
||||
|
||||
// Clamp in case fade zones overlap
|
||||
if (y1 > y2) { float mid = (y0 + y3) * 0.5f; y1 = y2 = mid; }
|
||||
|
||||
// UV coordinates — map screen position (logical) to render target texture.
|
||||
// Screen coords are in logical (DisplaySize) space. The FBO projection
|
||||
// maps these 1:1, so divide by logical dimensions to get [0,1] UVs.
|
||||
float uL = screenMin.x / (float)logicalW;
|
||||
float uR = screenMax.x / (float)logicalW;
|
||||
|
||||
#ifdef SCROLL_FADE_GL
|
||||
// OpenGL: FBO Y is flipped (ImGui top=0 → GL bottom=0)
|
||||
auto uvY = [&](float y) -> float { return 1.0f - y / (float)logicalH; };
|
||||
#else
|
||||
// DX11: no Y flip (both ImGui and DX11 have (0,0) at top-left)
|
||||
auto uvY = [&](float y) -> float { return y / (float)logicalH; };
|
||||
#endif
|
||||
|
||||
ImU32 colOpaque = IM_COL32(255, 255, 255, 255);
|
||||
ImU32 colClear = IM_COL32(255, 255, 255, 0);
|
||||
ImU32 colTop = needTop ? colClear : colOpaque;
|
||||
ImU32 colBot = needBot ? colClear : colOpaque;
|
||||
|
||||
dl->PushTextureID(texID);
|
||||
dl->PrimReserve(18, 8);
|
||||
|
||||
ImDrawVert* vtx = dl->_VtxWritePtr;
|
||||
ImDrawIdx* idx = dl->_IdxWritePtr;
|
||||
ImDrawIdx base = (ImDrawIdx)dl->_VtxCurrentIdx;
|
||||
|
||||
vtx[0] = { ImVec2(left, y0), ImVec2(uL, uvY(y0)), colTop };
|
||||
vtx[1] = { ImVec2(right, y0), ImVec2(uR, uvY(y0)), colTop };
|
||||
vtx[2] = { ImVec2(left, y1), ImVec2(uL, uvY(y1)), colOpaque };
|
||||
vtx[3] = { ImVec2(right, y1), ImVec2(uR, uvY(y1)), colOpaque };
|
||||
vtx[4] = { ImVec2(left, y2), ImVec2(uL, uvY(y2)), colOpaque };
|
||||
vtx[5] = { ImVec2(right, y2), ImVec2(uR, uvY(y2)), colOpaque };
|
||||
vtx[6] = { ImVec2(left, y3), ImVec2(uL, uvY(y3)), colBot };
|
||||
vtx[7] = { ImVec2(right, y3), ImVec2(uR, uvY(y3)), colBot };
|
||||
|
||||
idx[0] = base+0; idx[1] = base+1; idx[2] = base+3;
|
||||
idx[3] = base+0; idx[4] = base+3; idx[5] = base+2;
|
||||
idx[6] = base+2; idx[7] = base+3; idx[8] = base+5;
|
||||
idx[9] = base+2; idx[10] = base+5; idx[11] = base+4;
|
||||
idx[12] = base+4; idx[13] = base+5; idx[14] = base+7;
|
||||
idx[15] = base+4; idx[16] = base+7; idx[17] = base+6;
|
||||
|
||||
dl->_VtxWritePtr += 8;
|
||||
dl->_IdxWritePtr += 18;
|
||||
dl->_VtxCurrentIdx += 8;
|
||||
|
||||
dl->PopTextureID();
|
||||
}
|
||||
|
||||
} // namespace effects
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
#endif // SCROLL_FADE_HAS_OFFSCREEN
|
||||
401
src/ui/explorer/explorer_block_cache.cpp
Normal file
401
src/ui/explorer/explorer_block_cache.cpp
Normal file
@@ -0,0 +1,401 @@
|
||||
#include "explorer_block_cache.h"
|
||||
|
||||
#include "../../util/logger.h"
|
||||
#include "../../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kCacheSchemaVersion = 1;
|
||||
|
||||
struct Statement {
|
||||
sqlite3_stmt* handle = nullptr;
|
||||
|
||||
Statement(sqlite3* db, const char* sql)
|
||||
{
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
|
||||
handle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
~Statement()
|
||||
{
|
||||
if (handle) sqlite3_finalize(handle);
|
||||
}
|
||||
|
||||
Statement(const Statement&) = delete;
|
||||
Statement& operator=(const Statement&) = delete;
|
||||
};
|
||||
|
||||
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
|
||||
{
|
||||
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool blockSummaryFromJson(const json& source, ExplorerBlockSummary& block)
|
||||
{
|
||||
if (!source.is_object()) return false;
|
||||
try {
|
||||
block.height = source.value("height", 0);
|
||||
block.hash = source.value("hash", std::string());
|
||||
block.tx_count = source.value("tx_count", 0);
|
||||
block.size = source.value("size", 0);
|
||||
block.time = source.value("time", static_cast<std::int64_t>(0));
|
||||
block.difficulty = source.value("difficulty", 0.0);
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
return block.height > 0 && !block.hash.empty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExplorerBlockCache::ExplorerBlockCache()
|
||||
: ExplorerBlockCache(defaultDatabasePath(), defaultLegacyJsonPath())
|
||||
{
|
||||
}
|
||||
|
||||
ExplorerBlockCache::ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath)
|
||||
: database_path_(std::move(databasePath)), legacy_json_path_(std::move(legacyJsonPath))
|
||||
{
|
||||
}
|
||||
|
||||
ExplorerBlockCache::~ExplorerBlockCache()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks.sqlite").string();
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::defaultLegacyJsonPath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks_cache.json").string();
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to create explorer cache directory: %s\n", e.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open explorer block cache: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
migrateLegacyJsonIfNeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight, int maxHeight)
|
||||
{
|
||||
std::map<int, ExplorerBlockSummary> blocks;
|
||||
if (minHeight > maxHeight) std::swap(minHeight, maxHeight);
|
||||
if (minHeight < 1 || maxHeight < 1 || !ensureOpen()) return blocks;
|
||||
|
||||
Statement statement(db_,
|
||||
"SELECT height, hash, tx_count, size, time, difficulty "
|
||||
"FROM explorer_blocks WHERE height BETWEEN ? AND ? ORDER BY height DESC");
|
||||
if (!statement.handle) return blocks;
|
||||
|
||||
sqlite3_bind_int(statement.handle, 1, minHeight);
|
||||
sqlite3_bind_int(statement.handle, 2, maxHeight);
|
||||
|
||||
while (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
ExplorerBlockSummary block;
|
||||
block.height = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* hashText = sqlite3_column_text(statement.handle, 1);
|
||||
if (hashText) block.hash = reinterpret_cast<const char*>(hashText);
|
||||
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
||||
block.size = sqlite3_column_int(statement.handle, 3);
|
||||
block.time = static_cast<std::int64_t>(sqlite3_column_int64(statement.handle, 4));
|
||||
block.difficulty = sqlite3_column_double(statement.handle, 5);
|
||||
if (block.height > 0 && !block.hash.empty()) {
|
||||
blocks[block.height] = std::move(block);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
std::vector<ExplorerBlockSummary> ExplorerBlockCache::searchBlocks(const std::string& query, int limit)
|
||||
{
|
||||
std::vector<ExplorerBlockSummary> results;
|
||||
if (query.empty() || limit < 1 || !ensureOpen()) return results;
|
||||
|
||||
// Escape LIKE wildcards in the user input so '%' / '_' are matched literally.
|
||||
std::string escaped;
|
||||
escaped.reserve(query.size());
|
||||
for (char c : query) {
|
||||
if (c == '%' || c == '_' || c == '\\') escaped.push_back('\\');
|
||||
escaped.push_back(c);
|
||||
}
|
||||
std::string pattern = "%" + escaped + "%";
|
||||
|
||||
Statement statement(db_,
|
||||
"SELECT height, hash, tx_count, size, time, difficulty "
|
||||
"FROM explorer_blocks "
|
||||
"WHERE CAST(height AS TEXT) LIKE ?1 ESCAPE '\\' OR hash LIKE ?1 ESCAPE '\\' "
|
||||
"ORDER BY height DESC LIMIT ?2");
|
||||
if (!statement.handle) return results;
|
||||
|
||||
sqlite3_bind_text(statement.handle, 1, pattern.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int(statement.handle, 2, limit);
|
||||
|
||||
while (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
ExplorerBlockSummary block;
|
||||
block.height = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* hashText = sqlite3_column_text(statement.handle, 1);
|
||||
if (hashText) block.hash = reinterpret_cast<const char*>(hashText);
|
||||
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
||||
block.size = sqlite3_column_int(statement.handle, 3);
|
||||
block.time = static_cast<std::int64_t>(sqlite3_column_int64(statement.handle, 4));
|
||||
block.difficulty = sqlite3_column_double(statement.handle, 5);
|
||||
if (block.height > 0 && !block.hash.empty()) results.push_back(std::move(block));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||
{
|
||||
if (block.height < 1 || block.hash.empty() || !ensureOpen()) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO explorer_blocks "
|
||||
"(height, hash, tx_count, size, time, difficulty) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
if (!statement.handle) return false;
|
||||
|
||||
sqlite3_bind_int(statement.handle, 1, block.height);
|
||||
if (!bindText(statement.handle, 2, block.hash)) return false;
|
||||
sqlite3_bind_int(statement.handle, 3, block.tx_count);
|
||||
sqlite3_bind_int(statement.handle, 4, block.size);
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(block.time));
|
||||
sqlite3_bind_double(statement.handle, 6, block.difficulty);
|
||||
|
||||
return sqlite3_step(statement.handle) == SQLITE_DONE;
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::cachedBlockCount()
|
||||
{
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM explorer_blocks");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
return sqlite3_column_int(statement.handle, 0);
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::clearBlocks()
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
exec("DELETE FROM explorer_blocks");
|
||||
}
|
||||
|
||||
ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
||||
int currentHeight, const std::string& currentBestHash)
|
||||
{
|
||||
SavedTipValidation validation;
|
||||
if (currentHeight <= 0 || !ensureOpen()) return validation;
|
||||
|
||||
int savedHeight = getMetaInt("tip_height", 0);
|
||||
std::string savedHash = getMetaValue("tip_hash");
|
||||
|
||||
if (savedHeight <= 0 || savedHash.empty()) {
|
||||
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentHeight < savedHeight) {
|
||||
clearBlocks();
|
||||
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
|
||||
else updateTip(0, std::string());
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentHeight == savedHeight) {
|
||||
if (currentBestHash.empty()) return validation;
|
||||
if (currentBestHash != savedHash) clearBlocks();
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentBestHash.empty()) return validation;
|
||||
|
||||
validation.needed = true;
|
||||
validation.height = savedHeight;
|
||||
validation.expectedHash = savedHash;
|
||||
return validation;
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::applySavedTipValidation(const SavedTipValidation& validation,
|
||||
const std::string& actualHash,
|
||||
int currentHeight,
|
||||
const std::string& currentBestHash)
|
||||
{
|
||||
if (!validation.needed || !ensureOpen()) return;
|
||||
if (actualHash.empty()) return;
|
||||
|
||||
if (actualHash != validation.expectedHash) {
|
||||
clearBlocks();
|
||||
}
|
||||
|
||||
if (currentHeight > 0 && !currentBestHash.empty()) {
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::updateTip(int height, const std::string& hash)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
setMetaValue("tip_height", std::to_string(std::max(0, height)));
|
||||
setMetaValue("tip_hash", hash);
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
|
||||
if (result != SQLITE_OK) {
|
||||
DEBUG_LOGF("Explorer block cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::getMetaValue(const std::string& key)
|
||||
{
|
||||
if (!ensureOpen()) return {};
|
||||
|
||||
Statement statement(db_, "SELECT value FROM explorer_cache_meta WHERE key = ?");
|
||||
if (!statement.handle) return {};
|
||||
if (!bindText(statement.handle, 1, key)) return {};
|
||||
if (sqlite3_step(statement.handle) != SQLITE_ROW) return {};
|
||||
|
||||
const unsigned char* valueText = sqlite3_column_text(statement.handle, 0);
|
||||
return valueText ? reinterpret_cast<const char*>(valueText) : std::string();
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::getMetaInt(const std::string& key, int fallback)
|
||||
{
|
||||
std::string value = getMetaValue(key);
|
||||
if (value.empty()) return fallback;
|
||||
try {
|
||||
return std::stoi(value);
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::setMetaValue(const std::string& key, const std::string& value)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO explorer_cache_meta (key, value) VALUES (?, ?)");
|
||||
if (!statement.handle) return;
|
||||
if (!bindText(statement.handle, 1, key)) return;
|
||||
if (!bindText(statement.handle, 2, value)) return;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS explorer_blocks ("
|
||||
"height INTEGER PRIMARY KEY,"
|
||||
"hash TEXT NOT NULL,"
|
||||
"tx_count INTEGER NOT NULL,"
|
||||
"size INTEGER NOT NULL,"
|
||||
"time INTEGER NOT NULL,"
|
||||
"difficulty REAL NOT NULL)") &&
|
||||
exec("CREATE TABLE IF NOT EXISTS explorer_cache_meta ("
|
||||
"key TEXT PRIMARY KEY,"
|
||||
"value TEXT NOT NULL)") &&
|
||||
exec("INSERT OR IGNORE INTO explorer_cache_meta (key, value) VALUES "
|
||||
"('schema_version', '1')");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::migrateLegacyJsonIfNeeded()
|
||||
{
|
||||
if (!db_ || getMetaValue("json_migrated") == "1") return;
|
||||
|
||||
bool migrated = false;
|
||||
try {
|
||||
if (!legacy_json_path_.empty() && fs::exists(legacy_json_path_)) {
|
||||
std::ifstream file(legacy_json_path_);
|
||||
json cache;
|
||||
file >> cache;
|
||||
|
||||
if (cache.is_object() && cache.value("version", 0) == kCacheSchemaVersion) {
|
||||
exec("BEGIN IMMEDIATE TRANSACTION");
|
||||
if (cache.contains("blocks") && cache["blocks"].is_array()) {
|
||||
for (const auto& entry : cache["blocks"]) {
|
||||
ExplorerBlockSummary block;
|
||||
if (blockSummaryFromJson(entry, block)) {
|
||||
storeBlock(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
exec("COMMIT");
|
||||
|
||||
int tipHeight = cache.value("tip_height", 0);
|
||||
std::string tipHash = cache.value("tip_hash", std::string());
|
||||
if (tipHeight > 0 && !tipHash.empty()) {
|
||||
updateTip(tipHeight, tipHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
migrated = true;
|
||||
} catch (const std::exception& e) {
|
||||
exec("ROLLBACK");
|
||||
DEBUG_LOGF("Failed to migrate explorer JSON cache: %s\n", e.what());
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) setMetaValue("json_migrated", "1");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::close()
|
||||
{
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
74
src/ui/explorer/explorer_block_cache.h
Normal file
74
src/ui/explorer/explorer_block_cache.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ExplorerBlockSummary {
|
||||
int height = 0;
|
||||
std::string hash;
|
||||
int tx_count = 0;
|
||||
int size = 0;
|
||||
std::int64_t time = 0;
|
||||
double difficulty = 0.0;
|
||||
};
|
||||
|
||||
class ExplorerBlockCache {
|
||||
public:
|
||||
struct SavedTipValidation {
|
||||
bool needed = false;
|
||||
int height = 0;
|
||||
std::string expectedHash;
|
||||
};
|
||||
|
||||
ExplorerBlockCache();
|
||||
ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath);
|
||||
~ExplorerBlockCache();
|
||||
|
||||
ExplorerBlockCache(const ExplorerBlockCache&) = delete;
|
||||
ExplorerBlockCache& operator=(const ExplorerBlockCache&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
static std::string defaultLegacyJsonPath();
|
||||
|
||||
bool ensureOpen();
|
||||
bool isOpen() const { return db_ != nullptr; }
|
||||
const std::string& databasePath() const { return database_path_; }
|
||||
|
||||
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
||||
// Fuzzy search over cached blocks: matches when the query is a substring of the height (as text)
|
||||
// or the block hash (case-insensitive). Returns newest-first, capped at `limit`.
|
||||
std::vector<ExplorerBlockSummary> searchBlocks(const std::string& query, int limit);
|
||||
bool storeBlock(const ExplorerBlockSummary& block);
|
||||
int cachedBlockCount();
|
||||
void clearBlocks();
|
||||
|
||||
SavedTipValidation prepareValidation(int currentHeight, const std::string& currentBestHash);
|
||||
void applySavedTipValidation(const SavedTipValidation& validation,
|
||||
const std::string& actualHash,
|
||||
int currentHeight,
|
||||
const std::string& currentBestHash);
|
||||
void updateTip(int height, const std::string& hash);
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
std::string getMetaValue(const std::string& key);
|
||||
int getMetaInt(const std::string& key, int fallback);
|
||||
void setMetaValue(const std::string& key, const std::string& value);
|
||||
bool createSchema();
|
||||
void migrateLegacyJsonIfNeeded();
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::string legacy_json_path_;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -432,6 +432,20 @@ inline float columnOffset(float ratio, float availW) {
|
||||
return availW * ratio;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs
|
||||
// ============================================================================
|
||||
|
||||
inline float kDialogDefaultWidth() { return schema::UI().drawElement("dialog", "width-default").sizeOr(480.0f) * dpiScale(); }
|
||||
inline float kDialogLargeWidth() { return schema::UI().drawElement("dialog", "width-lg").sizeOr(600.0f) * dpiScale(); }
|
||||
inline float kDialogExtraLargeWidth() { return schema::UI().drawElement("dialog", "width-xl").sizeOr(660.0f) * dpiScale(); }
|
||||
inline float kDialogMinWidth() { return schema::UI().drawElement("dialog", "min-width").sizeOr(280.0f) * dpiScale(); }
|
||||
inline float kDialogFormWidth() { return schema::UI().drawElement("dialog", "form-width").sizeOr(400.0f) * dpiScale(); }
|
||||
inline float kDialogActionWidth() { return schema::UI().drawElement("dialog", "action-width").sizeOr(100.0f) * dpiScale(); }
|
||||
inline float kDialogActionGap() { return schema::UI().drawElement("dialog", "action-gap").sizeOr(8.0f) * dpiScale(); }
|
||||
inline float kDialogMaxHeightRatio() { return schema::UI().drawElement("dialog", "max-height-ratio").sizeOr(0.94f); }
|
||||
inline float kDialogCompactBottomRatio() { return schema::UI().drawElement("dialog", "compact-bottom-ratio").sizeOr(0.64f); }
|
||||
|
||||
// ============================================================================
|
||||
// Buttons
|
||||
// ============================================================================
|
||||
@@ -562,7 +576,7 @@ inline float mainCardTargetH(float formW, float vs) {
|
||||
float innerW = formW - pad * 2;
|
||||
float qrColW = innerW * 0.35f;
|
||||
float qrPad = spacingMd();
|
||||
float maxQrSz = std::min(qrColW - qrPad * 2, 280.0f * dp);
|
||||
float maxQrSz = std::min(std::max(0.0f, qrColW - qrPad * 2), 280.0f * dp);
|
||||
float qrSize = std::max(100.0f * dp, maxQrSz);
|
||||
float totalQr = qrSize + qrPad * 2;
|
||||
float innerGap = spacingLg();
|
||||
|
||||
@@ -1,501 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "layout.h"
|
||||
#include "colors.h"
|
||||
#include "typography.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// App Layout Manager
|
||||
// ============================================================================
|
||||
// Manages the overall application layout following Material Design patterns.
|
||||
//
|
||||
// Usage:
|
||||
// // In your main render loop:
|
||||
// auto& layout = AppLayout::instance();
|
||||
// layout.beginFrame();
|
||||
//
|
||||
// // Render app bar
|
||||
// if (layout.beginAppBar("DragonX Wallet")) {
|
||||
// // App bar content (menu items, etc.)
|
||||
// layout.endAppBar();
|
||||
// }
|
||||
//
|
||||
// // Render navigation
|
||||
// if (layout.beginNavigation()) {
|
||||
// layout.navItem("Balance", ICON_WALLET, currentTab == 0);
|
||||
// layout.navItem("Send", ICON_SEND, currentTab == 1);
|
||||
// layout.endNavigation();
|
||||
// }
|
||||
//
|
||||
// // Render main content
|
||||
// if (layout.beginContent()) {
|
||||
// // Your content here
|
||||
// layout.endContent();
|
||||
// }
|
||||
//
|
||||
// layout.endFrame();
|
||||
|
||||
class AppLayout {
|
||||
public:
|
||||
static AppLayout& instance() {
|
||||
static AppLayout s_instance;
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Frame Management
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Begin a new frame layout
|
||||
*
|
||||
* Call this at the start of each frame before any layout calls.
|
||||
* Updates responsive breakpoints and calculates regions.
|
||||
*/
|
||||
void beginFrame();
|
||||
|
||||
/**
|
||||
* @brief End the frame layout
|
||||
*/
|
||||
void endFrame();
|
||||
|
||||
// ========================================================================
|
||||
// Layout Regions
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Begin the app bar region
|
||||
*
|
||||
* @param title App title to display
|
||||
* @param showBack Show back button (for sub-pages)
|
||||
* @return true if app bar is visible
|
||||
*/
|
||||
bool beginAppBar(const char* title, bool showBack = false);
|
||||
void endAppBar();
|
||||
|
||||
/**
|
||||
* @brief Begin the navigation region (drawer/rail/bottom)
|
||||
*
|
||||
* @return true if navigation region is visible
|
||||
*/
|
||||
bool beginNavigation();
|
||||
void endNavigation();
|
||||
|
||||
/**
|
||||
* @brief Render a navigation item
|
||||
*
|
||||
* @param label Item label
|
||||
* @param icon Icon glyph (can be nullptr)
|
||||
* @param selected Whether this item is currently selected
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool navItem(const char* label, const char* icon, bool selected);
|
||||
|
||||
/**
|
||||
* @brief Add a navigation section divider
|
||||
*
|
||||
* @param title Optional section title
|
||||
*/
|
||||
void navSection(const char* title = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Begin the main content region
|
||||
*
|
||||
* @return true if content region is visible
|
||||
*/
|
||||
bool beginContent();
|
||||
void endContent();
|
||||
|
||||
// ========================================================================
|
||||
// Card Helpers
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Begin a Material Design card
|
||||
*
|
||||
* @param id Unique ID for the card
|
||||
* @param layout Card layout configuration
|
||||
* @return true if card is visible
|
||||
*/
|
||||
bool beginCard(const char* id, const CardLayout& layout = CardLayout());
|
||||
void endCard();
|
||||
|
||||
// ========================================================================
|
||||
// Layout Queries
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* @brief Get current breakpoint category
|
||||
*/
|
||||
breakpoint::Category getBreakpoint() const { return breakpoint_; }
|
||||
|
||||
/**
|
||||
* @brief Get current navigation style
|
||||
*/
|
||||
breakpoint::NavStyle getNavStyle() const { return navStyle_; }
|
||||
|
||||
/**
|
||||
* @brief Get content region available width
|
||||
*/
|
||||
float getContentWidth() const { return contentWidth_; }
|
||||
|
||||
/**
|
||||
* @brief Get content region available height
|
||||
*/
|
||||
float getContentHeight() const { return contentHeight_; }
|
||||
|
||||
/**
|
||||
* @brief Check if navigation drawer is expanded
|
||||
*/
|
||||
bool isNavExpanded() const { return navExpanded_; }
|
||||
|
||||
/**
|
||||
* @brief Toggle navigation drawer expansion
|
||||
*/
|
||||
void toggleNav() { navExpanded_ = !navExpanded_; }
|
||||
|
||||
/**
|
||||
* @brief Set navigation drawer expansion state
|
||||
*/
|
||||
void setNavExpanded(bool expanded) { navExpanded_ = expanded; }
|
||||
|
||||
private:
|
||||
AppLayout();
|
||||
~AppLayout() = default;
|
||||
AppLayout(const AppLayout&) = delete;
|
||||
AppLayout& operator=(const AppLayout&) = delete;
|
||||
|
||||
// Layout state
|
||||
breakpoint::Category breakpoint_ = breakpoint::Category::Md;
|
||||
breakpoint::NavStyle navStyle_ = breakpoint::NavStyle::NavDrawer;
|
||||
float windowWidth_ = 0;
|
||||
float windowHeight_ = 0;
|
||||
float contentWidth_ = 0;
|
||||
float contentHeight_ = 0;
|
||||
bool navExpanded_ = true;
|
||||
|
||||
// Region tracking
|
||||
bool inAppBar_ = false;
|
||||
bool inNav_ = false;
|
||||
bool inContent_ = false;
|
||||
|
||||
// Calculated regions
|
||||
ImVec2 appBarPos_;
|
||||
ImVec2 appBarSize_;
|
||||
ImVec2 navPos_;
|
||||
ImVec2 navSize_;
|
||||
ImVec2 contentPos_;
|
||||
ImVec2 contentSize_;
|
||||
|
||||
void calculateRegions();
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Inline Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline AppLayout::AppLayout() {
|
||||
// Initialize with reasonable defaults
|
||||
navExpanded_ = true;
|
||||
}
|
||||
|
||||
inline void AppLayout::beginFrame() {
|
||||
// Get main viewport size
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
windowWidth_ = viewport->WorkSize.x;
|
||||
windowHeight_ = viewport->WorkSize.y;
|
||||
|
||||
// Update responsive state
|
||||
breakpoint_ = breakpoint::GetCategory(windowWidth_);
|
||||
navStyle_ = breakpoint::GetNavStyle(breakpoint_);
|
||||
|
||||
// Auto-collapse nav on small screens
|
||||
if (breakpoint_ == breakpoint::Category::Xs) {
|
||||
navExpanded_ = false;
|
||||
}
|
||||
|
||||
calculateRegions();
|
||||
}
|
||||
|
||||
inline void AppLayout::endFrame() {
|
||||
// Reset state
|
||||
inAppBar_ = false;
|
||||
inNav_ = false;
|
||||
inContent_ = false;
|
||||
}
|
||||
|
||||
inline void AppLayout::calculateRegions() {
|
||||
// App bar at top
|
||||
appBarPos_ = ImVec2(0, 0);
|
||||
appBarSize_ = ImVec2(windowWidth_, size::AppBarHeight);
|
||||
|
||||
float belowAppBar = size::AppBarHeight;
|
||||
float contentAreaHeight = windowHeight_ - belowAppBar;
|
||||
|
||||
// Navigation region
|
||||
switch (navStyle_) {
|
||||
case breakpoint::NavStyle::NavDrawer:
|
||||
if (navExpanded_) {
|
||||
navSize_ = ImVec2(size::NavDrawerWidth, contentAreaHeight);
|
||||
} else {
|
||||
navSize_ = ImVec2(size::NavRailWidth, contentAreaHeight);
|
||||
}
|
||||
navPos_ = ImVec2(0, belowAppBar);
|
||||
break;
|
||||
|
||||
case breakpoint::NavStyle::NavRail:
|
||||
navSize_ = ImVec2(size::NavRailWidth, contentAreaHeight);
|
||||
navPos_ = ImVec2(0, belowAppBar);
|
||||
break;
|
||||
|
||||
case breakpoint::NavStyle::BottomNav:
|
||||
// Bottom nav handled separately
|
||||
navSize_ = ImVec2(windowWidth_, size::NavItemHeight);
|
||||
navPos_ = ImVec2(0, windowHeight_ - size::NavItemHeight);
|
||||
contentAreaHeight -= size::NavItemHeight;
|
||||
break;
|
||||
}
|
||||
|
||||
// Content region
|
||||
if (navStyle_ == breakpoint::NavStyle::BottomNav) {
|
||||
contentPos_ = ImVec2(0, belowAppBar);
|
||||
contentSize_ = ImVec2(windowWidth_, contentAreaHeight);
|
||||
} else {
|
||||
contentPos_ = ImVec2(navSize_.x, belowAppBar);
|
||||
contentSize_ = ImVec2(windowWidth_ - navSize_.x, contentAreaHeight);
|
||||
}
|
||||
|
||||
contentWidth_ = contentSize_.x;
|
||||
contentHeight_ = contentSize_.y;
|
||||
}
|
||||
|
||||
inline bool AppLayout::beginAppBar(const char* title, bool showBack) {
|
||||
ImGui::SetNextWindowPos(appBarPos_);
|
||||
ImGui::SetNextWindowSize(appBarSize_);
|
||||
|
||||
ImGuiWindowFlags flags =
|
||||
ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
|
||||
// Use elevated surface color for app bar
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, SurfaceVec4(4));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(size::AppBarPadding, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||||
|
||||
bool visible = ImGui::Begin("##AppBar", nullptr, flags);
|
||||
|
||||
if (visible) {
|
||||
inAppBar_ = true;
|
||||
|
||||
// Center content vertically
|
||||
float centerY = (size::AppBarHeight - Typography::instance().getFont(TypeStyle::H6)->FontSize) * 0.5f;
|
||||
ImGui::SetCursorPosY(centerY);
|
||||
|
||||
// Menu/back button
|
||||
if (showBack) {
|
||||
if (ImGui::Button("<")) {
|
||||
// Back action - handled by caller
|
||||
}
|
||||
ImGui::SameLine();
|
||||
} else if (navStyle_ != breakpoint::NavStyle::BottomNav) {
|
||||
// Menu button to toggle nav
|
||||
if (ImGui::Button("=")) {
|
||||
toggleNav();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
}
|
||||
|
||||
// Title
|
||||
Typography::instance().text(TypeStyle::H6, title);
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void AppLayout::endAppBar() {
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopStyleColor();
|
||||
inAppBar_ = false;
|
||||
}
|
||||
|
||||
inline bool AppLayout::beginNavigation() {
|
||||
if (navStyle_ == breakpoint::NavStyle::BottomNav) {
|
||||
ImGui::SetNextWindowPos(navPos_);
|
||||
} else {
|
||||
ImGui::SetNextWindowPos(navPos_);
|
||||
}
|
||||
ImGui::SetNextWindowSize(navSize_);
|
||||
|
||||
ImGuiWindowFlags flags =
|
||||
ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
|
||||
// Nav drawer has higher elevation
|
||||
int elevation = (navStyle_ == breakpoint::NavStyle::NavDrawer) ? 16 : 0;
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, SurfaceVec4(elevation));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, size::NavSectionPadding));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||||
|
||||
bool visible = ImGui::Begin("##Navigation", nullptr, flags);
|
||||
|
||||
if (visible) {
|
||||
inNav_ = true;
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void AppLayout::endNavigation() {
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopStyleColor();
|
||||
inNav_ = false;
|
||||
}
|
||||
|
||||
inline bool AppLayout::navItem(const char* label, const char* icon, bool selected) {
|
||||
bool compact = !navExpanded_ || navStyle_ == breakpoint::NavStyle::NavRail;
|
||||
|
||||
float itemWidth = navSize_.x;
|
||||
float itemHeight = size::NavItemHeight;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Selection highlight
|
||||
if (selected) {
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* drawList = ImGui::GetWindowDrawList();
|
||||
drawList->AddRectFilled(
|
||||
pos,
|
||||
ImVec2(pos.x + itemWidth, pos.y + itemHeight),
|
||||
StateSelected()
|
||||
);
|
||||
}
|
||||
|
||||
// Padding
|
||||
ImGui::SetCursorPosX(size::NavItemPadding);
|
||||
|
||||
// Content
|
||||
bool clicked = false;
|
||||
ImGui::BeginGroup();
|
||||
|
||||
if (compact) {
|
||||
// Rail/collapsed: Icon only, centered
|
||||
CenterHorizontally(size::IconSize);
|
||||
clicked = ImGui::Selectable(icon ? icon : "?", selected, 0, ImVec2(size::IconSize, itemHeight));
|
||||
} else {
|
||||
// Drawer: Icon + label
|
||||
if (icon) {
|
||||
ImGui::Text("%s", icon);
|
||||
ImGui::SameLine();
|
||||
}
|
||||
float selectableWidth = itemWidth - size::NavItemPadding * 2 - (icon ? size::IconSize + spacing::Sm : 0);
|
||||
clicked = ImGui::Selectable(label, selected, 0, ImVec2(selectableWidth, itemHeight));
|
||||
}
|
||||
|
||||
ImGui::EndGroup();
|
||||
ImGui::PopID();
|
||||
|
||||
return clicked;
|
||||
}
|
||||
|
||||
inline void AppLayout::navSection(const char* title) {
|
||||
VSpace(1);
|
||||
|
||||
if (title && navExpanded_) {
|
||||
ImGui::SetCursorPosX(size::NavItemPadding);
|
||||
Typography::instance().textColored(TypeStyle::Caption, OnSurfaceMedium(), title);
|
||||
}
|
||||
|
||||
// Divider
|
||||
ImGui::Separator();
|
||||
VSpace(1);
|
||||
}
|
||||
|
||||
inline bool AppLayout::beginContent() {
|
||||
ImGui::SetNextWindowPos(contentPos_);
|
||||
ImGui::SetNextWindowSize(contentSize_);
|
||||
|
||||
ImGuiWindowFlags flags =
|
||||
ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGui::ColorConvertU32ToFloat4(Background()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spacing::Md, spacing::Md));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||||
|
||||
bool visible = ImGui::Begin("##Content", nullptr, flags);
|
||||
|
||||
if (visible) {
|
||||
inContent_ = true;
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void AppLayout::endContent() {
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopStyleColor();
|
||||
inContent_ = false;
|
||||
}
|
||||
|
||||
inline bool AppLayout::beginCard(const char* id, const CardLayout& layout) {
|
||||
float width = layout.width > 0 ? layout.width : ImGui::GetContentRegionAvail().x;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
// Card background
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, SurfaceVec4(layout.elevation));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, layout.cornerRadius);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(layout.padding, layout.padding));
|
||||
|
||||
ImVec2 size(width, layout.minHeight > 0 ? layout.minHeight : 0);
|
||||
bool visible = ImGui::BeginChild(id, size, ImGuiChildFlags_AutoResizeY);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void AppLayout::endCard() {
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleVar(2);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopID();
|
||||
|
||||
// Add spacing after card
|
||||
VSpace(2);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Function
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Get the app layout instance
|
||||
*/
|
||||
inline AppLayout& Layout() {
|
||||
return AppLayout::instance();
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,330 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "buttons.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design App Bar Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/app-bars-top
|
||||
//
|
||||
// The top app bar displays information and actions relating to the current
|
||||
// screen.
|
||||
|
||||
enum class AppBarType {
|
||||
Regular, // Standard height (56/64dp)
|
||||
Prominent, // Extended height for larger titles
|
||||
Dense // Smaller height for desktop
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief App bar configuration
|
||||
*/
|
||||
struct AppBarSpec {
|
||||
AppBarType type = AppBarType::Regular;
|
||||
ImU32 backgroundColor = 0; // 0 = use elevated surface
|
||||
bool elevated = true; // Show elevation
|
||||
bool centerTitle = false; // Center title (default: left)
|
||||
float elevation = 4.0f; // Elevation in dp
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a top app bar
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param spec App bar configuration
|
||||
* @return true if app bar is visible
|
||||
*/
|
||||
bool BeginAppBar(const char* id, const AppBarSpec& spec = AppBarSpec());
|
||||
|
||||
/**
|
||||
* @brief End app bar
|
||||
*/
|
||||
void EndAppBar();
|
||||
|
||||
/**
|
||||
* @brief Set app bar navigation icon (left side)
|
||||
*
|
||||
* @param icon Icon text (e.g., "☰" for menu)
|
||||
* @param tooltip Optional tooltip
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool AppBarNavIcon(const char* icon, const char* tooltip = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Set app bar title
|
||||
*/
|
||||
void AppBarTitle(const char* title);
|
||||
|
||||
/**
|
||||
* @brief Add app bar action button (right side)
|
||||
*
|
||||
* @param icon Icon text
|
||||
* @param tooltip Optional tooltip
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool AppBarAction(const char* icon, const char* tooltip = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Begin app bar action menu (for overflow)
|
||||
*/
|
||||
bool BeginAppBarMenu(const char* icon);
|
||||
|
||||
/**
|
||||
* @brief End app bar action menu
|
||||
*/
|
||||
void EndAppBarMenu();
|
||||
|
||||
/**
|
||||
* @brief Add menu item to app bar menu
|
||||
*/
|
||||
bool AppBarMenuItem(const char* label, const char* icon = nullptr);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
struct AppBarState {
|
||||
ImVec2 barMin;
|
||||
ImVec2 barMax;
|
||||
float height;
|
||||
float navIconWidth;
|
||||
float actionsStartX;
|
||||
float titleX;
|
||||
bool hasNavIcon;
|
||||
bool centerTitle;
|
||||
ImU32 backgroundColor;
|
||||
};
|
||||
|
||||
static AppBarState g_appBarState;
|
||||
|
||||
inline bool BeginAppBar(const char* id, const AppBarSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
// Calculate height based on type
|
||||
float barHeight;
|
||||
switch (spec.type) {
|
||||
case AppBarType::Prominent:
|
||||
barHeight = 128.0f;
|
||||
break;
|
||||
case AppBarType::Dense:
|
||||
barHeight = 48.0f;
|
||||
break;
|
||||
default:
|
||||
barHeight = size::AppBarHeight; // 56dp
|
||||
break;
|
||||
}
|
||||
|
||||
g_appBarState.height = barHeight;
|
||||
g_appBarState.hasNavIcon = false;
|
||||
g_appBarState.centerTitle = spec.centerTitle;
|
||||
g_appBarState.navIconWidth = 0;
|
||||
g_appBarState.actionsStartX = io.DisplaySize.x; // Will be adjusted as actions added
|
||||
|
||||
// Bar position (always at top)
|
||||
g_appBarState.barMin = ImVec2(0, 0);
|
||||
g_appBarState.barMax = ImVec2(io.DisplaySize.x, barHeight);
|
||||
|
||||
// Background color
|
||||
if (spec.backgroundColor != 0) {
|
||||
g_appBarState.backgroundColor = spec.backgroundColor;
|
||||
} else {
|
||||
g_appBarState.backgroundColor = Surface(Elevation::Dp4);
|
||||
}
|
||||
|
||||
// Draw app bar background
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
drawList->AddRectFilled(g_appBarState.barMin, g_appBarState.barMax, g_appBarState.backgroundColor);
|
||||
|
||||
// Bottom divider/shadow
|
||||
if (spec.elevated) {
|
||||
drawList->AddLine(
|
||||
ImVec2(g_appBarState.barMin.x, g_appBarState.barMax.y),
|
||||
ImVec2(g_appBarState.barMax.x, g_appBarState.barMax.y),
|
||||
schema::UI().resolveColor("var(--app-bar-shadow)", IM_COL32(0, 0, 0, 50))
|
||||
);
|
||||
}
|
||||
|
||||
// Set up layout
|
||||
g_appBarState.titleX = spacing::dp(2); // Default title position
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void EndAppBar() {
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
inline bool AppBarNavIcon(const char* icon, const char* tooltip) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
g_appBarState.hasNavIcon = true;
|
||||
g_appBarState.navIconWidth = size::TouchTarget;
|
||||
|
||||
// Position nav icon on left
|
||||
float iconX = spacing::dp(0.5f); // 4dp left margin
|
||||
float centerY = g_appBarState.barMin.y + g_appBarState.height * 0.5f;
|
||||
|
||||
ImVec2 buttonPos(iconX, centerY - size::TouchTarget * 0.5f);
|
||||
|
||||
// Draw icon button
|
||||
ImGui::SetCursorScreenPos(buttonPos);
|
||||
bool clicked = IconButton(icon, tooltip);
|
||||
|
||||
// Update title position
|
||||
g_appBarState.titleX = iconX + size::TouchTarget + spacing::dp(1);
|
||||
|
||||
return clicked;
|
||||
}
|
||||
|
||||
inline void AppBarTitle(const char* title) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
float centerY = g_appBarState.barMin.y + g_appBarState.height * 0.5f;
|
||||
|
||||
// Calculate title position
|
||||
float titleX;
|
||||
if (g_appBarState.centerTitle) {
|
||||
// Center title between nav icon and actions
|
||||
float availableWidth = g_appBarState.actionsStartX - g_appBarState.titleX;
|
||||
Typography::instance().pushFont(TypeStyle::H6);
|
||||
float titleWidth = ImGui::CalcTextSize(title).x;
|
||||
Typography::instance().popFont();
|
||||
titleX = g_appBarState.titleX + (availableWidth - titleWidth) * 0.5f;
|
||||
} else {
|
||||
titleX = g_appBarState.titleX;
|
||||
}
|
||||
|
||||
// Render title
|
||||
Typography::instance().pushFont(TypeStyle::H6);
|
||||
float titleY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
drawList->AddText(ImVec2(titleX, titleY), OnSurface(), title);
|
||||
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
inline bool AppBarAction(const char* icon, const char* tooltip) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
// Actions are positioned from right edge
|
||||
g_appBarState.actionsStartX -= size::TouchTarget;
|
||||
|
||||
float centerY = g_appBarState.barMin.y + g_appBarState.height * 0.5f;
|
||||
ImVec2 buttonPos(g_appBarState.actionsStartX, centerY - size::TouchTarget * 0.5f);
|
||||
|
||||
ImGui::SetCursorScreenPos(buttonPos);
|
||||
return IconButton(icon, tooltip);
|
||||
}
|
||||
|
||||
inline bool BeginAppBarMenu(const char* icon) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
// Position menu button
|
||||
g_appBarState.actionsStartX -= size::TouchTarget;
|
||||
|
||||
float centerY = g_appBarState.barMin.y + g_appBarState.height * 0.5f;
|
||||
ImVec2 buttonPos(g_appBarState.actionsStartX, centerY - size::TouchTarget * 0.5f);
|
||||
|
||||
ImGui::SetCursorScreenPos(buttonPos);
|
||||
|
||||
bool menuOpen = false;
|
||||
if (IconButton(icon, "More options")) {
|
||||
ImGui::OpenPopup("##appbar_menu");
|
||||
}
|
||||
|
||||
// Style the popup menu
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, spacing::dp(1)));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_PopupRounding, size::MenuCornerRadius);
|
||||
ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGui::ColorConvertU32ToFloat4(Surface(Elevation::Dp8)));
|
||||
|
||||
if (ImGui::BeginPopup("##appbar_menu")) {
|
||||
menuOpen = true;
|
||||
}
|
||||
|
||||
return menuOpen;
|
||||
}
|
||||
|
||||
inline void EndAppBarMenu() {
|
||||
ImGui::EndPopup();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopStyleVar(2);
|
||||
}
|
||||
|
||||
inline bool AppBarMenuItem(const char* label, const char* icon) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
|
||||
const float itemHeight = size::ListItemHeight;
|
||||
const float itemWidth = 200.0f; // Menu min width
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect itemBB(pos, ImVec2(pos.x + itemWidth, pos.y + itemHeight));
|
||||
|
||||
ImGuiID id = window->GetID(label);
|
||||
ImGui::ItemSize(itemBB);
|
||||
if (!ImGui::ItemAdd(itemBB, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(itemBB, id, &hovered, &held);
|
||||
|
||||
// Draw
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
if (hovered) {
|
||||
drawList->AddRectFilled(itemBB.Min, itemBB.Max, schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 10)));
|
||||
}
|
||||
|
||||
float contentX = pos.x + spacing::dp(2);
|
||||
float centerY = pos.y + itemHeight * 0.5f;
|
||||
|
||||
// Icon
|
||||
if (icon) {
|
||||
drawList->AddText(ImVec2(contentX, centerY - 12.0f), OnSurfaceMedium(), icon);
|
||||
contentX += 24.0f + spacing::dp(2);
|
||||
}
|
||||
|
||||
// Label
|
||||
Typography::instance().pushFont(TypeStyle::Body1);
|
||||
float labelY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
drawList->AddText(ImVec2(contentX, labelY), OnSurface(), label);
|
||||
Typography::instance().popFont();
|
||||
|
||||
if (pressed) {
|
||||
ImGui::CloseCurrentPopup();
|
||||
}
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../tooltip_style.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
@@ -271,7 +272,7 @@ inline bool IconButton(const char* icon, const char* tooltip, bool enabled) {
|
||||
|
||||
// Tooltip
|
||||
if (tooltip && hovered) {
|
||||
ImGui::SetTooltip("%s", tooltip);
|
||||
material::Tooltip("%s", tooltip);
|
||||
}
|
||||
|
||||
return pressed && enabled;
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Card Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/cards
|
||||
//
|
||||
// Cards contain content and actions about a single subject.
|
||||
// They can be elevated (with shadow) or outlined (with border).
|
||||
|
||||
/**
|
||||
* @brief Card configuration
|
||||
*/
|
||||
struct CardSpec {
|
||||
int elevation = 1; // Elevation in dp (0-24)
|
||||
bool outlined = false; // Use outline instead of elevation
|
||||
float cornerRadius = 4.0f; // Corner radius in dp
|
||||
bool clickable = false; // Make entire card clickable
|
||||
float padding = 16.0f; // Content padding
|
||||
float minHeight = 0.0f; // Minimum height (0 = auto)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a Material Design card
|
||||
*
|
||||
* @param id Unique identifier for the card
|
||||
* @param spec Card configuration
|
||||
* @return true if card is visible and content should be rendered
|
||||
*/
|
||||
bool BeginCard(const char* id, const CardSpec& spec = CardSpec());
|
||||
|
||||
/**
|
||||
* @brief End the card
|
||||
*/
|
||||
void EndCard();
|
||||
|
||||
/**
|
||||
* @brief Begin a clickable card that returns click state
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param spec Card configuration
|
||||
* @param clicked Output: true if card was clicked
|
||||
* @return true if card is visible
|
||||
*/
|
||||
bool BeginClickableCard(const char* id, const CardSpec& spec, bool* clicked);
|
||||
|
||||
/**
|
||||
* @brief Card header with title and optional subtitle
|
||||
*
|
||||
* @param title Primary title text
|
||||
* @param subtitle Optional secondary text
|
||||
* @param avatar Optional avatar texture (rendered as circle)
|
||||
*/
|
||||
void CardHeader(const char* title, const char* subtitle = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Card supporting text/content
|
||||
*
|
||||
* @param text Body text content
|
||||
*/
|
||||
void CardContent(const char* text);
|
||||
|
||||
/**
|
||||
* @brief Begin card action area (for buttons)
|
||||
*
|
||||
* Actions are typically placed at the bottom of the card.
|
||||
*/
|
||||
void CardActions();
|
||||
|
||||
/**
|
||||
* @brief End card action area
|
||||
*/
|
||||
void CardActionsEnd();
|
||||
|
||||
/**
|
||||
* @brief Add divider within card
|
||||
*/
|
||||
void CardDivider();
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool BeginCard(const char* id, const CardSpec& spec) {
|
||||
ImGui::PushID(id);
|
||||
|
||||
// Calculate surface color based on elevation
|
||||
ImU32 bgColor = spec.outlined ? Surface() : GetElevatedSurface(GetCurrentColorTheme(), spec.elevation);
|
||||
|
||||
// When acrylic backdrop is active, scale card bg alpha by UI opacity
|
||||
// so cards smoothly transition from opaque (1.0) to see-through.
|
||||
bool opaqueCards = dragonx::ui::effects::isLowSpecMode();
|
||||
if (IsBackdropActive() && !opaqueCards) {
|
||||
ImVec4 c = ImGui::ColorConvertU32ToFloat4(bgColor);
|
||||
float uiOp = dragonx::ui::effects::ImGuiAcrylic::GetUIOpacity();
|
||||
c.w *= uiOp;
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, c);
|
||||
} else {
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgColor));
|
||||
}
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, spec.cornerRadius);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spec.padding, spec.padding));
|
||||
|
||||
// Border for outlined variant
|
||||
if (spec.outlined) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(Outline()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f);
|
||||
} else {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 0.0f);
|
||||
}
|
||||
|
||||
ImVec2 size(0, spec.minHeight); // 0 width = use available width
|
||||
ImGuiChildFlags flags = ImGuiChildFlags_AutoResizeY;
|
||||
if (spec.outlined) {
|
||||
flags |= ImGuiChildFlags_Borders;
|
||||
}
|
||||
|
||||
bool visible = ImGui::BeginChild(id, size, flags);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void EndCard() {
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::PopStyleVar(3); // ChildRounding, WindowPadding, ChildBorderSize
|
||||
ImGui::PopStyleColor(1); // ChildBg
|
||||
|
||||
// Check if we used outline style (need to pop extra color)
|
||||
// Note: We always push the border size var, handle outline color in BeginCard
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
// Add spacing after card
|
||||
VSpace(2);
|
||||
}
|
||||
|
||||
inline bool BeginClickableCard(const char* id, const CardSpec& spec, bool* clicked) {
|
||||
*clicked = false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
ImVec2 startPos = ImGui::GetCursorScreenPos();
|
||||
|
||||
// Render card background
|
||||
ImU32 bgColor = spec.outlined ? Surface() : GetElevatedSurface(GetCurrentColorTheme(), spec.elevation);
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgColor));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, spec.cornerRadius);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(spec.padding, spec.padding));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, spec.outlined ? 1.0f : 0.0f);
|
||||
|
||||
if (spec.outlined) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(Outline()));
|
||||
}
|
||||
|
||||
ImVec2 size(0, spec.minHeight);
|
||||
ImGuiChildFlags flags = ImGuiChildFlags_AutoResizeY;
|
||||
if (spec.outlined) {
|
||||
flags |= ImGuiChildFlags_Borders;
|
||||
}
|
||||
|
||||
bool visible = ImGui::BeginChild(id, size, flags);
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
inline void CardHeader(const char* title, const char* subtitle) {
|
||||
Typography::instance().text(TypeStyle::H6, title);
|
||||
|
||||
if (subtitle) {
|
||||
Typography::instance().textColored(TypeStyle::Body2, OnSurfaceMedium(), subtitle);
|
||||
}
|
||||
|
||||
VSpace(1);
|
||||
}
|
||||
|
||||
inline void CardContent(const char* text) {
|
||||
Typography::instance().textWrapped(TypeStyle::Body2, text);
|
||||
VSpace(1);
|
||||
}
|
||||
|
||||
inline void CardActions() {
|
||||
ImGui::Separator();
|
||||
VSpace(1);
|
||||
ImGui::BeginGroup();
|
||||
}
|
||||
|
||||
inline void CardActionsEnd() {
|
||||
ImGui::EndGroup();
|
||||
}
|
||||
|
||||
inline void CardDivider() {
|
||||
ImGui::Separator();
|
||||
VSpace(1);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,296 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Chips Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/chips
|
||||
//
|
||||
// Chips are compact elements that represent an input, attribute, or action.
|
||||
|
||||
enum class ChipType {
|
||||
Input, // User input (deletable)
|
||||
Choice, // Single selection from set
|
||||
Filter, // Filter/checkbox style
|
||||
Action // Triggers action
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Chip configuration
|
||||
*/
|
||||
struct ChipSpec {
|
||||
ChipType type = ChipType::Action;
|
||||
const char* label = nullptr;
|
||||
const char* icon = nullptr; // Leading icon
|
||||
const char* avatar = nullptr; // Avatar text (overrides icon)
|
||||
ImU32 avatarColor = 0; // Avatar background color
|
||||
bool selected = false; // For choice/filter chips
|
||||
bool disabled = false;
|
||||
bool outlined = false; // Outlined vs filled style
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Render a chip
|
||||
*
|
||||
* @param spec Chip configuration
|
||||
* @return For filter/choice: true if clicked (toggle selection)
|
||||
* For input: true if delete clicked
|
||||
* For action: true if clicked
|
||||
*/
|
||||
bool Chip(const ChipSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Simple action chip
|
||||
*/
|
||||
bool Chip(const char* label);
|
||||
|
||||
/**
|
||||
* @brief Filter chip (toggleable)
|
||||
*/
|
||||
bool FilterChip(const char* label, bool* selected);
|
||||
|
||||
/**
|
||||
* @brief Choice chip (radio-style)
|
||||
*/
|
||||
bool ChoiceChip(const char* label, bool selected);
|
||||
|
||||
/**
|
||||
* @brief Input chip with delete
|
||||
*/
|
||||
bool InputChip(const char* label, const char* avatar = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Begin a chip group for layout
|
||||
*/
|
||||
void BeginChipGroup();
|
||||
|
||||
/**
|
||||
* @brief End a chip group
|
||||
*/
|
||||
void EndChipGroup();
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool Chip(const ChipSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(spec.label);
|
||||
|
||||
// Chip dimensions
|
||||
const float chipHeight = 32.0f;
|
||||
const float cornerRadius = chipHeight * 0.5f;
|
||||
const float horizontalPadding = 12.0f;
|
||||
const float iconSize = 18.0f;
|
||||
const float avatarSize = 24.0f;
|
||||
const float deleteIconSize = 18.0f;
|
||||
|
||||
// Calculate content width
|
||||
float contentWidth = horizontalPadding * 2;
|
||||
|
||||
bool hasLeading = spec.icon || spec.avatar;
|
||||
bool hasDelete = (spec.type == ChipType::Input);
|
||||
bool hasCheckmark = (spec.type == ChipType::Filter && spec.selected);
|
||||
|
||||
if (spec.avatar) {
|
||||
contentWidth += avatarSize + 8.0f;
|
||||
} else if (spec.icon || hasCheckmark) {
|
||||
contentWidth += iconSize + 8.0f;
|
||||
}
|
||||
|
||||
contentWidth += ImGui::CalcTextSize(spec.label).x;
|
||||
|
||||
if (hasDelete) {
|
||||
contentWidth += deleteIconSize + 8.0f;
|
||||
}
|
||||
|
||||
// Layout
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect chipBB(pos, ImVec2(pos.x + contentWidth, pos.y + chipHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID("##chip");
|
||||
ImGui::ItemSize(chipBB);
|
||||
if (!ImGui::ItemAdd(chipBB, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool clicked = ImGui::ButtonBehavior(chipBB, id, &hovered, &held) && !spec.disabled;
|
||||
|
||||
// Delete button hit test (for input chips)
|
||||
bool deleteClicked = false;
|
||||
if (hasDelete) {
|
||||
float deleteX = chipBB.Max.x - horizontalPadding - deleteIconSize;
|
||||
ImRect deleteBB(
|
||||
ImVec2(deleteX, pos.y + (chipHeight - deleteIconSize) * 0.5f),
|
||||
ImVec2(deleteX + deleteIconSize, pos.y + (chipHeight + deleteIconSize) * 0.5f)
|
||||
);
|
||||
|
||||
ImGuiID deleteId = window->GetID("##delete");
|
||||
bool deleteHovered, deleteHeld;
|
||||
deleteClicked = ImGui::ButtonBehavior(deleteBB, deleteId, &deleteHovered, &deleteHeld);
|
||||
}
|
||||
|
||||
// Draw
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Background
|
||||
ImU32 bgColor;
|
||||
ImU32 borderColor = 0;
|
||||
|
||||
if (spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--surface-hover)", IM_COL32(255, 255, 255, 30));
|
||||
} else if (spec.selected) {
|
||||
bgColor = WithAlpha(Primary(), 51); // Primary at 20%
|
||||
} else if (spec.outlined) {
|
||||
bgColor = 0; // Transparent
|
||||
borderColor = OnSurfaceMedium();
|
||||
} else {
|
||||
bgColor = schema::UI().resolveColor("var(--surface-hover)", IM_COL32(255, 255, 255, 30));
|
||||
}
|
||||
|
||||
// Hover/press overlay
|
||||
if (!spec.disabled) {
|
||||
if (held) {
|
||||
bgColor = IM_COL32_ADD(bgColor, schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
} else if (hovered) {
|
||||
bgColor = IM_COL32_ADD(bgColor, schema::UI().resolveColor("var(--active-overlay)", IM_COL32(255, 255, 255, 10)));
|
||||
}
|
||||
}
|
||||
|
||||
// Draw background
|
||||
if (bgColor) {
|
||||
drawList->AddRectFilled(chipBB.Min, chipBB.Max, bgColor, cornerRadius);
|
||||
}
|
||||
|
||||
// Draw border for outlined
|
||||
if (borderColor) {
|
||||
drawList->AddRect(chipBB.Min, chipBB.Max, borderColor, cornerRadius, 0, 1.0f);
|
||||
}
|
||||
|
||||
// Content
|
||||
float currentX = pos.x + horizontalPadding;
|
||||
float centerY = pos.y + chipHeight * 0.5f;
|
||||
|
||||
ImU32 contentColor = spec.disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
ImU32 iconColor = spec.disabled ? OnSurfaceDisabled() :
|
||||
spec.selected ? Primary() : OnSurfaceMedium();
|
||||
|
||||
// Avatar or icon
|
||||
if (spec.avatar) {
|
||||
// Avatar circle
|
||||
ImVec2 avatarCenter(currentX + avatarSize * 0.5f - 4.0f, centerY);
|
||||
ImU32 avatarBg = spec.avatarColor ? spec.avatarColor : Primary();
|
||||
drawList->AddCircleFilled(avatarCenter, avatarSize * 0.5f, avatarBg);
|
||||
|
||||
// Avatar text
|
||||
ImVec2 textSize = ImGui::CalcTextSize(spec.avatar);
|
||||
ImVec2 textPos(avatarCenter.x - textSize.x * 0.5f, avatarCenter.y - textSize.y * 0.5f);
|
||||
drawList->AddText(textPos, OnPrimary(), spec.avatar);
|
||||
|
||||
currentX += avatarSize + 4.0f;
|
||||
} else if (hasCheckmark) {
|
||||
// Checkmark for selected filter chips
|
||||
ImFont* iconFont = Typography::instance().iconSmall();
|
||||
ImVec2 chkSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, ICON_MD_CHECK);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(currentX, centerY - chkSz.y * 0.5f), Primary(), ICON_MD_CHECK);
|
||||
currentX += iconSize + 4.0f;
|
||||
} else if (spec.icon) {
|
||||
drawList->AddText(ImVec2(currentX, centerY - iconSize * 0.5f), iconColor, spec.icon);
|
||||
currentX += iconSize + 4.0f;
|
||||
}
|
||||
|
||||
// Label
|
||||
Typography::instance().pushFont(TypeStyle::Body2);
|
||||
float labelY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
drawList->AddText(ImVec2(currentX, labelY), contentColor, spec.label);
|
||||
Typography::instance().popFont();
|
||||
|
||||
// Delete icon (for input chips)
|
||||
if (hasDelete) {
|
||||
float deleteX = chipBB.Max.x - horizontalPadding - deleteIconSize;
|
||||
ImFont* iconFont = Typography::instance().iconSmall();
|
||||
ImVec2 delSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, ICON_MD_CLOSE);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(deleteX, centerY - delSz.y * 0.5f),
|
||||
OnSurfaceMedium(), ICON_MD_CLOSE
|
||||
);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
// Return value depends on chip type
|
||||
if (spec.type == ChipType::Input) {
|
||||
return deleteClicked;
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
inline bool Chip(const char* label) {
|
||||
ChipSpec spec;
|
||||
spec.label = label;
|
||||
spec.type = ChipType::Action;
|
||||
return Chip(spec);
|
||||
}
|
||||
|
||||
inline bool FilterChip(const char* label, bool* selected) {
|
||||
ChipSpec spec;
|
||||
spec.label = label;
|
||||
spec.type = ChipType::Filter;
|
||||
spec.selected = *selected;
|
||||
|
||||
bool clicked = Chip(spec);
|
||||
if (clicked) {
|
||||
*selected = !*selected;
|
||||
}
|
||||
return clicked;
|
||||
}
|
||||
|
||||
inline bool ChoiceChip(const char* label, bool selected) {
|
||||
ChipSpec spec;
|
||||
spec.label = label;
|
||||
spec.type = ChipType::Choice;
|
||||
spec.selected = selected;
|
||||
return Chip(spec);
|
||||
}
|
||||
|
||||
inline bool InputChip(const char* label, const char* avatar) {
|
||||
ChipSpec spec;
|
||||
spec.label = label;
|
||||
spec.type = ChipType::Input;
|
||||
spec.avatar = avatar;
|
||||
return Chip(spec);
|
||||
}
|
||||
|
||||
inline void BeginChipGroup() {
|
||||
ImGui::BeginGroup();
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing::dp(1), spacing::dp(1))); // 8dp spacing
|
||||
}
|
||||
|
||||
inline void EndChipGroup() {
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::EndGroup();
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,122 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Components - Unified Header
|
||||
// ============================================================================
|
||||
// Include this single header to get all Material Design components.
|
||||
//
|
||||
// Based on Material Design 2 (m2.material.io)
|
||||
//
|
||||
// All components are in the namespace: dragonx::ui::material
|
||||
|
||||
// Core dependencies
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
|
||||
// Components
|
||||
#include "buttons.h" // Button, IconButton, FAB
|
||||
#include "cards.h" // Card, CardHeader, CardContent, CardActions
|
||||
#include "text_fields.h" // TextField
|
||||
#include "lists.h" // ListItem, ListDivider, ListSubheader
|
||||
#include "dialogs.h" // Dialog, ConfirmDialog, AlertDialog
|
||||
#include "inputs.h" // Switch, Checkbox, RadioButton
|
||||
#include "progress.h" // LinearProgress, CircularProgress
|
||||
#include "snackbar.h" // Snackbar, ShowSnackbar
|
||||
#include "slider.h" // Slider, SliderDiscrete, SliderRange
|
||||
#include "tabs.h" // TabBar, Tab
|
||||
#include "chips.h" // Chip, FilterChip, ChoiceChip, InputChip
|
||||
#include "nav_drawer.h" // NavDrawer, NavItem
|
||||
#include "app_bar.h" // AppBar, AppBarTitle, AppBarAction
|
||||
|
||||
// ============================================================================
|
||||
// Quick Reference
|
||||
// ============================================================================
|
||||
//
|
||||
// BUTTONS:
|
||||
// Button(label, spec) - Generic button with style config
|
||||
// TextButton(label) - Text-only button
|
||||
// OutlinedButton(label) - Button with outline
|
||||
// ContainedButton(label) - Filled button (primary)
|
||||
// IconButton(icon, tooltip) - Circular icon button
|
||||
// FAB(icon) - Floating action button
|
||||
//
|
||||
// CARDS:
|
||||
// BeginCard(spec)/EndCard() - Card container
|
||||
// CardHeader(title, subtitle) - Card header section
|
||||
// CardContent(text) - Card body text
|
||||
// CardActions()/EndCardActions()- Card button area
|
||||
//
|
||||
// TEXT FIELDS:
|
||||
// TextField(label, buf, size) - Text input field
|
||||
// TextField(id, buf, size, spec)- Configurable text field
|
||||
//
|
||||
// LISTS:
|
||||
// BeginList(id)/EndList() - List container
|
||||
// ListItem(text) - Simple list item
|
||||
// ListItem(primary, secondary) - Two-line item
|
||||
// ListItem(spec) - Full config item
|
||||
// ListDivider(inset) - Divider line
|
||||
// ListSubheader(text) - Section header
|
||||
//
|
||||
// DIALOGS:
|
||||
// BeginDialog(id, &open, spec) - Modal dialog
|
||||
// EndDialog()
|
||||
// ConfirmDialog(...) - Confirm/cancel dialog
|
||||
// AlertDialog(...) - Single-action alert
|
||||
//
|
||||
// SELECTION CONTROLS:
|
||||
// Switch(label, &value) - Toggle switch
|
||||
// Checkbox(label, &value) - Checkbox
|
||||
// RadioButton(label, active) - Radio button
|
||||
// RadioButton(label, &sel, val) - Radio with int selection
|
||||
//
|
||||
// PROGRESS:
|
||||
// LinearProgress(fraction) - Determinate progress bar
|
||||
// LinearProgressIndeterminate() - Indeterminate progress bar
|
||||
// CircularProgress(fraction) - Circular progress
|
||||
// CircularProgressIndeterminate()- Spinner
|
||||
//
|
||||
// SNACKBAR:
|
||||
// ShowSnackbar(msg, action) - Show notification
|
||||
// DismissSnackbar() - Dismiss current snackbar
|
||||
// RenderSnackbar() - Call each frame to render
|
||||
//
|
||||
// SLIDER:
|
||||
// Slider(label, &val, min, max) - Continuous slider
|
||||
// SliderInt(label, &val, ...) - Integer slider
|
||||
// SliderDiscrete(...) - Stepped slider
|
||||
// SliderRange(...) - Two-thumb range slider
|
||||
//
|
||||
// TABS:
|
||||
// BeginTabBar(id, &idx) - Tab bar container
|
||||
// Tab(label) - Tab item
|
||||
// EndTabBar()
|
||||
// TabBar(id, labels, count, &idx) - Simple tab bar
|
||||
//
|
||||
// CHIPS:
|
||||
// Chip(label) - Action chip
|
||||
// FilterChip(label, &selected) - Toggleable filter chip
|
||||
// ChoiceChip(label, selected) - Choice chip
|
||||
// InputChip(label, avatar) - Deletable input chip
|
||||
// BeginChipGroup()/EndChipGroup()- Chip layout helper
|
||||
//
|
||||
// NAVIGATION DRAWER:
|
||||
// BeginNavDrawer(id, &open, spec) - Navigation drawer
|
||||
// EndNavDrawer()
|
||||
// NavItem(icon, label, selected) - Navigation item
|
||||
// NavDivider() - Drawer divider
|
||||
// NavSubheader(text) - Section header
|
||||
//
|
||||
// APP BAR:
|
||||
// BeginAppBar(id, spec) - Top app bar
|
||||
// EndAppBar()
|
||||
// AppBarNavIcon(icon) - Navigation icon (left)
|
||||
// AppBarTitle(title) - App bar title
|
||||
// AppBarAction(icon) - Action button (right)
|
||||
// BeginAppBarMenu(icon) - Overflow menu
|
||||
// AppBarMenuItem(label) - Menu item
|
||||
@@ -1,293 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "buttons.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Dialog Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/dialogs
|
||||
//
|
||||
// Dialogs inform users about a task and can contain critical information,
|
||||
// require decisions, or involve multiple tasks.
|
||||
|
||||
/**
|
||||
* @brief Dialog configuration
|
||||
*/
|
||||
struct DialogSpec {
|
||||
const char* title = nullptr; // Dialog title
|
||||
float width = 560.0f; // Dialog width (280-560dp typical)
|
||||
float maxHeight = 0; // Max height (0 = auto)
|
||||
bool scrollableContent = false; // Enable content scrolling
|
||||
bool fullWidth = false; // Actions span full width
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a modal dialog
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param open Pointer to open state (will be set false when closed)
|
||||
* @param spec Dialog configuration
|
||||
* @return true if dialog is open
|
||||
*/
|
||||
bool BeginDialog(const char* id, bool* open, const DialogSpec& spec = DialogSpec());
|
||||
|
||||
/**
|
||||
* @brief End a dialog
|
||||
*/
|
||||
void EndDialog();
|
||||
|
||||
/**
|
||||
* @brief Simple dialog with just text content
|
||||
*/
|
||||
bool BeginDialog(const char* id, bool* open, const char* title);
|
||||
|
||||
/**
|
||||
* @brief Dialog content area (scrollable if configured)
|
||||
*/
|
||||
void BeginDialogContent();
|
||||
|
||||
/**
|
||||
* @brief End dialog content area
|
||||
*/
|
||||
void EndDialogContent();
|
||||
|
||||
/**
|
||||
* @brief Dialog actions area (buttons)
|
||||
*/
|
||||
void BeginDialogActions();
|
||||
|
||||
/**
|
||||
* @brief End dialog actions area
|
||||
*/
|
||||
void EndDialogActions();
|
||||
|
||||
/**
|
||||
* @brief Standard confirm dialog
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param open Pointer to open state
|
||||
* @param title Dialog title
|
||||
* @param message Dialog message
|
||||
* @param confirmText Confirm button text
|
||||
* @param cancelText Cancel button text (nullptr for no cancel)
|
||||
* @return 0 = still open, 1 = confirmed, -1 = cancelled
|
||||
*/
|
||||
int ConfirmDialog(const char* id, bool* open, const char* title, const char* message,
|
||||
const char* confirmText = "Confirm", const char* cancelText = "Cancel");
|
||||
|
||||
/**
|
||||
* @brief Alert dialog (single action)
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param open Pointer to open state
|
||||
* @param title Dialog title
|
||||
* @param message Dialog message
|
||||
* @param buttonText Button text
|
||||
* @return true when dismissed
|
||||
*/
|
||||
bool AlertDialog(const char* id, bool* open, const char* title, const char* message,
|
||||
const char* buttonText = "OK");
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
// Internal state for dialog rendering
|
||||
struct DialogState {
|
||||
ImVec2 contentMin;
|
||||
ImVec2 contentMax;
|
||||
float contentScrollY;
|
||||
bool scrollable;
|
||||
float width;
|
||||
};
|
||||
|
||||
static DialogState g_currentDialog;
|
||||
|
||||
inline bool BeginDialog(const char* id, bool* open, const DialogSpec& spec) {
|
||||
if (!*open)
|
||||
return false;
|
||||
|
||||
// Center dialog on screen
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
ImVec2 center = ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f);
|
||||
ImGui::SetNextWindowPos(center, ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||||
|
||||
// Set dialog size
|
||||
float dialogWidth = spec.width;
|
||||
ImGui::SetNextWindowSizeConstraints(
|
||||
ImVec2(280.0f, 0), // Min size
|
||||
ImVec2(dialogWidth, spec.maxHeight > 0 ? spec.maxHeight : io.DisplaySize.y * 0.9f)
|
||||
);
|
||||
|
||||
// Style dialog
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, size::DialogCornerRadius);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImGui::ColorConvertU32ToFloat4(Surface(Elevation::Dp24)));
|
||||
ImGui::PushStyleColor(ImGuiCol_PopupBg, ImGui::ColorConvertU32ToFloat4(Surface(Elevation::Dp24)));
|
||||
|
||||
// Modal background (scrim)
|
||||
ImDrawList* bgDrawList = ImGui::GetBackgroundDrawList();
|
||||
bgDrawList->AddRectFilled(
|
||||
ImVec2(0, 0), io.DisplaySize,
|
||||
schema::UI().resolveColor("var(--scrim)", IM_COL32(0, 0, 0, (int)(0.32f * 255)))
|
||||
);
|
||||
|
||||
// Open popup
|
||||
ImGui::OpenPopup(id);
|
||||
bool isOpen = ImGui::BeginPopupModal(id, open,
|
||||
ImGuiWindowFlags_AlwaysAutoResize |
|
||||
ImGuiWindowFlags_NoMove |
|
||||
ImGuiWindowFlags_NoTitleBar);
|
||||
|
||||
if (isOpen) {
|
||||
g_currentDialog.scrollable = spec.scrollableContent;
|
||||
g_currentDialog.width = dialogWidth;
|
||||
|
||||
// Title
|
||||
if (spec.title) {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(3))); // 24dp top padding
|
||||
ImGui::SetCursorPosX(spacing::dp(3)); // 24dp left padding
|
||||
Typography::instance().text(TypeStyle::H6, spec.title);
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(2))); // 16dp below title
|
||||
} else {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(2))); // 16dp top padding without title
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::PopStyleColor(2);
|
||||
ImGui::PopStyleVar(2);
|
||||
|
||||
return isOpen;
|
||||
}
|
||||
|
||||
inline void EndDialog() {
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
inline bool BeginDialog(const char* id, bool* open, const char* title) {
|
||||
DialogSpec spec;
|
||||
spec.title = title;
|
||||
return BeginDialog(id, open, spec);
|
||||
}
|
||||
|
||||
inline void BeginDialogContent() {
|
||||
ImGui::SetCursorPosX(spacing::dp(3)); // 24dp left padding
|
||||
|
||||
// Start content region
|
||||
float maxWidth = g_currentDialog.width - spacing::dp(6); // 24dp padding each side
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + maxWidth);
|
||||
|
||||
if (g_currentDialog.scrollable) {
|
||||
ImGui::BeginChild("##dialogContent", ImVec2(maxWidth, 200), false);
|
||||
}
|
||||
}
|
||||
|
||||
inline void EndDialogContent() {
|
||||
if (g_currentDialog.scrollable) {
|
||||
ImGui::EndChild();
|
||||
}
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(3))); // 24dp after content
|
||||
}
|
||||
|
||||
inline void BeginDialogActions() {
|
||||
// Actions area - right-aligned buttons with 8dp spacing
|
||||
float contentWidth = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(spacing::dp(1)); // 8dp left padding for actions
|
||||
|
||||
// Push style for action buttons
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(spacing::dp(1), 0)); // 8dp between buttons
|
||||
|
||||
// Right-align: use a dummy to push buttons right
|
||||
// Buttons will be added inline with SameLine
|
||||
}
|
||||
|
||||
inline void EndDialogActions() {
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp bottom padding
|
||||
}
|
||||
|
||||
inline int ConfirmDialog(const char* id, bool* open, const char* title, const char* message,
|
||||
const char* confirmText, const char* cancelText) {
|
||||
int result = 0;
|
||||
|
||||
if (BeginDialog(id, open, title)) {
|
||||
BeginDialogContent();
|
||||
Typography::instance().textWrapped(TypeStyle::Body1, message);
|
||||
EndDialogContent();
|
||||
|
||||
BeginDialogActions();
|
||||
|
||||
// Calculate button positions for right alignment
|
||||
float cancelWidth = cancelText ? ImGui::CalcTextSize(cancelText).x + spacing::dp(2) : 0;
|
||||
float confirmWidth = ImGui::CalcTextSize(confirmText).x + spacing::dp(2);
|
||||
float totalButtonWidth = cancelWidth + confirmWidth + (cancelText ? spacing::dp(1) : 0);
|
||||
float startX = ImGui::GetContentRegionAvail().x - totalButtonWidth - spacing::dp(2);
|
||||
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + startX);
|
||||
|
||||
if (cancelText) {
|
||||
if (TextButton(cancelText)) {
|
||||
*open = false;
|
||||
result = -1;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
}
|
||||
|
||||
if (ContainedButton(confirmText)) {
|
||||
*open = false;
|
||||
result = 1;
|
||||
}
|
||||
|
||||
EndDialogActions();
|
||||
EndDialog();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
inline bool AlertDialog(const char* id, bool* open, const char* title, const char* message,
|
||||
const char* buttonText) {
|
||||
bool dismissed = false;
|
||||
|
||||
if (BeginDialog(id, open, title)) {
|
||||
BeginDialogContent();
|
||||
Typography::instance().textWrapped(TypeStyle::Body1, message);
|
||||
EndDialogContent();
|
||||
|
||||
BeginDialogActions();
|
||||
|
||||
// Right-align single button
|
||||
float buttonWidth = ImGui::CalcTextSize(buttonText).x + spacing::dp(2);
|
||||
float startX = ImGui::GetContentRegionAvail().x - buttonWidth - spacing::dp(2);
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + startX);
|
||||
|
||||
if (ContainedButton(buttonText)) {
|
||||
*open = false;
|
||||
dismissed = true;
|
||||
}
|
||||
|
||||
EndDialogActions();
|
||||
EndDialog();
|
||||
}
|
||||
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,414 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Input Controls
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/selection-controls
|
||||
//
|
||||
// Selection controls allow users to complete tasks that involve making choices:
|
||||
// - Switch: Toggle single option on/off
|
||||
// - Checkbox: Select multiple options
|
||||
// - Radio: Select one option from a set
|
||||
|
||||
// ============================================================================
|
||||
// Switch
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Material Design switch (toggle)
|
||||
*
|
||||
* @param label Text label
|
||||
* @param value Pointer to boolean value
|
||||
* @param disabled If true, switch is non-interactive
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool Switch(const char* label, bool* value, bool disabled = false);
|
||||
|
||||
// ============================================================================
|
||||
// Checkbox
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Checkbox state
|
||||
*/
|
||||
enum class CheckboxState {
|
||||
Unchecked,
|
||||
Checked,
|
||||
Indeterminate // For parent with mixed children
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Material Design checkbox
|
||||
*
|
||||
* @param label Text label
|
||||
* @param value Pointer to boolean value
|
||||
* @param disabled If true, checkbox is non-interactive
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool Checkbox(const char* label, bool* value, bool disabled = false);
|
||||
|
||||
/**
|
||||
* @brief Tri-state checkbox
|
||||
*/
|
||||
bool Checkbox(const char* label, CheckboxState* state, bool disabled = false);
|
||||
|
||||
// ============================================================================
|
||||
// Radio Button
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Material Design radio button
|
||||
*
|
||||
* @param label Text label
|
||||
* @param active true if this option is selected
|
||||
* @param disabled If true, radio is non-interactive
|
||||
* @return true if clicked (caller should update selection)
|
||||
*/
|
||||
bool RadioButton(const char* label, bool active, bool disabled = false);
|
||||
|
||||
/**
|
||||
* @brief Radio button with int selection
|
||||
*
|
||||
* @param label Text label
|
||||
* @param selection Pointer to current selection
|
||||
* @param value Value this radio represents
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool RadioButton(const char* label, int* selection, int value, bool disabled = false);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool Switch(const char* label, bool* value, bool disabled) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Switch dimensions (Material spec: 36x14 track, 20dp thumb)
|
||||
const float trackWidth = 36.0f;
|
||||
const float trackHeight = 14.0f;
|
||||
const float thumbRadius = 10.0f; // 20dp diameter
|
||||
const float thumbTravel = trackWidth - thumbRadius * 2;
|
||||
|
||||
// Calculate layout
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float labelWidth = ImGui::CalcTextSize(label).x;
|
||||
float totalWidth = trackWidth + spacing::dp(2) + labelWidth;
|
||||
float totalHeight = ImMax(trackHeight + 6.0f, size::TouchTarget); // Min 48dp touch target
|
||||
|
||||
ImRect bb(pos, ImVec2(pos.x + totalWidth, pos.y + totalHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID("##switch");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held) && !disabled;
|
||||
|
||||
bool changed = false;
|
||||
if (pressed) {
|
||||
*value = !*value;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Animation (simple snap for now)
|
||||
float thumbX = *value ? (thumbTravel) : 0;
|
||||
|
||||
// Draw track
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float trackY = pos.y + totalHeight * 0.5f;
|
||||
ImVec2 trackMin(pos.x, trackY - trackHeight * 0.5f);
|
||||
ImVec2 trackMax(pos.x + trackWidth, trackY + trackHeight * 0.5f);
|
||||
|
||||
ImU32 trackColor;
|
||||
if (disabled) {
|
||||
trackColor = schema::UI().resolveColor("var(--switch-track-off)", IM_COL32(255, 255, 255, 30));
|
||||
} else if (*value) {
|
||||
trackColor = PrimaryVariant(); // Primary at 50% opacity
|
||||
} else {
|
||||
trackColor = schema::UI().resolveColor("var(--switch-track-on)", IM_COL32(255, 255, 255, 97));
|
||||
}
|
||||
|
||||
drawList->AddRectFilled(trackMin, trackMax, trackColor, trackHeight * 0.5f);
|
||||
|
||||
// Draw thumb
|
||||
ImVec2 thumbCenter(pos.x + thumbRadius + thumbX, trackY);
|
||||
|
||||
ImU32 thumbColor;
|
||||
if (disabled) {
|
||||
thumbColor = schema::UI().resolveColor("var(--switch-thumb-off)", IM_COL32(189, 189, 189, 255));
|
||||
} else if (*value) {
|
||||
thumbColor = Primary();
|
||||
} else {
|
||||
thumbColor = schema::UI().resolveColor("var(--switch-thumb-on)", IM_COL32(250, 250, 250, 255));
|
||||
}
|
||||
|
||||
// Thumb shadow
|
||||
drawList->AddCircleFilled(ImVec2(thumbCenter.x + 1, thumbCenter.y + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
drawList->AddCircleFilled(thumbCenter, thumbRadius, thumbColor);
|
||||
|
||||
// Hover ripple effect
|
||||
if (hovered && !disabled) {
|
||||
ImU32 ripple = *value ? WithAlpha(Primary(), 25) : schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25));
|
||||
drawList->AddCircleFilled(thumbCenter, thumbRadius + 12.0f, ripple);
|
||||
}
|
||||
|
||||
// Draw label
|
||||
ImVec2 labelPos(pos.x + trackWidth + spacing::dp(2), pos.y + (totalHeight - ImGui::GetFontSize()) * 0.5f);
|
||||
ImU32 labelColor = disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
drawList->AddText(labelPos, labelColor, label);
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool Checkbox(const char* label, bool* value, bool disabled) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Checkbox dimensions (18dp box, 48dp touch target)
|
||||
const float boxSize = 18.0f;
|
||||
|
||||
// Calculate layout
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float labelWidth = ImGui::CalcTextSize(label).x;
|
||||
float totalWidth = boxSize + spacing::dp(2) + labelWidth;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImRect bb(pos, ImVec2(pos.x + totalWidth, pos.y + totalHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID("##checkbox");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held) && !disabled;
|
||||
|
||||
bool changed = false;
|
||||
if (pressed) {
|
||||
*value = !*value;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// Draw checkbox
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
ImVec2 boxMin(pos.x, centerY - boxSize * 0.5f);
|
||||
ImVec2 boxMax(pos.x + boxSize, centerY + boxSize * 0.5f);
|
||||
|
||||
ImU32 boxColor, checkColor;
|
||||
if (disabled) {
|
||||
boxColor = OnSurfaceDisabled();
|
||||
checkColor = schema::UI().resolveColor("var(--checkbox-check)", IM_COL32(0, 0, 0, 255));
|
||||
} else if (*value) {
|
||||
boxColor = Primary();
|
||||
checkColor = OnPrimary();
|
||||
} else {
|
||||
boxColor = OnSurfaceMedium();
|
||||
checkColor = OnPrimary();
|
||||
}
|
||||
|
||||
if (*value) {
|
||||
// Filled checkbox with checkmark
|
||||
drawList->AddRectFilled(boxMin, boxMax, boxColor, 2.0f);
|
||||
|
||||
// Draw checkmark
|
||||
ImVec2 checkStart(boxMin.x + 4, centerY);
|
||||
ImVec2 checkMid(boxMin.x + 7, centerY + 3);
|
||||
ImVec2 checkEnd(boxMin.x + 14, centerY - 4);
|
||||
|
||||
drawList->AddLine(checkStart, checkMid, checkColor, 2.0f);
|
||||
drawList->AddLine(checkMid, checkEnd, checkColor, 2.0f);
|
||||
} else {
|
||||
// Empty checkbox border
|
||||
drawList->AddRect(boxMin, boxMax, boxColor, 2.0f, 0, 2.0f);
|
||||
}
|
||||
|
||||
// Hover ripple
|
||||
if (hovered && !disabled) {
|
||||
ImVec2 boxCenter((boxMin.x + boxMax.x) * 0.5f, centerY);
|
||||
drawList->AddCircleFilled(boxCenter, boxSize,
|
||||
*value ? WithAlpha(Primary(), 25) : schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
}
|
||||
|
||||
// Draw label
|
||||
ImVec2 labelPos(pos.x + boxSize + spacing::dp(2), pos.y + (totalHeight - ImGui::GetFontSize()) * 0.5f);
|
||||
ImU32 labelColor = disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
drawList->AddText(labelPos, labelColor, label);
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool Checkbox(const char* label, CheckboxState* state, bool disabled) {
|
||||
bool checked = (*state == CheckboxState::Checked);
|
||||
bool indeterminate = (*state == CheckboxState::Indeterminate);
|
||||
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
const float boxSize = 18.0f;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float labelWidth = ImGui::CalcTextSize(label).x;
|
||||
float totalWidth = boxSize + spacing::dp(2) + labelWidth;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImRect bb(pos, ImVec2(pos.x + totalWidth, pos.y + totalHeight));
|
||||
|
||||
ImGuiID id = window->GetID("##checkbox");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held) && !disabled;
|
||||
|
||||
bool changed = false;
|
||||
if (pressed) {
|
||||
// Cycle: Unchecked -> Checked -> Unchecked (indeterminate only set programmatically)
|
||||
*state = (*state == CheckboxState::Checked) ? CheckboxState::Unchecked : CheckboxState::Checked;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
ImVec2 boxMin(pos.x, centerY - boxSize * 0.5f);
|
||||
ImVec2 boxMax(pos.x + boxSize, centerY + boxSize * 0.5f);
|
||||
|
||||
ImU32 boxColor = disabled ? OnSurfaceDisabled() : (checked || indeterminate) ? Primary() : OnSurfaceMedium();
|
||||
|
||||
if (checked || indeterminate) {
|
||||
drawList->AddRectFilled(boxMin, boxMax, boxColor, 2.0f);
|
||||
|
||||
if (indeterminate) {
|
||||
// Horizontal line for indeterminate
|
||||
drawList->AddLine(
|
||||
ImVec2(boxMin.x + 4, centerY),
|
||||
ImVec2(boxMax.x - 4, centerY),
|
||||
OnPrimary(), 2.0f
|
||||
);
|
||||
} else {
|
||||
// Checkmark
|
||||
ImVec2 checkStart(boxMin.x + 4, centerY);
|
||||
ImVec2 checkMid(boxMin.x + 7, centerY + 3);
|
||||
ImVec2 checkEnd(boxMin.x + 14, centerY - 4);
|
||||
drawList->AddLine(checkStart, checkMid, OnPrimary(), 2.0f);
|
||||
drawList->AddLine(checkMid, checkEnd, OnPrimary(), 2.0f);
|
||||
}
|
||||
} else {
|
||||
drawList->AddRect(boxMin, boxMax, boxColor, 2.0f, 0, 2.0f);
|
||||
}
|
||||
|
||||
if (hovered && !disabled) {
|
||||
ImVec2 boxCenter((boxMin.x + boxMax.x) * 0.5f, centerY);
|
||||
drawList->AddCircleFilled(boxCenter, boxSize, schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
}
|
||||
|
||||
ImVec2 labelPos(pos.x + boxSize + spacing::dp(2), pos.y + (totalHeight - ImGui::GetFontSize()) * 0.5f);
|
||||
ImU32 labelColor = disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
drawList->AddText(labelPos, labelColor, label);
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool RadioButton(const char* label, bool active, bool disabled) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Radio button dimensions (20dp outer, 10dp inner when selected)
|
||||
const float outerRadius = 10.0f;
|
||||
const float innerRadius = 5.0f;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float labelWidth = ImGui::CalcTextSize(label).x;
|
||||
float totalWidth = outerRadius * 2 + spacing::dp(2) + labelWidth;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImRect bb(pos, ImVec2(pos.x + totalWidth, pos.y + totalHeight));
|
||||
|
||||
ImGuiID id = window->GetID("##radio");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held) && !disabled;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
ImVec2 center(pos.x + outerRadius, centerY);
|
||||
|
||||
ImU32 ringColor = disabled ? OnSurfaceDisabled() : active ? Primary() : OnSurfaceMedium();
|
||||
|
||||
// Outer ring
|
||||
drawList->AddCircle(center, outerRadius, ringColor, 0, 2.0f);
|
||||
|
||||
// Inner dot when active
|
||||
if (active) {
|
||||
drawList->AddCircleFilled(center, innerRadius, ringColor);
|
||||
}
|
||||
|
||||
// Hover ripple
|
||||
if (hovered && !disabled) {
|
||||
drawList->AddCircleFilled(center, outerRadius + 12.0f,
|
||||
active ? WithAlpha(Primary(), 25) : schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
}
|
||||
|
||||
// Label
|
||||
ImVec2 labelPos(pos.x + outerRadius * 2 + spacing::dp(2), pos.y + (totalHeight - ImGui::GetFontSize()) * 0.5f);
|
||||
ImU32 labelColor = disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
drawList->AddText(labelPos, labelColor, label);
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool RadioButton(const char* label, int* selection, int value, bool disabled) {
|
||||
bool active = (*selection == value);
|
||||
if (RadioButton(label, active, disabled)) {
|
||||
*selection = value;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,306 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design List Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/lists
|
||||
//
|
||||
// Lists present content in a way that makes it easy to identify a specific
|
||||
// item in a collection and act on it.
|
||||
|
||||
/**
|
||||
* @brief List item configuration
|
||||
*/
|
||||
struct ListItemSpec {
|
||||
const char* leadingIcon = nullptr; // Optional leading icon (text representation)
|
||||
const char* leadingAvatar = nullptr; // Optional avatar text (for initials)
|
||||
ImU32 leadingAvatarColor = 0; // Avatar background color (0 = primary)
|
||||
bool leadingCheckbox = false; // Show checkbox instead of icon
|
||||
bool checkboxChecked = false; // Checkbox state
|
||||
const char* primaryText = nullptr; // Main text (required)
|
||||
const char* secondaryText = nullptr; // Secondary text (optional)
|
||||
const char* trailingIcon = nullptr; // Optional trailing icon
|
||||
const char* trailingText = nullptr; // Optional trailing metadata text
|
||||
bool selected = false; // Selected state (highlight)
|
||||
bool disabled = false; // Disabled state
|
||||
bool dividerBelow = false; // Draw divider below item
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a list container
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param withPadding Add top/bottom padding
|
||||
*/
|
||||
void BeginList(const char* id, bool withPadding = true);
|
||||
|
||||
/**
|
||||
* @brief End a list container
|
||||
*/
|
||||
void EndList();
|
||||
|
||||
/**
|
||||
* @brief Render a list item
|
||||
*
|
||||
* @param spec Item configuration
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool ListItem(const ListItemSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Simple single-line list item
|
||||
*/
|
||||
bool ListItem(const char* text);
|
||||
|
||||
/**
|
||||
* @brief Two-line list item with primary and secondary text
|
||||
*/
|
||||
bool ListItem(const char* primary, const char* secondary);
|
||||
|
||||
/**
|
||||
* @brief List divider (full width or inset)
|
||||
*
|
||||
* @param inset If true, indented to align with text
|
||||
*/
|
||||
void ListDivider(bool inset = false);
|
||||
|
||||
/**
|
||||
* @brief List subheader text
|
||||
*/
|
||||
void ListSubheader(const char* text);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline void BeginList(const char* id, bool withPadding) {
|
||||
ImGui::PushID(id);
|
||||
if (withPadding) {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp top padding
|
||||
}
|
||||
}
|
||||
|
||||
inline void EndList() {
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
inline bool ListItem(const ListItemSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
// Calculate item height based on content
|
||||
bool hasSecondary = (spec.secondaryText != nullptr);
|
||||
bool hasLeadingElement = (spec.leadingIcon || spec.leadingAvatar || spec.leadingCheckbox);
|
||||
|
||||
float itemHeight;
|
||||
if (hasSecondary) {
|
||||
itemHeight = size::ListItemTwoLineHeight; // 72dp for two-line
|
||||
} else if (hasLeadingElement) {
|
||||
itemHeight = size::ListItemHeight; // 56dp with leading element
|
||||
} else {
|
||||
itemHeight = 48.0f; // 48dp simple one-line
|
||||
}
|
||||
|
||||
// Item dimensions
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
float itemWidth = ImGui::GetContentRegionAvail().x;
|
||||
ImRect bb(pos, ImVec2(pos.x + itemWidth, pos.y + itemHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID itemId = window->GetID(spec.primaryText);
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, itemId))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, itemId, &hovered, &held) && !spec.disabled;
|
||||
|
||||
// Draw background
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
ImU32 bgColor = 0;
|
||||
|
||||
if (spec.selected) {
|
||||
bgColor = PrimaryContainer();
|
||||
} else if (held && !spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25));
|
||||
} else if (hovered && !spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--active-overlay)", IM_COL32(255, 255, 255, 10));
|
||||
}
|
||||
|
||||
if (bgColor) {
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, bgColor);
|
||||
}
|
||||
|
||||
// Layout positions
|
||||
float leftPadding = spacing::dp(2); // 16dp left padding
|
||||
float currentX = bb.Min.x + leftPadding;
|
||||
float centerY = bb.Min.y + itemHeight * 0.5f;
|
||||
|
||||
// Draw leading element
|
||||
if (spec.leadingAvatar) {
|
||||
// Avatar circle with text
|
||||
float avatarRadius = 20.0f; // 40dp diameter
|
||||
ImVec2 avatarCenter(currentX + avatarRadius, centerY);
|
||||
|
||||
ImU32 avatarBg = spec.leadingAvatarColor ? spec.leadingAvatarColor : Primary();
|
||||
drawList->AddCircleFilled(avatarCenter, avatarRadius, avatarBg);
|
||||
|
||||
// Avatar text (centered)
|
||||
ImVec2 textSize = ImGui::CalcTextSize(spec.leadingAvatar);
|
||||
ImVec2 textPos(avatarCenter.x - textSize.x * 0.5f, avatarCenter.y - textSize.y * 0.5f);
|
||||
drawList->AddText(textPos, OnPrimary(), spec.leadingAvatar);
|
||||
|
||||
currentX += 40.0f + spacing::dp(2); // 40dp avatar + 16dp gap
|
||||
} else if (spec.leadingIcon) {
|
||||
// Icon
|
||||
ImVec2 iconSize = ImGui::CalcTextSize(spec.leadingIcon);
|
||||
float iconY = centerY - iconSize.y * 0.5f;
|
||||
ImU32 iconColor = spec.disabled ? OnSurfaceDisabled() : OnSurfaceMedium();
|
||||
drawList->AddText(ImVec2(currentX, iconY), iconColor, spec.leadingIcon);
|
||||
currentX += 24.0f + spacing::dp(2); // 24dp icon + 16dp gap
|
||||
} else if (spec.leadingCheckbox) {
|
||||
// Checkbox (simplified visual)
|
||||
float checkboxSize = 18.0f;
|
||||
ImVec2 checkMin(currentX, centerY - checkboxSize * 0.5f);
|
||||
ImVec2 checkMax(currentX + checkboxSize, centerY + checkboxSize * 0.5f);
|
||||
|
||||
if (spec.checkboxChecked) {
|
||||
drawList->AddRectFilled(checkMin, checkMax, Primary(), 2.0f);
|
||||
// Checkmark
|
||||
ImFont* iconFont = Typography::instance().iconSmall();
|
||||
ImVec2 chkSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, ICON_MD_CHECK);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(checkMin.x + (checkboxSize - chkSz.x) * 0.5f, checkMin.y + (checkboxSize - chkSz.y) * 0.5f),
|
||||
OnPrimary(), ICON_MD_CHECK);
|
||||
} else {
|
||||
drawList->AddRect(checkMin, checkMax, OnSurfaceMedium(), 2.0f, 0, 2.0f);
|
||||
}
|
||||
|
||||
currentX += checkboxSize + spacing::dp(2);
|
||||
}
|
||||
|
||||
// Calculate text area
|
||||
float rightPadding = spacing::dp(2); // 16dp right padding
|
||||
float trailingSpace = 0;
|
||||
if (spec.trailingIcon) trailingSpace += 24.0f + spacing::dp(1);
|
||||
if (spec.trailingText) trailingSpace += ImGui::CalcTextSize(spec.trailingText).x + spacing::dp(1);
|
||||
|
||||
float textMaxX = bb.Max.x - rightPadding - trailingSpace;
|
||||
|
||||
// Draw text
|
||||
ImU32 primaryColor = spec.disabled ? OnSurfaceDisabled() : OnSurface();
|
||||
ImU32 secondaryColor = spec.disabled ? OnSurfaceDisabled() : OnSurfaceMedium();
|
||||
|
||||
if (hasSecondary) {
|
||||
// Two-line layout
|
||||
float primaryY = bb.Min.y + 16.0f;
|
||||
float secondaryY = primaryY + 20.0f;
|
||||
|
||||
Typography::instance().pushFont(TypeStyle::Body1);
|
||||
drawList->AddText(ImVec2(currentX, primaryY), primaryColor, spec.primaryText);
|
||||
Typography::instance().popFont();
|
||||
|
||||
Typography::instance().pushFont(TypeStyle::Body2);
|
||||
drawList->AddText(ImVec2(currentX, secondaryY), secondaryColor, spec.secondaryText);
|
||||
Typography::instance().popFont();
|
||||
} else {
|
||||
// Single-line layout
|
||||
Typography::instance().pushFont(TypeStyle::Body1);
|
||||
float textY = centerY - Typography::instance().getFont(TypeStyle::Body1)->FontSize * 0.5f;
|
||||
drawList->AddText(ImVec2(currentX, textY), primaryColor, spec.primaryText);
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
// Draw trailing elements
|
||||
float trailingX = bb.Max.x - rightPadding;
|
||||
|
||||
if (spec.trailingText) {
|
||||
ImVec2 textSize = ImGui::CalcTextSize(spec.trailingText);
|
||||
trailingX -= textSize.x;
|
||||
float textY = centerY - textSize.y * 0.5f;
|
||||
drawList->AddText(ImVec2(trailingX, textY), secondaryColor, spec.trailingText);
|
||||
trailingX -= spacing::dp(1);
|
||||
}
|
||||
|
||||
if (spec.trailingIcon) {
|
||||
ImVec2 iconSize = ImGui::CalcTextSize(spec.trailingIcon);
|
||||
trailingX -= 24.0f;
|
||||
float iconY = centerY - iconSize.y * 0.5f;
|
||||
drawList->AddText(ImVec2(trailingX, iconY), OnSurfaceMedium(), spec.trailingIcon);
|
||||
}
|
||||
|
||||
// Draw divider
|
||||
if (spec.dividerBelow) {
|
||||
float dividerY = bb.Max.y - 0.5f;
|
||||
drawList->AddLine(
|
||||
ImVec2(bb.Min.x + leftPadding, dividerY),
|
||||
ImVec2(bb.Max.x, dividerY),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
}
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool ListItem(const char* text) {
|
||||
ListItemSpec spec;
|
||||
spec.primaryText = text;
|
||||
return ListItem(spec);
|
||||
}
|
||||
|
||||
inline bool ListItem(const char* primary, const char* secondary) {
|
||||
ListItemSpec spec;
|
||||
spec.primaryText = primary;
|
||||
spec.secondaryText = secondary;
|
||||
return ListItem(spec);
|
||||
}
|
||||
|
||||
inline void ListDivider(bool inset) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
float width = ImGui::GetContentRegionAvail().x;
|
||||
float leftOffset = inset ? 72.0f : 0; // Align with text after avatar/icon
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
drawList->AddLine(
|
||||
ImVec2(pos.x + leftOffset, pos.y),
|
||||
ImVec2(pos.x + width, pos.y),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
|
||||
ImGui::Dummy(ImVec2(width, 1.0f));
|
||||
}
|
||||
|
||||
inline void ListSubheader(const char* text) {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp top padding
|
||||
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + spacing::dp(2)); // 16dp left padding
|
||||
Typography::instance().textColored(TypeStyle::Caption, Primary(), text);
|
||||
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp bottom padding
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,379 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Navigation Drawer Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/navigation-drawer
|
||||
//
|
||||
// Navigation drawers provide access to destinations in your app.
|
||||
|
||||
enum class NavDrawerType {
|
||||
Standard, // Permanent, always visible
|
||||
Modal, // Overlay with scrim, can be dismissed
|
||||
Dismissible // Can be shown/hidden, no scrim
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Navigation drawer configuration
|
||||
*/
|
||||
struct NavDrawerSpec {
|
||||
NavDrawerType type = NavDrawerType::Standard;
|
||||
float width = 256.0f; // 256dp standard width
|
||||
bool showDividerBottom = true; // Divider at bottom
|
||||
const char* headerTitle = nullptr; // Optional header title
|
||||
const char* headerSubtitle = nullptr;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Navigation item configuration
|
||||
*/
|
||||
struct NavItemSpec {
|
||||
const char* icon = nullptr; // Leading icon
|
||||
const char* label = nullptr; // Item label (required)
|
||||
bool selected = false; // Selected state
|
||||
bool disabled = false;
|
||||
int badgeCount = 0; // Badge (0 = no badge)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a navigation drawer
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param open Pointer to open state (for modal/dismissible)
|
||||
* @param spec Drawer configuration
|
||||
* @return true if drawer is visible
|
||||
*/
|
||||
bool BeginNavDrawer(const char* id, bool* open, const NavDrawerSpec& spec = NavDrawerSpec());
|
||||
|
||||
/**
|
||||
* @brief Begin standard (always visible) navigation drawer
|
||||
*/
|
||||
bool BeginNavDrawer(const char* id, const NavDrawerSpec& spec = NavDrawerSpec());
|
||||
|
||||
/**
|
||||
* @brief End navigation drawer
|
||||
*/
|
||||
void EndNavDrawer();
|
||||
|
||||
/**
|
||||
* @brief Render a navigation item
|
||||
*
|
||||
* @param spec Item configuration
|
||||
* @return true if clicked
|
||||
*/
|
||||
bool NavItem(const NavItemSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Simple navigation item
|
||||
*/
|
||||
bool NavItem(const char* icon, const char* label, bool selected = false);
|
||||
|
||||
/**
|
||||
* @brief Navigation divider
|
||||
*/
|
||||
void NavDivider();
|
||||
|
||||
/**
|
||||
* @brief Navigation subheader
|
||||
*/
|
||||
void NavSubheader(const char* text);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
struct NavDrawerState {
|
||||
float width;
|
||||
ImVec2 contentMin;
|
||||
ImVec2 contentMax;
|
||||
bool isModal;
|
||||
};
|
||||
|
||||
static NavDrawerState g_navDrawerState;
|
||||
|
||||
inline bool BeginNavDrawer(const char* id, bool* open, const NavDrawerSpec& spec) {
|
||||
// For modal drawers, check open state
|
||||
if (spec.type == NavDrawerType::Modal && !*open) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
g_navDrawerState.width = spec.width;
|
||||
g_navDrawerState.isModal = (spec.type == NavDrawerType::Modal);
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// For modal, draw scrim and handle dismiss
|
||||
if (spec.type == NavDrawerType::Modal) {
|
||||
ImDrawList* bgDrawList = ImGui::GetBackgroundDrawList();
|
||||
bgDrawList->AddRectFilled(
|
||||
ImVec2(0, 0), io.DisplaySize,
|
||||
schema::UI().resolveColor("var(--scrim)", IM_COL32(0, 0, 0, (int)(0.32f * 255)))
|
||||
);
|
||||
|
||||
// Click outside to dismiss
|
||||
if (ImGui::IsMouseClicked(0)) {
|
||||
ImVec2 mousePos = io.MousePos;
|
||||
if (mousePos.x > spec.width) {
|
||||
*open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drawer position and size
|
||||
ImVec2 drawerPos(0, 0);
|
||||
ImVec2 drawerSize(spec.width, io.DisplaySize.y);
|
||||
|
||||
// If not modal, account for app bar
|
||||
if (spec.type != NavDrawerType::Modal) {
|
||||
drawerPos.y = size::AppBarHeight;
|
||||
drawerSize.y = io.DisplaySize.y - size::AppBarHeight;
|
||||
}
|
||||
|
||||
ImRect drawerBB(drawerPos, ImVec2(drawerPos.x + drawerSize.x, drawerPos.y + drawerSize.y));
|
||||
|
||||
// Draw drawer background
|
||||
ImU32 bgColor = Surface(Elevation::Dp16);
|
||||
drawList->AddRectFilled(drawerBB.Min, drawerBB.Max, bgColor);
|
||||
|
||||
// Store content region
|
||||
g_navDrawerState.contentMin = ImVec2(drawerBB.Min.x, drawerBB.Min.y);
|
||||
g_navDrawerState.contentMax = drawerBB.Max;
|
||||
|
||||
// Header
|
||||
float currentY = drawerBB.Min.y;
|
||||
|
||||
if (spec.headerTitle || spec.headerSubtitle) {
|
||||
// Header area (optional)
|
||||
float headerHeight = 64.0f;
|
||||
|
||||
ImVec2 headerMin(drawerBB.Min.x, currentY);
|
||||
ImVec2 headerMax(drawerBB.Max.x, currentY + headerHeight);
|
||||
|
||||
// Header background (slightly elevated)
|
||||
drawList->AddRectFilled(headerMin, headerMax, Surface(Elevation::Dp16));
|
||||
|
||||
// Header title
|
||||
if (spec.headerTitle) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(drawerBB.Min.x + spacing::dp(2), currentY + 20.0f));
|
||||
Typography::instance().text(TypeStyle::H6, spec.headerTitle);
|
||||
}
|
||||
|
||||
if (spec.headerSubtitle) {
|
||||
ImGui::SetCursorScreenPos(ImVec2(drawerBB.Min.x + spacing::dp(2), currentY + 42.0f));
|
||||
Typography::instance().textColored(TypeStyle::Body2, OnSurfaceMedium(), spec.headerSubtitle);
|
||||
}
|
||||
|
||||
currentY += headerHeight;
|
||||
|
||||
// Divider under header
|
||||
drawList->AddLine(
|
||||
ImVec2(drawerBB.Min.x, currentY),
|
||||
ImVec2(drawerBB.Max.x, currentY),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
}
|
||||
|
||||
// Set cursor for nav items
|
||||
ImGui::SetCursorScreenPos(ImVec2(drawerBB.Min.x, currentY + spacing::dp(1)));
|
||||
ImGui::BeginGroup();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline bool BeginNavDrawer(const char* id, const NavDrawerSpec& spec) {
|
||||
static bool alwaysOpen = true;
|
||||
NavDrawerSpec standardSpec = spec;
|
||||
standardSpec.type = NavDrawerType::Standard;
|
||||
return BeginNavDrawer(id, &alwaysOpen, standardSpec);
|
||||
}
|
||||
|
||||
inline void EndNavDrawer() {
|
||||
ImGui::EndGroup();
|
||||
|
||||
// Divider at bottom if configured
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Right edge divider
|
||||
drawList->AddLine(
|
||||
ImVec2(g_navDrawerState.contentMax.x - 1, g_navDrawerState.contentMin.y),
|
||||
ImVec2(g_navDrawerState.contentMax.x - 1, g_navDrawerState.contentMax.y),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
inline bool NavItem(const NavItemSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(spec.label);
|
||||
|
||||
// Item dimensions
|
||||
const float itemHeight = 48.0f;
|
||||
const float iconSize = 24.0f;
|
||||
const float horizontalPadding = spacing::dp(2); // 16dp
|
||||
const float iconLabelGap = spacing::dp(4); // 32dp from left edge to label
|
||||
|
||||
float itemWidth = g_navDrawerState.width - spacing::dp(1); // 8dp margin right
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
pos.x += spacing::dp(1); // 8dp margin left
|
||||
|
||||
ImRect itemBB(pos, ImVec2(pos.x + itemWidth, pos.y + itemHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID("##navitem");
|
||||
ImGui::ItemSize(ImRect(window->DC.CursorPos, ImVec2(window->DC.CursorPos.x + g_navDrawerState.width, window->DC.CursorPos.y + itemHeight)));
|
||||
if (!ImGui::ItemAdd(itemBB, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(itemBB, id, &hovered, &held) && !spec.disabled;
|
||||
|
||||
// Draw background
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
ImU32 bgColor = 0;
|
||||
if (spec.selected) {
|
||||
bgColor = WithAlpha(Primary(), 30); // Primary at ~12%
|
||||
} else if (held && !spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--sidebar-hover)", IM_COL32(255, 255, 255, 25));
|
||||
} else if (hovered && !spec.disabled) {
|
||||
bgColor = schema::UI().resolveColor("var(--active-overlay)", IM_COL32(255, 255, 255, 10));
|
||||
}
|
||||
|
||||
if (bgColor) {
|
||||
drawList->AddRectFilled(itemBB.Min, itemBB.Max, bgColor, size::ButtonCornerRadius);
|
||||
}
|
||||
|
||||
// Selected indicator (left edge)
|
||||
if (spec.selected) {
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(itemBB.Min.x, itemBB.Min.y + 8.0f),
|
||||
ImVec2(itemBB.Min.x + 4.0f, itemBB.Max.y - 8.0f),
|
||||
Primary(), 2.0f
|
||||
);
|
||||
}
|
||||
|
||||
// Content
|
||||
float contentX = pos.x + horizontalPadding;
|
||||
float centerY = pos.y + itemHeight * 0.5f;
|
||||
|
||||
ImU32 iconColor = spec.disabled ? OnSurfaceDisabled() :
|
||||
spec.selected ? Primary() : OnSurfaceMedium();
|
||||
ImU32 labelColor = spec.disabled ? OnSurfaceDisabled() :
|
||||
spec.selected ? Primary() : OnSurface();
|
||||
|
||||
// Icon
|
||||
if (spec.icon) {
|
||||
drawList->AddText(
|
||||
ImVec2(contentX, centerY - iconSize * 0.5f),
|
||||
iconColor, spec.icon
|
||||
);
|
||||
contentX += iconSize + spacing::dp(2); // 16dp gap after icon
|
||||
}
|
||||
|
||||
// Label
|
||||
Typography::instance().pushFont(TypeStyle::Body1);
|
||||
float labelY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
drawList->AddText(ImVec2(contentX, labelY), labelColor, spec.label);
|
||||
Typography::instance().popFont();
|
||||
|
||||
// Badge
|
||||
if (spec.badgeCount > 0) {
|
||||
char badgeText[8];
|
||||
if (spec.badgeCount > 999) {
|
||||
snprintf(badgeText, sizeof(badgeText), "999+");
|
||||
} else {
|
||||
snprintf(badgeText, sizeof(badgeText), "%d", spec.badgeCount);
|
||||
}
|
||||
|
||||
ImVec2 badgeSize = ImGui::CalcTextSize(badgeText);
|
||||
float badgeWidth = ImMax(24.0f, badgeSize.x + 12.0f);
|
||||
float badgeHeight = 20.0f;
|
||||
float badgeX = itemBB.Max.x - horizontalPadding - badgeWidth;
|
||||
float badgeY = centerY - badgeHeight * 0.5f;
|
||||
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(badgeX, badgeY),
|
||||
ImVec2(badgeX + badgeWidth, badgeY + badgeHeight),
|
||||
Primary(), badgeHeight * 0.5f
|
||||
);
|
||||
|
||||
Typography::instance().pushFont(TypeStyle::Caption);
|
||||
ImVec2 textPos(badgeX + (badgeWidth - badgeSize.x) * 0.5f, badgeY + (badgeHeight - badgeSize.y) * 0.5f);
|
||||
drawList->AddText(textPos, OnPrimary(), badgeText);
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return pressed;
|
||||
}
|
||||
|
||||
inline bool NavItem(const char* icon, const char* label, bool selected) {
|
||||
NavItemSpec spec;
|
||||
spec.icon = icon;
|
||||
spec.label = label;
|
||||
spec.selected = selected;
|
||||
return NavItem(spec);
|
||||
}
|
||||
|
||||
inline void NavDivider() {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp spacing above
|
||||
|
||||
drawList->AddLine(
|
||||
ImVec2(pos.x + spacing::dp(2), pos.y + spacing::dp(1)),
|
||||
ImVec2(pos.x + g_navDrawerState.width - spacing::dp(2), pos.y + spacing::dp(1)),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp spacing below
|
||||
}
|
||||
|
||||
inline void NavSubheader(const char* text) {
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp above
|
||||
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
ImGui::SetCursorScreenPos(ImVec2(pos.x + spacing::dp(2), pos.y));
|
||||
|
||||
Typography::instance().textColored(TypeStyle::Caption, OnSurfaceMedium(), text);
|
||||
|
||||
ImGui::Dummy(ImVec2(0, spacing::dp(1))); // 8dp below
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,303 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Progress Indicators
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/progress-indicators
|
||||
//
|
||||
// Progress indicators express an unspecified wait time or display the length
|
||||
// of a process.
|
||||
|
||||
// ============================================================================
|
||||
// Linear Progress
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Determinate linear progress bar
|
||||
*
|
||||
* @param fraction Progress value 0.0 to 1.0
|
||||
* @param width Width of bar (0 = full available width)
|
||||
*/
|
||||
void LinearProgress(float fraction, float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Indeterminate linear progress bar (animated)
|
||||
*
|
||||
* @param width Width of bar (0 = full available width)
|
||||
*/
|
||||
void LinearProgressIndeterminate(float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Buffer linear progress bar
|
||||
*
|
||||
* @param fraction Primary progress 0.0 to 1.0
|
||||
* @param buffer Buffer progress 0.0 to 1.0
|
||||
* @param width Width of bar (0 = full available width)
|
||||
*/
|
||||
void LinearProgressBuffer(float fraction, float buffer, float width = 0);
|
||||
|
||||
// ============================================================================
|
||||
// Circular Progress
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Determinate circular progress indicator
|
||||
*
|
||||
* @param fraction Progress value 0.0 to 1.0
|
||||
* @param radius Radius of circle (default 20dp)
|
||||
*/
|
||||
void CircularProgress(float fraction, float radius = 20.0f);
|
||||
|
||||
/**
|
||||
* @brief Indeterminate circular progress (spinner)
|
||||
*
|
||||
* @param radius Radius of circle (default 20dp)
|
||||
*/
|
||||
void CircularProgressIndeterminate(float radius = 20.0f);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline void LinearProgress(float fraction, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float barHeight = 4.0f; // Material spec: 4dp height
|
||||
float barWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + barWidth, pos.y + barHeight));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track (background)
|
||||
ImU32 trackColor = WithAlpha(Primary(), 64); // Primary at 25%
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, trackColor, 0);
|
||||
|
||||
// Progress indicator
|
||||
float progressWidth = barWidth * ImClamp(fraction, 0.0f, 1.0f);
|
||||
if (progressWidth > 0) {
|
||||
drawList->AddRectFilled(
|
||||
bb.Min,
|
||||
ImVec2(bb.Min.x + progressWidth, bb.Max.y),
|
||||
Primary(), 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
inline void LinearProgressIndeterminate(float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float barHeight = 4.0f;
|
||||
float barWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + barWidth, pos.y + barHeight));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track
|
||||
ImU32 trackColor = WithAlpha(Primary(), 64);
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, trackColor, 0);
|
||||
|
||||
// Animated indicator - sliding back and forth
|
||||
float time = (float)ImGui::GetTime();
|
||||
float cycleTime = fmodf(time, 2.0f); // 2 second cycle
|
||||
|
||||
// Two bars: primary and secondary with different phases
|
||||
float indicatorWidth = barWidth * 0.3f; // 30% of track
|
||||
|
||||
// Primary indicator
|
||||
float primaryPhase = fmodf(time * 1.2f, 2.0f);
|
||||
float primaryPos;
|
||||
if (primaryPhase < 1.0f) {
|
||||
// Accelerating from left
|
||||
primaryPos = primaryPhase * primaryPhase * (barWidth + indicatorWidth) - indicatorWidth;
|
||||
} else {
|
||||
// Continue off right (reset happens at 2.0)
|
||||
primaryPos = (2.0f - primaryPhase) * (2.0f - primaryPhase) * -(barWidth + indicatorWidth) + barWidth;
|
||||
}
|
||||
|
||||
float primaryStart = ImMax(bb.Min.x, bb.Min.x + primaryPos);
|
||||
float primaryEnd = ImMin(bb.Max.x, bb.Min.x + primaryPos + indicatorWidth);
|
||||
|
||||
if (primaryEnd > primaryStart) {
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(primaryStart, bb.Min.y),
|
||||
ImVec2(primaryEnd, bb.Max.y),
|
||||
Primary(), 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
inline void LinearProgressBuffer(float fraction, float buffer, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float barHeight = 4.0f;
|
||||
float barWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + barWidth, pos.y + barHeight));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track
|
||||
ImU32 trackColor = WithAlpha(Primary(), 38); // Primary at 15%
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, trackColor, 0);
|
||||
|
||||
// Buffer (lighter than progress)
|
||||
float bufferWidth = barWidth * ImClamp(buffer, 0.0f, 1.0f);
|
||||
if (bufferWidth > 0) {
|
||||
drawList->AddRectFilled(
|
||||
bb.Min,
|
||||
ImVec2(bb.Min.x + bufferWidth, bb.Max.y),
|
||||
WithAlpha(Primary(), 102), 0 // Primary at 40%
|
||||
);
|
||||
}
|
||||
|
||||
// Progress
|
||||
float progressWidth = barWidth * ImClamp(fraction, 0.0f, 1.0f);
|
||||
if (progressWidth > 0) {
|
||||
drawList->AddRectFilled(
|
||||
bb.Min,
|
||||
ImVec2(bb.Min.x + progressWidth, bb.Max.y),
|
||||
Primary(), 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
inline void CircularProgress(float fraction, float radius) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float thickness = 4.0f; // Stroke width
|
||||
float diameter = radius * 2;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImVec2 center(pos.x + radius, pos.y + radius);
|
||||
ImRect bb(pos, ImVec2(pos.x + diameter, pos.y + diameter));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track circle
|
||||
ImU32 trackColor = WithAlpha(Primary(), 64);
|
||||
drawList->AddCircle(center, radius - thickness * 0.5f, trackColor, 0, thickness);
|
||||
|
||||
// Progress arc
|
||||
float clampedFraction = ImClamp(fraction, 0.0f, 1.0f);
|
||||
if (clampedFraction > 0) {
|
||||
float startAngle = -IM_PI * 0.5f; // Start at top (12 o'clock)
|
||||
float endAngle = startAngle + IM_PI * 2.0f * clampedFraction;
|
||||
|
||||
// Draw arc as line segments
|
||||
const int segments = (int)(32 * clampedFraction) + 1;
|
||||
float angleStep = (endAngle - startAngle) / segments;
|
||||
|
||||
for (int i = 0; i < segments; i++) {
|
||||
float a1 = startAngle + angleStep * i;
|
||||
float a2 = startAngle + angleStep * (i + 1);
|
||||
|
||||
ImVec2 p1(center.x + cosf(a1) * (radius - thickness * 0.5f),
|
||||
center.y + sinf(a1) * (radius - thickness * 0.5f));
|
||||
ImVec2 p2(center.x + cosf(a2) * (radius - thickness * 0.5f),
|
||||
center.y + sinf(a2) * (radius - thickness * 0.5f));
|
||||
|
||||
drawList->AddLine(p1, p2, Primary(), thickness);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline void CircularProgressIndeterminate(float radius) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return;
|
||||
|
||||
const float thickness = 4.0f;
|
||||
float diameter = radius * 2;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImVec2 center(pos.x + radius, pos.y + radius);
|
||||
ImRect bb(pos, ImVec2(pos.x + diameter, pos.y + diameter));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, 0))
|
||||
return;
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
float time = (float)ImGui::GetTime();
|
||||
|
||||
// Rotation animation
|
||||
float rotation = fmodf(time * 2.0f * IM_PI / 1.4f, IM_PI * 2.0f); // ~1.4s rotation
|
||||
|
||||
// Arc length animation (grows and shrinks)
|
||||
float cycleTime = fmodf(time, 1.333f); // ~1.333s cycle
|
||||
float arcLength;
|
||||
if (cycleTime < 0.666f) {
|
||||
// Growing phase
|
||||
arcLength = (cycleTime / 0.666f) * 0.75f + 0.1f; // 10% to 85%
|
||||
} else {
|
||||
// Shrinking phase
|
||||
arcLength = ((1.333f - cycleTime) / 0.666f) * 0.75f + 0.1f;
|
||||
}
|
||||
|
||||
float startAngle = rotation - IM_PI * 0.5f;
|
||||
float endAngle = startAngle + IM_PI * 2.0f * arcLength;
|
||||
|
||||
// Draw arc
|
||||
const int segments = (int)(32 * arcLength) + 1;
|
||||
float angleStep = (endAngle - startAngle) / segments;
|
||||
|
||||
for (int i = 0; i < segments; i++) {
|
||||
float a1 = startAngle + angleStep * i;
|
||||
float a2 = startAngle + angleStep * (i + 1);
|
||||
|
||||
ImVec2 p1(center.x + cosf(a1) * (radius - thickness * 0.5f),
|
||||
center.y + sinf(a1) * (radius - thickness * 0.5f));
|
||||
ImVec2 p2(center.x + cosf(a2) * (radius - thickness * 0.5f),
|
||||
center.y + sinf(a2) * (radius - thickness * 0.5f));
|
||||
|
||||
drawList->AddLine(p1, p2, Primary(), thickness);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,402 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Slider Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/sliders
|
||||
//
|
||||
// Sliders allow users to make selections from a range of values.
|
||||
|
||||
/**
|
||||
* @brief Continuous slider
|
||||
*
|
||||
* @param label Label for the slider (hidden, used for ID)
|
||||
* @param value Pointer to current value
|
||||
* @param minValue Minimum value
|
||||
* @param maxValue Maximum value
|
||||
* @param format Printf format for value display (nullptr = no display)
|
||||
* @param width Slider width (0 = full available)
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool Slider(const char* label, float* value, float minValue, float maxValue,
|
||||
const char* format = nullptr, float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Integer slider
|
||||
*/
|
||||
bool SliderInt(const char* label, int* value, int minValue, int maxValue,
|
||||
const char* format = nullptr, float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Discrete slider with steps
|
||||
*
|
||||
* @param label Label for the slider
|
||||
* @param value Pointer to current value
|
||||
* @param minValue Minimum value
|
||||
* @param maxValue Maximum value
|
||||
* @param step Step size
|
||||
* @param showTicks Show tick marks
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool SliderDiscrete(const char* label, float* value, float minValue, float maxValue,
|
||||
float step, bool showTicks = true, float width = 0);
|
||||
|
||||
/**
|
||||
* @brief Range slider (two thumbs)
|
||||
*
|
||||
* @param label Label for the slider
|
||||
* @param minVal Pointer to range minimum
|
||||
* @param maxVal Pointer to range maximum
|
||||
* @param rangeMin Allowed minimum
|
||||
* @param rangeMax Allowed maximum
|
||||
* @return true if either value changed
|
||||
*/
|
||||
bool SliderRange(const char* label, float* minVal, float* maxVal,
|
||||
float rangeMin, float rangeMax, float width = 0);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool Slider(const char* label, float* value, float minValue, float maxValue,
|
||||
const char* format, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
// Slider dimensions
|
||||
const float trackHeight = 4.0f;
|
||||
const float thumbRadius = 10.0f; // 20dp diameter
|
||||
float sliderWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
float totalHeight = size::TouchTarget; // 48dp touch target
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + sliderWidth, pos.y + totalHeight));
|
||||
|
||||
// Item interaction
|
||||
ImGuiID id = window->GetID("##slider");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(bb, id, &hovered, &held);
|
||||
|
||||
// Calculate thumb position
|
||||
float trackLeft = pos.x + thumbRadius;
|
||||
float trackRight = pos.x + sliderWidth - thumbRadius;
|
||||
float trackWidth = trackRight - trackLeft;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
|
||||
float fraction = (*value - minValue) / (maxValue - minValue);
|
||||
fraction = ImClamp(fraction, 0.0f, 1.0f);
|
||||
float thumbX = trackLeft + trackWidth * fraction;
|
||||
|
||||
// Handle dragging
|
||||
bool changed = false;
|
||||
if (held) {
|
||||
float mouseX = ImGui::GetIO().MousePos.x;
|
||||
float newFraction = (mouseX - trackLeft) / trackWidth;
|
||||
newFraction = ImClamp(newFraction, 0.0f, 1.0f);
|
||||
float newValue = minValue + newFraction * (maxValue - minValue);
|
||||
|
||||
if (newValue != *value) {
|
||||
*value = newValue;
|
||||
changed = true;
|
||||
}
|
||||
thumbX = trackLeft + trackWidth * newFraction;
|
||||
}
|
||||
|
||||
// Draw
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track (inactive part)
|
||||
ImU32 trackInactiveColor = WithAlpha(Primary(), 64); // Primary at 25%
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(trackRight, centerY + trackHeight * 0.5f),
|
||||
trackInactiveColor, trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Track (active part)
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(thumbX, centerY + trackHeight * 0.5f),
|
||||
Primary(), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Thumb shadow
|
||||
drawList->AddCircleFilled(ImVec2(thumbX + 1, centerY + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
|
||||
// Thumb
|
||||
drawList->AddCircleFilled(ImVec2(thumbX, centerY), thumbRadius, Primary());
|
||||
|
||||
// Hover/pressed ripple
|
||||
if (hovered || held) {
|
||||
ImU32 rippleColor = WithAlpha(Primary(), held ? 51 : 25);
|
||||
drawList->AddCircleFilled(ImVec2(thumbX, centerY), thumbRadius + 12.0f, rippleColor);
|
||||
}
|
||||
|
||||
// Value label (when held)
|
||||
if (held && format) {
|
||||
char valueText[64];
|
||||
snprintf(valueText, sizeof(valueText), format, *value);
|
||||
|
||||
ImVec2 textSize = ImGui::CalcTextSize(valueText);
|
||||
float labelY = centerY - thumbRadius - 32.0f;
|
||||
float labelX = thumbX - textSize.x * 0.5f;
|
||||
|
||||
// Label background (rounded rectangle)
|
||||
float labelPadX = 8.0f;
|
||||
float labelPadY = 4.0f;
|
||||
ImVec2 labelMin(labelX - labelPadX, labelY - labelPadY);
|
||||
ImVec2 labelMax(labelX + textSize.x + labelPadX, labelY + textSize.y + labelPadY);
|
||||
|
||||
drawList->AddRectFilled(labelMin, labelMax, Primary(), 4.0f);
|
||||
drawList->AddText(ImVec2(labelX, labelY), OnPrimary(), valueText);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool SliderInt(const char* label, int* value, int minValue, int maxValue,
|
||||
const char* format, float width) {
|
||||
float floatVal = (float)*value;
|
||||
bool changed = Slider(label, &floatVal, (float)minValue, (float)maxValue, format, width);
|
||||
if (changed) {
|
||||
*value = (int)roundf(floatVal);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool SliderDiscrete(const char* label, float* value, float minValue, float maxValue,
|
||||
float step, bool showTicks, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
const float trackHeight = 4.0f;
|
||||
const float thumbRadius = 10.0f;
|
||||
const float tickRadius = 2.0f;
|
||||
float sliderWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + sliderWidth, pos.y + totalHeight));
|
||||
|
||||
ImGuiID id = window->GetID("##slider");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
bool hovered, held;
|
||||
ImGui::ButtonBehavior(bb, id, &hovered, &held);
|
||||
|
||||
float trackLeft = pos.x + thumbRadius;
|
||||
float trackRight = pos.x + sliderWidth - thumbRadius;
|
||||
float trackWidth = trackRight - trackLeft;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
|
||||
// Snap to step
|
||||
float snappedValue = roundf((*value - minValue) / step) * step + minValue;
|
||||
snappedValue = ImClamp(snappedValue, minValue, maxValue);
|
||||
|
||||
float fraction = (snappedValue - minValue) / (maxValue - minValue);
|
||||
float thumbX = trackLeft + trackWidth * fraction;
|
||||
|
||||
bool changed = false;
|
||||
if (held) {
|
||||
float mouseX = ImGui::GetIO().MousePos.x;
|
||||
float newFraction = (mouseX - trackLeft) / trackWidth;
|
||||
newFraction = ImClamp(newFraction, 0.0f, 1.0f);
|
||||
float rawValue = minValue + newFraction * (maxValue - minValue);
|
||||
float newValue = roundf((rawValue - minValue) / step) * step + minValue;
|
||||
newValue = ImClamp(newValue, minValue, maxValue);
|
||||
|
||||
if (newValue != *value) {
|
||||
*value = newValue;
|
||||
changed = true;
|
||||
}
|
||||
fraction = (newValue - minValue) / (maxValue - minValue);
|
||||
thumbX = trackLeft + trackWidth * fraction;
|
||||
}
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Track
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(trackRight, centerY + trackHeight * 0.5f),
|
||||
WithAlpha(Primary(), 64), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(thumbX, centerY + trackHeight * 0.5f),
|
||||
Primary(), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Tick marks
|
||||
if (showTicks) {
|
||||
int numSteps = (int)((maxValue - minValue) / step);
|
||||
for (int i = 0; i <= numSteps; i++) {
|
||||
float tickFraction = (float)i / numSteps;
|
||||
float tickX = trackLeft + trackWidth * tickFraction;
|
||||
|
||||
ImU32 tickColor = (tickX <= thumbX) ? OnPrimary() : WithAlpha(Primary(), 128);
|
||||
drawList->AddCircleFilled(ImVec2(tickX, centerY), tickRadius, tickColor);
|
||||
}
|
||||
}
|
||||
|
||||
// Thumb
|
||||
drawList->AddCircleFilled(ImVec2(thumbX + 1, centerY + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
drawList->AddCircleFilled(ImVec2(thumbX, centerY), thumbRadius, Primary());
|
||||
|
||||
if (hovered || held) {
|
||||
ImU32 rippleColor = WithAlpha(Primary(), held ? 51 : 25);
|
||||
drawList->AddCircleFilled(ImVec2(thumbX, centerY), thumbRadius + 12.0f, rippleColor);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
inline bool SliderRange(const char* label, float* minVal, float* maxVal,
|
||||
float rangeMin, float rangeMax, float width) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(label);
|
||||
|
||||
const float trackHeight = 4.0f;
|
||||
const float thumbRadius = 10.0f;
|
||||
float sliderWidth = width > 0 ? width : ImGui::GetContentRegionAvail().x;
|
||||
float totalHeight = size::TouchTarget;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + sliderWidth, pos.y + totalHeight));
|
||||
|
||||
ImGuiID id = window->GetID("##slider");
|
||||
ImGui::ItemSize(bb);
|
||||
if (!ImGui::ItemAdd(bb, id))
|
||||
return false;
|
||||
|
||||
float trackLeft = pos.x + thumbRadius;
|
||||
float trackRight = pos.x + sliderWidth - thumbRadius;
|
||||
float trackWidth = trackRight - trackLeft;
|
||||
float centerY = pos.y + totalHeight * 0.5f;
|
||||
|
||||
float minFraction = (*minVal - rangeMin) / (rangeMax - rangeMin);
|
||||
float maxFraction = (*maxVal - rangeMin) / (rangeMax - rangeMin);
|
||||
float minThumbX = trackLeft + trackWidth * minFraction;
|
||||
float maxThumbX = trackLeft + trackWidth * maxFraction;
|
||||
|
||||
// Hit test both thumbs
|
||||
ImVec2 mousePos = ImGui::GetIO().MousePos;
|
||||
float distToMin = fabsf(mousePos.x - minThumbX);
|
||||
float distToMax = fabsf(mousePos.x - maxThumbX);
|
||||
bool nearMin = distToMin < distToMax;
|
||||
|
||||
ImGuiID minId = window->GetID("##min");
|
||||
ImGuiID maxId = window->GetID("##max");
|
||||
|
||||
bool minHovered, minHeld;
|
||||
bool maxHovered, maxHeld;
|
||||
ImRect minHitBox(ImVec2(minThumbX - thumbRadius - 8, centerY - thumbRadius - 8),
|
||||
ImVec2(minThumbX + thumbRadius + 8, centerY + thumbRadius + 8));
|
||||
ImRect maxHitBox(ImVec2(maxThumbX - thumbRadius - 8, centerY - thumbRadius - 8),
|
||||
ImVec2(maxThumbX + thumbRadius + 8, centerY + thumbRadius + 8));
|
||||
|
||||
ImGui::ButtonBehavior(nearMin ? minHitBox : maxHitBox, nearMin ? minId : maxId,
|
||||
nearMin ? &minHovered : &maxHovered,
|
||||
nearMin ? &minHeld : &maxHeld);
|
||||
|
||||
bool changed = false;
|
||||
|
||||
if (minHeld) {
|
||||
float newFraction = (mousePos.x - trackLeft) / trackWidth;
|
||||
newFraction = ImClamp(newFraction, 0.0f, maxFraction - 0.01f);
|
||||
float newValue = rangeMin + newFraction * (rangeMax - rangeMin);
|
||||
if (newValue != *minVal) {
|
||||
*minVal = newValue;
|
||||
changed = true;
|
||||
}
|
||||
minThumbX = trackLeft + trackWidth * newFraction;
|
||||
}
|
||||
|
||||
if (maxHeld) {
|
||||
float newFraction = (mousePos.x - trackLeft) / trackWidth;
|
||||
newFraction = ImClamp(newFraction, minFraction + 0.01f, 1.0f);
|
||||
float newValue = rangeMin + newFraction * (rangeMax - rangeMin);
|
||||
if (newValue != *maxVal) {
|
||||
*maxVal = newValue;
|
||||
changed = true;
|
||||
}
|
||||
maxThumbX = trackLeft + trackWidth * newFraction;
|
||||
}
|
||||
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Inactive track
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(trackLeft, centerY - trackHeight * 0.5f),
|
||||
ImVec2(trackRight, centerY + trackHeight * 0.5f),
|
||||
WithAlpha(Primary(), 64), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Active track (between thumbs)
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(minThumbX, centerY - trackHeight * 0.5f),
|
||||
ImVec2(maxThumbX, centerY + trackHeight * 0.5f),
|
||||
Primary(), trackHeight * 0.5f
|
||||
);
|
||||
|
||||
// Min thumb
|
||||
drawList->AddCircleFilled(ImVec2(minThumbX + 1, centerY + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
drawList->AddCircleFilled(ImVec2(minThumbX, centerY), thumbRadius, Primary());
|
||||
|
||||
if (minHovered || minHeld) {
|
||||
ImU32 rippleColor = WithAlpha(Primary(), minHeld ? 51 : 25);
|
||||
drawList->AddCircleFilled(ImVec2(minThumbX, centerY), thumbRadius + 12.0f, rippleColor);
|
||||
}
|
||||
|
||||
// Max thumb
|
||||
drawList->AddCircleFilled(ImVec2(maxThumbX + 1, centerY + 2), thumbRadius, schema::UI().resolveColor("var(--control-shadow)", IM_COL32(0, 0, 0, 60)));
|
||||
drawList->AddCircleFilled(ImVec2(maxThumbX, centerY), thumbRadius, Primary());
|
||||
|
||||
if (maxHovered || maxHeld) {
|
||||
ImU32 rippleColor = WithAlpha(Primary(), maxHeld ? 51 : 25);
|
||||
drawList->AddCircleFilled(ImVec2(maxThumbX, centerY), thumbRadius + 12.0f, rippleColor);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,242 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "../draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Snackbar Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/snackbars
|
||||
//
|
||||
// Snackbars provide brief messages about app processes at the bottom of the
|
||||
// screen. They can include a single action.
|
||||
|
||||
/**
|
||||
* @brief Snackbar configuration
|
||||
*/
|
||||
struct SnackbarSpec {
|
||||
const char* message = nullptr; // Message text
|
||||
const char* actionText = nullptr; // Optional action button text
|
||||
float duration = 4.0f; // Duration in seconds (0 = indefinite)
|
||||
bool multiLine = false; // Allow multi-line message
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Snackbar manager for showing notifications
|
||||
*/
|
||||
class Snackbar {
|
||||
public:
|
||||
static Snackbar& instance();
|
||||
|
||||
/**
|
||||
* @brief Show a snackbar message
|
||||
*
|
||||
* @param message Message text
|
||||
* @param actionText Optional action text
|
||||
* @param duration Display duration (0 = until dismissed)
|
||||
*/
|
||||
void show(const char* message, const char* actionText = nullptr, float duration = 4.0f);
|
||||
|
||||
/**
|
||||
* @brief Show a snackbar with full configuration
|
||||
*/
|
||||
void show(const SnackbarSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Dismiss current snackbar
|
||||
*/
|
||||
void dismiss();
|
||||
|
||||
/**
|
||||
* @brief Render snackbar (call each frame)
|
||||
*
|
||||
* @return true if action was clicked
|
||||
*/
|
||||
bool render();
|
||||
|
||||
/**
|
||||
* @brief Check if snackbar is visible
|
||||
*/
|
||||
bool isVisible() const { return m_visible; }
|
||||
|
||||
private:
|
||||
Snackbar() = default;
|
||||
|
||||
bool m_visible = false;
|
||||
SnackbarSpec m_currentSpec;
|
||||
float m_showTime = 0;
|
||||
float m_animProgress = 0; // 0 = hidden, 1 = fully shown
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Convenience Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Show a snackbar message
|
||||
*/
|
||||
inline void ShowSnackbar(const char* message, const char* action = nullptr, float duration = 4.0f) {
|
||||
Snackbar::instance().show(message, action, duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Dismiss current snackbar
|
||||
*/
|
||||
inline void DismissSnackbar() {
|
||||
Snackbar::instance().dismiss();
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Render snackbar system (call once per frame in main render loop)
|
||||
*
|
||||
* @return true if action was clicked
|
||||
*/
|
||||
inline bool RenderSnackbar() {
|
||||
return Snackbar::instance().render();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline Snackbar& Snackbar::instance() {
|
||||
static Snackbar s_instance;
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
inline void Snackbar::show(const char* message, const char* actionText, float duration) {
|
||||
SnackbarSpec spec;
|
||||
spec.message = message;
|
||||
spec.actionText = actionText;
|
||||
spec.duration = duration;
|
||||
show(spec);
|
||||
}
|
||||
|
||||
inline void Snackbar::show(const SnackbarSpec& spec) {
|
||||
m_currentSpec = spec;
|
||||
m_visible = true;
|
||||
m_showTime = (float)ImGui::GetTime();
|
||||
m_animProgress = 0;
|
||||
}
|
||||
|
||||
inline void Snackbar::dismiss() {
|
||||
m_visible = false;
|
||||
}
|
||||
|
||||
inline bool Snackbar::render() {
|
||||
if (!m_visible && m_animProgress <= 0)
|
||||
return false;
|
||||
|
||||
bool actionClicked = false;
|
||||
float currentTime = (float)ImGui::GetTime();
|
||||
|
||||
// Check auto-dismiss
|
||||
if (m_visible && m_currentSpec.duration > 0) {
|
||||
if (currentTime - m_showTime > m_currentSpec.duration) {
|
||||
m_visible = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Animate in/out
|
||||
float animTarget = m_visible ? 1.0f : 0.0f;
|
||||
float animSpeed = 8.0f; // Animation speed
|
||||
if (m_animProgress < animTarget) {
|
||||
m_animProgress = ImMin(m_animProgress + ImGui::GetIO().DeltaTime * animSpeed, animTarget);
|
||||
} else if (m_animProgress > animTarget) {
|
||||
m_animProgress = ImMax(m_animProgress - ImGui::GetIO().DeltaTime * animSpeed, animTarget);
|
||||
}
|
||||
|
||||
if (m_animProgress <= 0)
|
||||
return false;
|
||||
|
||||
// Snackbar dimensions
|
||||
const float snackbarHeight = m_currentSpec.multiLine ? 68.0f : 48.0f;
|
||||
const float snackbarMinWidth = 344.0f;
|
||||
const float snackbarMaxWidth = 672.0f;
|
||||
const float margin = spacing::dp(3); // 24dp from edges
|
||||
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
// Calculate width based on content
|
||||
float messageWidth = ImGui::CalcTextSize(m_currentSpec.message).x;
|
||||
float actionWidth = m_currentSpec.actionText ?
|
||||
ImGui::CalcTextSize(m_currentSpec.actionText).x + spacing::dp(2) : 0;
|
||||
float contentWidth = messageWidth + actionWidth + spacing::dp(4); // 32dp padding
|
||||
float snackbarWidth = ImClamp(contentWidth, snackbarMinWidth, snackbarMaxWidth);
|
||||
|
||||
// Position at bottom center
|
||||
float bottomY = io.DisplaySize.y - margin - snackbarHeight;
|
||||
float slideOffset = (1.0f - m_animProgress) * (snackbarHeight + margin);
|
||||
|
||||
ImVec2 snackbarPos(
|
||||
(io.DisplaySize.x - snackbarWidth) * 0.5f,
|
||||
bottomY + slideOffset
|
||||
);
|
||||
|
||||
// Draw snackbar
|
||||
ImDrawList* drawList = ImGui::GetForegroundDrawList();
|
||||
|
||||
// Background (elevation dp6 equivalent)
|
||||
ImU32 snackBg = schema::UI().resolveColor("var(--snackbar-bg)", IM_COL32(50, 50, 50, 255));
|
||||
ImU32 bgColor = ScaleAlpha(snackBg, m_animProgress);
|
||||
ImVec2 snackbarMin = snackbarPos;
|
||||
ImVec2 snackbarMax(snackbarPos.x + snackbarWidth, snackbarPos.y + snackbarHeight);
|
||||
|
||||
drawList->AddRectFilled(snackbarMin, snackbarMax, bgColor, 4.0f);
|
||||
|
||||
// Message text
|
||||
float textY = snackbarPos.y + (snackbarHeight - ImGui::GetFontSize()) * 0.5f;
|
||||
float textX = snackbarPos.x + spacing::dp(2); // 16dp left padding
|
||||
|
||||
ImU32 snackText = schema::UI().resolveColor("var(--snackbar-text)", IM_COL32(255, 255, 255, 222));
|
||||
ImU32 textColor = ScaleAlpha(snackText, m_animProgress);
|
||||
drawList->AddText(ImVec2(textX, textY), textColor, m_currentSpec.message);
|
||||
|
||||
// Action button
|
||||
if (m_currentSpec.actionText) {
|
||||
float actionX = snackbarMax.x - spacing::dp(2) - actionWidth;
|
||||
|
||||
// Hit test for action
|
||||
ImVec2 actionMin(actionX, snackbarPos.y);
|
||||
ImVec2 actionMax(snackbarMax.x, snackbarMax.y);
|
||||
|
||||
ImVec2 mousePos = io.MousePos;
|
||||
bool hovered = (mousePos.x >= actionMin.x && mousePos.x < actionMax.x &&
|
||||
mousePos.y >= actionMin.y && mousePos.y < actionMax.y);
|
||||
|
||||
// Action text color
|
||||
ImU32 actionColor;
|
||||
if (hovered) {
|
||||
actionColor = ScaleAlpha(schema::UI().resolveColor("var(--snackbar-action-hover)", IM_COL32(255, 213, 79, 255)), m_animProgress);
|
||||
} else {
|
||||
actionColor = ScaleAlpha(schema::UI().resolveColor("var(--snackbar-action)", IM_COL32(255, 193, 7, 255)), m_animProgress);
|
||||
}
|
||||
|
||||
drawList->AddText(ImVec2(actionX, textY), actionColor, m_currentSpec.actionText);
|
||||
|
||||
// Check click
|
||||
if (hovered && io.MouseClicked[0]) {
|
||||
actionClicked = true;
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
return actionClicked;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,319 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "../../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Tabs Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/tabs
|
||||
//
|
||||
// Tabs organize content across different screens, data sets, and other
|
||||
// interactions.
|
||||
|
||||
/**
|
||||
* @brief Tab bar configuration
|
||||
*/
|
||||
struct TabBarSpec {
|
||||
bool scrollable = false; // Enable horizontal scrolling
|
||||
bool fullWidth = true; // Tabs fill available width
|
||||
bool showIndicator = true; // Show selection indicator
|
||||
bool centered = false; // Center tabs (when not full width)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Individual tab configuration
|
||||
*/
|
||||
struct TabSpec {
|
||||
const char* label = nullptr;
|
||||
const char* icon = nullptr; // Optional icon (text representation)
|
||||
bool disabled = false;
|
||||
int badgeCount = 0; // Badge count (0 = no badge)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Begin a tab bar
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param selectedIndex Pointer to selected tab index
|
||||
* @param spec Tab bar configuration
|
||||
* @return true if tab bar is visible
|
||||
*/
|
||||
bool BeginTabBar(const char* id, int* selectedIndex, const TabBarSpec& spec = TabBarSpec());
|
||||
|
||||
/**
|
||||
* @brief End a tab bar
|
||||
*/
|
||||
void EndTabBar();
|
||||
|
||||
/**
|
||||
* @brief Add a tab to current tab bar
|
||||
*
|
||||
* @param spec Tab configuration
|
||||
* @return true if this tab is selected
|
||||
*/
|
||||
bool Tab(const TabSpec& spec);
|
||||
|
||||
/**
|
||||
* @brief Simple tab with just label
|
||||
*/
|
||||
bool Tab(const char* label);
|
||||
|
||||
/**
|
||||
* @brief Simple tab bar - returns selected index
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param labels Array of tab labels
|
||||
* @param count Number of tabs
|
||||
* @param selectedIndex Current selected index (will be updated)
|
||||
* @return true if selection changed
|
||||
*/
|
||||
bool TabBar(const char* id, const char** labels, int count, int* selectedIndex);
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
// Internal state for tab rendering
|
||||
struct TabBarState {
|
||||
int* selectedIndex;
|
||||
int currentTabIndex;
|
||||
TabBarSpec spec;
|
||||
float tabBarWidth;
|
||||
float tabWidth;
|
||||
float indicatorX;
|
||||
float indicatorWidth;
|
||||
ImVec2 barPos;
|
||||
};
|
||||
|
||||
static TabBarState g_tabBarState;
|
||||
|
||||
inline bool BeginTabBar(const char* id, int* selectedIndex, const TabBarSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
g_tabBarState.selectedIndex = selectedIndex;
|
||||
g_tabBarState.currentTabIndex = 0;
|
||||
g_tabBarState.spec = spec;
|
||||
g_tabBarState.tabBarWidth = ImGui::GetContentRegionAvail().x;
|
||||
g_tabBarState.tabWidth = 0; // Will be calculated if fullWidth
|
||||
g_tabBarState.barPos = window->DC.CursorPos;
|
||||
g_tabBarState.indicatorX = 0;
|
||||
g_tabBarState.indicatorWidth = 0;
|
||||
|
||||
// Reserve space for tab bar
|
||||
float barHeight = size::TabBarHeight;
|
||||
ImRect bb(g_tabBarState.barPos,
|
||||
ImVec2(g_tabBarState.barPos.x + g_tabBarState.tabBarWidth,
|
||||
g_tabBarState.barPos.y + barHeight));
|
||||
|
||||
ImGui::ItemSize(bb);
|
||||
|
||||
// Draw tab bar background
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, Surface(Elevation::Dp4));
|
||||
|
||||
// Begin horizontal layout for tabs
|
||||
ImGui::SetCursorScreenPos(g_tabBarState.barPos);
|
||||
ImGui::BeginGroup();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
inline void EndTabBar() {
|
||||
ImGui::EndGroup();
|
||||
|
||||
// Draw indicator line
|
||||
if (g_tabBarState.spec.showIndicator && g_tabBarState.indicatorWidth > 0) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
float indicatorY = g_tabBarState.barPos.y + size::TabBarHeight - 2.0f;
|
||||
drawList->AddRectFilled(
|
||||
ImVec2(g_tabBarState.indicatorX, indicatorY),
|
||||
ImVec2(g_tabBarState.indicatorX + g_tabBarState.indicatorWidth, indicatorY + 2.0f),
|
||||
Primary()
|
||||
);
|
||||
}
|
||||
|
||||
// Add bottom divider
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
float dividerY = g_tabBarState.barPos.y + size::TabBarHeight;
|
||||
drawList->AddLine(
|
||||
ImVec2(g_tabBarState.barPos.x, dividerY),
|
||||
ImVec2(g_tabBarState.barPos.x + g_tabBarState.tabBarWidth, dividerY),
|
||||
OnSurfaceDisabled()
|
||||
);
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
inline bool Tab(const TabSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
int tabIndex = g_tabBarState.currentTabIndex++;
|
||||
bool isSelected = (*g_tabBarState.selectedIndex == tabIndex);
|
||||
|
||||
// Calculate tab dimensions
|
||||
float minTabWidth = spec.icon ? 72.0f : 90.0f; // Material min widths
|
||||
float maxTabWidth = 360.0f;
|
||||
float labelWidth = ImGui::CalcTextSize(spec.label).x;
|
||||
float iconWidth = spec.icon ? 24.0f + spacing::dp(1) : 0;
|
||||
float contentWidth = labelWidth + iconWidth + spacing::dp(4); // 32dp padding
|
||||
|
||||
float tabWidth;
|
||||
if (g_tabBarState.spec.fullWidth) {
|
||||
// Divide evenly (assuming we don't know total count here - simplified)
|
||||
tabWidth = ImMax(minTabWidth, contentWidth);
|
||||
} else {
|
||||
tabWidth = ImClamp(contentWidth, minTabWidth, maxTabWidth);
|
||||
}
|
||||
|
||||
float tabHeight = size::TabBarHeight;
|
||||
|
||||
ImVec2 tabPos = window->DC.CursorPos;
|
||||
ImRect tabBB(tabPos, ImVec2(tabPos.x + tabWidth, tabPos.y + tabHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID id = window->GetID(spec.label);
|
||||
bool hovered, held;
|
||||
bool pressed = ImGui::ButtonBehavior(tabBB, id, &hovered, &held) && !spec.disabled;
|
||||
|
||||
if (pressed && !isSelected) {
|
||||
*g_tabBarState.selectedIndex = tabIndex;
|
||||
}
|
||||
|
||||
// Update indicator position for selected tab
|
||||
if (isSelected) {
|
||||
g_tabBarState.indicatorX = tabPos.x;
|
||||
g_tabBarState.indicatorWidth = tabWidth;
|
||||
}
|
||||
|
||||
// Draw
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
// Hover/press state overlay
|
||||
if (!spec.disabled) {
|
||||
if (held) {
|
||||
drawList->AddRectFilled(tabBB.Min, tabBB.Max, schema::UI().resolveColor("var(--hover-overlay)", IM_COL32(255, 255, 255, 25)));
|
||||
} else if (hovered) {
|
||||
drawList->AddRectFilled(tabBB.Min, tabBB.Max, schema::UI().resolveColor("var(--active-overlay)", IM_COL32(255, 255, 255, 10)));
|
||||
}
|
||||
}
|
||||
|
||||
// Content color
|
||||
ImU32 contentColor;
|
||||
if (spec.disabled) {
|
||||
contentColor = OnSurfaceDisabled();
|
||||
} else if (isSelected) {
|
||||
contentColor = Primary();
|
||||
} else {
|
||||
contentColor = OnSurfaceMedium();
|
||||
}
|
||||
|
||||
// Draw content (icon and/or label)
|
||||
float contentX = tabPos.x + (tabWidth - labelWidth - iconWidth) * 0.5f;
|
||||
float centerY = tabPos.y + tabHeight * 0.5f;
|
||||
|
||||
if (spec.icon) {
|
||||
ImFont* iconFont = Type().iconMed();
|
||||
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, spec.icon);
|
||||
ImVec2 iconPos(contentX, centerY - iconSize.y * 0.5f);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize, iconPos, contentColor, spec.icon);
|
||||
contentX += iconSize.x + spacing::Xs;
|
||||
}
|
||||
|
||||
// Label (uppercase)
|
||||
Typography::instance().pushFont(TypeStyle::Button);
|
||||
float labelY = centerY - ImGui::GetFontSize() * 0.5f;
|
||||
|
||||
// Convert to uppercase
|
||||
char upperLabel[128];
|
||||
size_t i = 0;
|
||||
for (const char* p = spec.label; *p && i < sizeof(upperLabel) - 1; p++, i++) {
|
||||
upperLabel[i] = (*p >= 'a' && *p <= 'z') ? (*p - 32) : *p;
|
||||
}
|
||||
upperLabel[i] = '\0';
|
||||
|
||||
drawList->AddText(ImVec2(contentX, labelY), contentColor, upperLabel);
|
||||
Typography::instance().popFont();
|
||||
|
||||
// Badge
|
||||
if (spec.badgeCount > 0) {
|
||||
float badgeX = tabPos.x + tabWidth - 16.0f;
|
||||
float badgeY = tabPos.y + 8.0f;
|
||||
float badgeRadius = 8.0f;
|
||||
|
||||
drawList->AddCircleFilled(ImVec2(badgeX, badgeY), badgeRadius, Error());
|
||||
|
||||
char badgeText[8];
|
||||
if (spec.badgeCount > 99) {
|
||||
snprintf(badgeText, sizeof(badgeText), "99+");
|
||||
} else {
|
||||
snprintf(badgeText, sizeof(badgeText), "%d", spec.badgeCount);
|
||||
}
|
||||
|
||||
ImVec2 badgeTextSize = ImGui::CalcTextSize(badgeText);
|
||||
ImVec2 badgeTextPos(badgeX - badgeTextSize.x * 0.5f, badgeY - badgeTextSize.y * 0.5f);
|
||||
|
||||
Typography::instance().pushFont(TypeStyle::Caption);
|
||||
drawList->AddText(badgeTextPos, OnError(), badgeText);
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
// Advance cursor
|
||||
ImGui::SameLine(0, 0);
|
||||
ImGui::SetCursorScreenPos(ImVec2(tabPos.x + tabWidth, tabPos.y));
|
||||
|
||||
return isSelected;
|
||||
}
|
||||
|
||||
inline bool Tab(const char* label) {
|
||||
TabSpec spec;
|
||||
spec.label = label;
|
||||
return Tab(spec);
|
||||
}
|
||||
|
||||
inline bool TabBar(const char* id, const char** labels, int count, int* selectedIndex) {
|
||||
int oldIndex = *selectedIndex;
|
||||
|
||||
TabBarSpec spec;
|
||||
spec.fullWidth = true;
|
||||
|
||||
if (BeginTabBar(id, selectedIndex, spec)) {
|
||||
// Calculate tab width for full-width mode
|
||||
float tabWidth = ImGui::GetContentRegionAvail().x / count;
|
||||
|
||||
for (int i = 0; i < count; i++) {
|
||||
TabSpec tabSpec;
|
||||
tabSpec.label = labels[i];
|
||||
Tab(tabSpec);
|
||||
}
|
||||
|
||||
EndTabBar();
|
||||
}
|
||||
|
||||
return (*selectedIndex != oldIndex);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,227 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../colors.h"
|
||||
#include "../typography.h"
|
||||
#include "../layout.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Text Field Component
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/components/text-fields
|
||||
//
|
||||
// Two variants:
|
||||
// - Filled: Background fill with bottom line indicator
|
||||
// - Outlined: Border around entire field
|
||||
|
||||
enum class TextFieldStyle {
|
||||
Filled, // Background fill
|
||||
Outlined // Border only
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Text field configuration
|
||||
*/
|
||||
struct TextFieldSpec {
|
||||
TextFieldStyle style = TextFieldStyle::Outlined;
|
||||
const char* label = nullptr; // Floating label text
|
||||
const char* hint = nullptr; // Placeholder when empty
|
||||
const char* helperText = nullptr; // Helper text below field
|
||||
const char* errorText = nullptr; // Error message (shows in error state)
|
||||
const char* prefix = nullptr; // Prefix text (e.g., "$")
|
||||
const char* suffix = nullptr; // Suffix text (e.g., "DRGX")
|
||||
bool password = false; // Mask input
|
||||
bool readOnly = false; // Read-only field
|
||||
bool multiline = false; // Multi-line text area
|
||||
int multilineRows = 3; // Number of rows for multiline
|
||||
float width = 0; // Width (0 = full available)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Render a Material Design text field
|
||||
*
|
||||
* @param id Unique identifier
|
||||
* @param buf Text buffer
|
||||
* @param bufSize Buffer size
|
||||
* @param spec Field configuration
|
||||
* @return true if value changed
|
||||
*/
|
||||
bool TextField(const char* id, char* buf, size_t bufSize, const TextFieldSpec& spec = TextFieldSpec());
|
||||
|
||||
/**
|
||||
* @brief Render a simple text field with label
|
||||
*/
|
||||
inline bool TextField(const char* label, char* buf, size_t bufSize) {
|
||||
TextFieldSpec spec;
|
||||
spec.label = label;
|
||||
return TextField(label, buf, bufSize, spec);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline bool TextField(const char* id, char* buf, size_t bufSize, const TextFieldSpec& spec) {
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
if (window->SkipItems)
|
||||
return false;
|
||||
|
||||
ImGui::PushID(id);
|
||||
|
||||
bool hasError = (spec.errorText != nullptr);
|
||||
bool hasValue = (buf[0] != '\0');
|
||||
|
||||
// Calculate dimensions
|
||||
float fieldWidth = spec.width > 0 ? spec.width : ImGui::GetContentRegionAvail().x;
|
||||
float fieldHeight = spec.multiline ?
|
||||
(size::TextFieldHeight + (spec.multilineRows - 1) * Typography::instance().getFont(TypeStyle::Body1)->FontSize * 1.5f) :
|
||||
size::TextFieldHeight;
|
||||
|
||||
ImVec2 pos = window->DC.CursorPos;
|
||||
ImRect bb(pos, ImVec2(pos.x + fieldWidth, pos.y + fieldHeight));
|
||||
|
||||
// Interaction
|
||||
ImGuiID inputId = window->GetID("##input");
|
||||
bool focused = (ImGui::GetFocusID() == inputId);
|
||||
|
||||
// Colors
|
||||
ImU32 bgColor, borderColor, labelColor;
|
||||
|
||||
if (hasError) {
|
||||
borderColor = Error();
|
||||
labelColor = Error();
|
||||
} else if (focused) {
|
||||
borderColor = Primary();
|
||||
labelColor = Primary();
|
||||
} else {
|
||||
borderColor = OnSurfaceMedium();
|
||||
labelColor = OnSurfaceMedium();
|
||||
}
|
||||
|
||||
if (spec.style == TextFieldStyle::Filled) {
|
||||
bgColor = GetElevatedSurface(GetCurrentColorTheme(), 1);
|
||||
} else {
|
||||
bgColor = 0; // Transparent for outlined
|
||||
}
|
||||
|
||||
// Draw background/border
|
||||
ImDrawList* drawList = window->DrawList;
|
||||
|
||||
if (spec.style == TextFieldStyle::Filled) {
|
||||
// Filled style: background with bottom line
|
||||
drawList->AddRectFilled(bb.Min, bb.Max, bgColor,
|
||||
size::TextFieldCornerRadius, ImDrawFlags_RoundCornersTop);
|
||||
|
||||
// Bottom indicator line
|
||||
float lineThickness = focused ? 2.0f : 1.0f;
|
||||
drawList->AddLine(
|
||||
ImVec2(bb.Min.x, bb.Max.y - lineThickness),
|
||||
ImVec2(bb.Max.x, bb.Max.y - lineThickness),
|
||||
borderColor, lineThickness
|
||||
);
|
||||
} else {
|
||||
// Outlined style: border around entire field
|
||||
float lineThickness = focused ? 2.0f : 1.0f;
|
||||
drawList->AddRect(bb.Min, bb.Max, borderColor,
|
||||
size::TextFieldCornerRadius, 0, lineThickness);
|
||||
}
|
||||
|
||||
// Label (floating or inline)
|
||||
bool labelFloating = focused || hasValue;
|
||||
if (spec.label) {
|
||||
ImVec2 labelPos;
|
||||
TypeStyle labelStyle;
|
||||
|
||||
if (labelFloating) {
|
||||
// Floating label (smaller, at top)
|
||||
labelPos.x = bb.Min.x + size::TextFieldPadding;
|
||||
labelPos.y = bb.Min.y + 4.0f;
|
||||
labelStyle = TypeStyle::Caption;
|
||||
} else {
|
||||
// Inline label (body size, centered)
|
||||
labelPos.x = bb.Min.x + size::TextFieldPadding;
|
||||
labelPos.y = bb.Min.y + (fieldHeight - Typography::instance().getFont(TypeStyle::Body1)->FontSize) * 0.5f;
|
||||
labelStyle = TypeStyle::Body1;
|
||||
}
|
||||
|
||||
// For outlined style, need to clear background behind floating label
|
||||
if (spec.style == TextFieldStyle::Outlined && labelFloating) {
|
||||
ImVec2 labelSize = ImGui::CalcTextSize(spec.label);
|
||||
ImVec2 clearMin(labelPos.x - 4.0f, bb.Min.y - 1.0f);
|
||||
ImVec2 clearMax(labelPos.x + labelSize.x + 4.0f, bb.Min.y + Typography::instance().getFont(TypeStyle::Caption)->FontSize);
|
||||
drawList->AddRectFilled(clearMin, clearMax, Background());
|
||||
}
|
||||
|
||||
Typography::instance().pushFont(labelStyle);
|
||||
drawList->AddText(labelPos, labelColor, spec.label);
|
||||
Typography::instance().popFont();
|
||||
}
|
||||
|
||||
// Input field
|
||||
float inputY = spec.label && labelFloating ? bb.Min.y + 20.0f : bb.Min.y + 12.0f;
|
||||
float inputHeight = bb.Max.y - inputY - 8.0f;
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(bb.Min.x + size::TextFieldPadding, inputY));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(0, 0, 0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurface()));
|
||||
|
||||
ImGuiInputTextFlags flags = 0;
|
||||
if (spec.password) flags |= ImGuiInputTextFlags_Password;
|
||||
if (spec.readOnly) flags |= ImGuiInputTextFlags_ReadOnly;
|
||||
|
||||
float inputWidth = fieldWidth - size::TextFieldPadding * 2;
|
||||
if (spec.prefix) {
|
||||
ImGui::TextUnformatted(spec.prefix);
|
||||
ImGui::SameLine();
|
||||
inputWidth -= ImGui::CalcTextSize(spec.prefix).x + 4.0f;
|
||||
}
|
||||
|
||||
ImGui::PushItemWidth(inputWidth);
|
||||
bool changed;
|
||||
if (spec.multiline) {
|
||||
changed = ImGui::InputTextMultiline("##input", buf, bufSize,
|
||||
ImVec2(inputWidth, inputHeight), flags);
|
||||
} else {
|
||||
changed = ImGui::InputText("##input", buf, bufSize, flags);
|
||||
}
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
if (spec.suffix) {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", spec.suffix);
|
||||
}
|
||||
|
||||
ImGui::PopStyleColor(2);
|
||||
ImGui::PopStyleVar();
|
||||
|
||||
// Helper/Error text below field
|
||||
ImGui::SetCursorScreenPos(ImVec2(bb.Min.x, bb.Max.y + 4.0f));
|
||||
if (spec.errorText) {
|
||||
Typography::instance().textColored(TypeStyle::Caption, Error(), spec.errorText);
|
||||
ImGui::SetCursorScreenPos(ImVec2(bb.Min.x, bb.Max.y + 4.0f + Typography::instance().getFont(TypeStyle::Caption)->FontSize + 4.0f));
|
||||
} else if (spec.helperText) {
|
||||
Typography::instance().textColored(TypeStyle::Caption, OnSurfaceMedium(), spec.helperText);
|
||||
ImGui::SetCursorScreenPos(ImVec2(bb.Min.x, bb.Max.y + 4.0f + Typography::instance().getFont(TypeStyle::Caption)->FontSize + 4.0f));
|
||||
}
|
||||
|
||||
// Advance cursor
|
||||
ImGui::SetCursorScreenPos(ImVec2(pos.x, bb.Max.y + (spec.errorText || spec.helperText ? 24.0f : 8.0f)));
|
||||
|
||||
ImGui::PopID();
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include "colors.h"
|
||||
#include "type.h"
|
||||
#include "tooltip_style.h"
|
||||
#include "../layout.h"
|
||||
#include "../schema/element_styles.h"
|
||||
#include "../schema/color_var_resolver.h"
|
||||
@@ -21,6 +22,7 @@
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -90,10 +92,40 @@ inline void DrawTextShadow(ImDrawList* dl, const ImVec2& pos, ImU32 col,
|
||||
// and will return true even when a modal popup covers the rect, which
|
||||
// causes background elements to show hover highlights through dialogs.
|
||||
|
||||
inline int& OverlayDialogActiveFrame()
|
||||
{
|
||||
static int s_frame = -1;
|
||||
return s_frame;
|
||||
}
|
||||
|
||||
inline void MarkOverlayDialogActive()
|
||||
{
|
||||
OverlayDialogActiveFrame() = ImGui::GetFrameCount();
|
||||
}
|
||||
|
||||
inline bool IsCurrentWindowOverlayDialog()
|
||||
{
|
||||
ImGuiWindow* window = ImGui::GetCurrentWindow();
|
||||
for (ImGuiWindow* node = window; node; node = node->ParentWindow) {
|
||||
if (node->Name && strcmp(node->Name, "##OverlayScrim") == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
inline bool IsOverlayDialogBlockingInput()
|
||||
{
|
||||
int activeFrame = OverlayDialogActiveFrame();
|
||||
int currentFrame = ImGui::GetFrameCount();
|
||||
return activeFrame == currentFrame || activeFrame == (currentFrame - 1);
|
||||
}
|
||||
|
||||
inline bool IsRectHovered(const ImVec2& r_min, const ImVec2& r_max, bool clip = true)
|
||||
{
|
||||
if (!ImGui::IsMouseHoveringRect(r_min, r_max, clip))
|
||||
return false;
|
||||
if (IsOverlayDialogBlockingInput() && !IsCurrentWindowOverlayDialog())
|
||||
return false;
|
||||
// If a modal popup is open and it is not the current window, treat
|
||||
// the content as non-hoverable (same logic ImGui uses internally
|
||||
// inside IsWindowContentHoverable for modal blocking).
|
||||
@@ -885,12 +917,29 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
||||
// Creates a fullscreen semi-transparent overlay with a centered card dialog.
|
||||
// Similar to the shutdown screen pattern but for interactive dialogs.
|
||||
|
||||
inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f)
|
||||
// Per-dialog content height (keyed by the child's id) measured at the end of each frame, so the next
|
||||
// frame can size the glass card to its content instead of a fixed viewport band. g_overlayCurrentKey
|
||||
// carries the active dialog's key from BeginOverlayDialog to EndOverlayDialog (overlays don't nest).
|
||||
inline std::unordered_map<std::string, float> g_overlayCardHeights;
|
||||
inline std::string g_overlayCurrentKey;
|
||||
|
||||
inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f,
|
||||
float cardBottomViewportRatio = 0.85f, const char* idSuffix = nullptr)
|
||||
{
|
||||
MarkOverlayDialogActive();
|
||||
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
ImVec2 vp_pos = vp->Pos;
|
||||
ImVec2 vp_size = vp->Size;
|
||||
|
||||
|
||||
// Dialog widths are authored as raw pixels, but the fonts/spacing inside scale with
|
||||
// Layout::dpiScale() (which includes the user's font-size setting). Scale the card by the same
|
||||
// factor so the content doesn't outgrow a fixed card and overflow/misalign at non-default
|
||||
// scales. No-op at the default scale (dpiScale() == 1). Clamped to the viewport so a large scale
|
||||
// can't push the card off-screen.
|
||||
cardWidth *= Layout::dpiScale();
|
||||
cardWidth = std::min(cardWidth, vp_size.x - 32.0f);
|
||||
|
||||
// Fullscreen scrim overlay
|
||||
ImGui::SetNextWindowPos(vp_pos);
|
||||
ImGui::SetNextWindowSize(vp_size);
|
||||
@@ -900,7 +949,16 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
|
||||
bool opened = ImGui::Begin("##OverlayScrim", nullptr,
|
||||
std::string scrimId = "##OverlayScrim";
|
||||
std::string childId = "##OverlayDialogContent";
|
||||
if (idSuffix && idSuffix[0] != '\0') {
|
||||
scrimId += "_";
|
||||
scrimId += idSuffix;
|
||||
childId += "_";
|
||||
childId += idSuffix;
|
||||
}
|
||||
|
||||
bool opened = ImGui::Begin(scrimId.c_str(), nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav |
|
||||
@@ -914,14 +972,34 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
}
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
// Consume pointer input on the scrim so the overlay owns clicks and wheel
|
||||
// events even when the click lands outside the card content.
|
||||
ImGui::SetCursorScreenPos(vp_pos);
|
||||
ImGui::InvisibleButton("##OverlayInputBlocker", vp_size,
|
||||
ImGuiButtonFlags_MouseButtonLeft |
|
||||
ImGuiButtonFlags_MouseButtonRight |
|
||||
ImGuiButtonFlags_MouseButtonMiddle);
|
||||
|
||||
// Calculate card position (centered)
|
||||
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
|
||||
float cardY = vp_pos.y + vp_size.y * 0.15f;
|
||||
|
||||
|
||||
// Size the card height to its content. The content child below is AutoResizeY, so a glass card
|
||||
// drawn to a fixed viewport ratio left a tall band of empty glass under short dialogs. Reuse the
|
||||
// height the child reported LAST frame (content is stable frame-to-frame, so no visible lag) and
|
||||
// fall back to the ratio on the first frame. Still capped at the ratio so a very tall dialog can't
|
||||
// run off-screen (its content spills/scrolls as before).
|
||||
g_overlayCurrentKey = childId;
|
||||
float ratioMaxY = vp_pos.y + vp_size.y * cardBottomViewportRatio;
|
||||
auto prevHeightIt = g_overlayCardHeights.find(childId);
|
||||
float cardBottomY = (prevHeightIt != g_overlayCardHeights.end() && prevHeightIt->second > 0.0f)
|
||||
? std::min(cardY + prevHeightIt->second, ratioMaxY)
|
||||
: ratioMaxY;
|
||||
|
||||
// Draw glass card background
|
||||
ImVec2 cardMin(cardX, cardY);
|
||||
ImVec2 cardMax(cardX + cardWidth, vp_pos.y + vp_size.y * 0.85f);
|
||||
ImVec2 cardMax(cardX + cardWidth, cardBottomY);
|
||||
|
||||
// Card background with glass effect
|
||||
GlassPanelSpec cardGlass;
|
||||
@@ -930,6 +1008,14 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
cardGlass.borderAlpha = 50;
|
||||
cardGlass.borderWidth = 1.0f;
|
||||
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
|
||||
|
||||
// Click outside the card dismisses the dialog — but NOT on the frame it first appears, otherwise
|
||||
// the very click that opened it (a button fired the same frame) is read as an outside-click and
|
||||
// the dialog flashes open then instantly closes.
|
||||
if (p_open && !ImGui::IsWindowAppearing() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
|
||||
!ImGui::IsMouseHoveringRect(cardMin, cardMax, false)) {
|
||||
*p_open = false;
|
||||
}
|
||||
|
||||
// Set up child region for card content
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
|
||||
@@ -937,7 +1023,7 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24));
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // Transparent - glass already drawn
|
||||
|
||||
bool childVisible = ImGui::BeginChild("##OverlayDialogContent",
|
||||
bool childVisible = ImGui::BeginChild(childId.c_str(),
|
||||
ImVec2(cardWidth, 0), // 0 height = auto-size
|
||||
ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding,
|
||||
ImGuiWindowFlags_NoScrollbar);
|
||||
@@ -953,6 +1039,11 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
inline void EndOverlayDialog()
|
||||
{
|
||||
ImGui::EndChild();
|
||||
// Remember the rendered card height (the child is the last item) so the next frame's
|
||||
// BeginOverlayDialog can size the glass to the content — kills the empty band under short dialogs.
|
||||
if (!g_overlayCurrentKey.empty()) {
|
||||
g_overlayCardHeights[g_overlayCurrentKey] = ImGui::GetItemRectSize().y;
|
||||
}
|
||||
ImGui::PopStyleColor(); // ChildBg
|
||||
ImGui::PopStyleVar(2); // ChildRounding, WindowPadding (for child)
|
||||
|
||||
@@ -961,6 +1052,23 @@ inline void EndOverlayDialog()
|
||||
ImGui::PopStyleColor(); // WindowBg scrim
|
||||
}
|
||||
|
||||
inline void PlaceOverlayDialogActions(float totalWidth)
|
||||
{
|
||||
float rowStartX = ImGui::GetCursorPosX();
|
||||
float contentW = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalWidth) * 0.5f));
|
||||
}
|
||||
|
||||
inline void BeginOverlayDialogFooter(float totalActionWidth, bool drawSeparator = true)
|
||||
{
|
||||
ImGui::Spacing();
|
||||
if (drawSeparator) {
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
PlaceOverlayDialogActions(totalActionWidth);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "colors.h"
|
||||
#include "../effects/low_spec.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "imgui.h"
|
||||
#include "imgui_internal.h"
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Material Design Elevation and Shadow System
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/design/environment/elevation.html
|
||||
//
|
||||
// Material Design uses two light sources to create shadows:
|
||||
// - Key light: Creates sharper, directional shadows
|
||||
// - Ambient light: Creates softer, omnidirectional shadows
|
||||
//
|
||||
// In dark themes, elevation is primarily shown through surface color overlays
|
||||
// rather than shadows. However, shadows can still enhance depth perception.
|
||||
|
||||
// ============================================================================
|
||||
// Shadow Specifications
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Individual shadow layer specification
|
||||
*
|
||||
* Material shadows are composed of multiple layers with different
|
||||
* blur radii and offsets to simulate real-world lighting.
|
||||
*/
|
||||
struct ShadowLayer {
|
||||
float offsetX; // Horizontal offset (typically 0)
|
||||
float offsetY; // Vertical offset (key light from above)
|
||||
float blurRadius; // Blur spread
|
||||
float spreadRadius; // Size adjustment
|
||||
float opacity; // Alpha 0.0-1.0
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Complete shadow specification for an elevation level
|
||||
*/
|
||||
struct ShadowSpec {
|
||||
ShadowLayer umbra; // Darkest part, sharp edge
|
||||
ShadowLayer penumbra; // Mid-tone, softer
|
||||
ShadowLayer ambient; // Lightest, most diffuse
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Get shadow specification for elevation level
|
||||
*
|
||||
* @param elevationDp Elevation in dp (0, 1, 2, 3, 4, 6, 8, 12, 16, 24)
|
||||
* @return ShadowSpec for the elevation
|
||||
*/
|
||||
ShadowSpec GetShadowSpec(int elevationDp);
|
||||
|
||||
// ============================================================================
|
||||
// Shadow Rendering
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Draw Material Design shadow for a rectangle
|
||||
*
|
||||
* Uses multi-layer soft shadow rendering to approximate Material shadows.
|
||||
*
|
||||
* @param drawList ImGui draw list
|
||||
* @param rect Rectangle bounds
|
||||
* @param elevationDp Elevation in dp
|
||||
* @param cornerRadius Corner radius for rounded rectangles
|
||||
*/
|
||||
void DrawShadow(ImDrawList* drawList, const ImRect& rect, int elevationDp, float cornerRadius = 0);
|
||||
|
||||
/**
|
||||
* @brief Draw shadow with position/size parameters
|
||||
*/
|
||||
void DrawShadow(ImDrawList* drawList, const ImVec2& pos, const ImVec2& size,
|
||||
int elevationDp, float cornerRadius = 0);
|
||||
|
||||
/**
|
||||
* @brief Draw soft shadow (single layer, for custom effects)
|
||||
*
|
||||
* @param drawList ImGui draw list
|
||||
* @param rect Rectangle bounds
|
||||
* @param color Shadow color with alpha
|
||||
* @param blurRadius Blur amount
|
||||
* @param offset Shadow offset
|
||||
* @param cornerRadius Corner radius
|
||||
*/
|
||||
void DrawSoftShadow(ImDrawList* drawList, const ImRect& rect, ImU32 color,
|
||||
float blurRadius, const ImVec2& offset = ImVec2(0, 0),
|
||||
float cornerRadius = 0);
|
||||
|
||||
// ============================================================================
|
||||
// Elevation Transition Helper
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Animated elevation value
|
||||
*
|
||||
* Use this to smoothly transition between elevation levels (e.g., card hover)
|
||||
*/
|
||||
class ElevationAnimator {
|
||||
public:
|
||||
ElevationAnimator(int initialElevation = 0);
|
||||
|
||||
/**
|
||||
* @brief Set target elevation (will animate towards it)
|
||||
*/
|
||||
void setTarget(int targetElevation);
|
||||
|
||||
/**
|
||||
* @brief Update animation (call each frame)
|
||||
* @param deltaTime Frame delta time
|
||||
*/
|
||||
void update(float deltaTime);
|
||||
|
||||
/**
|
||||
* @brief Get current animated elevation value
|
||||
*/
|
||||
float getCurrent() const { return m_current; }
|
||||
|
||||
/**
|
||||
* @brief Get current elevation as integer (for shadow lookup)
|
||||
*/
|
||||
int getCurrentInt() const { return static_cast<int>(m_current + 0.5f); }
|
||||
|
||||
/**
|
||||
* @brief Check if currently animating
|
||||
*/
|
||||
bool isAnimating() const { return m_current != m_target; }
|
||||
|
||||
private:
|
||||
float m_current;
|
||||
float m_target;
|
||||
float m_animationSpeed = 16.0f; // dp per second
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Implementation
|
||||
// ============================================================================
|
||||
|
||||
inline ShadowSpec GetShadowSpec(int elevationDp) {
|
||||
// Material Design shadow values adapted from the spec
|
||||
// These approximate the CSS box-shadow values from material.io
|
||||
|
||||
switch (elevationDp) {
|
||||
case 0:
|
||||
return {
|
||||
{0, 0, 0, 0, 0}, // No shadow
|
||||
{0, 0, 0, 0, 0},
|
||||
{0, 0, 0, 0, 0}
|
||||
};
|
||||
case 1:
|
||||
return {
|
||||
{0, 2, 1, -1, 0.2f}, // Umbra
|
||||
{0, 1, 1, 0, 0.14f}, // Penumbra
|
||||
{0, 1, 3, 0, 0.12f} // Ambient
|
||||
};
|
||||
case 2:
|
||||
return {
|
||||
{0, 3, 1, -2, 0.2f},
|
||||
{0, 2, 2, 0, 0.14f},
|
||||
{0, 1, 5, 0, 0.12f}
|
||||
};
|
||||
case 3:
|
||||
return {
|
||||
{0, 3, 3, -2, 0.2f},
|
||||
{0, 3, 4, 0, 0.14f},
|
||||
{0, 1, 8, 0, 0.12f}
|
||||
};
|
||||
case 4:
|
||||
return {
|
||||
{0, 2, 4, -1, 0.2f},
|
||||
{0, 4, 5, 0, 0.14f},
|
||||
{0, 1, 10, 0, 0.12f}
|
||||
};
|
||||
case 6:
|
||||
return {
|
||||
{0, 3, 5, -1, 0.2f},
|
||||
{0, 6, 10, 0, 0.14f},
|
||||
{0, 1, 18, 0, 0.12f}
|
||||
};
|
||||
case 8:
|
||||
return {
|
||||
{0, 5, 5, -3, 0.2f},
|
||||
{0, 8, 10, 1, 0.14f},
|
||||
{0, 3, 14, 2, 0.12f}
|
||||
};
|
||||
case 12:
|
||||
return {
|
||||
{0, 7, 8, -4, 0.2f},
|
||||
{0, 12, 17, 2, 0.14f},
|
||||
{0, 5, 22, 4, 0.12f}
|
||||
};
|
||||
case 16:
|
||||
return {
|
||||
{0, 8, 10, -5, 0.2f},
|
||||
{0, 16, 24, 2, 0.14f},
|
||||
{0, 6, 30, 5, 0.12f}
|
||||
};
|
||||
case 24:
|
||||
return {
|
||||
{0, 11, 15, -7, 0.2f},
|
||||
{0, 24, 38, 3, 0.14f},
|
||||
{0, 9, 46, 8, 0.12f}
|
||||
};
|
||||
default:
|
||||
// Interpolate for non-standard elevations
|
||||
if (elevationDp < 0) return GetShadowSpec(0);
|
||||
if (elevationDp > 24) return GetShadowSpec(24);
|
||||
|
||||
// Find nearest standard elevation
|
||||
int lower = 0, upper = 1;
|
||||
int standards[] = {0, 1, 2, 3, 4, 6, 8, 12, 16, 24};
|
||||
for (int i = 0; i < 9; i++) {
|
||||
if (standards[i] <= elevationDp && standards[i + 1] >= elevationDp) {
|
||||
lower = standards[i];
|
||||
upper = standards[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Use nearest
|
||||
return GetShadowSpec((elevationDp - lower < upper - elevationDp) ? lower : upper);
|
||||
}
|
||||
}
|
||||
|
||||
inline void DrawSoftShadow(ImDrawList* drawList, const ImRect& rect, ImU32 color,
|
||||
float blurRadius, const ImVec2& offset, float cornerRadius) {
|
||||
if (blurRadius <= 0 || (color & IM_COL32_A_MASK) == 0)
|
||||
return;
|
||||
|
||||
// For ImGui, we'll simulate soft shadows using multiple semi-transparent layers
|
||||
// This is a performance-friendly approximation
|
||||
|
||||
// In low-spec mode use only 1 layer instead of up to 8
|
||||
const int numLayers = dragonx::ui::effects::isLowSpecMode()
|
||||
? 1
|
||||
: ImClamp((int)(blurRadius / 2), 2, 8);
|
||||
const float layerStep = blurRadius / numLayers;
|
||||
|
||||
// Extract base alpha
|
||||
float baseAlpha = ((color >> IM_COL32_A_SHIFT) & 0xFF) / 255.0f;
|
||||
ImU32 baseColor = color & ~IM_COL32_A_MASK;
|
||||
|
||||
for (int i = numLayers - 1; i >= 0; i--) {
|
||||
float expansion = layerStep * (i + 1);
|
||||
float alpha = baseAlpha * (1.0f - (float)i / numLayers) / numLayers;
|
||||
|
||||
ImU32 layerColor = baseColor | (((ImU32)(alpha * 255)) << IM_COL32_A_SHIFT);
|
||||
|
||||
ImRect expandedRect(
|
||||
rect.Min.x - expansion + offset.x,
|
||||
rect.Min.y - expansion + offset.y,
|
||||
rect.Max.x + expansion + offset.x,
|
||||
rect.Max.y + expansion + offset.y
|
||||
);
|
||||
|
||||
drawList->AddRectFilled(expandedRect.Min, expandedRect.Max, layerColor,
|
||||
cornerRadius + expansion * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
inline void DrawShadow(ImDrawList* drawList, const ImRect& rect, int elevationDp, float cornerRadius) {
|
||||
if (elevationDp <= 0)
|
||||
return;
|
||||
|
||||
ShadowSpec spec = GetShadowSpec(elevationDp);
|
||||
|
||||
// Shadow multiplier: light themes need stronger shadows for card depth,
|
||||
// dark themes rely more on surface color overlay for elevation.
|
||||
// Configurable via ui.toml [style] shadow-multiplier / shadow-multiplier-light.
|
||||
const float shadowMultiplier = schema::UI().isDarkTheme()
|
||||
? schema::UI().drawElement("style", "shadow-multiplier").sizeOr(0.6f)
|
||||
: schema::UI().drawElement("style", "shadow-multiplier-light").sizeOr(1.0f);
|
||||
|
||||
// Draw ambient shadow (largest, most diffuse)
|
||||
if (spec.ambient.opacity > 0) {
|
||||
ImU32 ambientColor = IM_COL32(0, 0, 0, (int)(spec.ambient.opacity * shadowMultiplier * 255));
|
||||
ImRect ambientRect = rect;
|
||||
ambientRect.Expand(spec.ambient.spreadRadius);
|
||||
DrawSoftShadow(drawList, ambientRect, ambientColor, spec.ambient.blurRadius,
|
||||
ImVec2(spec.ambient.offsetX, spec.ambient.offsetY), cornerRadius);
|
||||
}
|
||||
|
||||
// Draw penumbra (medium)
|
||||
if (spec.penumbra.opacity > 0) {
|
||||
ImU32 penumbraColor = IM_COL32(0, 0, 0, (int)(spec.penumbra.opacity * shadowMultiplier * 255));
|
||||
ImRect penumbraRect = rect;
|
||||
penumbraRect.Expand(spec.penumbra.spreadRadius);
|
||||
DrawSoftShadow(drawList, penumbraRect, penumbraColor, spec.penumbra.blurRadius,
|
||||
ImVec2(spec.penumbra.offsetX, spec.penumbra.offsetY), cornerRadius);
|
||||
}
|
||||
|
||||
// Draw umbra (sharpest, darkest)
|
||||
if (spec.umbra.opacity > 0) {
|
||||
ImU32 umbraColor = IM_COL32(0, 0, 0, (int)(spec.umbra.opacity * shadowMultiplier * 255));
|
||||
ImRect umbraRect = rect;
|
||||
umbraRect.Expand(spec.umbra.spreadRadius);
|
||||
DrawSoftShadow(drawList, umbraRect, umbraColor, spec.umbra.blurRadius,
|
||||
ImVec2(spec.umbra.offsetX, spec.umbra.offsetY), cornerRadius);
|
||||
}
|
||||
}
|
||||
|
||||
inline void DrawShadow(ImDrawList* drawList, const ImVec2& pos, const ImVec2& size,
|
||||
int elevationDp, float cornerRadius) {
|
||||
ImRect rect(pos, ImVec2(pos.x + size.x, pos.y + size.y));
|
||||
DrawShadow(drawList, rect, elevationDp, cornerRadius);
|
||||
}
|
||||
|
||||
inline ElevationAnimator::ElevationAnimator(int initialElevation)
|
||||
: m_current(static_cast<float>(initialElevation))
|
||||
, m_target(static_cast<float>(initialElevation))
|
||||
{
|
||||
}
|
||||
|
||||
inline void ElevationAnimator::setTarget(int targetElevation) {
|
||||
m_target = static_cast<float>(targetElevation);
|
||||
}
|
||||
|
||||
inline void ElevationAnimator::update(float deltaTime) {
|
||||
if (m_current == m_target)
|
||||
return;
|
||||
|
||||
float diff = m_target - m_current;
|
||||
float change = m_animationSpeed * deltaTime;
|
||||
|
||||
if (std::abs(diff) <= change) {
|
||||
m_current = m_target;
|
||||
} else {
|
||||
m_current += (diff > 0 ? 1 : -1) * change;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,190 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
//
|
||||
// GPU alpha mask — the ImGui equivalent of CSS mask-image: linear-gradient().
|
||||
// Uses AddCallback to switch the GPU blend mode so that gradient quads
|
||||
// multiply the framebuffer's alpha (and RGB) by the source alpha, producing
|
||||
// a smooth per-pixel fade without vertex-spacing artefacts.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
#include <d3d11.h>
|
||||
#else
|
||||
#ifdef DRAGONX_HAS_GLAD
|
||||
#include <glad/gl.h>
|
||||
#else
|
||||
#include <SDL3/SDL_opengl.h>
|
||||
#endif
|
||||
#include <SDL3/SDL.h> // for SDL_GL_GetProcAddress
|
||||
#endif
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
|
||||
// ============================================================================
|
||||
// Blend-mode callbacks — called by ImGui's backend during draw list rendering
|
||||
// ============================================================================
|
||||
|
||||
#ifdef DRAGONX_USE_DX11
|
||||
|
||||
// Cached DX11 blend state for the mask pass
|
||||
inline ID3D11BlendState* GetMaskBlendState() {
|
||||
static ID3D11BlendState* s_maskBlend = nullptr;
|
||||
if (!s_maskBlend) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!io.BackendRendererUserData) return nullptr;
|
||||
ID3D11Device* dev = *reinterpret_cast<ID3D11Device**>(io.BackendRendererUserData);
|
||||
if (!dev) return nullptr;
|
||||
|
||||
D3D11_BLEND_DESC desc = {};
|
||||
desc.RenderTarget[0].BlendEnable = TRUE;
|
||||
desc.RenderTarget[0].SrcBlend = D3D11_BLEND_ZERO;
|
||||
desc.RenderTarget[0].DestBlend = D3D11_BLEND_SRC_ALPHA;
|
||||
desc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;
|
||||
desc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ZERO;
|
||||
desc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_SRC_ALPHA;
|
||||
desc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD;
|
||||
desc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
|
||||
dev->CreateBlendState(&desc, &s_maskBlend);
|
||||
}
|
||||
return s_maskBlend;
|
||||
}
|
||||
|
||||
// Switch to mask blend: dst *= srcAlpha (both RGB and A)
|
||||
inline void MaskBlendCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!io.BackendRendererUserData) return;
|
||||
// The ImGui DX11 backend stores the device as the first pointer
|
||||
ID3D11Device* dev = *reinterpret_cast<ID3D11Device**>(io.BackendRendererUserData);
|
||||
if (!dev) return;
|
||||
ID3D11DeviceContext* ctx = nullptr;
|
||||
dev->GetImmediateContext(&ctx);
|
||||
if (!ctx) return;
|
||||
|
||||
ID3D11BlendState* bs = GetMaskBlendState();
|
||||
if (bs) {
|
||||
float blendFactor[4] = {0, 0, 0, 0};
|
||||
ctx->OMSetBlendState(bs, blendFactor, 0xFFFFFFFF);
|
||||
}
|
||||
ctx->Release();
|
||||
}
|
||||
|
||||
// Restore normal ImGui blend: src*srcA + dst*(1-srcA)
|
||||
inline void RestoreBlendCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (!io.BackendRendererUserData) return;
|
||||
ID3D11Device* dev = *reinterpret_cast<ID3D11Device**>(io.BackendRendererUserData);
|
||||
if (!dev) return;
|
||||
ID3D11DeviceContext* ctx = nullptr;
|
||||
dev->GetImmediateContext(&ctx);
|
||||
if (!ctx) return;
|
||||
// Setting nullptr restores the default blend state that ImGui's DX11
|
||||
// backend configures at the start of each frame.
|
||||
ctx->OMSetBlendState(nullptr, nullptr, 0xFFFFFFFF);
|
||||
ctx->Release();
|
||||
}
|
||||
|
||||
#else // OpenGL
|
||||
|
||||
// glBlendFuncSeparate may not be in the GLAD profile — load it once via SDL.
|
||||
typedef void (*PFN_glBlendFuncSeparate)(GLenum, GLenum, GLenum, GLenum);
|
||||
inline PFN_glBlendFuncSeparate GetBlendFuncSeparate() {
|
||||
static PFN_glBlendFuncSeparate fn = nullptr;
|
||||
static bool resolved = false;
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
fn = (PFN_glBlendFuncSeparate)(void*)SDL_GL_GetProcAddress("glBlendFuncSeparate");
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
inline void MaskBlendCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
// dst.rgb = dst.rgb * srcAlpha (erase content where mask alpha < 1)
|
||||
// dst.a = dst.a * srcAlpha (match alpha channel)
|
||||
auto fn = GetBlendFuncSeparate();
|
||||
if (fn)
|
||||
fn(GL_ZERO, GL_SRC_ALPHA, GL_ZERO, GL_SRC_ALPHA);
|
||||
else
|
||||
glBlendFunc(GL_ZERO, GL_SRC_ALPHA);
|
||||
}
|
||||
|
||||
inline void RestoreBlendCallback(const ImDrawList*, const ImDrawCmd*) {
|
||||
// Restore ImGui's exact blend state:
|
||||
// RGB: src*srcA + dst*(1-srcA)
|
||||
// Alpha: src*1 + dst*(1-srcA)
|
||||
auto fn = GetBlendFuncSeparate();
|
||||
if (fn)
|
||||
fn(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
|
||||
else
|
||||
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
// ============================================================================
|
||||
// DrawScrollFadeMask — draw gradient quads that mask the top/bottom edges
|
||||
// of a scrollable child region, producing a smooth per-pixel fade.
|
||||
//
|
||||
// Call this on the child's draw list BEFORE EndChild().
|
||||
// The gradient quads use the mask blend mode to multiply the existing
|
||||
// framebuffer content by their alpha, so alpha=1 means "keep" and
|
||||
// alpha=0 means "erase to transparent/black".
|
||||
//
|
||||
// Parameters:
|
||||
// dl — the child window's draw list (ImGui::GetWindowDrawList())
|
||||
// clipMin/Max — the child window's visible area (screen coords)
|
||||
// fadeH — the height of the fade zone in pixels
|
||||
// scrollY — current scroll offset (ImGui::GetScrollY())
|
||||
// scrollMaxY — maximum scroll offset (ImGui::GetScrollMaxY())
|
||||
// ============================================================================
|
||||
inline void DrawScrollFadeMask(ImDrawList* dl,
|
||||
const ImVec2& clipMin, const ImVec2& clipMax,
|
||||
float fadeH,
|
||||
float scrollY, float scrollMaxY)
|
||||
{
|
||||
if (fadeH <= 0.0f) return;
|
||||
|
||||
bool needTop = scrollY > 1.0f;
|
||||
bool needBottom = scrollMaxY > 0 && scrollY < scrollMaxY - 1.0f;
|
||||
if (!needTop && !needBottom) return;
|
||||
|
||||
float left = clipMin.x;
|
||||
float right = clipMax.x;
|
||||
|
||||
// Switch to mask blend mode
|
||||
dl->AddCallback(MaskBlendCallback, nullptr);
|
||||
|
||||
if (needTop) {
|
||||
// Top gradient: alpha=0 at top edge (erase) → alpha=1 at top+fadeH (keep)
|
||||
ImVec2 tMin(left, clipMin.y);
|
||||
ImVec2 tMax(right, clipMin.y + fadeH);
|
||||
ImU32 transparent = IM_COL32(0, 0, 0, 0);
|
||||
ImU32 opaque = IM_COL32(0, 0, 0, 255);
|
||||
dl->AddRectFilledMultiColor(tMin, tMax,
|
||||
transparent, transparent, // top-left, top-right
|
||||
opaque, opaque); // bottom-left, bottom-right
|
||||
}
|
||||
|
||||
if (needBottom) {
|
||||
// Bottom gradient: alpha=1 at bottom-fadeH (keep) → alpha=0 at bottom (erase)
|
||||
ImVec2 bMin(left, clipMax.y - fadeH);
|
||||
ImVec2 bMax(right, clipMax.y);
|
||||
ImU32 opaque = IM_COL32(0, 0, 0, 255);
|
||||
ImU32 transparent = IM_COL32(0, 0, 0, 0);
|
||||
dl->AddRectFilledMultiColor(bMin, bMax,
|
||||
opaque, opaque, // top-left, top-right
|
||||
transparent, transparent); // bottom-left, bottom-right
|
||||
}
|
||||
|
||||
// Restore normal blend mode
|
||||
dl->AddCallback(RestoreBlendCallback, nullptr);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -1,160 +0,0 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
// ============================================================================
|
||||
// Material Design 2 - Complete UI System
|
||||
// ============================================================================
|
||||
// Based on https://m2.material.io/design/foundation-overview
|
||||
//
|
||||
// This header provides the complete Material Design 2 implementation for
|
||||
// the DragonX Wallet ImGui interface.
|
||||
//
|
||||
// Namespace: dragonx::ui::material
|
||||
|
||||
// Foundation
|
||||
#include "color_theme.h" // ColorTheme struct, theme presets
|
||||
#include "colors.h" // Color accessor functions
|
||||
#include "typography.h" // Typography system, type scale
|
||||
#include "layout.h" // Spacing grid, breakpoints, sizes
|
||||
|
||||
// Effects
|
||||
#include "elevation.h" // Shadow rendering, elevation animation
|
||||
#include "ripple.h" // Touch ripple effect
|
||||
#include "draw_helpers.h" // DrawTextShadow, DrawGlassPanel
|
||||
|
||||
// Motion
|
||||
#include "motion.h" // Easing curves, AnimatedValue, StaggerAnimation
|
||||
#include "transitions.h" // View transitions, FadeTransition, ExpandableSection
|
||||
|
||||
// Layout
|
||||
#include "app_layout.h" // Application layout manager
|
||||
|
||||
// Components
|
||||
#include "components/components.h" // All Material components
|
||||
|
||||
// ============================================================================
|
||||
// Quick Start Guide
|
||||
// ============================================================================
|
||||
//
|
||||
// 1. INITIALIZATION
|
||||
// In your app startup, initialize the material system:
|
||||
//
|
||||
// ```cpp
|
||||
// using namespace dragonx::ui::material;
|
||||
//
|
||||
// // Initialize color theme (creates global theme)
|
||||
// SetDragonXTheme(); // or SetHushTheme() for HUSH variant
|
||||
//
|
||||
// // Initialize typography (load fonts)
|
||||
// Typography::instance().initialize(io);
|
||||
// ```
|
||||
//
|
||||
// 2. FRAME SETUP
|
||||
// At the start of each frame:
|
||||
//
|
||||
// ```cpp
|
||||
// // Update ripple animations
|
||||
// UpdateRipples();
|
||||
// ```
|
||||
//
|
||||
// 3. USING COLORS
|
||||
// Access theme colors with helper functions:
|
||||
//
|
||||
// ```cpp
|
||||
// ImU32 bg = Background(); // App background
|
||||
// ImU32 primary = Primary(); // Brand color
|
||||
// ImU32 cardBg = Surface(Elevation::Dp4); // Elevated surface
|
||||
// ImU32 text = OnSurface(); // Text on surfaces
|
||||
// ```
|
||||
//
|
||||
// 4. USING TYPOGRAPHY
|
||||
// Render text with the type scale:
|
||||
//
|
||||
// ```cpp
|
||||
// Typography::instance().text(TypeStyle::H6, "Section Title");
|
||||
// Typography::instance().text(TypeStyle::Body1, "Body text here...");
|
||||
// Typography::instance().textColored(TypeStyle::Caption, OnSurfaceMedium(), "Hint");
|
||||
// ```
|
||||
//
|
||||
// 5. USING COMPONENTS
|
||||
// Components follow Material Design patterns:
|
||||
//
|
||||
// ```cpp
|
||||
// // Buttons
|
||||
// if (ContainedButton("Send")) { ... }
|
||||
// if (OutlinedButton("Cancel")) { ... }
|
||||
// if (TextButton("Learn More")) { ... }
|
||||
//
|
||||
// // Cards
|
||||
// BeginCard(myCardSpec);
|
||||
// CardHeader("Card Title", "Subtitle");
|
||||
// CardContent("Card body content...");
|
||||
// CardActions();
|
||||
// TextButton("Action 1");
|
||||
// TextButton("Action 2");
|
||||
// CardActionsEnd();
|
||||
// EndCard();
|
||||
//
|
||||
// // Lists
|
||||
// BeginList("myList");
|
||||
// if (ListItem("Item 1")) { ... }
|
||||
// if (ListItem("Item 2", "Secondary text")) { ... }
|
||||
// ListDivider();
|
||||
// if (ListItem("Item 3")) { ... }
|
||||
// EndList();
|
||||
//
|
||||
// // Dialogs
|
||||
// static bool showDialog = false;
|
||||
// if (ContainedButton("Open Dialog")) showDialog = true;
|
||||
// int result = ConfirmDialog("confirm", &showDialog, "Confirm",
|
||||
// "Are you sure?", "Yes", "No");
|
||||
// ```
|
||||
//
|
||||
// 6. LAYOUT
|
||||
// Use the spacing system for consistent layouts:
|
||||
//
|
||||
// ```cpp
|
||||
// ImGui::Dummy(ImVec2(0, spacing::dp(2))); // 16dp vertical space
|
||||
// ImGui::SetCursorPosX(spacing::dp(3)); // 24dp indent
|
||||
// ```
|
||||
//
|
||||
// ============================================================================
|
||||
// Module Reference
|
||||
// ============================================================================
|
||||
//
|
||||
// COLORS (colors.h)
|
||||
// Primary(), PrimaryVariant(), PrimaryContainer()
|
||||
// Secondary(), SecondaryVariant()
|
||||
// Background(), Surface(elevation), SurfaceVariant()
|
||||
// OnPrimary(), OnSecondary(), OnBackground(), OnSurface()
|
||||
// OnSurfaceMedium(), OnSurfaceDisabled()
|
||||
// Error(), OnError()
|
||||
// StateHover(), StateFocus(), StatePressed(), StateSelected()
|
||||
//
|
||||
// TYPOGRAPHY (typography.h)
|
||||
// TypeStyle: H1-H6, Subtitle1-2, Body1-2, Button, Caption, Overline
|
||||
// Typography::text(style, text)
|
||||
// Typography::textColored(style, color, text)
|
||||
// Typography::textWrapped(style, text)
|
||||
// Typography::pushFont(style) / popFont()
|
||||
//
|
||||
// LAYOUT (layout.h)
|
||||
// spacing::dp(n) - n * 8dp
|
||||
// spacing::Unit - 8dp
|
||||
// size::TouchTarget - 48dp
|
||||
// size::ButtonHeight - 36dp
|
||||
// breakpoint::current() - Get current breakpoint
|
||||
//
|
||||
// ELEVATION (elevation.h)
|
||||
// DrawShadow(drawList, rect, elevationDp, cornerRadius)
|
||||
// ElevationAnimator - Smooth elevation transitions
|
||||
//
|
||||
// RIPPLE (ripple.h)
|
||||
// DrawRippleEffect(drawList, rect, id, cornerRadius, hovered, held)
|
||||
// UpdateRipples() - Call each frame
|
||||
//
|
||||
// COMPONENTS (components/components.h)
|
||||
// See components.h for full component reference
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user