settings.json and addressbook.json were written in place with a bare ofstream — a crash or power loss mid-write truncated the file, and on the next launch the parse failure silently reset every preference (hidden/favorite addresses, labels, pool workers, language, theme, lite-server list) because the next save overwrote the corrupt file with defaults. Add Platform::writeFileAtomically() (temp file -> fsync -> atomic rename; dir fsync on POSIX, MoveFileEx on Windows; optional owner-only 0600) and route both saves through it. On a parse failure, quarantine the unreadable settings file to settings.json.corrupt-<ts> instead of clobbering it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
440 lines
21 KiB
C++
440 lines
21 KiB
C++
// 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 <nlohmann/json.hpp>
|
|
#include <fstream>
|
|
#include <filesystem>
|
|
#include <ctime>
|
|
|
|
#include "../util/logger.h"
|
|
#include "../util/platform.h"
|
|
|
|
#ifdef _WIN32
|
|
#include <shlobj.h>
|
|
#else
|
|
#include <pwd.h>
|
|
#include <unistd.h>
|
|
#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<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";
|
|
fs::create_directories(dir);
|
|
return dir + "\\settings.json";
|
|
}
|
|
return "settings.json";
|
|
#elif defined(__APPLE__)
|
|
const char* home = getenv("HOME");
|
|
if (!home) {
|
|
struct passwd* pw = getpwuid(getuid());
|
|
home = pw->pw_dir;
|
|
}
|
|
std::string dir = std::string(home) + "/Library/Application Support/ObsidianDragon";
|
|
fs::create_directories(dir);
|
|
return dir + "/settings.json";
|
|
#else
|
|
const char* home = getenv("HOME");
|
|
if (!home) {
|
|
struct passwd* pw = getpwuid(getuid());
|
|
home = pw->pw_dir;
|
|
}
|
|
std::string dir = std::string(home) + "/.config/ObsidianDragon";
|
|
fs::create_directories(dir);
|
|
return dir + "/settings.json";
|
|
#endif
|
|
}
|
|
|
|
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;
|
|
|
|
if (j.contains("theme")) theme_ = j["theme"].get<std::string>();
|
|
if (j.contains("save_ztxs")) save_ztxs_ = j["save_ztxs"].get<bool>();
|
|
if (j.contains("auto_shield")) auto_shield_ = j["auto_shield"].get<bool>();
|
|
if (j.contains("use_tor")) use_tor_ = j["use_tor"].get<bool>();
|
|
if (j.contains("allow_custom_fees")) allow_custom_fees_ = j["allow_custom_fees"].get<bool>();
|
|
if (j.contains("default_fee")) default_fee_ = j["default_fee"].get<double>();
|
|
if (j.contains("fetch_prices")) fetch_prices_ = j["fetch_prices"].get<bool>();
|
|
if (j.contains("tx_explorer_url")) tx_explorer_url_ = j["tx_explorer_url"].get<std::string>();
|
|
if (j.contains("address_explorer_url")) address_explorer_url_ = j["address_explorer_url"].get<std::string>();
|
|
if (j.contains("language")) language_ = j["language"].get<std::string>();
|
|
if (j.contains("skin_id")) skin_id_ = j["skin_id"].get<std::string>();
|
|
if (j.contains("acrylic_enabled")) acrylic_enabled_ = j["acrylic_enabled"].get<bool>();
|
|
if (j.contains("acrylic_quality")) acrylic_quality_ = j["acrylic_quality"].get<int>();
|
|
if (j.contains("blur_multiplier")) blur_multiplier_ = j["blur_multiplier"].get<float>();
|
|
if (j.contains("noise_opacity")) noise_opacity_ = j["noise_opacity"].get<float>();
|
|
if (j.contains("gradient_background")) gradient_background_ = j["gradient_background"].get<bool>();
|
|
// Migrate legacy reduced_transparency bool -> ui_opacity float
|
|
if (j.contains("ui_opacity")) {
|
|
ui_opacity_ = j["ui_opacity"].get<float>();
|
|
} else if (j.contains("reduced_transparency") && j["reduced_transparency"].get<bool>()) {
|
|
ui_opacity_ = 1.0f; // legacy: reduced = fully opaque
|
|
}
|
|
if (j.contains("window_opacity")) window_opacity_ = j["window_opacity"].get<float>();
|
|
if (j.contains("balance_layout")) {
|
|
if (j["balance_layout"].is_string())
|
|
balance_layout_ = j["balance_layout"].get<std::string>();
|
|
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<int>();
|
|
if (idx >= 0 && idx < 9) balance_layout_ = legacyIds[idx];
|
|
}
|
|
}
|
|
if (j.contains("scanline_enabled")) scanline_enabled_ = j["scanline_enabled"].get<bool>();
|
|
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<std::string>());
|
|
}
|
|
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<std::string>());
|
|
}
|
|
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<std::string>();
|
|
if (meta.contains("icon") && meta["icon"].is_string())
|
|
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;
|
|
}
|
|
}
|
|
if (j.contains("wizard_completed")) wizard_completed_ = j["wizard_completed"].get<bool>();
|
|
if (j.contains("auto_lock_timeout")) auto_lock_timeout_ = j["auto_lock_timeout"].get<int>();
|
|
if (j.contains("unlock_duration")) unlock_duration_ = j["unlock_duration"].get<int>();
|
|
if (j.contains("pin_enabled")) pin_enabled_ = j["pin_enabled"].get<bool>();
|
|
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();
|
|
for (const auto& c : j["debug_categories"])
|
|
if (c.is_string()) debug_categories_.insert(c.get<std::string>());
|
|
}
|
|
if (j.contains("theme_effects_enabled")) theme_effects_enabled_ = j["theme_effects_enabled"].get<bool>();
|
|
if (j.contains("low_spec_mode")) low_spec_mode_ = j["low_spec_mode"].get<bool>();
|
|
if (j.contains("reduce_motion")) reduce_motion_ = j["reduce_motion"].get<bool>();
|
|
if (j.contains("selected_exchange")) selected_exchange_ = j["selected_exchange"].get<std::string>();
|
|
if (j.contains("selected_pair")) selected_pair_ = j["selected_pair"].get<std::string>();
|
|
if (j.contains("pool_url")) pool_url_ = j["pool_url"].get<std::string>();
|
|
// Migrate old default pool URL that was missing the stratum port
|
|
if (pool_url_ == "pool.dragonx.is") pool_url_ = "pool.dragonx.is:3433";
|
|
if (j.contains("pool_algo")) pool_algo_ = j["pool_algo"].get<std::string>();
|
|
if (j.contains("pool_worker")) pool_worker_ = j["pool_worker"].get<std::string>();
|
|
if (j.contains("pool_threads")) pool_threads_ = j["pool_threads"].get<int>();
|
|
if (j.contains("pool_tls")) pool_tls_ = j["pool_tls"].get<bool>();
|
|
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>();
|
|
if (j.contains("idle_threads_idle")) idle_threads_idle_ = j["idle_threads_idle"].get<int>();
|
|
if (j.contains("idle_gpu_aware")) idle_gpu_aware_ = j["idle_gpu_aware"].get<bool>();
|
|
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<std::string>());
|
|
}
|
|
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<std::string>());
|
|
}
|
|
if (j.contains("font_scale") && j["font_scale"].is_number())
|
|
font_scale_ = std::max(1.0f, std::min(1.5f, j["font_scale"].get<float>()));
|
|
if (j.contains("window_width") && j["window_width"].is_number_integer())
|
|
window_width_ = j["window_width"].get<int>();
|
|
if (j.contains("window_height") && j["window_height"].is_number_integer())
|
|
window_height_ = j["window_height"].get<int>();
|
|
|
|
// Version tracking — detect upgrades so we can re-save with new defaults
|
|
if (j.contains("settings_version")) settings_version_ = j["settings_version"].get<std::string>();
|
|
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<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;
|
|
}
|
|
}
|
|
|
|
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["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["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["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["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
|