From 2feba4447a482840e21fa2d3b33f1a5da12d09b7 Mon Sep 17 00:00:00 2001 From: Fabien Proriol Date: Thu, 22 May 2025 17:10:35 +0200 Subject: qt: Add Qt Wrapper library and QML module --- src/changewatcher.cpp | 1 - src/kzsettings.cpp | 285 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/kzsettings.h | 180 +++++++++++++++++++++++++++++++ src/settings.cpp | 285 -------------------------------------------------- src/settings.h | 180 ------------------------------- 5 files changed, 465 insertions(+), 466 deletions(-) create mode 100644 src/kzsettings.cpp create mode 100644 src/kzsettings.h delete mode 100644 src/settings.cpp delete mode 100644 src/settings.h (limited to 'src') diff --git a/src/changewatcher.cpp b/src/changewatcher.cpp index 4762ebb..ef54019 100644 --- a/src/changewatcher.cpp +++ b/src/changewatcher.cpp @@ -1,5 +1,4 @@ #include "changewatcher.h" -#include #include #include #include diff --git a/src/kzsettings.cpp b/src/kzsettings.cpp new file mode 100644 index 0000000..5580a80 --- /dev/null +++ b/src/kzsettings.cpp @@ -0,0 +1,285 @@ +#include "kzsettings.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "changewatcher.h" + +using namespace KaZoe; + +static inline void trim(std::string & str) +{ + str.erase(str.begin(), std::find_if(str.begin(), str.end(), [](const unsigned char ch){return !std::isspace(ch);})); + str.erase(std::find_if(str.rbegin(), str.rend(), [](const unsigned char ch){return !std::isspace(ch);}).base(), str.end()); +} + +static inline bool list_contains(std::vector list, const std::string & str) +{ + return (std::find(list.begin(), list.end(), str) != list.end()); +} + +namespace KaZoe { +class KzSettingsPrivate { + friend class KzSettings; + KzSettings *m_parent; + std::map m_data; + ChangeWatcher m_watcher; + std::map m_owner; + std::string m_id; + std::mutex m_mutex_data; + std::mutex m_mutex_notifier; + bool m_bypass {false}; + std::vector> m_notifier; + +public: + KzSettingsPrivate(KzSettings *parent) + : m_parent(parent) { + char result[PATH_MAX]; + ssize_t count = readlink("/proc/self/exe", result, PATH_MAX); + std::string binary = std::string(result, (count > 0) ? count : 0); + std::filesystem::path bin = binary; + m_id = bin.filename(); + if(m_id == "kzsettings") + { + // Only "kzsettings cli tools can bypass the owner protection" + m_bypass = true; + } + + m_watcher.setFileAdded([this](const std::string path){ refreshDirectory(path); }); + m_watcher.setFileRemoved([this](const std::string path){ refreshAll(); }); + m_watcher.setFileWrited([this](const std::string path){ refreshFile(path); }); + } + + void parseFile(const std::string &filename, std::map &data, bool watch = false) + { + if(!std::filesystem::exists(filename)) + { + return; + } + std::filesystem::path pfile = filename; + if(pfile.filename().string().starts_with(".")) return; + if(pfile.extension() != ".conf") return; + + if(watch) + { + m_watcher.watch(filename); + } + + std::ifstream file(filename); + std::string line; + std::string category = ""; + + while (std::getline(file, line)) { + trim(line); + + if(line.empty() || line.starts_with("#") || line.ends_with(";")) { + continue; + } + if(line.starts_with("[") && line.ends_with("]")) { + category = line.substr(1, line.length() - 2); + if(category == "General") category.clear(); + continue; + } + size_t pos = line.find("="); + std::string key = line.substr(0, pos); + trim(key); + std::string value = line.substr(pos + 1); + trim(value); + + if(key.starts_with("@")) + { + m_owner[KzSettingKey(category, key.substr(1))] = value; + continue; + } + if(key.ends_with("/owner")) + { + std::cerr << "WARNING: Legacy usage for owner: " << key << " should be @" << key.substr(0, key.size() - 6) << std::endl; + m_owner[KzSettingKey(category, key.substr(0, key.size() - 6))] = value; + continue; + } + + data[KzSettingKey(category, key)] = makeValue(value); + } + } + + void parseDir(const std::string &dconf, std::map &data, bool watch) + { + if(std::filesystem::exists(dconf) && std::filesystem::is_directory(dconf)) + { + for (const auto& entry : std::filesystem::directory_iterator(dconf)) + { + if(entry.is_directory()) continue; + parseFile(std::filesystem::absolute(entry.path()), data, watch); + } + } + } + + void parseConf(const std::string &fconf, std::map &data, bool watch) + { + if(std::filesystem::exists(fconf)) + { + parseFile(fconf, data, watch); + } + std::string dconf = fconf + ".d"; + parseDir(dconf, data, watch); + if(watch) + { + m_watcher.watch(dconf); + } + } + + void refreshData(const std::map &cust_data) + { + std::lock_guard lock(m_mutex_data); + std::lock_guard lock_notifier(m_mutex_notifier); + for(const auto& [key, value] : cust_data) { + if(!m_data.contains(key) || m_data[key] != value) + { + // New ITEM OR Data changed + m_data[key] = value; + for(auto ¬ifier: m_notifier) + { + notifier(key.first, key.second, value); + } + continue; + } + } + } + + void refreshFile(const std::string &file) + { + std::map cust_data; + parseFile(file, cust_data); + refreshData(cust_data); + } + + void refreshDirectory(const std::string &dirname) + { + std::map cust_data; + parseDir(dirname, cust_data, true); + refreshData(cust_data); + } + + void refreshAll() + { + std::map cust_data; + parseConf("/etc/kazoe.conf", cust_data, false); + parseConf("/var/kazoe.conf", cust_data, true); + refreshData(cust_data); + } +}; +}; + +KzSettings::KzSettings() + : m_ptr(std::make_unique(this)) +{ + m_ptr->parseConf("/etc/kazoe.conf", m_ptr->m_data, false); + m_ptr->parseConf("/var/kazoe.conf", m_ptr->m_data, true); +} + +KzSettingValue KzSettings::get(const std::string &key, const std::string &category, KzSettingValue def) const +{ + std::lock_guard lock(m_ptr->m_mutex_data); + std::string gkey = key; + std::string gcategory = category; + if(gcategory.empty() && gkey.starts_with("[")) + { + gcategory = gkey.substr(1, gkey.find(']') - 1); + gkey = gkey.substr(gkey.find(']') + 1); + } + + KzSettingKey pkey(gcategory, gkey); + + if(m_ptr->m_data.count(pkey)) + { + return m_ptr->m_data[pkey]; + } + return def; +} + +bool KzSettings::set(const std::string &key, KzSettingValue value, const std::string &category) +{ + // Check if variable is writable + std::lock_guard lock(m_ptr->m_mutex_data); + KzSettingKey pkey(category, key); + std::string id; + + if(!m_ptr->m_owner.count(pkey)) + { + std::cerr << "Variable " << category << ">" << key << " is not writable" << std::endl; + return false; + } + + if(m_ptr->m_bypass) + { + id = m_ptr->m_owner[pkey]; + } + else + { + id = m_ptr->m_id; + if(m_ptr->m_owner[pkey] != id) + { + std::cerr << "Variable " << category << ">" << key << " is not own by " << m_ptr->m_id << std::endl; + return false; + } + } + + // Permission is OK, get old custom variable + std::map cust_data; + m_ptr->parseFile("/var/kazoe.conf.d/"+id+".conf", cust_data); + cust_data[pkey] = value; + std::string last_category = ""; + std::ofstream file("/var/kazoe.conf.d/"+id+".conf"); + + for(const auto& [key, value] : cust_data) { + if(key.first != last_category) + { + last_category = key.first.empty() ? "General" : key.first; + file << "[" << last_category << "]\n"; + } + file << key.second << " = " << KaZoe::valueToStr(value) << "\n"; + } + + m_ptr->m_data[pkey] = value; + return true; +} + +std::string KzSettings::id() const +{ + return m_ptr->m_id; +} + +void KzSettings::setId(const std::string &newid) +{ + m_ptr->m_id = newid; +} + +void KzSettings::setNotifier(const std::function &callback) +{ + std::lock_guard lock(m_ptr->m_mutex_notifier); + m_ptr->m_notifier.push_back(callback); + m_ptr->m_watcher.setEnable(true); +} + +std::size_t KzSettings::size() const +{ + std::lock_guard lock(m_ptr->m_mutex_data); + return m_ptr->m_data.size(); +} + +void KzSettings::forEach(const std::function &callback) const +{ + std::lock_guard lock(m_ptr->m_mutex_data); + for (const auto& [key, value] : m_ptr->m_data) { + callback(key, value); + } +} + +KzSettings::~KzSettings() +{ + m_ptr->m_watcher.setEnable(false); +} diff --git a/src/kzsettings.h b/src/kzsettings.h new file mode 100644 index 0000000..9f8d35b --- /dev/null +++ b/src/kzsettings.h @@ -0,0 +1,180 @@ +#ifndef KZSETTINGS_H +#define KZSETTINGS_H + +#include +#include +#include +#include +#include + +/** @brief Key type for settings, composed of category and name */ +using KzSettingKey = std::pair; + +/** @brief Value type that can hold string, int, double or boolean */ +using KzSettingValue = std::variant; + + +namespace KaZoe { + +class KzSettingsPrivate; + +/** + * @brief Main class for managing KaZoe settings + * + * This class provides a thread-safe interface for storing and retrieving + * configuration settings with support for different value types. + */ +class KzSettings +{ +public: + /** @brief Constructs a new Settings instance */ + explicit KzSettings(); + + /** @brief Virtual destructor */ + virtual ~KzSettings(); + + /** + * @brief Retrieves a setting value + * @param key The setting key + * @param category The setting category (optional) + * @param def Default value if setting not found (optional) + * @return The setting value or default if not found + */ + KzSettingValue get(const std::string &key, const std::string &category = "", KzSettingValue def = KzSettingValue()) const; + + /** + * @brief Sets a setting value + * @param key The setting key + * @param value The value to set + * @param category The setting category (optional) + * @return true if successful, false otherwise + */ + bool set(const std::string &key, KzSettingValue value, const std::string &category = ""); + + /** + * @brief Gets the current instance identifier + * @return The instance ID string, by default, this is the excecutable filename + */ + std::string id() const; + + /** + * @brief Sets the instance identifier + * @param newid The new ID to set + */ + void setId(const std::string &newid); + + /** + * @brief Sets a callback for setting changes notification + * @param callback Function to call when settings change + */ + void setNotifier(const std::function& callback); + + /** + * @brief Gets the number of settings + * @return The total count of settings + */ + std::size_t size() const; + + /** + * @brief Iterates over all settings in a thread-safe manner + * @param callback Function called for each key-value pair + */ + void forEach(const std::function& callback) const; + +private: + std::unique_ptr m_ptr; ///< Private implementation pointer +}; + + +/** + * @brief Functor to convert SettingValue variants to string + * + * Provides string conversion for all supported setting value types + */ +struct settingValueFunctor { + std::string operator()(const std::string &x) const { return x; } + std::string operator()(int x) const { return std::to_string(x); } + std::string operator()(double x) const { return std::to_string(x); } + std::string operator()(bool x) const { return x ? "true" : "false"; } +}; + +/** + * @brief Convert a SettingValue into a string for display + * @param value Input SettingValue to convert + * @return std::string containing the converted value + */ +static inline std::string valueToStr(const KzSettingValue &value) +{ + return std::visit(settingValueFunctor(), value); +} + +/** + * @brief Check if a string represents a valid numeric value + * @param str String to check + * @return true if string is numeric, false otherwise + */ +static inline bool is_numeric(const std::string& str) { + return !str.empty() && + str.find_first_not_of("0123456789.-") == std::string::npos && + (std::count(str.begin(), str.end(), '.') <= 1) && + (std::count(str.begin(), str.end(), '-') <= 1) && + (str[0] == '-' || std::isdigit(str[0])); +} + +/** + * @brief Convert a string to appropriate SettingValue type + * @param value Input string to convert + * @return SettingValue containing the converted value + * + * Conversion rules: + * - Quoted strings -> string without quotes + * - Numeric values -> int or double + * - "true"/"on" -> boolean true + * - "false"/"off" -> boolean false + * - Other -> string + */ +static inline KzSettingValue makeValue(const std::string &value) +{ + if(value.starts_with('"') || value.starts_with('\'')) + { + return value.substr(1, value.length() - 2); + } + + if(is_numeric(value)) + { + // double + if(value.find('.') != value.npos) + { + return std::stod(value); + } + else + { + return std::stoi(value); + } + } + + // To Lower case + std::string lvalue = value; + std::transform(lvalue.begin(), lvalue.end(), lvalue.begin(), + [](unsigned char c){ return std::tolower(c); }); + + + // bool true + if(lvalue == "true" || lvalue == "on") + { + return true; + } + + // bool false + if(lvalue == "false" || lvalue == "off") + { + return false; + } + + // else, string without quote + return value; +} + +}; + +#endif // KZSETTINGS_H diff --git a/src/settings.cpp b/src/settings.cpp deleted file mode 100644 index addaa32..0000000 --- a/src/settings.cpp +++ /dev/null @@ -1,285 +0,0 @@ -#include "settings.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include "changewatcher.h" - -using namespace KaZoe; - -static inline void trim(std::string & str) -{ - str.erase(str.begin(), std::find_if(str.begin(), str.end(), [](const unsigned char ch){return !std::isspace(ch);})); - str.erase(std::find_if(str.rbegin(), str.rend(), [](const unsigned char ch){return !std::isspace(ch);}).base(), str.end()); -} - -static inline bool list_contains(std::vector list, const std::string & str) -{ - return (std::find(list.begin(), list.end(), str) != list.end()); -} - -namespace KaZoe { -class SettingsPrivate { - friend class Settings; - Settings *m_parent; - std::map m_data; - ChangeWatcher m_watcher; - std::map m_owner; - std::string m_id; - std::mutex m_mutex_data; - std::mutex m_mutex_notifier; - bool m_bypass {false}; - std::vector> m_notifier; - -public: - SettingsPrivate(Settings *parent) - : m_parent(parent) { - char result[PATH_MAX]; - ssize_t count = readlink("/proc/self/exe", result, PATH_MAX); - std::string binary = std::string(result, (count > 0) ? count : 0); - std::filesystem::path bin = binary; - m_id = bin.filename(); - if(m_id == "settings") - { - // Only "settings cli tools can bypass the owner protection" - m_bypass = true; - } - - m_watcher.setFileAdded([this](const std::string path){ refreshDirectory(path); }); - m_watcher.setFileRemoved([this](const std::string path){ refreshAll(); }); - m_watcher.setFileWrited([this](const std::string path){ refreshFile(path); }); - } - - void parseFile(const std::string &filename, std::map &data, bool watch = false) - { - if(!std::filesystem::exists(filename)) - { - return; - } - std::filesystem::path pfile = filename; - if(pfile.filename().string().starts_with(".")) return; - if(pfile.extension() != ".conf") return; - - if(watch) - { - m_watcher.watch(filename); - } - - std::ifstream file(filename); - std::string line; - std::string category = ""; - - while (std::getline(file, line)) { - trim(line); - - if(line.empty() || line.starts_with("#") || line.ends_with(";")) { - continue; - } - if(line.starts_with("[") && line.ends_with("]")) { - category = line.substr(1, line.length() - 2); - if(category == "General") category.clear(); - continue; - } - size_t pos = line.find("="); - std::string key = line.substr(0, pos); - trim(key); - std::string value = line.substr(pos + 1); - trim(value); - - if(key.starts_with("@")) - { - m_owner[SettingKey(category, key.substr(1))] = value; - continue; - } - if(key.ends_with("/owner")) - { - std::cerr << "WARNING: Legacy usage for owner: " << key << " should be @" << key.substr(0, key.size() - 6) << std::endl; - m_owner[SettingKey(category, key.substr(0, key.size() - 6))] = value; - continue; - } - - data[SettingKey(category, key)] = makeValue(value); - } - } - - void parseDir(const std::string &dconf, std::map &data, bool watch) - { - if(std::filesystem::exists(dconf) && std::filesystem::is_directory(dconf)) - { - for (const auto& entry : std::filesystem::directory_iterator(dconf)) - { - if(entry.is_directory()) continue; - parseFile(std::filesystem::absolute(entry.path()), data, watch); - } - } - } - - void parseConf(const std::string &fconf, std::map &data, bool watch) - { - if(std::filesystem::exists(fconf)) - { - parseFile(fconf, data, watch); - } - std::string dconf = fconf + ".d"; - parseDir(dconf, data, watch); - if(watch) - { - m_watcher.watch(dconf); - } - } - - void refreshData(const std::map &cust_data) - { - std::lock_guard lock(m_mutex_data); - std::lock_guard lock_notifier(m_mutex_notifier); - for(const auto& [key, value] : cust_data) { - if(!m_data.contains(key) || m_data[key] != value) - { - // New ITEM OR Data changed - m_data[key] = value; - for(auto ¬ifier: m_notifier) - { - notifier(key.first, key.second, value); - } - continue; - } - } - } - - void refreshFile(const std::string &file) - { - std::map cust_data; - parseFile(file, cust_data); - refreshData(cust_data); - } - - void refreshDirectory(const std::string &dirname) - { - std::map cust_data; - parseDir(dirname, cust_data, true); - refreshData(cust_data); - } - - void refreshAll() - { - std::map cust_data; - parseConf("/etc/kazoe.conf", cust_data, false); - parseConf("/var/kazoe.conf", cust_data, true); - refreshData(cust_data); - } -}; -}; - -Settings::Settings() - : m_ptr(std::make_unique(this)) -{ - m_ptr->parseConf("/etc/kazoe.conf", m_ptr->m_data, false); - m_ptr->parseConf("/var/kazoe.conf", m_ptr->m_data, true); -} - -SettingValue Settings::get(const std::string &key, const std::string &category, SettingValue def) const -{ - std::lock_guard lock(m_ptr->m_mutex_data); - std::string gkey = key; - std::string gcategory = category; - if(gcategory.empty() && gkey.starts_with("[")) - { - gcategory = gkey.substr(1, gkey.find(']') - 1); - gkey = gkey.substr(gkey.find(']') + 1); - } - - SettingKey pkey(gcategory, gkey); - - if(m_ptr->m_data.count(pkey)) - { - return m_ptr->m_data[pkey]; - } - return def; -} - -bool Settings::set(const std::string &key, SettingValue value, const std::string &category) -{ - // Check if variable is writable - std::lock_guard lock(m_ptr->m_mutex_data); - SettingKey pkey(category, key); - std::string id; - - if(!m_ptr->m_owner.count(pkey)) - { - std::cerr << "Variable " << category << ">" << key << " is not writable" << std::endl; - return false; - } - - if(m_ptr->m_bypass) - { - id = m_ptr->m_owner[pkey]; - } - else - { - id = m_ptr->m_id; - if(m_ptr->m_owner[pkey] != id) - { - std::cerr << "Variable " << category << ">" << key << " is not own by " << m_ptr->m_id << std::endl; - return false; - } - } - - // Permission is OK, get old custom variable - std::map cust_data; - m_ptr->parseFile("/var/kazoe.conf.d/"+id+".conf", cust_data); - cust_data[pkey] = value; - std::string last_category = ""; - std::ofstream file("/var/kazoe.conf.d/"+id+".conf"); - - for(const auto& [key, value] : cust_data) { - if(key.first != last_category) - { - last_category = key.first.empty() ? "General" : key.first; - file << "[" << last_category << "]\n"; - } - file << key.second << " = " << KaZoe::valueToStr(value) << "\n"; - } - - m_ptr->m_data[pkey] = value; - return true; -} - -std::string Settings::id() const -{ - return m_ptr->m_id; -} - -void Settings::setId(const std::string &newid) -{ - m_ptr->m_id = newid; -} - -void Settings::setNotifier(const std::function &callback) -{ - std::lock_guard lock(m_ptr->m_mutex_notifier); - m_ptr->m_notifier.push_back(callback); - m_ptr->m_watcher.setEnable(true); -} - -std::size_t Settings::size() const -{ - std::lock_guard lock(m_ptr->m_mutex_data); - return m_ptr->m_data.size(); -} - -void Settings::forEach(const std::function &callback) const -{ - std::lock_guard lock(m_ptr->m_mutex_data); - for (const auto& [key, value] : m_ptr->m_data) { - callback(key, value); - } -} - -Settings::~Settings() -{ - m_ptr->m_watcher.setEnable(false); -} diff --git a/src/settings.h b/src/settings.h deleted file mode 100644 index 5dfe90b..0000000 --- a/src/settings.h +++ /dev/null @@ -1,180 +0,0 @@ -#ifndef KAZOESETTINGS_H -#define KAZOESETTINGS_H - -#include -#include -#include -#include -#include - -/** @brief Key type for settings, composed of category and name */ -using SettingKey = std::pair; - -/** @brief Value type that can hold string, int, double or boolean */ -using SettingValue = std::variant; - - -namespace KaZoe { - -class SettingsPrivate; - -/** - * @brief Main class for managing KaZoe settings - * - * This class provides a thread-safe interface for storing and retrieving - * configuration settings with support for different value types. - */ -class Settings -{ -public: - /** @brief Constructs a new Settings instance */ - explicit Settings(); - - /** @brief Virtual destructor */ - virtual ~Settings(); - - /** - * @brief Retrieves a setting value - * @param key The setting key - * @param category The setting category (optional) - * @param def Default value if setting not found (optional) - * @return The setting value or default if not found - */ - SettingValue get(const std::string &key, const std::string &category = "", SettingValue def = SettingValue()) const; - - /** - * @brief Sets a setting value - * @param key The setting key - * @param value The value to set - * @param category The setting category (optional) - * @return true if successful, false otherwise - */ - bool set(const std::string &key, SettingValue value, const std::string &category = ""); - - /** - * @brief Gets the current instance identifier - * @return The instance ID string, by default, this is the excecutable filename - */ - std::string id() const; - - /** - * @brief Sets the instance identifier - * @param newid The new ID to set - */ - void setId(const std::string &newid); - - /** - * @brief Sets a callback for setting changes notification - * @param callback Function to call when settings change - */ - void setNotifier(const std::function& callback); - - /** - * @brief Gets the number of settings - * @return The total count of settings - */ - std::size_t size() const; - - /** - * @brief Iterates over all settings in a thread-safe manner - * @param callback Function called for each key-value pair - */ - void forEach(const std::function& callback) const; - -private: - std::unique_ptr m_ptr; ///< Private implementation pointer -}; - - -/** - * @brief Functor to convert SettingValue variants to string - * - * Provides string conversion for all supported setting value types - */ -struct settingValueFunctor { - std::string operator()(const std::string &x) const { return x; } - std::string operator()(int x) const { return std::to_string(x); } - std::string operator()(double x) const { return std::to_string(x); } - std::string operator()(bool x) const { return x ? "true" : "false"; } -}; - -/** - * @brief Convert a SettingValue into a string for display - * @param value Input SettingValue to convert - * @return std::string containing the converted value - */ -static inline std::string valueToStr(const SettingValue &value) -{ - return std::visit(settingValueFunctor(), value); -} - -/** - * @brief Check if a string represents a valid numeric value - * @param str String to check - * @return true if string is numeric, false otherwise - */ -static inline bool is_numeric(const std::string& str) { - return !str.empty() && - str.find_first_not_of("0123456789.-") == std::string::npos && - (std::count(str.begin(), str.end(), '.') <= 1) && - (std::count(str.begin(), str.end(), '-') <= 1) && - (str[0] == '-' || std::isdigit(str[0])); -} - -/** - * @brief Convert a string to appropriate SettingValue type - * @param value Input string to convert - * @return SettingValue containing the converted value - * - * Conversion rules: - * - Quoted strings -> string without quotes - * - Numeric values -> int or double - * - "true"/"on" -> boolean true - * - "false"/"off" -> boolean false - * - Other -> string - */ -static inline SettingValue makeValue(const std::string &value) -{ - if(value.starts_with('"') || value.starts_with('\'')) - { - return value.substr(1, value.length() - 2); - } - - if(is_numeric(value)) - { - // double - if(value.find('.') != value.npos) - { - return std::stod(value); - } - else - { - return std::stoi(value); - } - } - - // To Lower case - std::string lvalue = value; - std::transform(lvalue.begin(), lvalue.end(), lvalue.begin(), - [](unsigned char c){ return std::tolower(c); }); - - - // bool true - if(lvalue == "true" || lvalue == "on") - { - return true; - } - - // bool false - if(lvalue == "false" || lvalue == "off") - { - return false; - } - - // else, string without quote - return value; -} - -}; - -#endif // KAZOESETTINGS_H -- cgit v1.2.3