diff --git a/src/plugin-qt/wallpapercache/CMakeLists.txt b/src/plugin-qt/wallpapercache/CMakeLists.txt index 96c5b3c1..e5275e21 100644 --- a/src/plugin-qt/wallpapercache/CMakeLists.txt +++ b/src/plugin-qt/wallpapercache/CMakeLists.txt @@ -13,6 +13,7 @@ set(CMAKE_AUTORCC ON) include(GNUInstallDirs) file(GLOB_RECURSE SRCS "*.h" "*.cpp") +list(FILTER SRCS EXCLUDE REGEX "/tests/") find_package(PkgConfig REQUIRED) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Core Gui DBus) diff --git a/src/plugin-qt/wallpapercache/cachedwallpaper.cpp b/src/plugin-qt/wallpapercache/cachedwallpaper.cpp index 31251f44..a7e7bb32 100644 --- a/src/plugin-qt/wallpapercache/cachedwallpaper.cpp +++ b/src/plugin-qt/wallpapercache/cachedwallpaper.cpp @@ -37,18 +37,13 @@ QStringList CachedWallpaper::getCachedImagePaths(const QString &originalPath, co QString pathMd5 = ScaleImageThread::pathMd5(originalPath, isMd5Path); - if (m_cachedImages.contains(pathMd5)) { - const QMap &map = m_cachedImages[pathMd5]; - for (const QSize &size : sizes) { - QString strSize = ScaleImageThread::sizeToString(size); - if (map.contains(strSize)) { - results.append(map[strSize]); - } else { - noCachedSizes.append(size); - } + for (const QSize &size : sizes) { + const QStringList cached = findCachedImagePaths(pathMd5, {size}); + if (cached.isEmpty()) { + noCachedSizes.append(size); + } else { + results.append(cached); } - } else { - noCachedSizes = sizes; } if (!noCachedSizes.isEmpty()) { @@ -59,6 +54,23 @@ QStringList CachedWallpaper::getCachedImagePaths(const QString &originalPath, co return results; } +QStringList CachedWallpaper::findCachedImagePaths(const QString &pathMd5, const QList &sizes) const +{ + QStringList results; + const auto it = m_cachedImages.constFind(pathMd5); + if (it == m_cachedImages.constEnd()) + return results; + + for (const QSize &size : sizes) { + const QString path = it->value(ScaleImageThread::sizeToString(size)); + const QFileInfo info(path); + if (path.isEmpty() || !info.isFile() || !info.isReadable() || info.size() == 0) + return {}; + results.append(path); + } + return results; +} + void CachedWallpaper::cacheImage(const QString &originalPathMd5, const QString &size, const QString &processedPath) { qDebug() << "cache Image:" << processedPath; diff --git a/src/plugin-qt/wallpapercache/cachedwallpaper.h b/src/plugin-qt/wallpapercache/cachedwallpaper.h index 6bc97fea..383b0ba0 100644 --- a/src/plugin-qt/wallpapercache/cachedwallpaper.h +++ b/src/plugin-qt/wallpapercache/cachedwallpaper.h @@ -12,7 +12,10 @@ #include // Shared cache path constants -inline const QString kWallpaperCacheDir = QStringLiteral("/var/cache/dde-wallpaper-cache"); +#ifndef WALLPAPER_CACHE_DIR +#define WALLPAPER_CACHE_DIR "/var/cache/dde-wallpaper-cache" +#endif +inline const QString kWallpaperCacheDir = QStringLiteral(WALLPAPER_CACHE_DIR); inline const QString kBlurCacheDir = kWallpaperCacheDir + QStringLiteral("/blur"); class CachedWallpaper : public QObject @@ -29,6 +32,7 @@ class CachedWallpaper : public QObject public: static CachedWallpaper *instance(); QStringList getCachedImagePaths(const QString &originalPath, const QList &sizes, bool isMd5Path = false); + QStringList findCachedImagePaths(const QString &pathMd5, const QList &sizes) const; void cacheImage(const QString &originalPathMd5, const QString &size, const QString &processedPath); // Blur wallpaper interfaces diff --git a/src/plugin-qt/wallpapercache/scaleimagethread.cpp b/src/plugin-qt/wallpapercache/scaleimagethread.cpp index f6089d6a..18387f6a 100644 --- a/src/plugin-qt/wallpapercache/scaleimagethread.cpp +++ b/src/plugin-qt/wallpapercache/scaleimagethread.cpp @@ -97,12 +97,14 @@ bool ScaleImageThread::isIdle() void ScaleImageThread::run() { - while (!m_stop) { + for (;;) { QMutexLocker locker(&m_mutex); - if (m_tasks.isEmpty()) { + while (m_tasks.isEmpty() && !m_stop) { m_waitCondition.wait(&m_mutex); } + if (m_stop) + return; if (!m_tasks.isEmpty()) { TaskData task = m_tasks.head(); @@ -128,15 +130,10 @@ void ScaleImageThread::executeTask(const TaskData &task) QString originalPathMd5 = pathMd5(task.originalPath, task.isMd5Path); QString cachedFilePath = cacheImageToDisk(pixmap, task, originalPathMd5); + // Keep the imported source as a cache entry. Other queued sizes and + // clients receiving it as the immediate fallback may still need it. if (!cachedFilePath.isEmpty()) { Q_EMIT imageScaled(originalPathMd5, sizeToString(task.targetSize), cachedFilePath); - - if (task.isMd5Path) { - QFile file(task.originalPath); - if (file.exists()) { - file.remove(); - } - } } } diff --git a/src/plugin-qt/wallpapercache/tests/CMakeLists.txt b/src/plugin-qt/wallpapercache/tests/CMakeLists.txt index 4c073831..f88e2a9a 100644 --- a/src/plugin-qt/wallpapercache/tests/CMakeLists.txt +++ b/src/plugin-qt/wallpapercache/tests/CMakeLists.txt @@ -23,3 +23,25 @@ target_compile_definitions(test_wallpaper_cache PRIVATE QT_MESSAGELOGCONTEXT) message(STATUS "Test build: test_wallpaper_cache") message(STATUS "Run: sudo ./test_wallpaper_cache [wallpaper_path]") + +include(CTest) +if(BUILD_TESTING) + find_package(Qt6 REQUIRED COMPONENTS Test) + find_package(PkgConfig REQUIRED) + pkg_check_modules(DtkGui REQUIRED IMPORTED_TARGET dtk6gui) + set(WALLPAPER_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/..") + add_executable(test_wallpaper_cache_unit + test_wallpaper_cache_unit.cpp + ${WALLPAPER_SOURCE_DIR}/wallpapercacheservice.cpp + ${WALLPAPER_SOURCE_DIR}/cachedwallpaper.cpp + ${WALLPAPER_SOURCE_DIR}/wallpapercachemanager.cpp + ${WALLPAPER_SOURCE_DIR}/scaleimagethread.cpp + ${WALLPAPER_SOURCE_DIR}/imageeffectprocessor.cpp + ) + target_include_directories(test_wallpaper_cache_unit PRIVATE ${WALLPAPER_SOURCE_DIR}) + target_compile_definitions(test_wallpaper_cache_unit PRIVATE + WALLPAPER_CACHE_DIR="${CMAKE_CURRENT_BINARY_DIR}/unit-cache") + target_link_libraries(test_wallpaper_cache_unit PRIVATE + Qt6::Core Qt6::DBus Qt6::Gui Qt6::Test PkgConfig::DtkGui) + add_test(NAME wallpaper-cache-unit COMMAND test_wallpaper_cache_unit) +endif() diff --git a/src/plugin-qt/wallpapercache/tests/test_wallpaper_cache_unit.cpp b/src/plugin-qt/wallpapercache/tests/test_wallpaper_cache_unit.cpp new file mode 100644 index 00000000..0282094e --- /dev/null +++ b/src/plugin-qt/wallpapercache/tests/test_wallpaper_cache_unit.cpp @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: 2026 UnionTech Software Technology Co., Ltd. +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "cachedwallpaper.h" +#include "scaleimagethread.h" +#include "wallpapercacheservice.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +class WallpaperCacheTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase() + { + QVERIFY(kWallpaperCacheDir.contains("/unit-cache")); + QVERIFY(QDir().mkpath(kWallpaperCacheDir)); + } + + void repeatedImportReplacesLongerImage() + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString input = dir.filePath("input.bmp"); + const QString key(32, '1'); + WallpaperCacheService service; + QImage large(300, 200, QImage::Format_RGB32); + large.fill(Qt::red); + QVERIFY(large.save(input)); + QFile file(input); + QVERIFY(file.open(QIODevice::ReadOnly)); + const auto first = service.GetProcessedImagePathByFd(QDBusUnixFileDescriptor(file.handle()), key, {}); + QCOMPARE(first.size(), 1); + const qint64 largeSize = QFileInfo(first.first()).size(); + file.close(); + + QImage small(30, 20, QImage::Format_RGB32); + small.fill(Qt::blue); + QVERIFY(small.save(input)); + QVERIFY(file.open(QIODevice::ReadOnly)); + const auto second = service.GetProcessedImagePathByFd(QDBusUnixFileDescriptor(file.handle()), key, {}); + QCOMPARE(second, first); + QCOMPARE(QFileInfo(second.first()).size(), file.size()); + QVERIFY(QFileInfo(second.first()).size() < largeSize); + QCOMPARE(QImage(second.first()).size(), small.size()); + QCOMPARE(QImage(second.first()).pixelColor(0, 0), QColor(Qt::blue)); + } + + void invalidImportPreservesPublishedImage() + { + QTemporaryDir dir; + const QString input = dir.filePath("input.png"); + QImage image(20, 20, QImage::Format_RGB32); + image.fill(Qt::green); + QVERIFY(image.save(input)); + QFile file(input); + QVERIFY(file.open(QIODevice::ReadOnly)); + WallpaperCacheService service; + const QString key(32, '2'); + const auto first = service.GetProcessedImagePathByFd(QDBusUnixFileDescriptor(file.handle()), key, {}); + QCOMPARE(first.size(), 1); + file.close(); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate)); + QCOMPARE(file.write("invalid image"), qint64(13)); + file.close(); + QVERIFY(file.open(QIODevice::ReadOnly)); + QVERIFY(service.GetProcessedImagePathByFd(QDBusUnixFileDescriptor(file.handle()), key, {}).isEmpty()); + QCOMPARE(QImage(first.first()).pixelColor(0, 0), QColor(Qt::green)); + QCOMPARE(QDir(kWallpaperCacheDir).entryList({".fd-import-*"}, QDir::Files | QDir::Hidden).size(), 0); + } + + void descriptorReadFailureLeavesNoImport() + { + const int directoryFd = ::open("/tmp", O_RDONLY | O_DIRECTORY); + QVERIFY(directoryFd >= 0); + QDBusUnixFileDescriptor fd; + fd.giveFileDescriptor(directoryFd); + WallpaperCacheService service; + QVERIFY(service.GetProcessedImagePathByFd(fd, QString(32, '3'), {}).isEmpty()); + QVERIFY(!QFile::exists(kWallpaperCacheDir + "/" + QString(32, '3'))); + QCOMPARE(QDir(kWallpaperCacheDir).entryList({".fd-import-*"}, QDir::Files | QDir::Hidden).size(), 0); + } + + void rejectInvalidCacheKey() + { + QFile file("/dev/null"); + QVERIFY(file.open(QIODevice::ReadOnly)); + WallpaperCacheService service; + QVERIFY(service.GetProcessedImagePathByFd(QDBusUnixFileDescriptor(file.handle()), "../outside", {}).isEmpty()); + } + + void completeCacheHitDoesNotReadDescriptor() + { + QTemporaryDir dir; + const QString cached = dir.filePath("cached.png"); + QImage image(40, 30, QImage::Format_RGB32); + image.fill(Qt::yellow); + QVERIFY(image.save(cached)); + const QString key(32, '4'); + CachedWallpaper::instance()->cacheImage(key, "40x30", cached); + QFile input(cached); + QVERIFY(input.open(QIODevice::ReadOnly)); + const QVariantList sizes{QVariant::fromValue(QSize(40, 30))}; + WallpaperCacheService service; + QDBusUnixFileDescriptor fd(input.handle()); + QCOMPARE(service.GetProcessedImagePathByFd(fd, key, sizes), QStringList{cached}); + QCOMPARE(::lseek(fd.fileDescriptor(), 0, SEEK_CUR), off_t(0)); + QCOMPARE(service.GetProcessedImagePathByFdWithBlur(fd, key, sizes, false), QStringList{cached}); + QCOMPARE(::lseek(fd.fileDescriptor(), 0, SEEK_CUR), off_t(0)); + QVERIFY(!QFile::exists(kWallpaperCacheDir + "/" + key + ".png")); + } + + void partialCacheHitStillQueuesMissingSizes() + { + QTemporaryDir dir; + const QString original = dir.filePath("input.png"); + QImage image(80, 60, QImage::Format_RGB32); + image.fill(Qt::cyan); + QVERIFY(image.save(original)); + const QString key(32, '5'); + const QString cached = dir.filePath("cached.png"); + QVERIFY(image.save(cached)); + CachedWallpaper::instance()->cacheImage(key, "40x30", cached); + QFile input(original); + QVERIFY(input.open(QIODevice::ReadOnly)); + QSignalSpy pending(CachedWallpaper::instance(), &CachedWallpaper::needHandleImage); + WallpaperCacheService service; + QCOMPARE(service.GetProcessedImagePathByFd(QDBusUnixFileDescriptor(input.handle()), key, + {QVariant::fromValue(QSize(40, 30)), QVariant::fromValue(QSize(20, 15))}), QStringList{cached}); + QCOMPARE(pending.size(), 1); + QCOMPARE(pending.first().at(1).value>(), QList{QSize(20, 15)}); + QVERIFY(QFile::exists(pending.first().at(0).toString())); + } + + void removedCacheEntryIsRegenerated() + { + QTemporaryDir dir; + const QString missing = dir.filePath("removed.png"); + QImage image(20, 20, QImage::Format_RGB32); + QVERIFY(image.save(missing)); + const QString source = dir.filePath("source.png"); + const QString key = ScaleImageThread::pathMd5(source, false); + CachedWallpaper::instance()->cacheImage(key, "20x20", missing); + QVERIFY(QFile::remove(missing)); + QSignalSpy pending(CachedWallpaper::instance(), &CachedWallpaper::needHandleImage); + QVERIFY(CachedWallpaper::instance()->getCachedImagePaths(source, {QSize(20, 20)}).isEmpty()); + QCOMPARE(pending.size(), 1); + } + + void multipleSizesKeepFallbackSourceReadable() + { + QTemporaryDir dir; + const QString source = dir.filePath(QString(32, '6') + ".png"); + QImage image(200, 150, QImage::Format_RGB32); + image.fill(Qt::magenta); + QVERIFY(image.save(source)); + ScaleImageThread worker; + worker.setCachePath(dir.path()); + QSignalSpy scaled(&worker, &ScaleImageThread::imageScaled); + worker.addTasks(source, {QSize(80, 60), QSize(40, 30)}, true); + QTRY_COMPARE_WITH_TIMEOUT(scaled.size(), 2, 5000); + for (const auto &reply : scaled) { + const QString path = reply.at(2).toString(); + const QImage result(path); + QVERIFY(!result.isNull()); + QCOMPARE(ScaleImageThread::sizeToString(result.size()), reply.at(1).toString()); + } + QVERIFY(!QImage(source).isNull()); + worker.addTasks(source, {QSize(20, 15)}, true); + QTRY_COMPARE_WITH_TIMEOUT(scaled.size(), 3, 5000); + QCOMPARE(QImage(scaled.last().at(2).toString()).size(), QSize(20, 15)); + worker.stopThread(); + QVERIFY(worker.wait(5000)); + } + + void idleWorkerStops() + { + ScaleImageThread worker; + worker.start(); + worker.stopThread(); + QVERIFY(worker.wait(5000)); + } +}; + +QTEST_GUILESS_MAIN(WallpaperCacheTest) +#include "test_wallpaper_cache_unit.moc" diff --git a/src/plugin-qt/wallpapercache/wallpapercacheservice.cpp b/src/plugin-qt/wallpapercache/wallpapercacheservice.cpp index 67dd7f3d..7086d6db 100644 --- a/src/plugin-qt/wallpapercache/wallpapercacheservice.cpp +++ b/src/plugin-qt/wallpapercache/wallpapercacheservice.cpp @@ -11,6 +11,12 @@ #include #include #include +#include +#include +#include + +#include +#include #include @@ -23,6 +29,10 @@ QList WallpaperCacheService::parseSizeArray(const QVariantList &sizeArray { QList sizes; for (const QVariant &variant : sizeArray) { + if (variant.canConvert()) { + sizes.append(variant.toSize()); + continue; + } auto arg = variant.value(); QSize size; arg >> size; @@ -50,13 +60,20 @@ QStringList WallpaperCacheService::GetProcessedImagePaths(const QString &origina QStringList WallpaperCacheService::GetProcessedImagePathByFd(const QDBusUnixFileDescriptor &fd, const QString &imagePathMd5, const QVariantList &sizeArray) { + const QList sizes = parseSizeArray(sizeArray); + if (fd.fileDescriptor() < 0) + return {}; + + const QStringList cached = CachedWallpaper::instance()->findCachedImagePaths(imagePathMd5, sizes); + if (!sizes.isEmpty() && cached.size() == sizes.size()) + return cached; + QString destinationPath = saveImageFromFd(fd, imagePathMd5); if (destinationPath.isEmpty()) { return QStringList(); } qDebug() << "get processed image from origin path:" << destinationPath; - QList sizes = parseSizeArray(sizeArray); QStringList results = CachedWallpaper::instance()->getCachedImagePaths(destinationPath, sizes, true); if (results.isEmpty()) { @@ -89,6 +106,9 @@ QStringList WallpaperCacheService::GetProcessedImageWithBlur(const QString &orig QStringList WallpaperCacheService::GetProcessedImagePathByFdWithBlur(const QDBusUnixFileDescriptor &fd, const QString &imagePathMd5, const QVariantList &sizeArray, bool needBlur) { + if (!needBlur) + return GetProcessedImagePathByFd(fd, imagePathMd5, sizeArray); + QString destinationPath = saveImageFromFd(fd, imagePathMd5); if (destinationPath.isEmpty()) { return QStringList(); @@ -97,15 +117,7 @@ QStringList WallpaperCacheService::GetProcessedImagePathByFdWithBlur(const QDBus qDebug() << "get processed image from origin path:" << destinationPath; QList sizes = parseSizeArray(sizeArray); - if (needBlur) { - return CachedWallpaper::instance()->getProcessedImageWithBlur(destinationPath, sizes, needBlur); - } else { - QStringList results = CachedWallpaper::instance()->getCachedImagePaths(destinationPath, sizes, true); - if (results.isEmpty()) { - results << destinationPath; - } - return results; - } + return CachedWallpaper::instance()->getProcessedImageWithBlur(destinationPath, sizes, true); } QStringList WallpaperCacheService::GetWallpaperListForScreen(const QString &originalPath, const QVariantList &sizeArray, bool needBlur) @@ -174,46 +186,57 @@ void WallpaperCacheService::ClearEffectCache(const QString &effect) QString WallpaperCacheService::saveImageFromFd(const QDBusUnixFileDescriptor &fd, const QString &imagePathMd5) { - int fdi = fd.fileDescriptor(); - if (fdi <= 0) { - return QString(); - } - - QString originPath = kWallpaperCacheDir + "/" + imagePathMd5; - QFile originFile(originPath); - if (!originFile.open(QIODevice::ReadWrite)) { - qWarning() << "Failed to open destination file for writing:" << originPath; - return QString(); - } - - char buffer[4096]; - ssize_t bytesRead; - while ((bytesRead = read(fdi, buffer, sizeof(buffer))) > 0) { - if (originFile.write(buffer, bytesRead) != bytesRead) { - qWarning() << "Failed to write data to destination file."; + const int fdi = fd.fileDescriptor(); + static const QRegularExpression md5Pattern(QStringLiteral("\\A[0-9a-fA-F]{32}\\z")); + if (fdi < 0 || !md5Pattern.match(imagePathMd5).hasMatch()) + return {}; + + if (!QDir().mkpath(kWallpaperCacheDir)) + return {}; + + // Import privately: failed or concurrent copies must never expose a partial + // image under the shared source name. + QTemporaryFile originFile(kWallpaperCacheDir + "/.fd-import-XXXXXX"); + if (!originFile.open()) { + qWarning() << "Failed to create imported image:" << originFile.errorString(); + return {}; + } + // Imported sources are returned over D-Bus and read by other clients. + if (!originFile.setPermissions(QFile::ReadOwner | QFile::WriteOwner | QFile::ReadGroup | QFile::ReadOther)) + return {}; + + char buffer[64 * 1024]; + for (;;) { + const ssize_t bytesRead = read(fdi, buffer, sizeof(buffer)); + if (bytesRead < 0) { + if (errno == EINTR) + continue; + qWarning() << "Error reading image descriptor:" << errno; + return {}; + } + if (bytesRead == 0) break; + if (originFile.write(buffer, bytesRead) != bytesRead) { + qWarning() << "Failed to write imported image:" << originFile.errorString(); + return {}; } } - if (bytesRead < 0) { - qWarning() << "Error reading from file descriptor."; - originFile.close(); - return QString(); - } - originFile.close(); + if (!originFile.flush()) + return {}; - // Detect format using static method to avoid loading entire image - QByteArray format = QImageReader::imageFormat(originPath); + const QByteArray format = QImageReader::imageFormat(originFile.fileName()); if (format.isEmpty()) { - qWarning() << "Failed to detect image format:" << originPath; - QFile::remove(originPath); - return QString(); - } - QString destinationPath = originPath + QString(".%1").arg(QString::fromLatin1(format)); - QFile file(originPath); - if (!file.rename(destinationPath)) { - qWarning() << "originPath rename failed"; - return QString(); - } - + qWarning() << "Failed to detect imported image format"; + return {}; + } + const QString destinationPath = kWallpaperCacheDir + "/" + imagePathMd5 + "." + QString::fromLatin1(format); + // POSIX rename atomically replaces an earlier import, including a longer + // image. Existing readers retain their opened file until they finish. + if (::rename(QFile::encodeName(originFile.fileName()).constData(), + QFile::encodeName(destinationPath).constData()) != 0) { + qWarning() << "Failed to publish imported image:" << errno; + return {}; + } + originFile.setAutoRemove(false); return destinationPath; }