Files
ObsidianDragon/src/ui/schema/skin_manager.cpp
dan_s 4a841fd032 daemon version check, idle mining control, bootstrap mirror, import key paste, and cleanup
- Add startup binary version checking for dragonxd/xmrig
- Display daemon version in UI
- Add idle mining thread count adjustment
- Add bootstrap mirror option (bootstrap2.dragonx.is) in setup wizard
- Add paste button to import private key dialog with address validation
- Add z-address generation UI feedback (loading indicator)
- Add option to delete blockchain data while preserving wallet.dat
- Add font scale slider hotkey tooltip (Ctrl+Plus/Ctrl+Minus)
- Fix Windows RPC auth: trim \r from config values, add .cookie fallback
- Fix connection status message during block index loading
- Improve application shutdown to prevent lingering background process
2026-03-17 14:57:12 -05:00

806 lines
28 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "skin_manager.h"
#include "ui_schema.h"
#include "../../util/platform.h"
#include "../../resources/embedded_resources.h"
#include "../theme.h"
#include "../material/color_theme.h"
#include "../effects/theme_effects.h"
#include "../effects/imgui_acrylic.h"
#include <toml++/toml.hpp>
#include <fstream>
#include <filesystem>
#include <algorithm>
#include <cstdio>
#include "../../util/logger.h"
namespace fs = std::filesystem;
namespace dragonx {
namespace ui {
namespace schema {
// ============================================================================
// Singleton
// ============================================================================
SkinManager& SkinManager::instance() {
static SkinManager s;
return s;
}
// ============================================================================
// Directory helpers
// ============================================================================
std::string SkinManager::getBundledSkinsDirectory() {
// Bundled skins live in res/themes/ next to the executable
fs::path exe_dir = util::getExecutableDirectory();
fs::path themes_dir = exe_dir / "res" / "themes";
if (fs::exists(themes_dir)) {
// Update any stale overlay themes from embedded versions
int updated = resources::updateBundledThemes(themes_dir.string());
if (updated > 0)
DEBUG_LOGF("[SkinManager] Updated %d stale theme(s) in %s\n",
updated, themes_dir.string().c_str());
return themes_dir.string();
}
// Fallback: current working directory
themes_dir = fs::current_path() / "res" / "themes";
if (fs::exists(themes_dir)) {
return themes_dir.string();
}
// No on-disk themes dir found (single-file Windows distribution).
// Extract embedded overlay themes to the config directory.
fs::path configDir = util::Platform::getObsidianDragonDir();
fs::path extractedDir = configDir / "bundled-themes";
int extracted = resources::extractBundledThemes(extractedDir.string());
if (extracted > 0) {
DEBUG_LOGF("[SkinManager] Extracted %d embedded bundled themes to %s\n",
extracted, extractedDir.string().c_str());
}
if (fs::exists(extractedDir)) {
return extractedDir.string();
}
return (exe_dir / "res" / "themes").string();
}
std::string SkinManager::getUserSkinsDirectory() {
// User themes in ObsidianDragon config directory — folder-based
fs::path configDir = util::Platform::getObsidianDragonDir();
return (configDir / "themes").string();
}
// ============================================================================
// Scan for skins
// ============================================================================
void SkinManager::scanDirectory(const std::string& dir, bool bundled) {
if (!fs::exists(dir) || !fs::is_directory(dir)) {
DEBUG_LOGF("[SkinManager] Directory does not exist: %s\n", dir.c_str());
return;
}
DEBUG_LOGF("[SkinManager] Scanning %s directory: %s\n", bundled ? "bundled" : "user", dir.c_str());
if (bundled) {
// Bundled skins: all .toml files in res/themes/ except ui.toml
// (ui.toml is the base theme and is always loaded as "dragonx")
for (const auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_regular_file()) continue;
fs::path p = entry.path();
if (p.extension() != ".toml") continue;
std::string stem = p.stem().string();
// Skip ui.toml - it's the base theme handled separately as "dragonx"
if (stem == "ui") continue;
// Try to parse and extract metadata
toml::table root;
try {
root = toml::parse_file(p.string());
} catch (...) {
DEBUG_LOGF("[SkinManager] Skipping '%s': invalid TOML\n", p.filename().string().c_str());
continue;
}
auto* theme = root["theme"].as_table();
if (!theme) {
DEBUG_LOGF("[SkinManager] Skipping '%s': no [theme] section\n", p.filename().string().c_str());
continue;
}
SkinInfo info;
info.path = p.string();
info.bundled = true;
// ID = filename stem (e.g. "dark" from dark.toml)
info.id = stem;
if (auto name = (*theme)["name"].value<std::string>()) {
info.name = *name;
} else {
info.name = info.id;
}
if (auto author = (*theme)["author"].value<std::string>()) {
info.author = *author;
}
if (auto dark = (*theme)["dark"].value<bool>()) {
info.dark = *dark;
}
// Resolve image paths from theme.images (bundled: res/img/)
fs::path imgDir = p.parent_path().parent_path() / "img";
std::string bgFilename;
std::string logoFilename;
if (auto* images = (*theme)["images"].as_table()) {
if (auto bg = (*images)["background_image"].value<std::string>()) {
bgFilename = *bg;
}
if (auto logo = (*images)["logo"].value<std::string>()) {
logoFilename = *logo;
}
}
if (!bgFilename.empty()) {
fs::path bgPath = imgDir / bgFilename;
if (fs::exists(bgPath)) {
info.backgroundImagePath = bgPath.string();
}
}
if (!logoFilename.empty()) {
fs::path logoImgPath = imgDir / logoFilename;
if (fs::exists(logoImgPath)) {
info.logoPath = logoImgPath.string();
}
}
skins_.push_back(std::move(info));
}
} else {
// User themes: each subfolder must contain a theme.toml
for (const auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_directory()) continue;
fs::path themeDir = entry.path();
fs::path themeToml = themeDir / "theme.toml";
if (!fs::exists(themeToml)) {
DEBUG_LOGF("[SkinManager] Skipping folder '%s': no theme.toml found\n",
themeDir.filename().string().c_str());
continue;
}
DEBUG_LOGF("[SkinManager] Found theme folder: %s (theme.toml exists)\n", themeDir.filename().string().c_str());
// Validate the theme file
auto validation = validateSkinFile(themeToml.string());
// Parse metadata even from invalid themes (so they show in the list)
toml::table root;
try {
root = toml::parse_file(themeToml.string());
} catch (...) {
// Still add as invalid
SkinInfo info;
info.id = themeDir.filename().string();
info.name = info.id;
info.path = themeToml.string();
info.directory = themeDir.string();
info.bundled = false;
info.valid = false;
info.validationError = "Invalid TOML";
skins_.push_back(std::move(info));
continue;
}
SkinInfo info;
info.id = themeDir.filename().string();
info.path = themeToml.string();
info.directory = themeDir.string();
info.bundled = false;
info.valid = validation.valid;
info.validationError = validation.error;
// Extract metadata from theme section
if (auto* theme = root["theme"].as_table()) {
if (auto name = (*theme)["name"].value<std::string>()) {
info.name = *name;
} else {
info.name = info.id;
}
if (auto author = (*theme)["author"].value<std::string>()) {
info.author = *author;
}
if (auto dark = (*theme)["dark"].value<bool>()) {
info.dark = *dark;
}
// Resolve image paths (from TOML)
fs::path imgDir = themeDir / "img";
std::string bgFilename;
std::string logoFilename;
if (auto* images = (*theme)["images"].as_table()) {
if (auto bg = (*images)["background_image"].value<std::string>()) {
bgFilename = *bg;
}
if (auto logo = (*images)["logo"].value<std::string>()) {
logoFilename = *logo;
}
}
// Check if image files exist
if (!bgFilename.empty()) {
fs::path bgPath = imgDir / bgFilename;
if (fs::exists(bgPath)) {
info.backgroundImagePath = bgPath.string();
}
}
if (!logoFilename.empty()) {
fs::path logoImgPath = imgDir / logoFilename;
if (fs::exists(logoImgPath)) {
info.logoPath = logoImgPath.string();
}
}
} else {
info.name = info.id;
}
skins_.push_back(std::move(info));
}
// Also scan for loose .toml files (unified format with [theme.palette])
for (const auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_regular_file()) continue;
fs::path p = entry.path();
if (p.extension() != ".toml") continue;
toml::table root;
try {
root = toml::parse_file(p.string());
} catch (...) {
DEBUG_LOGF("[SkinManager] Skipping '%s': invalid TOML\n", p.filename().string().c_str());
continue;
}
SkinInfo info;
info.path = p.string();
info.id = p.stem().string();
info.bundled = false;
// Check for unified format ([theme] with [theme.palette])
auto* theme = root["theme"].as_table();
if (theme) {
if (auto name = (*theme)["name"].value<std::string>()) {
info.name = *name;
} else {
info.name = info.id;
}
if (auto author = (*theme)["author"].value<std::string>()) {
info.author = *author;
}
if (auto dark = (*theme)["dark"].value<bool>()) {
info.dark = *dark;
}
auto validation = validateSkinFile(p.string());
info.valid = validation.valid;
info.validationError = validation.error;
// Resolve image paths (look in same directory as the .toml file)
fs::path imgDir = p.parent_path();
std::string bgFilename;
std::string logoFilename;
if (auto* images = (*theme)["images"].as_table()) {
if (auto bg = (*images)["background_image"].value<std::string>()) {
bgFilename = *bg;
}
if (auto logo = (*images)["logo"].value<std::string>()) {
logoFilename = *logo;
}
}
if (!bgFilename.empty()) {
fs::path bgPath = imgDir / bgFilename;
if (fs::exists(bgPath)) {
info.backgroundImagePath = bgPath.string();
}
}
if (!logoFilename.empty()) {
fs::path logoImgPath = imgDir / logoFilename;
if (fs::exists(logoImgPath)) {
info.logoPath = logoImgPath.string();
}
}
}
else {
DEBUG_LOGF("[SkinManager] Skipping '%s': unrecognized TOML format\n",
p.filename().string().c_str());
continue;
}
skins_.push_back(std::move(info));
}
}
}
void SkinManager::refresh() {
skins_.clear();
// Scan bundled skins (res/ directory)
scanDirectory(getBundledSkinsDirectory(), true);
// Scan user skins
std::string userDir = getUserSkinsDirectory();
if (fs::exists(userDir)) {
scanDirectory(userDir, false);
}
// Ensure the base "dragonx" theme always appears (it's ui.toml, the main theme).
// Other bundled themes are discovered automatically from res/themes/*.toml.
{
bool found = false;
for (const auto& s : skins_) {
if (s.id == "dragonx") { found = true; break; }
}
if (!found) {
SkinInfo info;
info.id = "dragonx";
info.name = "DragonX";
info.author = "The Hush Developers";
info.dark = true;
info.bundled = true;
info.valid = true;
// Try to set path to ui.toml if it exists
fs::path uiPath = fs::path(getBundledSkinsDirectory()) / "ui.toml";
if (fs::exists(uiPath)) {
info.path = uiPath.string();
}
skins_.push_back(std::move(info));
DEBUG_LOGF("[SkinManager] Injected base theme: dragonx\n");
}
}
// Sort: "dragonx" first, then bundled grouped by mode (dark then light), then user
std::sort(skins_.begin(), skins_.end(), [](const SkinInfo& a, const SkinInfo& b) {
// DragonX always first
if (a.id == "dragonx") return true;
if (b.id == "dragonx") return false;
// Bundled before user
if (a.bundled != b.bundled) return a.bundled;
// Group: dark themes first, then light themes
if (a.dark != b.dark) return a.dark;
// Alphabetical by name within each group
return a.name < b.name;
});
DEBUG_LOGF("[SkinManager] Found %zu skins\n", skins_.size());
}
// ============================================================================
// Find
// ============================================================================
const SkinManager::SkinInfo* SkinManager::findById(const std::string& id) const {
for (const auto& skin : skins_) {
if (skin.id == id) return &skin;
}
return nullptr;
}
// ============================================================================
// Validation
// ============================================================================
SkinManager::ValidationResult SkinManager::validateSkinFile(const std::string& path) {
ValidationResult result;
// 1. Must be valid TOML
toml::table root;
try {
root = toml::parse_file(path);
} catch (const toml::parse_error& e) {
result.error = std::string("Invalid TOML: ") + e.what();
return result;
}
// 3. Must contain "theme" table
auto* theme = root["theme"].as_table();
if (!theme) {
result.error = "Missing or invalid 'theme' section";
return result;
}
// 4. theme.name must be a non-empty string
auto name = (*theme)["name"].value<std::string>();
if (!name || name->empty()) {
result.error = "theme.name must be a non-empty string";
return result;
}
// 5. theme.palette must exist with at least --primary and --background
auto* palette = (*theme)["palette"].as_table();
if (!palette) {
result.error = "Missing theme.palette table";
return result;
}
if (!palette->contains("--primary")) {
result.error = "Palette missing required '--primary' color";
return result;
}
if (!palette->contains("--background")) {
result.error = "Palette missing required '--background' color";
return result;
}
// 6. If globals exists, must be a table
if (root.contains("globals") && !root["globals"].is_table()) {
result.error = "'globals' must be a table";
return result;
}
result.valid = true;
return result;
}
// ============================================================================
// Import
// ============================================================================
bool SkinManager::importSkin(const std::string& sourcePath) {
fs::path srcPath(sourcePath);
std::string userDir = getUserSkinsDirectory();
try {
fs::create_directories(userDir);
} catch (const fs::filesystem_error& e) {
DEBUG_LOGF("[SkinManager] Failed to create themes directory: %s\n", e.what());
return false;
}
if (fs::is_directory(srcPath)) {
// Import a theme folder — copy entire folder into themes/
fs::path themeToml = srcPath / "theme.toml";
if (!fs::exists(themeToml)) {
DEBUG_LOGF("[SkinManager] Import folder has no theme.toml: %s\n", sourcePath.c_str());
return false;
}
auto validation = validateSkinFile(themeToml.string());
if (!validation.valid) {
DEBUG_LOGF("[SkinManager] Import validation failed: %s\n", validation.error.c_str());
return false;
}
fs::path destDir = fs::path(userDir) / srcPath.filename();
try {
fs::copy(srcPath, destDir, fs::copy_options::recursive | fs::copy_options::overwrite_existing);
} catch (const fs::filesystem_error& e) {
DEBUG_LOGF("[SkinManager] Failed to copy theme folder: %s\n", e.what());
return false;
}
DEBUG_LOGF("[SkinManager] Imported theme folder: %s → %s\n", sourcePath.c_str(), destDir.string().c_str());
} else {
// Import a single .toml file — create a folder for it
auto validation = validateSkinFile(sourcePath);
if (!validation.valid) {
DEBUG_LOGF("[SkinManager] Import validation failed: %s\n", validation.error.c_str());
return false;
}
std::string folderName = srcPath.stem().string();
fs::path destDir = fs::path(userDir) / folderName;
try {
fs::create_directories(destDir);
fs::copy_file(srcPath, destDir / "theme.toml", fs::copy_options::overwrite_existing);
} catch (const fs::filesystem_error& e) {
DEBUG_LOGF("[SkinManager] Failed to import skin file: %s\n", e.what());
return false;
}
DEBUG_LOGF("[SkinManager] Imported skin file as folder: %s → %s\n", sourcePath.c_str(), destDir.string().c_str());
}
refresh();
return true;
}
// ============================================================================
// Remove
// ============================================================================
bool SkinManager::removeSkin(const std::string& id) {
const SkinInfo* skin = findById(id);
if (!skin) {
DEBUG_LOGF("[SkinManager] Skin not found: %s\n", id.c_str());
return false;
}
if (skin->bundled) {
DEBUG_LOGF("[SkinManager] Cannot remove bundled skin: %s\n", id.c_str());
return false;
}
try {
if (!skin->directory.empty() && fs::is_directory(skin->directory)) {
// Folder-based theme — remove the entire directory
fs::remove_all(skin->directory);
} else {
// Legacy flat file
fs::remove(skin->path);
}
} catch (const fs::filesystem_error& e) {
DEBUG_LOGF("[SkinManager] Failed to remove skin: %s\n", e.what());
return false;
}
DEBUG_LOGF("[SkinManager] Removed skin: %s\n", id.c_str());
// If we removed the active skin, fall back to default
if (activeSkinId_ == id) {
setActiveSkin("dragonx");
}
refresh();
return true;
}
// ============================================================================
// Activate skin
// ============================================================================
bool SkinManager::setActiveSkin(const std::string& id) {
const SkinInfo* skin = findById(id);
if (!skin) {
DEBUG_LOGF("[SkinManager] Skin not found: %s\n", id.c_str());
return false;
}
if (!skin->valid) {
DEBUG_LOGF("[SkinManager] Skin is invalid: %s (%s)\n", id.c_str(), skin->validationError.c_str());
return false;
}
bool loaded = false;
// For skin files: always reload base layout first, then merge visual
// overlay on top. This ensures overlays only change palette + backdrop
// while inheriting all layout values from ui.toml.
if (!skin->path.empty()) {
auto& schema = UISchema::instance();
std::string basePath = schema.basePath();
if (!basePath.empty() || schema.hasEmbeddedBase()) {
if (!basePath.empty() && basePath == skin->path) {
// Switching back to the base theme: full reload, clear overlay
schema.reloadBase();
schema.reapplyColorsToImGui();
loaded = true;
} else {
// Switching to a non-base skin: reload base then merge overlay
if (schema.reloadBase()) {
if (schema.mergeOverlayFromFile(skin->path)) {
schema.reapplyColorsToImGui();
loaded = true;
}
}
}
}
// Fallback: no base path or embedded data, full load of skin file
if (!loaded && schema.loadFromFile(skin->path)) {
schema.reapplyColorsToImGui();
loaded = true;
}
} else if (!id.empty()) {
// Skin with no path (e.g., "dragonx" on Windows with embedded ui.toml):
// just reload the base to restore the default theme
auto& schema = UISchema::instance();
if (schema.hasEmbeddedBase() || !schema.basePath().empty()) {
schema.reloadBase();
schema.reapplyColorsToImGui();
loaded = true;
}
}
// Fall back to built-in C++ themes (works even without theme files)
if (!loaded) {
if (!SetThemeById(id)) {
DEBUG_LOGF("[SkinManager] Failed to load skin: %s\n", id.c_str());
return false;
}
DEBUG_LOGF("[SkinManager] Loaded via built-in theme fallback: %s\n", id.c_str());
loaded = true;
}
activeSkinId_ = id;
DEBUG_LOGF("[SkinManager] Activated skin: %s (%s)\n", id.c_str(), skin->name.c_str());
// Reload theme visual effects config from the new skin's [effects] section
effects::ThemeEffects::instance().loadFromTheme();
// Resolve image paths from UISchema (which parsed [theme] images from the TOML).
// The UISchema stores relative filenames (e.g. "backgrounds/texture/drgx_bg.png");
// resolve them to absolute paths using the theme's directory structure.
{
// Use a mutable reference to update the SkinInfo
SkinInfo* mutableSkin = nullptr;
for (auto& s : skins_) {
if (s.id == id) { mutableSkin = &s; break; }
}
fs::path imgDir = resolveImgDir(skin);
resolveAndFireCallback(mutableSkin, imgDir);
}
// Force acrylic to re-capture the background with new theme colors/images.
// This must happen AFTER images are reloaded so the next frame renders the
// updated background before capture.
effects::ImGuiAcrylic::InvalidateCapture();
return true;
}
void SkinManager::resolveAndReloadImages(const std::string& skinId, const std::string& tomlPath) {
// Find the skin and update its image paths from the current UISchema values
SkinInfo* skin = nullptr;
for (auto& s : skins_) {
if (s.id == skinId) {
skin = &s;
break;
}
}
fs::path imgDir = resolveImgDir(skin);
resolveAndFireCallback(skin, imgDir);
}
// ---------------------------------------------------------------------------
// Gradient mode
// ---------------------------------------------------------------------------
void SkinManager::setGradientMode(bool enabled) {
if (gradientMode_ == enabled) return;
gradientMode_ = enabled;
// Re-resolve + reload for the currently active skin
const SkinInfo* skin = findById(activeSkinId_);
if (!skin) return;
SkinInfo* mutableSkin = nullptr;
for (auto& s : skins_) {
if (s.id == activeSkinId_) { mutableSkin = &s; break; }
}
fs::path imgDir = resolveImgDir(skin);
resolveAndFireCallback(mutableSkin, imgDir);
effects::ImGuiAcrylic::InvalidateCapture();
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
fs::path SkinManager::resolveImgDir(const SkinInfo* skin) const {
if (!skin) {
return fs::path(getBundledSkinsDirectory()).parent_path() / "img";
}
if (skin->bundled) {
fs::path themeDir = skin->path.empty()
? fs::path(getBundledSkinsDirectory())
: fs::path(skin->path).parent_path();
return themeDir.parent_path() / "img";
}
if (!skin->directory.empty()) {
return fs::path(skin->directory) / "img";
}
if (!skin->path.empty()) {
return fs::path(skin->path).parent_path();
}
return fs::path(getBundledSkinsDirectory()).parent_path() / "img";
}
std::string SkinManager::resolveGradientBg(const std::string& bgFilename,
const fs::path& imgDir,
bool isDark) const {
// Given bgFilename like "backgrounds/texture/drgx_bg.png",
// look for "backgrounds/gradient/gradient_drgx_bg.png".
fs::path bgRel(bgFilename);
std::string stem = bgRel.stem().string(); // "drgx_bg"
std::string ext = bgRel.extension().string(); // ".png"
// Build the gradient candidate: backgrounds/gradient/gradient_<stem><ext>
std::string gradientRel = "backgrounds/gradient/gradient_" + stem + ext;
fs::path gradientPath = imgDir / gradientRel;
if (fs::exists(gradientPath)) {
return gradientPath.string();
}
// Fallback: dark_gradient.png or light_gradient.png
std::string fallbackName = isDark ? "dark_gradient.png" : "light_gradient.png";
std::string fallbackRel = "backgrounds/gradient/" + fallbackName;
fs::path fallbackPath = imgDir / fallbackRel;
if (fs::exists(fallbackPath)) {
return fallbackPath.string();
}
// Last resort: pass the relative gradient filename for embedded lookup (Windows)
return gradientRel;
}
void SkinManager::resolveAndFireCallback(SkinInfo* skin, const fs::path& imgDir) {
auto& schema = UISchema::instance();
std::string bgFilename = schema.backgroundImagePath();
std::string logoFilename = schema.logoImagePath();
std::string resolvedBg;
std::string resolvedLogo;
if (!bgFilename.empty()) {
if (gradientMode_) {
resolvedBg = resolveGradientBg(bgFilename, imgDir, schema.isDarkTheme());
} else {
fs::path bgPath = imgDir / bgFilename;
if (fs::exists(bgPath)) {
resolvedBg = bgPath.string();
} else {
resolvedBg = bgFilename;
}
}
}
if (!logoFilename.empty()) {
fs::path logoPath = imgDir / logoFilename;
if (fs::exists(logoPath)) {
resolvedLogo = logoPath.string();
} else {
resolvedLogo = logoFilename;
}
}
if (skin) {
skin->backgroundImagePath = resolvedBg;
skin->logoPath = resolvedLogo;
}
DEBUG_LOGF("[SkinManager] Resolved images (gradient=%s): bg='%s', logo='%s'\n",
gradientMode_ ? "on" : "off", resolvedBg.c_str(), resolvedLogo.c_str());
if (imageReloadCb_) {
imageReloadCb_(resolvedBg, resolvedLogo);
}
}
} // namespace schema
} // namespace ui
} // namespace dragonx