// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 // // settings.cpp — JSON settings persistence. Loads/saves user preferences // to ~/.config/ObsidianDragon/settings.json (Linux/macOS) or %APPDATA% (Windows). #include "settings.h" #include "version.h" #include #include #include #include #include #include #include "../util/logger.h" #include "../util/platform.h" #ifdef _WIN32 #include #else #include #include #endif namespace fs = std::filesystem; using json = nlohmann::json; namespace dragonx { 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(); 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"; } Settings::PoolSelectMode parsePoolSelectMode(const json& value) { if (!value.is_string()) return Settings::PoolSelectMode::Manual; const std::string mode = value.get(); if (mode == "auto_balance" || mode == "auto") { return Settings::PoolSelectMode::AutoBalance; } return Settings::PoolSelectMode::Manual; } const char* poolSelectModeName(Settings::PoolSelectMode mode) { switch (mode) { case Settings::PoolSelectMode::Manual: return "manual"; case Settings::PoolSelectMode::AutoBalance: return "auto_balance"; } return "manual"; } // True if j[key] exists and holds a JSON value convertible to T. Guards every scalar // read so a malformed value (wrong type / missing key) leaves the field's default // instead of throwing out of the whole load(). template bool jsonHasType(const json& v) { if constexpr (std::is_same_v) return v.is_boolean(); else if constexpr (std::is_same_v) return v.is_string(); else if constexpr (std::is_floating_point_v) return v.is_number(); else if constexpr (std::is_integral_v) return v.is_number_integer(); else return false; } // Reads j[key] into field only when present AND of the matching type. template void loadScalar(const json& j, const char* key, T& field) { if (j.contains(key) && jsonHasType(j[key])) field = j[key].get(); } // Same as loadScalar but clamps the read value into [lo, hi]. template void loadClamped(const json& j, const char* key, T& field, T lo, T hi) { if (j.contains(key) && jsonHasType(j[key])) field = std::max(lo, std::min(hi, j[key].get())); } } // namespace std::string Settings::getDefaultPath() { // Single per-platform, per-variant config dir (util::Platform::getConfigDir handles the // _WIN32 / __APPLE__ / XDG split and the DRAGONX_APP_NAME variant suffix in one place). const std::string dir = util::Platform::getConfigDir(); fs::create_directories(dir); return (fs::path(dir) / "settings.json").string(); } bool Settings::load() { return load(getDefaultPath()); } bool Settings::load(const std::string& path) { settings_path_ = path; std::ifstream file(path); if (!file.is_open()) { return false; } try { json j; file >> j; loadScalar(j, "theme", theme_); loadScalar(j, "save_ztxs", save_ztxs_); loadScalar(j, "auto_shield", auto_shield_); loadScalar(j, "use_tor", use_tor_); loadScalar(j, "allow_custom_fees", allow_custom_fees_); loadScalar(j, "default_fee", default_fee_); loadScalar(j, "fetch_prices", fetch_prices_); loadScalar(j, "tx_explorer_url", tx_explorer_url_); loadScalar(j, "address_explorer_url", address_explorer_url_); loadScalar(j, "language", language_); loadScalar(j, "skin_id", skin_id_); loadScalar(j, "chat_reply_zaddr", chat_reply_zaddr_); loadScalar(j, "acrylic_enabled", acrylic_enabled_); loadScalar(j, "acrylic_quality", acrylic_quality_); loadScalar(j, "blur_multiplier", blur_multiplier_); loadScalar(j, "noise_opacity", noise_opacity_); loadScalar(j, "gradient_background", gradient_background_); // Migrate legacy reduced_transparency bool -> ui_opacity float if (j.contains("ui_opacity")) { ui_opacity_ = j["ui_opacity"].get(); } else if (j.contains("reduced_transparency") && j["reduced_transparency"].get()) { ui_opacity_ = 1.0f; // legacy: reduced = fully opaque } loadScalar(j, "window_opacity", window_opacity_); if (j.contains("balance_layout")) { if (j["balance_layout"].is_string()) balance_layout_ = j["balance_layout"].get(); else if (j["balance_layout"].is_number_integer()) { // Legacy migration: convert old int index to string ID static const char* legacyIds[] = { "classic","donut","consolidated","dashboard", "vertical-stack","shield","timeline","two-row","minimal" }; int idx = j["balance_layout"].get(); if (idx >= 0 && idx < 9) balance_layout_ = legacyIds[idx]; } } loadScalar(j, "scanline_enabled", scanline_enabled_); if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) { hidden_addresses_.clear(); for (const auto& a : j["hidden_addresses"]) if (a.is_string()) hidden_addresses_.insert(a.get()); } if (j.contains("favorite_addresses") && j["favorite_addresses"].is_array()) { favorite_addresses_.clear(); for (const auto& a : j["favorite_addresses"]) if (a.is_string()) favorite_addresses_.insert(a.get()); } if (j.contains("address_meta") && j["address_meta"].is_object()) { address_meta_.clear(); for (auto& [addr, meta] : j["address_meta"].items()) { AddressMeta m; if (meta.contains("label") && meta["label"].is_string()) m.label = meta["label"].get(); if (meta.contains("icon") && meta["icon"].is_string()) m.icon = meta["icon"].get(); if (meta.contains("order") && meta["order"].is_number_integer()) m.sortOrder = meta["order"].get(); if (meta.contains("mining") && meta["mining"].is_boolean()) m.mining = meta["mining"].get(); address_meta_[addr] = m; } } loadScalar(j, "wizard_completed", wizard_completed_); loadScalar(j, "seed_backup_reminded", seed_backup_reminded_); loadScalar(j, "auto_lock_timeout", auto_lock_timeout_); loadScalar(j, "unlock_duration", unlock_duration_); loadScalar(j, "pin_enabled", pin_enabled_); loadScalar(j, "keep_daemon_running", keep_daemon_running_); loadScalar(j, "stop_external_daemon", stop_external_daemon_); loadScalar(j, "max_connections", max_connections_); 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(); } if (lite.contains("chain_name") && lite["chain_name"].is_string()) { lite_chain_name_ = lite["chain_name"].get(); } // 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(); } else if (lite.contains("random_selection_seed") && lite["random_selection_seed"].is_number_integer()) { const auto seed = lite["random_selection_seed"].get(); lite_random_selection_seed_ = seed > 0 ? static_cast(seed) : 0; } if (lite.contains("persist_selected_server") && lite["persist_selected_server"].is_boolean()) { lite_persist_selected_server_ = lite["persist_selected_server"].get(); } 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(); } if (server.contains("label") && server["label"].is_string()) { preference.label = server["label"].get(); } if (server.contains("enabled") && server["enabled"].is_boolean()) { preference.enabled = server["enabled"].get(); } lite_servers_.push_back(preference); } } if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) { const auto v = lite["rollout_override"].get(); 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(); } 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()); } } loadScalar(j, "verbose_logging", verbose_logging_); if (j.contains("debug_categories") && j["debug_categories"].is_array()) { debug_categories_.clear(); for (const auto& c : j["debug_categories"]) if (c.is_string()) debug_categories_.insert(c.get()); } loadScalar(j, "theme_effects_enabled", theme_effects_enabled_); loadScalar(j, "low_spec_mode", low_spec_mode_); loadScalar(j, "reduce_motion", reduce_motion_); loadScalar(j, "selected_exchange", selected_exchange_); loadScalar(j, "selected_pair", selected_pair_); loadScalar(j, "pool_url", pool_url_); // Migrate old default pool URL that was missing the stratum port if (pool_url_ == "pool.dragonx.is") pool_url_ = "pool.dragonx.is:3433"; loadScalar(j, "pool_algo", pool_algo_); loadScalar(j, "pool_worker", pool_worker_); loadScalar(j, "pool_threads", pool_threads_); loadScalar(j, "pool_tls", pool_tls_); loadScalar(j, "pool_hugepages", pool_hugepages_); loadScalar(j, "pool_mode", pool_mode_); if (j.contains("pool_select_mode")) pool_select_mode_ = parsePoolSelectMode(j["pool_select_mode"]); loadScalar(j, "mine_when_idle", mine_when_idle_); loadScalar(j, "xmrig_version", xmrig_version_); // Lower-bounded only (min 30s), matching setMineIdleDelay; now type-guarded too. if (j.contains("mine_idle_delay") && j["mine_idle_delay"].is_number_integer()) mine_idle_delay_ = std::max(30, j["mine_idle_delay"].get()); loadScalar(j, "idle_thread_scaling", idle_thread_scaling_); loadScalar(j, "idle_threads_active", idle_threads_active_); loadScalar(j, "idle_threads_idle", idle_threads_idle_); loadScalar(j, "idle_gpu_aware", idle_gpu_aware_); if (j.contains("saved_pool_urls") && j["saved_pool_urls"].is_array()) { saved_pool_urls_.clear(); for (const auto& u : j["saved_pool_urls"]) if (u.is_string()) saved_pool_urls_.push_back(u.get()); } if (j.contains("saved_pool_workers") && j["saved_pool_workers"].is_array()) { saved_pool_workers_.clear(); for (const auto& w : j["saved_pool_workers"]) if (w.is_string()) saved_pool_workers_.push_back(w.get()); } if (j.contains("portfolio_entries") && j["portfolio_entries"].is_array()) { portfolio_entries_.clear(); for (const auto& e : j["portfolio_entries"]) { if (!e.is_object()) continue; PortfolioEntry entry; if (e.contains("label") && e["label"].is_string()) entry.label = e["label"].get(); if (e.contains("addresses") && e["addresses"].is_array()) for (const auto& a : e["addresses"]) if (a.is_string()) entry.addresses.push_back(a.get()); if (e.contains("icon") && e["icon"].is_string()) entry.icon = e["icon"].get(); if (e.contains("color") && e["color"].is_number()) entry.color = e["color"].get(); if (e.contains("outline_opacity") && e["outline_opacity"].is_number_integer()) entry.outlineOpacity = e["outline_opacity"].get(); if (e.contains("price_basis") && e["price_basis"].is_number_integer()) entry.priceBasis = e["price_basis"].get(); if (e.contains("manual_price") && e["manual_price"].is_number()) entry.manualPrice = e["manual_price"].get(); if (e.contains("manual_currency") && e["manual_currency"].is_string()) entry.manualCurrency = e["manual_currency"].get(); if (e.contains("show_drgx") && e["show_drgx"].is_boolean()) entry.showDrgx = e["show_drgx"].get(); if (e.contains("show_value") && e["show_value"].is_boolean()) entry.showValue = e["show_value"].get(); if (e.contains("show_24h") && e["show_24h"].is_boolean()) entry.show24h = e["show_24h"].get(); if (e.contains("show_sparkline") && e["show_sparkline"].is_boolean()) entry.showSparkline = e["show_sparkline"].get(); if (e.contains("sparkline_interval") && e["sparkline_interval"].is_number_integer()) entry.sparklineInterval = e["sparkline_interval"].get(); if (e.contains("grid_col") && e["grid_col"].is_number_integer()) entry.gridCol = e["grid_col"].get(); if (e.contains("grid_row") && e["grid_row"].is_number_integer()) entry.gridRow = e["grid_row"].get(); if (e.contains("grid_w") && e["grid_w"].is_number_integer()) entry.gridW = e["grid_w"].get(); if (e.contains("grid_h") && e["grid_h"].is_number_integer()) entry.gridH = e["grid_h"].get(); if (!entry.label.empty()) portfolio_entries_.push_back(std::move(entry)); } } loadClamped(j, "font_scale", font_scale_, 1.0f, 1.5f); loadScalar(j, "window_width", window_width_); loadScalar(j, "window_height", window_height_); // Version tracking — detect upgrades so we can re-save with new defaults loadScalar(j, "settings_version", settings_version_); if (settings_version_ != DRAGONX_VERSION) { DEBUG_LOGF("Settings version %s differs from wallet %s — will re-save\n", settings_version_.empty() ? "(none)" : settings_version_.c_str(), DRAGONX_VERSION); needs_upgrade_save_ = true; } 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(std::time(nullptr))); fs::rename(path, quarantine, ec); if (!ec) { DEBUG_LOGF("Quarantined corrupt settings to %s\n", quarantine.c_str()); } return false; } } bool Settings::save() { if (settings_path_.empty()) { settings_path_ = getDefaultPath(); } return save(settings_path_); } bool Settings::save(const std::string& path) { json j; j["theme"] = theme_; j["save_ztxs"] = save_ztxs_; j["auto_shield"] = auto_shield_; j["use_tor"] = use_tor_; j["allow_custom_fees"] = allow_custom_fees_; j["default_fee"] = default_fee_; j["fetch_prices"] = fetch_prices_; j["tx_explorer_url"] = tx_explorer_url_; j["address_explorer_url"] = address_explorer_url_; j["language"] = language_; j["skin_id"] = skin_id_; j["chat_reply_zaddr"] = chat_reply_zaddr_; j["acrylic_enabled"] = acrylic_enabled_; j["acrylic_quality"] = acrylic_quality_; j["blur_multiplier"] = blur_multiplier_; j["noise_opacity"] = noise_opacity_; j["gradient_background"] = gradient_background_; j["ui_opacity"] = ui_opacity_; j["window_opacity"] = window_opacity_; j["balance_layout"] = balance_layout_; // saved as string ID j["scanline_enabled"] = scanline_enabled_; j["hidden_addresses"] = json::array(); for (const auto& addr : hidden_addresses_) j["hidden_addresses"].push_back(addr); j["favorite_addresses"] = json::array(); for (const auto& addr : favorite_addresses_) j["favorite_addresses"].push_back(addr); { json meta_obj = json::object(); for (const auto& [addr, m] : address_meta_) { 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; } j["wizard_completed"] = wizard_completed_; j["seed_backup_reminded"] = seed_backup_reminded_; j["auto_lock_timeout"] = auto_lock_timeout_; j["unlock_duration"] = unlock_duration_; j["pin_enabled"] = pin_enabled_; 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_) j["debug_categories"].push_back(cat); j["theme_effects_enabled"] = theme_effects_enabled_; j["low_spec_mode"] = low_spec_mode_; j["reduce_motion"] = reduce_motion_; j["selected_exchange"] = selected_exchange_; j["selected_pair"] = selected_pair_; j["pool_url"] = pool_url_; j["pool_algo"] = pool_algo_; j["pool_worker"] = pool_worker_; j["pool_threads"] = pool_threads_; j["pool_tls"] = pool_tls_; j["pool_hugepages"] = pool_hugepages_; j["pool_mode"] = pool_mode_; j["pool_select_mode"] = poolSelectModeName(pool_select_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_; j["idle_threads_idle"] = idle_threads_idle_; j["idle_gpu_aware"] = idle_gpu_aware_; j["saved_pool_urls"] = json::array(); for (const auto& u : saved_pool_urls_) j["saved_pool_urls"].push_back(u); j["saved_pool_workers"] = json::array(); for (const auto& w : saved_pool_workers_) j["saved_pool_workers"].push_back(w); j["portfolio_entries"] = json::array(); for (const auto& e : portfolio_entries_) { json entry; entry["label"] = e.label; entry["addresses"] = json::array(); for (const auto& a : e.addresses) entry["addresses"].push_back(a); entry["icon"] = e.icon; entry["color"] = e.color; entry["outline_opacity"] = e.outlineOpacity; entry["price_basis"] = e.priceBasis; entry["manual_price"] = e.manualPrice; entry["manual_currency"] = e.manualCurrency; entry["show_drgx"] = e.showDrgx; entry["show_value"] = e.showValue; entry["show_24h"] = e.show24h; entry["show_sparkline"] = e.showSparkline; entry["sparkline_interval"] = e.sparklineInterval; entry["grid_col"] = e.gridCol; entry["grid_row"] = e.gridRow; entry["grid_w"] = e.gridW; entry["grid_h"] = e.gridH; j["portfolio_entries"].push_back(std::move(entry)); } j["font_scale"] = font_scale_; j["settings_version"] = std::string(DRAGONX_VERSION); if (window_width_ > 0 && window_height_ > 0) { j["window_width"] = window_width_; j["window_height"] = window_height_; } try { // 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; } } } // namespace config } // namespace dragonx