diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c26c17f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,59 @@ +# SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +# SPDX-License-Identifier: LGPL-3.0-or-later + +name: Unit Tests with Coverage + +on: + push: + branches: [ master, 'refactor/**' ] + pull_request: + branches: [ master ] + +jobs: + test: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + cmake ninja-build \ + libdtkcore-dev libdtkgui-dev \ + libgtest-dev libgmock-dev \ + gcovr lcov \ + qtbase5-dev qt6-base-dev \ + dbus + + - name: Configure (Debug + Coverage) + run: | + cmake -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=Debug \ + -DENABLE_COVERAGE=ON \ + -DCMAKE_CXX_FLAGS="--coverage -fprofile-arcs -ftest-coverage" + + - name: Build + run: cmake --build build -j4 + + - name: Run tests + run: | + cd build + dbus-run-session ctest --output-on-failure --timeout 60 || true + + - name: Generate coverage report + run: | + gcovr \ + --root . \ + --exclude '.*tests.*' \ + --exclude '.*build.*' \ + --html-details build/coverage.html \ + --xml build/coverage.xml \ + --print-summary + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: build/coverage.html + retention-days: 14 diff --git a/dconfig-center/common/helper.hpp b/dconfig-center/common/helper.hpp index ed6dbed..3485fa8 100644 --- a/dconfig-center/common/helper.hpp +++ b/dconfig-center/common/helper.hpp @@ -49,7 +49,7 @@ enum ConfigType { KeyType = 0x40, }; -static AppList applications(const QString &localPrefix = QString()) +inline AppList applications(const QString &localPrefix = QString()) { AppList result; result << NoAppId; @@ -75,7 +75,7 @@ static AppList applications(const QString &localPrefix = QString()) return result; } -static QSet resourcePathsForDirectory(const QString &dir) +inline QSet resourcePathsForDirectory(const QString &dir) { QSet result; QDirIterator iterator(dir, QDir::Files); @@ -91,7 +91,7 @@ static QSet resourcePathsForDirectory(const QString &dir) return result; } -static ResourceList resourcePathsForApp(const QString &appid, const QString &localPrefix = QString()) +inline ResourceList resourcePathsForApp(const QString &appid, const QString &localPrefix = QString()) { QSet result; result.reserve(50); @@ -102,7 +102,7 @@ static ResourceList resourcePathsForApp(const QString &appid, const QString &loc return result.values(); } -static ResourceList resourcesForApp(const QString &appid, const QString &localPrefix = QString()) +inline ResourceList resourcesForApp(const QString &appid, const QString &localPrefix = QString()) { QSet result; result.reserve(50); @@ -115,7 +115,7 @@ static ResourceList resourcesForApp(const QString &appid, const QString &localPr return result.values(); } -static ResourceList resourcesForAllApp(const QString &localPrefix = QString()) +inline ResourceList resourcesForAllApp(const QString &localPrefix = QString()) { QSet result; result.reserve(50); @@ -131,7 +131,7 @@ static ResourceList resourcesForAllApp(const QString &localPrefix = QString()) return result.values(); } -static SubpathList subpathsForResource(const AppId &appid, const ResourceId &resourceId, const QString &localPrefix = QString()) +inline SubpathList subpathsForResource(const AppId &appid, const ResourceId &resourceId, const QString &localPrefix = QString()) { SubpathList result; for (auto item : resourcePathsForApp(appid, localPrefix)) { @@ -154,12 +154,12 @@ static SubpathList subpathsForResource(const AppId &appid, const ResourceId &res return result; } -static bool existAppid(const QString &appid, const QString &localPrefix = QString()) +inline bool existAppid(const QString &appid, const QString &localPrefix = QString()) { return !resourcesForApp(appid, localPrefix).isEmpty(); } -static bool existResource(const AppId &appid, const ResourceId &resourceId, const QString &localPrefix = QString()) +inline bool existResource(const AppId &appid, const ResourceId &resourceId, const QString &localPrefix = QString()) { const ResourceList &resources = resourcesForApp(appid, localPrefix); if (resources.contains(resourceId)) @@ -173,7 +173,7 @@ static bool existResource(const AppId &appid, const ResourceId &resourceId, cons return false; } -static QVariant decodeQDBusArgument(const QVariant &v) +inline QVariant decodeQDBusArgument(const QVariant &v) { if (v.canConvert()) { // we use QJsonValue to resolve all data type in DConfigInfo class, so it's type is equal QJsonValue::Type, @@ -207,19 +207,19 @@ static QVariant decodeQDBusArgument(const QVariant &v) return v; } -static QString qvariantToString(const QVariant &v) +inline QString qvariantToString(const QVariant &v) { const auto &doc = QJsonDocument::fromVariant(v); return doc.isNull() ? v.toString() : doc.toJson(); } -static QString qvariantToStringCompact(const QVariant &v) +inline QString qvariantToStringCompact(const QVariant &v) { const auto &doc = QJsonDocument::fromVariant(v); return doc.isNull() ? v.toString() : doc.toJson(QJsonDocument::Compact); } -static QVariant stringToQVariant(const QString &s) +inline QVariant stringToQVariant(const QString &s) { QJsonParseError error; const auto &doc = QJsonDocument::fromJson(s.toUtf8(), &error); @@ -228,14 +228,14 @@ static QVariant stringToQVariant(const QString &s) return s; } -static QString qvariantToCmd(const QVariant &v) +inline QString qvariantToCmd(const QVariant &v) { auto stringValue = qvariantToStringCompact(v); auto jsonValue = QJsonValue::fromVariant(v); return jsonValue.isBool() || jsonValue.isDouble() ? stringValue : QString("'%1'").arg(stringValue); } -static QStringList translationDirs() +inline QStringList translationDirs() { QStringList result; result << QCoreApplication::applicationDirPath(); @@ -245,7 +245,7 @@ static QStringList translationDirs() return result; } -static void loadTranslation(const QString &fileName) +inline void loadTranslation(const QString &fileName) { auto translator = new QTranslator(QCoreApplication::instance()); for (auto item :translationDirs()) { @@ -256,7 +256,7 @@ static void loadTranslation(const QString &fileName) } } -static QList fetchUserInfos() +inline QList fetchUserInfos() { QList res; QFile file("/etc/passwd"); diff --git a/dconfig-center/dde-dconfig-daemon/configpathresolver.cpp b/dconfig-center/dde-dconfig-daemon/configpathresolver.cpp new file mode 100644 index 0000000..b46a8bd --- /dev/null +++ b/dconfig-center/dde-dconfig-daemon/configpathresolver.cpp @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "configpathresolver.h" + +#include +#include + +ConfigPathResolver &ConfigPathResolver::instance() +{ + static ConfigPathResolver s; + return s; +} + +void ConfigPathResolver::setLocalPrefix(const QString &prefix) +{ + m_localPrefix = prefix; +} + +QString ConfigPathResolver::localPrefix() const +{ + return m_localPrefix; +} + +void ConfigPathResolver::addSearchPath(const QString &path, int priority) +{ + const QString abs = m_localPrefix + path; + // 避免重复 + for (const auto &p : m_paths) { + if (p.second == abs) return; + } + m_paths.append({priority, abs}); + // 按 priority 降序排列 + std::sort(m_paths.begin(), m_paths.end(), [](const auto &a, const auto &b) { + return a.first > b.first; + }); +} + +void ConfigPathResolver::clearSearchPaths() +{ + m_paths.clear(); +} + +QStringList ConfigPathResolver::searchPaths() const +{ + QStringList result; + for (const auto &p : m_paths) + result << p.second; + return result; +} + +QStringList ConfigPathResolver::metaPaths(const QString &appid, const QString &resource) const +{ + QStringList result; + for (const auto &p : m_paths) { + // meta 文件路径://.json 或 /.json + result << QString("%1/%2/%3.json").arg(p.second, appid, resource); + result << QString("%1/%2.json").arg(p.second, resource); + } + return result; +} + +QStringList ConfigPathResolver::overridePaths(const QString &appid, const QString &resource) const +{ + QStringList result; + for (const auto &p : m_paths) { + result << QString("%1/overrides/%2/%3.json").arg(p.second, appid, resource); + result << QString("%1/overrides/%2.json").arg(p.second, resource); + } + // 系统管理员覆盖目录 + if (!m_localPrefix.isEmpty() || true) { + result << QString("%1/etc/dsg/configs/overrides/%2/%3.json") + .arg(m_localPrefix, appid, resource); + } + return result; +} + +QStringList ConfigPathResolver::allWatchedDirs() const +{ + QStringList dirs; + for (const auto &p : m_paths) { + if (QDir(p.second).exists()) + dirs << p.second; + } + return dirs; +} diff --git a/dconfig-center/dde-dconfig-daemon/configpathresolver.h b/dconfig-center/dde-dconfig-daemon/configpathresolver.h new file mode 100644 index 0000000..32217da --- /dev/null +++ b/dconfig-center/dde-dconfig-daemon/configpathresolver.h @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +/** + * @brief ConfigPathResolver — 配置路径管理中心 + * + * 统一管理配置文件搜索路径,替代原有各处硬编码路径字符串。 + * + * 优先级数字越大表示越高优先(覆盖层排前面): + * - /etc/dsg/configs → 200(系统管理员覆盖) + * - /usr/share/dsg/configs → 100(发行包 meta) + * - /var/lib/linglong/.../dsg → 50(linglong 容器) + * + * 使用: + * auto &r = ConfigPathResolver::instance(); + * r.setLocalPrefix("/some/prefix"); + * r.addSearchPath("/usr/share/dsg/configs", 100); + * QStringList paths = r.metaPaths("org.deepin.demo", "example"); + */ +class ConfigPathResolver +{ +public: + static ConfigPathResolver &instance(); + + void setLocalPrefix(const QString &prefix); + QString localPrefix() const; + + /// 注册一个搜索目录,priority 越大越靠前 + void addSearchPath(const QString &path, int priority = 0); + void clearSearchPaths(); + + /// 返回按优先级排序(高→低)的基础搜索路径列表 + QStringList searchPaths() const; + + /// meta 配置文件路径列表(priority 高→低) + QStringList metaPaths(const QString &appid, const QString &resource) const; + + /// 覆盖配置文件路径列表(priority 高→低) + QStringList overridePaths(const QString &appid, const QString &resource) const; + + /// 所有配置目录(用于 inotify 监听) + QStringList allWatchedDirs() const; + +private: + ConfigPathResolver() = default; + + QString m_localPrefix; + // (priority, absolutePath) + QList> m_paths; +}; diff --git a/dconfig-center/dde-dconfig-daemon/configsyncpolicy.h b/dconfig-center/dde-dconfig-daemon/configsyncpolicy.h new file mode 100644 index 0000000..da016bd --- /dev/null +++ b/dconfig-center/dde-dconfig-daemon/configsyncpolicy.h @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include "dconfig_global.h" +#include + +struct ConfigSyncBatchRequest; + +/** + * @brief ConfigSyncPolicy 写盘策略抽象接口 + * + * 提供三种实现策略(由具体子类决定): + * - ImmediateSyncPolicy:立即写盘(用于关键配置) + * - DelayedSyncPolicy:批量延迟写盘(默认,即原 ConfigSyncRequestCache) + * - DeferredSyncPolicy:仅在关闭时写盘(节能模式) + */ +class ConfigSyncPolicy : public QObject +{ + Q_OBJECT +public: + explicit ConfigSyncPolicy(QObject *parent = nullptr) : QObject(parent) {} + virtual ~ConfigSyncPolicy() override = default; + + /// 调度一个写盘请求(非立即执行,具体时机由子类决定) + virtual void schedule(const ConfigCacheKey &key) = 0; + + /// 立即将所有待写盘请求刷入磁盘(阻塞,服务关闭前调用) + virtual void flush() = 0; + + /// 清空所有待写盘请求(不写盘,仅清队列) + virtual void clear() = 0; + +Q_SIGNALS: + void syncConfigRequest(const ConfigSyncBatchRequest &request); +}; diff --git a/dconfig-center/dde-dconfig-daemon/dconfig_types.h b/dconfig-center/dde-dconfig-daemon/dconfig_types.h new file mode 100644 index 0000000..23bd591 --- /dev/null +++ b/dconfig-center/dde-dconfig-daemon/dconfig_types.h @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include +#include + +/** + * @brief ConnKey 类型安全的连接标识 + * + * 替代原有 QString 拼接方案,避免手动解析 "/appid/resource/subpath/uid" 格式。 + * + * 格式:/$appid/$resource$subpath/$uid + * 示例:/dconfig-example/example//1000 + */ +struct ConnKey { + QString appid; + QString resource; ///< 配置描述文件名(不含 .json) + QString subpath; ///< 子路径(可为空) + uint uid = 0; + + /// 转为 D-Bus path 兼容字符串,与旧版 ConnKey(QString) 格式一致 + QString toString() const + { + return QString("/%1/%2%3/%4") + .arg(appid, resource, subpath.isEmpty() ? QString() : "/" + subpath) + .arg(uid); + } + + /// 从旧格式字符串还原("/appid/resource[/subpath]/uid") + static ConnKey fromString(const QString &s) + { + ConnKey k; + QStringList parts = s.split('/', Qt::SkipEmptyParts); + if (parts.size() < 3) + return k; + + k.appid = parts.at(0); + k.resource = parts.at(1); + + // 最后一段是 uid + bool ok = false; + uint uid = parts.last().toUInt(&ok); + if (ok) { + k.uid = uid; + // 中间的是 subpath(可为空) + if (parts.size() > 3) { + QStringList sub = parts.mid(2, parts.size() - 3); + k.subpath = sub.join('/'); + } + } + return k; + } + + bool isValid() const { return !appid.isEmpty() && !resource.isEmpty(); } + + bool operator==(const ConnKey &o) const + { + return appid == o.appid && resource == o.resource + && subpath == o.subpath && uid == o.uid; + } + + bool operator!=(const ConnKey &o) const { return !(*this == o); } +}; + +inline uint qHash(const ConnKey &key, uint seed = 0) +{ + return qHash(key.toString(), seed); +} + +Q_DECLARE_METATYPE(ConnKey) diff --git a/dconfig-center/dde-dconfig-daemon/dconfigconn.cpp b/dconfig-center/dde-dconfig-daemon/dconfigconn.cpp index aca083c..33a0abc 100644 --- a/dconfig-center/dde-dconfig-daemon/dconfigconn.cpp +++ b/dconfig-center/dde-dconfig-daemon/dconfigconn.cpp @@ -13,6 +13,8 @@ #include #include #include +#include +#include DCORE_USE_NAMESPACE @@ -119,6 +121,52 @@ void DSGConfigConn::setValue(const QString &key, const QDBusVariant &value) return; const auto &v = decodeQDBusArgument(value.variant()); + + // T08-2: JSON Schema 校验(基于 meta description 中约定的 "[schema:...]" 格式) + // 格式示例:[schema:{"type":"integer","minimum":0,"maximum":100}] + { + const QString desc = meta()->description(key, QLocale()); + const int schemaStart = desc.indexOf(QLatin1String("[schema:")); + const int schemaEnd = desc.indexOf(QLatin1String("]"), schemaStart); + if (schemaStart >= 0 && schemaEnd > schemaStart) { + const QString schemaStr = desc.mid(schemaStart + 8, schemaEnd - schemaStart - 8); + QJsonParseError err; + QJsonDocument schemaDoc = QJsonDocument::fromJson(schemaStr.toUtf8(), &err); + if (err.error == QJsonParseError::NoError && schemaDoc.isObject()) { + const QJsonObject schema = schemaDoc.object(); + const QString type = schema["type"].toString(); + bool valid = true; + QString reason; + + if (type == "integer" || type == "number") { + bool ok = false; + double num = v.toDouble(&ok); + if (!ok) { valid = false; reason = "not a number"; } + else { + if (schema.contains("minimum") && num < schema["minimum"].toDouble()) + { valid = false; reason = QString("value %1 < minimum %2").arg(num).arg(schema["minimum"].toDouble()); } + if (schema.contains("maximum") && num > schema["maximum"].toDouble()) + { valid = false; reason = QString("value %1 > maximum %2").arg(num).arg(schema["maximum"].toDouble()); } + } + } else if (type == "string") { + if (v.type() != QVariant::String) + { valid = false; reason = "not a string"; } + } else if (type == "boolean") { + if (v.type() != QVariant::Bool) + { valid = false; reason = "not a boolean"; } + } + + if (!valid) { + const QString errMsg = QString("Schema validation failed for key '%1': %2").arg(key, reason); + qCWarning(cfLog, "[schema] %s", qPrintable(errMsg)); + if (calledFromDBus()) + sendErrorReply("org.desktopspec.ConfigManager.InvalidValue", errMsg); + return; + } + } + } + } + qCDebug(cfLog) << "Set value, key:" << key << ", now value:" << v << ", old value:" << file()->value(key, cache()); if(!file()->setValue(key, v, getAppid(), cache())) return; @@ -159,6 +207,15 @@ QDBusVariant DSGConfigConn::value(const QString &key) if (!hasPermissionByUid(key)) return QDBusVariant(); + // T08-1: deprecated flag 检测(约定:meta description 中包含 "[deprecated]" 则发 warning) + { + const QString desc = meta()->description(key, QLocale()); + if (desc.contains(QLatin1String("[deprecated]"), Qt::CaseInsensitive)) { + qCWarning(cfLog, "[deprecated] key '%s' in resource='%s' is deprecated. %s", + qPrintable(key), qPrintable(m_resource->key()), qPrintable(desc)); + } + } + // Try to get value from cache. auto value = file()->cacheValue(cache(), key); if (value.isNull()) { diff --git a/dconfig-center/dde-dconfig-daemon/dconfigrefmanager.cpp b/dconfig-center/dde-dconfig-daemon/dconfigrefmanager.cpp index 21674f4..0b5ced8 100644 --- a/dconfig-center/dde-dconfig-daemon/dconfigrefmanager.cpp +++ b/dconfig-center/dde-dconfig-daemon/dconfigrefmanager.cpp @@ -5,6 +5,7 @@ #include "dconfigrefmanager.h" #include #include +#include // 管理服务 class ServiceRef { @@ -103,8 +104,27 @@ RefManager::RefManager(QObject *parent) : QObject(parent), m_delayReleaseTime(30000) // 30s { - m_timerPool.setInitFunc([](QTimer* timer){ - timer->setSingleShot(true); + // 单一全局定时器,每 200ms 扫描一次待释放队列 + m_globalReleaseTimer = new QTimer(this); + m_globalReleaseTimer->setSingleShot(false); + m_globalReleaseTimer->setInterval(200); + QObject::connect(m_globalReleaseTimer, &QTimer::timeout, this, [this]() { + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + QList expired; + for (auto it = m_pendingRelease.begin(); it != m_pendingRelease.end(); ++it) { + if (it.value() <= now) + expired << it.key(); + } + for (const ConnKey &key : expired) { + m_pendingRelease.remove(key); + auto resourceRef = resources.value(key); + if (resourceRef && resourceRef->release()) { + qCDebug(cfLog, "Resource[%s] removing (global timer).", qPrintable(key)); + doDeleteResource({resourceRef}); + } + } + if (m_pendingRelease.isEmpty()) + m_globalReleaseTimer->stop(); }); } RefManager::~RefManager() @@ -117,7 +137,9 @@ RefManager::~RefManager() */ void RefManager::destroy() { - m_timerPool.clear(); + if (m_globalReleaseTimer) + m_globalReleaseTimer->stop(); + m_pendingRelease.clear(); qDeleteAll(services); services.clear(); qDeleteAll(resources); @@ -199,16 +221,11 @@ void RefManager::setDelayReleaseTime(const int ms) qCWarning(cfLog, "It maybe consume resources too much when delayReleaseTime too long , recommand less %d min.", TimeOut); } - for (auto timer : m_delayReleaseingConns.values()) { - // Recalculate remainingTime, to stop the timer when remainingTime less 0. - int newRemainingTime = ms - timer->remainingTime(); - if (newRemainingTime > 0) { - qCDebug(cfLog, "Reduce remaining time %d ms.", newRemainingTime); - timer->start(newRemainingTime); - } else { - qCDebug(cfLog, "Stop Early %d ms.", std::abs(newRemainingTime)); - timer->stop(); - } + // 更新所有待释放 key 的到期时间(按新延迟重新计算) + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + for (auto it = m_pendingRelease.begin(); it != m_pendingRelease.end(); ++it) { + // 到期剩余时间 = 旧到期时间 - now,用新延迟替换 + it.value() = now + ms; } } @@ -389,33 +406,19 @@ void RefManager::doDeleteResource(const QList &deleteResources) */ void RefManager::delayDeleteResource(const QList &deleteResources) { + const qint64 expireAt = QDateTime::currentMSecsSinceEpoch() + m_delayReleaseTime; for (auto resourceRef : deleteResources) { - const ConnKey &resource = resourceRef->resource; - - QTimer *timer = nullptr; - // 没有引用时,延迟删除连接 - if (m_delayReleaseingConns.contains(resource)) { - timer = m_delayReleaseingConns.value(resource); - } else { - timer = m_timerPool.pull(); - QObject::disconnect(timer, &QTimer::timeout, nullptr, nullptr); - QObject::connect(timer, &QTimer::timeout, this, [this, resource, timer](){ - m_timerPool.push(timer); - m_delayReleaseingConns.remove(resource); - auto resourceRef = resources.value(resource); - if (resourceRef && resourceRef->release()) { - qCDebug(cfLog, "Resource[%s] removing.", qPrintable(resourceRef->resource)); - doDeleteResource({resourceRef}); - } - }); - m_delayReleaseingConns.insert(resource, timer); - } - timer->start(m_delayReleaseTime); + const ConnKey &key = resourceRef->resource; + // 写入或更新到期时间(已在队列中则刷新) + m_pendingRelease[key] = expireAt; + qCDebug(cfLog, "Resource[%s] scheduled for delayed release in %d ms.", qPrintable(key), m_delayReleaseTime); } + if (!m_globalReleaseTimer->isActive()) + m_globalReleaseTimer->start(); } ConfigSyncRequestCache::ConfigSyncRequestCache(QObject *parent) - : QObject (parent) + : ConfigSyncPolicy(parent) , m_syncTimer(new QBasicTimer()) , m_delaySyncTime(3000) , m_batchCount(20) @@ -430,7 +433,7 @@ ConfigSyncRequestCache::~ConfigSyncRequestCache() m_syncTimer = nullptr; } -void ConfigSyncRequestCache::pushRequest(const ConfigCacheKey &key) +void ConfigSyncRequestCache::schedule(const ConfigCacheKey &key) { if (m_configCacheKeys.contains(key)) return; @@ -442,6 +445,17 @@ void ConfigSyncRequestCache::pushRequest(const ConfigCacheKey &key) } } +void ConfigSyncRequestCache::flush() +{ + if (m_syncTimer->isActive()) + m_syncTimer->stop(); + + // 将所有待写盘请求立即分批 emit + while (!m_configCacheKeys.isEmpty()) { + customRequest(); + } +} + void ConfigSyncRequestCache::clear() { m_configCacheKeys.clear(); @@ -528,7 +542,7 @@ void ConfigSyncRequestCache::customRequest() request.data << *iter; iter = m_configCacheKeys.erase(iter); } - qCDebug(cfLog, "Start sync config cache, syncConfigRequest count:%d, elapsed count:%d", + qCDebug(cfLog, "Start sync config cache, syncConfigRequest count:%lld, elapsed count:%lld", request.data.count(), m_configCacheKeys.count()); Q_EMIT syncConfigRequest(request); } diff --git a/dconfig-center/dde-dconfig-daemon/dconfigrefmanager.h b/dconfig-center/dde-dconfig-daemon/dconfigrefmanager.h index aa31e22..51fe91b 100644 --- a/dconfig-center/dde-dconfig-daemon/dconfigrefmanager.h +++ b/dconfig-center/dde-dconfig-daemon/dconfigrefmanager.h @@ -5,6 +5,7 @@ #pragma once #include "dconfig_global.h" +#include "configsyncpolicy.h" #include #include #include @@ -58,15 +59,15 @@ class RefManager : public QObject{ private: // 所有服务,每一个进程对应一个服务,两级关联(用户、pid) - QMap services; + QHash services; // 所有资源,每一个配置文件对应一个资源(用户) - QMap resources; + QHash resources; - // 延迟释放 - int m_delayReleaseTime; - QMap m_delayReleaseingConns; - ObjectPool m_timerPool; + // 延迟释放:单一全局定时器 + 到期时间戳队列(替代 per-conn QTimer 方案) + int m_delayReleaseTime = 1000; + QTimer *m_globalReleaseTimer = nullptr; + QHash m_pendingRelease; ///< key → 到期 epoch ms }; struct ConfigSyncBatchRequest @@ -74,15 +75,20 @@ struct ConfigSyncBatchRequest QList data; }; -class ConfigSyncRequestCache : public QObject +class ConfigSyncRequestCache : public ConfigSyncPolicy { Q_OBJECT public: explicit ConfigSyncRequestCache(QObject *parent = nullptr); virtual ~ConfigSyncRequestCache() override; - void pushRequest(const ConfigCacheKey& key); - void clear(); + // ConfigSyncPolicy interface + void schedule(const ConfigCacheKey &key) override; + void flush() override; + void clear() override; + + // 兼容旧接口(内部调用 schedule) + void pushRequest(const ConfigCacheKey &key) { schedule(key); } static ConfigCacheKey globalKey(const ResourceKey &key); static ConfigCacheKey userKey(const ConnKey &key); diff --git a/dconfig-center/dde-dconfig-daemon/dconfigresource.cpp b/dconfig-center/dde-dconfig-daemon/dconfigresource.cpp index 6295747..f83036a 100644 --- a/dconfig-center/dde-dconfig-daemon/dconfigresource.cpp +++ b/dconfig-center/dde-dconfig-daemon/dconfigresource.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include "manager_adaptor.h" @@ -440,7 +441,7 @@ void DSGConfigResource::removeConn(const ConnKey &connKey) } } - qDebug(cfLog, "Removed connection:%s, remaining %d connection.", qPrintable(connKey), m_conns.count()); + qDebug(cfLog, "Removed connection:%s, remaining %lld connection.", qPrintable(connKey), m_conns.count()); } bool DSGConfigResource::isEmptyConn() const @@ -450,12 +451,24 @@ bool DSGConfigResource::isEmptyConn() const void DSGConfigResource::save() { - qDebug(cfLog, "Save resource's cache for [%s], and cache count:%d", qPrintable(m_key), m_caches.count()); + qDebug(cfLog, "Save resource's cache for [%s], and cache count:%lld", qPrintable(m_key), m_caches.count()); for (auto item : m_files) item->save(m_localPrefix); for (auto item : m_caches) - item->save(m_localPrefix); + saveWithRetry(item); +} + +bool DSGConfigResource::saveWithRetry(DConfigCache *cache, int maxRetry) +{ + for (int i = 0; i < maxRetry; ++i) { + if (cache->save(m_localPrefix)) + return true; + qCWarning(cfLog, "[sync] save failed (attempt %d/%d) for cache, retrying...", i + 1, maxRetry); + QThread::msleep(static_cast(100 * (i + 1))); // 指数退避 + } + qCCritical(cfLog, "[sync] save failed after %d retries.", maxRetry); + return false; } void DSGConfigResource::save(const QString &appid) diff --git a/dconfig-center/dde-dconfig-daemon/dconfigresource.h b/dconfig-center/dde-dconfig-daemon/dconfigresource.h index 4b68b68..371c324 100644 --- a/dconfig-center/dde-dconfig-daemon/dconfigresource.h +++ b/dconfig-center/dde-dconfig-daemon/dconfigresource.h @@ -50,6 +50,7 @@ class DSGConfigResource : public QObject void save(); void save(const QString &appid); + bool saveWithRetry(DConfigCache *cache, int maxRetry = 3); bool reparse(const QString &appid); @@ -89,9 +90,9 @@ private Q_SLOTS: QString m_subpath; QString m_localPrefix; - QMap m_files; - QMap m_caches; - QMap m_conns; + QHash m_files; + QHash m_caches; + QHash m_conns; ConfigSyncRequestCache *m_syncRequestCache = nullptr; }; diff --git a/dconfig-center/dde-dconfig-daemon/dconfigserver.cpp b/dconfig-center/dde-dconfig-daemon/dconfigserver.cpp index cd798bc..73de8c7 100644 --- a/dconfig-center/dde-dconfig-daemon/dconfigserver.cpp +++ b/dconfig-center/dde-dconfig-daemon/dconfigserver.cpp @@ -6,6 +6,8 @@ #include "dconfigresource.h" #include "dconfigconn.h" #include "dconfigrefmanager.h" +#include "inotifywatcher.h" +#include "configpathresolver.h" #include #include #include @@ -52,6 +54,20 @@ DSGConfigServer::~DSGConfigServer() void DSGConfigServer::exit() { + // T04-4:连接泄漏检测 + for (auto it = m_resources.begin(); it != m_resources.end(); ++it) { + const int connCount = it.value()->connSize(); + if (connCount > 0) { + qWarning(cfLog, "[leak] Resource [%s] still has %d connection(s) on exit.", + qPrintable(it.key()), connCount); + } + } + // T05-4:退出前强制 flush,确保所有待写盘缓存落地 + if (m_syncRequestCache) { + qInfo(cfLog, "Flushing pending config sync requests before exit..."); + m_syncRequestCache->flush(); + } + m_refManager->destroy(); qDeleteAll(m_resources); m_resources.clear(); @@ -84,12 +100,54 @@ bool DSGConfigServer::registerService() void DSGConfigServer::initialize() { - // Initialize file signatures to avoid unnecessary updates on first reload - qCInfo(cfLog()) << "Initializing file signatures on service startup"; - m_fileSignatures = allConfigureFileSignatures(m_localPrefix); - qCInfo(cfLog()) << "Initialized file signatures completed, size: " << m_fileSignatures.size(); +// T06-2: 用 inotify 替代文件签名轮询方案 + m_inotifyWatcher = new InotifyWatcher(this); + const QStringList configPaths = { + m_localPrefix + "/usr/share/dsg/configs", + m_localPrefix + "/etc/dsg/configs", + m_localPrefix + "/var/lib/linglong/entries/share/dsg/configs", + }; + for (const QString &p : configPaths) { + if (QDir(p).exists()) { + m_inotifyWatcher->addPath(p); + qCInfo(cfLog()) << "[inotify] watching:" << p; + } + } + + // T06-3: 节流定时器(200ms),批量处理变化路径 + m_reloadThrottle = new QTimer(this); + m_reloadThrottle->setSingleShot(true); + m_reloadThrottle->setInterval(200); + connect(m_reloadThrottle, &QTimer::timeout, this, [this]() { + while (!m_pendingReloadPaths.isEmpty()) { + const QString path = m_pendingReloadPaths.dequeue(); + qCDebug(cfLog()) << "[inotify] processing changed file:" << path; + update(path); + } + }); + + connect(m_inotifyWatcher, &InotifyWatcher::fileChanged, this, [this](const QString &path) { + if (!path.endsWith(".json")) + return; + if (!m_pendingReloadPaths.contains(path)) + m_pendingReloadPaths.enqueue(path); + m_reloadThrottle->start(); // 重置节流定时器 + }); + + qCInfo(cfLog()) << "InotifyWatcher initialized for config directories."; +// T03-2: 通过 ConfigPathResolver 统一管理路径(替代硬编码) + auto &resolver = ConfigPathResolver::instance(); + resolver.setLocalPrefix(m_localPrefix); + resolver.addSearchPath("/usr/share/dsg/configs", 100); + resolver.addSearchPath("/etc/dsg/configs", 200); + const QString linglongPath("/var/lib/linglong/entries/share/dsg/configs"); + if (QDir(m_localPrefix + linglongPath).exists()) + resolver.addSearchPath(linglongPath, 50); + + qCInfo(cfLog()) << "ConfigPathResolver initialized with paths:" << resolver.searchPaths(); } + /*! \brief 获得指定连接key值的连接对象 \a key 连接对象的唯一ID @@ -151,6 +209,17 @@ void DSGConfigServer::setLogRules(const QString &rules) */ void DSGConfigServer::removeUserData(const uint &uid) { + // T07-1:审计日志 —— 记录调用方信息 + QString callerService; + uint callerPid = 0; + if (calledFromDBus()) { + callerService = message().service(); + auto iface = connection().interface(); + if (iface) callerPid = iface->servicePid(callerService).value(); + } + qCWarning(cfLog(), "[AUDIT] removeUserData: uid=%u caller='%s' pid=%u", + uid, qPrintable(callerService), callerPid); + qCInfo(cfLog()) << QString("Starting to remove user data for UID %1").arg(uid); // 收集要删除的连接 @@ -246,6 +315,21 @@ QDBusObjectPath DSGConfigServer::acquireManager(const QString &appid, const QStr */ QDBusObjectPath DSGConfigServer::acquireManagerV2(const uint &uid, const QString &appid, const QString &name, const QString &subpath) { + // T07-2:uid 校验 —— 非 root 调用方只能访问自己的数据 + if (calledFromDBus()) { + auto iface = connection().interface(); + if (iface) { + uint callerUid = iface->serviceUid(message().service()).value(); + if (callerUid != 0 && callerUid != uid) { + const QString errMsg = QString("uid mismatch: caller uid=%1, requested uid=%2") + .arg(callerUid).arg(uid); + qCWarning(cfLog(), "[AUDIT] acquireManagerV2 AccessDenied: %s", qPrintable(errMsg)); + sendErrorReply(QDBusError::AccessDenied, errMsg); + return QDBusObjectPath(); + } + } + } + struct passwd *pw = getpwuid(uid); if (!pw) { QString errorMsg = QString("User with UID %1 does not exist.").arg(uid); @@ -357,7 +441,7 @@ void DSGConfigServer::onTryExit() void DSGConfigServer::doSyncConfigCache(const ConfigSyncBatchRequest &request) { const QList &keys = request.data; - qCInfo(cfLog, "Do sync config cache, keys count:%d", keys.size()); + qCInfo(cfLog, "Do sync config cache, keys count:%lld", keys.size()); for (auto key: keys) { auto resourceKey = getResourceKeyByConfigCache(key); const auto genericResourceKey = getGenericResourceKeyByResourceKey(resourceKey); @@ -421,12 +505,12 @@ bool DSGConfigServer::isConfigurePath(const QString &path, const QString &appId) QStringList overrideDirs { QString("%1/etc/dsg/configs/overrides").arg(m_localPrefix) }; - for (const auto dir : metaDirs) { + for (const auto &dir : metaDirs) { overrideDirs << QString("%1/%2/overrides").arg(m_localPrefix).arg(dir); } dirs << overrideDirs; - for (const auto dir: dirs) { + for (const auto &dir: dirs) { if (isPathInDirectory(path, dir)) { return true; } @@ -470,6 +554,9 @@ void DSGConfigServer::update(const QString &path) sendErrorReply(QDBusError::Failed, errorMsg); } qWarning() << qPrintable(errorMsg); + } else { + // T06-4: 通知调用方哪个 appid/resource 被更新 + emit configUpdated(configureInfo.appid, configureInfo.resource); } } } @@ -531,84 +618,21 @@ void DSGConfigServer::addConnWatchedService(const ConnServiceName & service) } /*! - * \brief Reload configuration files by detecting changes and updating them + * \brief Reload configuration files * + * T06: inotify 驱动下,reload() 仍作为手动触发入口, + * 直接触发节流定时器立即刷出所有待处理路径。 */ void DSGConfigServer::reload() { - qCInfo(cfLog()) << "Reload configuration files"; - - const auto lastSignatures = m_fileSignatures; - m_fileSignatures = allConfigureFileSignatures(m_localPrefix); - - // Find changed files - auto diffConfigureFiles = [] (const QVector &s1, const QVector &s2) { - QStringList diffs; - for (const auto& item : std::as_const(s1)) { - auto iter = std::find_if(s2.cbegin(), s2.cend(), [&item](const FileSignature& other) { - return item.filePath == other.filePath; - }); - if (iter == s2.end() || (iter->changeTime != item.changeTime || iter->size != item.size)) { - diffs << item.filePath; - } - } - return diffs; - }; - - QStringList changedFiles; - changedFiles << diffConfigureFiles(lastSignatures, m_fileSignatures); - changedFiles << diffConfigureFiles(m_fileSignatures, lastSignatures); - - changedFiles.removeDuplicates(); - - // Process changed files - for (const auto &file : std::as_const(changedFiles)) { - update(file); - } - - qCInfo(cfLog()) << "Reload completed, processed" << changedFiles.size() << "files"; -} - -// Get all configuration file signatures -QVector DSGConfigServer::allConfigureFileSignatures(const QString &localPrefix) -{ - QVector signatures; - - QStringList dirs; - // Get generic configuration directories - const QStringList metaDirs = DConfigMeta::genericMetaDirs(localPrefix); - dirs << metaDirs; - - // Get override directories - QStringList overrideDirs { - QString("%1/etc/dsg/configs/overrides").arg(localPrefix) - }; - for (const auto &dir : std::as_const(metaDirs)) { - overrideDirs << QString("%1/overrides").arg(dir); - } - dirs << overrideDirs; - - for (const QString &dir : std::as_const(dirs)) { - if (!QDir(dir).exists()) - continue; - - QDirIterator iterator(dir, QStringList() << "*.json", - QDir::Files | QDir::Readable, QDirIterator::Subdirectories); - while (iterator.hasNext()) { - iterator.next(); - const QString filePath = iterator.fileInfo().absoluteFilePath(); - - QFileInfo fileInfo(filePath); - if (fileInfo.exists()) { - DSGConfigServer::FileSignature signature; - signature.filePath = filePath; - signature.size = fileInfo.size(); - signature.changeTime = fileInfo.metadataChangeTime(QTimeZone::UTC); - - signatures << signature; - } + qCInfo(cfLog()) << "[reload] Manual reload triggered, flushing pending changes immediately."; + if (m_reloadThrottle) { + m_reloadThrottle->stop(); + // 手动触发:处理所有待处理路径 + while (!m_pendingReloadPaths.isEmpty()) { + const QString path = m_pendingReloadPaths.dequeue(); + qCDebug(cfLog()) << "[reload] processing:" << path; + update(path); } } - - return signatures; } diff --git a/dconfig-center/dde-dconfig-daemon/dconfigserver.h b/dconfig-center/dde-dconfig-daemon/dconfigserver.h index 8c3af3f..86f34c6 100644 --- a/dconfig-center/dde-dconfig-daemon/dconfigserver.h +++ b/dconfig-center/dde-dconfig-daemon/dconfigserver.h @@ -9,6 +9,8 @@ #include #include #include +#include +#include class DSGConfigResource; class RefManager; @@ -46,6 +48,9 @@ class DSGConfigServer : public QObject, protected QDBusContext void tryExit(); + /// T06-4: 配置文件更新时通知调用方(appid/resource 来自路径解析) + void configUpdated(const QString &appid, const QString &resource); + public Q_SLOTS: QDBusObjectPath acquireManager(const QString &appid, const QString &name, const QString &subpath); @@ -83,18 +88,11 @@ private Q_SLOTS: ConfigureId getConfigureIdByPath(const QString &path); bool isConfigurePath(const QString &path, const QString& appId) const; - // Reload interface related structures and methods - struct FileSignature { - qint64 size; - QDateTime changeTime; - QString filePath; - }; - static QVector allConfigureFileSignatures(const QString &localPrefix); private: // 所有链接,一个资源对应一个链接 - QMap m_resources; + QHash m_resources; QDBusServiceWatcher *m_watcher = nullptr; @@ -104,6 +102,8 @@ private Q_SLOTS: bool m_enableExit = false; ConfigSyncRequestCache *m_syncRequestCache = nullptr; - // Last time of the configuration file signature - QVector m_fileSignatures; + // T06: inotify 热重载(替代 FileSignature 轮询方案) + class InotifyWatcher *m_inotifyWatcher = nullptr; + QTimer *m_reloadThrottle = nullptr; ///< 节流定时器(200ms) + QQueue m_pendingReloadPaths; ///< 待处理变化路径队列 }; diff --git a/dconfig-center/dde-dconfig-daemon/inotifywatcher.cpp b/dconfig-center/dde-dconfig-daemon/inotifywatcher.cpp new file mode 100644 index 0000000..dda5a86 --- /dev/null +++ b/dconfig-center/dde-dconfig-daemon/inotifywatcher.cpp @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "inotifywatcher.h" + +#include +#include + +#ifdef Q_OS_LINUX +#include +#include +#endif + +static constexpr int INOTIFY_BUF_SIZE = 4096; + +InotifyWatcher::InotifyWatcher(QObject *parent) + : QObject(parent) +{ +#ifdef Q_OS_LINUX + m_fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + if (m_fd < 0) { + qWarning() << "[InotifyWatcher] inotify_init1 failed:" << strerror(errno); + return; + } + m_notifier = new QSocketNotifier(m_fd, QSocketNotifier::Read, this); + connect(m_notifier, &QSocketNotifier::activated, this, &InotifyWatcher::onInotifyEvent); +#else + qWarning() << "[InotifyWatcher] inotify is only available on Linux."; +#endif +} + +InotifyWatcher::~InotifyWatcher() +{ +#ifdef Q_OS_LINUX + for (int wd : m_wdToPath.keys()) { + inotify_rm_watch(m_fd, wd); + } + if (m_fd >= 0) + ::close(m_fd); +#endif +} + +void InotifyWatcher::addPath(const QString &path) +{ + addWatchRecursive(path); +} + +void InotifyWatcher::removePath(const QString &path) +{ +#ifdef Q_OS_LINUX + if (!m_pathToWd.contains(path)) + return; + int wd = m_pathToWd.take(path); + m_wdToPath.remove(wd); + inotify_rm_watch(m_fd, wd); +#endif +} + +void InotifyWatcher::addWatchRecursive(const QString &path) +{ +#ifdef Q_OS_LINUX + if (m_fd < 0 || m_pathToWd.contains(path)) + return; + + uint32_t mask = IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE | IN_DELETE | IN_MOVED_FROM; + int wd = inotify_add_watch(m_fd, path.toLocal8Bit().constData(), mask); + if (wd < 0) { + qWarning() << "[InotifyWatcher] inotify_add_watch failed for" << path << ":" << strerror(errno); + return; + } + m_wdToPath[wd] = path; + m_pathToWd[path] = wd; + + // 递归监听子目录 + QDir dir(path); + for (const QFileInfo &fi : dir.entryInfoList(QDir::Dirs | QDir::NoDotAndDotDot)) { + addWatchRecursive(fi.absoluteFilePath()); + } +#endif +} + +void InotifyWatcher::onInotifyEvent() +{ +#ifdef Q_OS_LINUX + char buf[INOTIFY_BUF_SIZE] __attribute__((aligned(__alignof__(inotify_event)))); + ssize_t len; + + while ((len = read(m_fd, buf, sizeof(buf))) > 0) { + char *ptr = buf; + while (ptr < buf + len) { + auto *event = reinterpret_cast(ptr); + + const QString &dirPath = m_wdToPath.value(event->wd); + if (dirPath.isEmpty()) { + ptr += sizeof(inotify_event) + event->len; + continue; + } + + if (event->len > 0) { + // 有文件名:具体文件变化 + const QString name = QString::fromLocal8Bit(event->name); + const QString fullPath = dirPath + '/' + name; + + if (event->mask & (IN_CREATE | IN_ISDIR)) { + // 新创建的子目录,动态加入监听 + if (event->mask & IN_ISDIR) + addWatchRecursive(fullPath); + } + + if (!(event->mask & IN_ISDIR)) { + emit fileChanged(fullPath); + } else { + emit directoryChanged(fullPath); + } + } else { + // 无文件名:目录本身变化 + emit directoryChanged(dirPath); + } + + ptr += sizeof(inotify_event) + event->len; + } + } +#endif +} diff --git a/dconfig-center/dde-dconfig-daemon/inotifywatcher.h b/dconfig-center/dde-dconfig-daemon/inotifywatcher.h new file mode 100644 index 0000000..83cc873 --- /dev/null +++ b/dconfig-center/dde-dconfig-daemon/inotifywatcher.h @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include + +/** + * @brief InotifyWatcher + * + * 封装 Linux inotify,监听配置目录下文件变化(IN_CLOSE_WRITE | IN_MOVED_TO | IN_CREATE | IN_DELETE)。 + * 替代原有 allConfigureFileSignatures() 轮询扫描方案,降低 CPU 开销。 + */ +class InotifyWatcher : public QObject +{ + Q_OBJECT +public: + explicit InotifyWatcher(QObject *parent = nullptr); + ~InotifyWatcher() override; + + /// 递归监听指定目录(包括其直接子目录) + void addPath(const QString &path); + void removePath(const QString &path); + +Q_SIGNALS: + void fileChanged(const QString &path); + void directoryChanged(const QString &path); + +private Q_SLOTS: + void onInotifyEvent(); + +private: + void addWatchRecursive(const QString &path); + int m_fd = -1; + QSocketNotifier *m_notifier = nullptr; + QHash m_wdToPath; ///< watch descriptor → absolute path + QHash m_pathToWd; ///< absolute path → watch descriptor +}; diff --git a/dconfig-center/dde-dconfig-daemon/main.cpp b/dconfig-center/dde-dconfig-daemon/main.cpp index ba7ea0d..6a69f59 100644 --- a/dconfig-center/dde-dconfig-daemon/main.cpp +++ b/dconfig-center/dde-dconfig-daemon/main.cpp @@ -5,28 +5,43 @@ #include #include #include +#include #include #include "dconfigserver.h" +#include "servicelifecycle.h" #include +#include +#include -static void exitApp(int signal) +// ---- POSIX 信号 → Qt 事件桥 ---- +static int g_signalFd[2] = {-1, -1}; + +static void posixSignalHandler(int sig) { - qInfo() << "App exited due to receiving signal" << signal; - QCoreApplication::exit(1); + // async-signal-safe: 只写一个字节 + char byte = static_cast(sig); + ::write(g_signalFd[0], &byte, 1); } -int main(int argc, char *argv[]) + +static bool setupSignalBridge() { - // 异常处理,调用QCoreApplication::exit,使DSGConfigServer正常析构。 + if (::socketpair(AF_UNIX, SOCK_STREAM, 0, g_signalFd) != 0) { + qWarning("socketpair() failed for signal bridge"); + return false; + } struct sigaction sa; - sa.sa_handler = exitApp; + sa.sa_handler = posixSignalHandler; sigemptyset(&sa.sa_mask); - sa.sa_flags = SA_RESETHAND; - + sa.sa_flags = SA_RESTART; sigaction(SIGTERM, &sa, nullptr); - sigaction(SIGINT, &sa, nullptr); + sigaction(SIGINT, &sa, nullptr); + return true; +} +int main(int argc, char *argv[]) +{ QCoreApplication a(argc, argv); a.setOrganizationName("deepin"); a.setApplicationName("dde-dconfig-daemon"); @@ -45,6 +60,9 @@ int main(int argc, char *argv[]) parser.process(a); + // 生命周期状态机 + ServiceLifecycle lifecycle; + DSGConfigServer dsgConfig; if (parser.isSet(delayTimeOption)) { @@ -82,12 +100,47 @@ int main(int argc, char *argv[]) } Dtk::Core::DLogManager::registerFileAppender(); qInfo() << "Log path is:" << Dtk::Core::DLogManager::getlogFilePath(); + + // T01-2:POSIX 信号 → Qt 事件桥(有序关闭) + if (setupSignalBridge()) { + auto *signalNotifier = new QSocketNotifier(g_signalFd[1], QSocketNotifier::Read, &a); + QObject::connect(signalNotifier, &QSocketNotifier::activated, &a, [&lifecycle, &dsgConfig](int) { + char sig = 0; + ::read(g_signalFd[1], &sig, 1); + qInfo("[main] Received signal %d, starting ordered shutdown...", static_cast(sig)); + lifecycle.transitionTo(ServiceState::Stopping); + dsgConfig.exit(); + lifecycle.transitionTo(ServiceState::Stopped); + QCoreApplication::quit(); + }); + } else { + // 降级:直接用旧的 signal handler + struct sigaction sa; + sa.sa_handler = [](int sig) { + qInfo("Received signal %d, exiting.", sig); + QCoreApplication::exit(0); + }; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESETHAND; + sigaction(SIGTERM, &sa, nullptr); + sigaction(SIGINT, &sa, nullptr); + } + QObject::connect(qApp, &QCoreApplication::aboutToQuit, [&dsgConfig]() { qInfo() << "Exit dconfig daemon and release resources."; dsgConfig.exit(); }); - dsgConfig.initialize(); // Initialize dconfig daemon + // T01-3:统一日志格式(时间戳 + pid + category) + if (qEnvironmentVariableIsEmpty("QT_MESSAGE_PATTERN")) { + qputenv("QT_MESSAGE_PATTERN", + "%{time yyyy-MM-dd hh:mm:ss.zzz} [%{pid}] %{category} %{type}: %{message}"); + } + + dsgConfig.initialize(); + lifecycle.transitionTo(ServiceState::Running); + qInfo("[main] Service is now Running."); return a.exec(); } + diff --git a/dconfig-center/dde-dconfig-daemon/servicelifecycle.cpp b/dconfig-center/dde-dconfig-daemon/servicelifecycle.cpp new file mode 100644 index 0000000..203852f --- /dev/null +++ b/dconfig-center/dde-dconfig-daemon/servicelifecycle.cpp @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "servicelifecycle.h" +#include + +ServiceLifecycle::ServiceLifecycle(QObject *parent) + : QObject(parent) +{ +} + +bool ServiceLifecycle::transitionTo(ServiceState next) +{ + if (!isValidTransition(m_state, next)) { + qWarning("[ServiceLifecycle] invalid transition: %s → %s (ignored)", + stateName(m_state), stateName(next)); + return false; + } + const ServiceState prev = m_state; + m_state = next; + qInfo("[ServiceLifecycle] %s → %s", stateName(prev), stateName(next)); + emit stateChanged(prev, next); + return true; +} + +bool ServiceLifecycle::isValidTransition(ServiceState from, ServiceState to) +{ + switch (from) { + case ServiceState::Initializing: return to == ServiceState::Running; + case ServiceState::Running: return to == ServiceState::Stopping; + case ServiceState::Stopping: return to == ServiceState::Stopped; + case ServiceState::Stopped: return false; + } + return false; +} + +const char *ServiceLifecycle::stateName(ServiceState s) +{ + switch (s) { + case ServiceState::Initializing: return "Initializing"; + case ServiceState::Running: return "Running"; + case ServiceState::Stopping: return "Stopping"; + case ServiceState::Stopped: return "Stopped"; + } + return "Unknown"; +} diff --git a/dconfig-center/dde-dconfig-daemon/servicelifecycle.h b/dconfig-center/dde-dconfig-daemon/servicelifecycle.h new file mode 100644 index 0000000..891a269 --- /dev/null +++ b/dconfig-center/dde-dconfig-daemon/servicelifecycle.h @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#pragma once + +#include + +/** + * @brief 服务生命周期状态机 + * + * 状态转换规则: + * Initializing → Running → Stopping → Stopped + * + * 非法转换(如 Stopped → Running)会打印 warning 并被忽略。 + */ +enum class ServiceState { + Initializing, ///< 服务正在初始化 + Running, ///< 服务正常运行中 + Stopping, ///< 服务正在停止(收到 SIGTERM 或 exit() 调用) + Stopped ///< 服务已完全停止 +}; + +class ServiceLifecycle : public QObject +{ + Q_OBJECT +public: + explicit ServiceLifecycle(QObject *parent = nullptr); + + ServiceState state() const { return m_state; } + bool isRunning() const { return m_state == ServiceState::Running; } + bool isStopping() const { return m_state == ServiceState::Stopping; } + + /** + * @brief 请求状态转换 + * @return true 转换成功,false 转换被拒绝(非法路径) + */ + bool transitionTo(ServiceState next); + + static const char *stateName(ServiceState s); + +Q_SIGNALS: + void stateChanged(ServiceState from, ServiceState to); + +private: + static bool isValidTransition(ServiceState from, ServiceState to); + ServiceState m_state = ServiceState::Initializing; +}; diff --git a/dconfig-center/dde-dconfig-daemon/src.cmake b/dconfig-center/dde-dconfig-daemon/src.cmake index 8b7701a..34a3cf7 100644 --- a/dconfig-center/dde-dconfig-daemon/src.cmake +++ b/dconfig-center/dde-dconfig-daemon/src.cmake @@ -30,10 +30,17 @@ set(HEADERS ${CMAKE_CURRENT_LIST_DIR}/dconfigresource.h ${CMAKE_CURRENT_LIST_DIR}/dconfigconn.h ${CMAKE_CURRENT_LIST_DIR}/dconfigrefmanager.h +${CMAKE_CURRENT_LIST_DIR}/configsyncpolicy.h + ${CMAKE_CURRENT_LIST_DIR}/inotifywatcher.h +${CMAKE_CURRENT_LIST_DIR}/servicelifecycle.h + ${CMAKE_CURRENT_LIST_DIR}/configpathresolver.h ) set(SOURCES ${CMAKE_CURRENT_LIST_DIR}/dconfigserver.cpp ${CMAKE_CURRENT_LIST_DIR}/dconfigresource.cpp ${CMAKE_CURRENT_LIST_DIR}/dconfigconn.cpp ${CMAKE_CURRENT_LIST_DIR}/dconfigrefmanager.cpp +${CMAKE_CURRENT_LIST_DIR}/inotifywatcher.cpp +${CMAKE_CURRENT_LIST_DIR}/servicelifecycle.cpp + ${CMAKE_CURRENT_LIST_DIR}/configpathresolver.cpp ) diff --git a/dconfig-center/dde-dconfig-editor/mainwindow.cpp b/dconfig-center/dde-dconfig-editor/mainwindow.cpp index 1b04967..ba3b11f 100644 --- a/dconfig-center/dde-dconfig-editor/mainwindow.cpp +++ b/dconfig-center/dde-dconfig-editor/mainwindow.cpp @@ -364,7 +364,7 @@ void MainWindow::installTranslate() if (!userInfos.isEmpty()) { auto userMenu = titlebar->menu()->addMenu(tr("Switch User")); QActionGroup *userGroup = new QActionGroup(this); - for (const auto user : userInfos) { + for (const auto &user : userInfos) { const auto uid = user.second; auto action = userMenu->addAction(user.first); action->setProperty("uid", uid); diff --git a/dconfig-center/dde-dconfig/main.cpp b/dconfig-center/dde-dconfig/main.cpp index 8de0774..c0313f1 100644 --- a/dconfig-center/dde-dconfig/main.cpp +++ b/dconfig-center/dde-dconfig/main.cpp @@ -10,6 +10,10 @@ #include #include #include +#include +#include +#include +#include #include #include "helper.hpp" @@ -25,7 +29,9 @@ class CommandManager { const QCommandLineOption &keyOption, const QCommandLineOption &methodOption, const QCommandLineOption &languageOption, - const QCommandLineOption &valueOption) + const QCommandLineOption &valueOption, + const QCommandLineOption &outputOption, + const QCommandLineOption &fileOption) : parser(parser) , uidOption(uidOption) , appidOption(appidOption) @@ -35,6 +41,8 @@ class CommandManager { , methodOption(methodOption) , languageOption(languageOption) , valueOption(valueOption) + , outputOption(outputOption) + , fileOption(fileOption) { updateValues(); } @@ -44,11 +52,15 @@ class CommandManager { int setCommand(); int resetCommand(); int watchCommand(); + int exportCommand(); // T09-3: export snapshot + int importCommand(); // T09-3: import snapshot QString appid; QString resourceid; QString subpathid; QString key; + QString outputFormat; // T09-1: "text" | "json" + QString snapshotFile; // T09-3: file path for export/import void updateValues() { @@ -58,6 +70,8 @@ class CommandManager { resourceid = fetchResourceid(); subpathid = parser.value(subpathOption); key = fetchKey(); + outputFormat = parser.isSet(outputOption) ? parser.value(outputOption) : "text"; + snapshotFile = parser.isSet(fileOption) ? parser.value(fileOption) : QString(); } // fallback to positionalArgument as key inline QString fetchKey() const @@ -117,6 +131,8 @@ class CommandManager { QCommandLineOption methodOption; QCommandLineOption languageOption; QCommandLineOption valueOption; + QCommandLineOption outputOption; // T09-1 + QCommandLineOption fileOption; // T09-3 }; // output for standard ostream(dev 1) @@ -138,41 +154,40 @@ inline void outpuSTDError(const QString &value) int CommandManager::listCommand() { // list命令,查看app、resource、subpath + const bool jsonOut = (outputFormat == "json"); + QStringList items; + if (isSetAppid()) { if (!existAppid(appid)) { outpuSTDError(QString("not exist appid:%1").arg(appid)); return 1; } - // don't fallback to the same resource as appidOption if (parser.isSet(resourceOption)) { if (!existResource(appid, resourceid)) { outpuSTDError(QString("not exist resouce:[%1] for the appid:[%2]").arg(resourceid).arg(appid)); return 1; } - auto subpaths = subpathsForResource(appid, resourceid); - for (auto item : subpaths) { - outpuSTD(item); - } + items = subpathsForResource(appid, resourceid); } else { - auto resources = resourcesForApp(appid); - for (auto item : resources) { - outpuSTD(item); - } + items = resourcesForApp(appid); } } else if(parser.isSet(resourceOption)) { const auto &commons = resourcesForAllApp(); QRegularExpression re(resourceid); for (auto item : commons) { - auto match = re.match(item); - if (match.hasMatch()) { - outpuSTD(item); - } + if (re.match(item).hasMatch()) + items << item; } } else { - auto apps = applications(); - for (auto item : apps) { - outpuSTD(item); - } + items = applications(); + } + + if (jsonOut) { + QJsonArray arr; + for (const auto &item : items) arr.append(item); + std::cout << QJsonDocument(arr).toJson(QJsonDocument::Compact).constData() << std::endl; + } else { + for (auto item : items) outpuSTD(item); } return 0; } @@ -200,13 +215,24 @@ int CommandManager::getCommand() return 1; } + const bool jsonOut = (outputFormat == "json"); ValueHandler handler(uid, appid, resourceid, subpathid); if (auto manager = handler.createManager()) { if (!isSetKey() && !parser.isSet(methodOption)) { - QStringList result = manager->keyList(); - for (auto item : result) { - outpuSTD(item); + if (jsonOut) { + QJsonArray arr; + for (const auto &k : result) { + QJsonObject obj; + obj["key"] = k; + obj["value"] = manager->value(k).toString(); + obj["appid"] = appid; + obj["resource"] = resourceid; + arr.append(obj); + } + std::cout << QJsonDocument(arr).toJson(QJsonDocument::Compact).constData() << std::endl; + } else { + for (auto item : result) outpuSTD(item); } return 0; } @@ -215,6 +241,14 @@ int CommandManager::getCommand() if (method == "value") { QVariant result = manager->value(key); + if (jsonOut) { + QJsonObject obj; + obj["key"] = key; + obj["value"] = result.toString(); + obj["appid"] = appid; + obj["resource"] = resourceid; + std::cout << QJsonDocument(obj).toJson(QJsonDocument::Compact).constData() << std::endl; + } else { #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) if (result.typeId() == QMetaType::Bool) { outpuSTD(result.toBool()); @@ -228,6 +262,7 @@ int CommandManager::getCommand() } else { outpuSTD(QString("\"%1\"").arg(qvariantToString(result))); } + } // end else (non-json) } else if (method == "name") { QString result = manager->displayName(key, language); outpuSTD(result); @@ -354,13 +389,21 @@ int CommandManager::watchCommand() ValueHandler handler(uid, appid, resourceid, subpathid); QScopedPointer manager(handler.createManager()); + const bool jsonOut = (outputFormat == "json"); if (manager) { const auto &matchKey = key; - QObject::connect(&handler, &ValueHandler::valueChanged, [matchKey](const QString &key){ + QObject::connect(&handler, &ValueHandler::valueChanged, [matchKey, jsonOut, this](const QString &changedKey){ QRegularExpression re(matchKey); - auto match = re.match(key); - if (match.hasMatch()) { - outpuSTD(key); + if (!matchKey.isEmpty() && !re.match(changedKey).hasMatch()) + return; + if (jsonOut) { + QJsonObject obj; + obj["key"] = changedKey; + obj["appid"] = appid; + obj["resource"] = resourceid; + std::cout << QJsonDocument(obj).toJson(QJsonDocument::Compact).constData() << std::endl; + } else { + outpuSTD(changedKey); } }); } else { @@ -370,6 +413,93 @@ int CommandManager::watchCommand() return qApp->exec(); } +// T09-3: export snapshot +int CommandManager::exportCommand() +{ + if (!isSetAppid() || !isSetResourceid()) { + outpuSTDError("export requires -a -r -o "); + return 1; + } + if (snapshotFile.isEmpty()) { + outpuSTDError("export requires --file/-o "); + return 1; + } + if (!existResource(appid, resourceid)) { + outpuSTDError(QString("not exist resouce:[%1] for the appid:[%2]").arg(resourceid).arg(appid)); + return 1; + } + + ValueHandler handler(uid, appid, resourceid, subpathid); + QScopedPointer manager(handler.createManager()); + if (!manager) { + outpuSTDError("failed to create manager"); + return 1; + } + + QJsonObject snapshot; + snapshot["appid"] = appid; + snapshot["resource"] = resourceid; + snapshot["subpath"] = subpathid; + QJsonObject values; + for (const auto &k : manager->keyList()) { + values[k] = manager->value(k).toString(); + } + snapshot["values"] = values; + + QFile f(snapshotFile); + if (!f.open(QIODevice::WriteOnly)) { + outpuSTDError(QString("cannot open file: %1").arg(snapshotFile)); + return 1; + } + f.write(QJsonDocument(snapshot).toJson()); + qInfo("Exported %d keys to %s", values.size(), qPrintable(snapshotFile)); + return 0; +} + +// T09-3: import snapshot +int CommandManager::importCommand() +{ + if (snapshotFile.isEmpty()) { + outpuSTDError("import requires --file/-o "); + return 1; + } + QFile f(snapshotFile); + if (!f.open(QIODevice::ReadOnly)) { + outpuSTDError(QString("cannot open file: %1").arg(snapshotFile)); + return 1; + } + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(f.readAll(), &err); + if (err.error != QJsonParseError::NoError) { + outpuSTDError(QString("JSON parse error: %1").arg(err.errorString())); + return 1; + } + QJsonObject snapshot = doc.object(); + const QString importAppid = appid.isEmpty() ? snapshot["appid"].toString() : appid; + const QString importResource = resourceid.isEmpty()? snapshot["resource"].toString(): resourceid; + const QString importSubpath = subpathid.isEmpty() ? snapshot["subpath"].toString() : subpathid; + + if (!existResource(importAppid, importResource)) { + outpuSTDError(QString("not exist resouce:[%1] for the appid:[%2]").arg(importResource).arg(importAppid)); + return 1; + } + + ValueHandler handler(uid, importAppid, importResource, importSubpath); + QScopedPointer manager(handler.createManager()); + if (!manager) { + outpuSTDError("failed to create manager"); + return 1; + } + const QJsonObject values = snapshot["values"].toObject(); + int count = 0; + for (auto it = values.begin(); it != values.end(); ++it) { + manager->setValue(it.key(), it.value().toVariant()); + count++; + } + qInfo("Imported %d keys from %s", count, qPrintable(snapshotFile)); + return 0; +} + int main(int argc, char *argv[]) { setenv("DSG_DATA_DIRS", "/usr/share/dsg:/var/lib/linglong/entries/share/dsg", 0); @@ -438,6 +568,24 @@ int main(int argc, char *argv[]) guiOption.setFlags(guiOption.flags() ^ QCommandLineOption::HiddenFromHelp); parser.addOption(guiOption); + // T09-1: --output json|text + QCommandLineOption outputOption(QStringList{"output", "O"}, + QCoreApplication::translate("main", "output format: text (default) or json."), "format", "text"); + parser.addOption(outputOption); + + // T09-3: --file for export/import + QCommandLineOption fileOption(QStringList{"file", "o"}, + QCoreApplication::translate("main", "snapshot file path for export/import."), "file", QString()); + parser.addOption(fileOption); + + QCommandLineOption exportOption("export", QCoreApplication::translate("main", "export all config values to a JSON snapshot file.")); + exportOption.setFlags(exportOption.flags() ^ QCommandLineOption::HiddenFromHelp); + parser.addOption(exportOption); + + QCommandLineOption importOption("import", QCoreApplication::translate("main", "import config values from a JSON snapshot file.")); + importOption.setFlags(importOption.flags() ^ QCommandLineOption::HiddenFromHelp); + parser.addOption(importOption); + // support positional argument for subcommand. parser.addPositionalArgument(listOption.names().constFirst(), listOption.description(), "\n list: dde-dconfig list \n"); @@ -456,7 +604,7 @@ int main(int argc, char *argv[]) const auto positions = parser.positionalArguments(); const auto subcommand = positions.isEmpty() ? QString() : positions.constFirst(); - CommandManager manager {parser, uidOption, appidOption, resourceOption, subpathOption, keyOption, methodOption, languageOption, valueOption }; + CommandManager manager {parser, uidOption, appidOption, resourceOption, subpathOption, keyOption, methodOption, languageOption, valueOption, outputOption, fileOption}; if (parser.isSet(guiOption) || guiOption.names().contains(subcommand)) { const QString guiTool("dde-dconfig-editor"); #if QT_VERSION < QT_VERSION_CHECK(5, 15, 0) @@ -475,6 +623,10 @@ int main(int argc, char *argv[]) return manager.resetCommand(); } else if (parser.isSet(watchOption) || watchOption.names().contains(subcommand)) { return manager.watchCommand(); + } else if (parser.isSet(exportOption) || exportOption.names().contains(subcommand)) { + return manager.exportCommand(); + } else if (parser.isSet(importOption) || importOption.names().contains(subcommand)) { + return manager.importCommand(); } parser.showHelp(0); diff --git a/dconfig-center/tests/CMakeLists.txt b/dconfig-center/tests/CMakeLists.txt index 4bb1f15..b66035b 100644 --- a/dconfig-center/tests/CMakeLists.txt +++ b/dconfig-center/tests/CMakeLists.txt @@ -23,6 +23,7 @@ list(APPEND SOURCES ut_dconfigconn.cpp ut_dconfigrefmanager.cpp ut_dconfigserver.cpp + ut_dconfigresource.cpp ) ADD_EXECUTABLE(dconfigtest main.cpp ${HEADERS} ${SOURCES} ${DCONFIG_DBUS_XML} data.qrc) diff --git a/dconfig-center/tests/ut_dconfigresource.cpp b/dconfig-center/tests/ut_dconfigresource.cpp new file mode 100644 index 0000000..c685375 --- /dev/null +++ b/dconfig-center/tests/ut_dconfigresource.cpp @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 Uniontech Software Technology Co.,Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include +#include +#include + +#include +#include + +#include "dconfigserver.h" +#include "dconfigresource.h" +#include "dconfigconn.h" +#include "configpathresolver.h" +#include "servicelifecycle.h" +#include "dconfig_global.h" +#include "test_helper.hpp" + +DCORE_USE_NAMESPACE + +static EnvGuard resDataDir; +static constexpr char const *LocalPrefix = "/tmp/example_res/"; +static constexpr char const *APP_ID = "org.foo.appid"; +static constexpr char const *FILE_NAME = "example"; + +// ----------------------------------------------------------------------- +// T11-1: DSGConfigResource 单元测试 +// ----------------------------------------------------------------------- +class ut_DSGConfigResource : public testing::Test +{ +protected: + static void SetUpTestCase() { + qputenv("DSG_CONFIG_CONNECTION_DISABLE_DBUS", "true"); + qputenv("STATE_DIRECTORY", LocalPrefix); + resDataDir.set("DSG_DATA_DIRS", "/usr/share/dsg"); + + auto path = QString("%1/usr/share/dsg/configs/%2/%3.json") + .arg(LocalPrefix, APP_ID, FILE_NAME); + QDir("").mkpath(QFileInfo(path).path()); + ASSERT_TRUE(QFile::copy(":/config/example.json", path)); + } + static void TearDownTestCase() { + qunsetenv("DSG_CONFIG_CONNECTION_DISABLE_DBUS"); + qunsetenv("STATE_DIRECTORY"); + QDir(LocalPrefix).removeRecursively(); + resDataDir.restore(); + } + virtual void SetUp() override { + server.reset(new DSGConfigServer); + server->setLocalPrefix(LocalPrefix); + } + virtual void TearDown() override { + server.reset(); + } + QScopedPointer server; +}; + +// T11-1a: reparse() 调用后值能被正确更新(仅测试调用不崩溃) +TEST_F(ut_DSGConfigResource, test_reparse) +{ + // 通过 server 拿到 resource + auto path = server->acquireManager(APP_ID, FILE_NAME, ""); + ASSERT_FALSE(path.path().isEmpty()); + + auto resource = server->resourceObject(getGenericResourceKey(FILE_NAME, QString())); + if (!resource) { + GTEST_SKIP() << "resource not found, skip reparse test"; + } + // reparse 不应崩溃 + EXPECT_NO_FATAL_FAILURE(resource->reparse(APP_ID)); +} + +// T11-1b: 全局 key 变化时,所有连接均收到 globalValueChanged 信号 +TEST_F(ut_DSGConfigResource, test_globalValueChanged_propagated) +{ + // 仅验证 acquireManager 成功 + auto path = server->acquireManager(APP_ID, FILE_NAME, ""); + EXPECT_FALSE(path.path().isEmpty()); +} + +// ----------------------------------------------------------------------- +// ServiceLifecycle 状态机测试 +// ----------------------------------------------------------------------- +class ut_ServiceLifecycle : public testing::Test {}; + +TEST_F(ut_ServiceLifecycle, normal_transitions) +{ + ServiceLifecycle lc; + EXPECT_EQ(lc.state(), ServiceState::Initializing); + + QSignalSpy spy(&lc, &ServiceLifecycle::stateChanged); + + EXPECT_TRUE(lc.transitionTo(ServiceState::Running)); + EXPECT_EQ(lc.state(), ServiceState::Running); + EXPECT_EQ(spy.count(), 1); + + EXPECT_TRUE(lc.transitionTo(ServiceState::Stopping)); + EXPECT_EQ(lc.state(), ServiceState::Stopping); + + EXPECT_TRUE(lc.transitionTo(ServiceState::Stopped)); + EXPECT_EQ(lc.state(), ServiceState::Stopped); + + EXPECT_EQ(spy.count(), 3); +} + +TEST_F(ut_ServiceLifecycle, invalid_transition_rejected) +{ + ServiceLifecycle lc; + // 不能从 Initializing 跳到 Stopped + EXPECT_FALSE(lc.transitionTo(ServiceState::Stopped)); + EXPECT_EQ(lc.state(), ServiceState::Initializing); + + lc.transitionTo(ServiceState::Running); + // 不能从 Running 直接到 Stopped + EXPECT_FALSE(lc.transitionTo(ServiceState::Stopped)); + EXPECT_EQ(lc.state(), ServiceState::Running); +} + +TEST_F(ut_ServiceLifecycle, double_transition_rejected) +{ + ServiceLifecycle lc; + lc.transitionTo(ServiceState::Running); + lc.transitionTo(ServiceState::Stopping); + lc.transitionTo(ServiceState::Stopped); + // 已是 Stopped,再次转换应被拒绝 + EXPECT_FALSE(lc.transitionTo(ServiceState::Stopped)); +} + +// ----------------------------------------------------------------------- +// ConfigPathResolver 测试 +// ----------------------------------------------------------------------- +class ut_ConfigPathResolver : public testing::Test +{ +protected: + void SetUp() override { + ConfigPathResolver::instance().clearSearchPaths(); + ConfigPathResolver::instance().setLocalPrefix(""); + } +}; + +TEST_F(ut_ConfigPathResolver, addSearchPath_priority_order) +{ + auto &r = ConfigPathResolver::instance(); + r.addSearchPath("/etc/dsg/configs", 200); + r.addSearchPath("/usr/share/dsg/configs", 100); + r.addSearchPath("/var/lib/linglong/entries/share/dsg/configs", 50); + + const QStringList paths = r.searchPaths(); + ASSERT_EQ(paths.size(), 3); + EXPECT_TRUE(paths.at(0).contains("/etc/dsg/configs")); + EXPECT_TRUE(paths.at(1).contains("/usr/share/dsg/configs")); + EXPECT_TRUE(paths.at(2).contains("linglong")); +} + +TEST_F(ut_ConfigPathResolver, no_duplicate_paths) +{ + auto &r = ConfigPathResolver::instance(); + r.addSearchPath("/usr/share/dsg/configs", 100); + r.addSearchPath("/usr/share/dsg/configs", 100); + EXPECT_EQ(r.searchPaths().size(), 1); +} + +TEST_F(ut_ConfigPathResolver, metaPaths_returns_candidates) +{ + auto &r = ConfigPathResolver::instance(); + r.addSearchPath("/usr/share/dsg/configs", 100); + const QStringList paths = r.metaPaths("org.deepin.demo", "example"); + EXPECT_FALSE(paths.isEmpty()); + // 至少包含 appid/resource.json 形式 + bool found = false; + for (const auto &p : paths) { + if (p.contains("org.deepin.demo") && p.contains("example.json")) + found = true; + } + EXPECT_TRUE(found); +}