diff --git a/io/CMakeLists.txt b/io/CMakeLists.txt index bef7571..f0194ca 100644 --- a/io/CMakeLists.txt +++ b/io/CMakeLists.txt @@ -21,7 +21,7 @@ add_library(cutefish-framework-io STATIC ${IO_PUBLIC_HEADERS} volumemanager.cpp error.cpp filepath.cpp filesystemurl.cpp fileitem.cpp fileenumerator.cpp operationcontext.cpp localfilesystem.cpp trash.cpp filewatcher.cpp directorylister.cpp directorymodel.cpp task.cpp thumbnailcache.cpp thumbnailprovider.cpp - async.h posix_p.h) + thumbnailers.cpp thumbnailers_p.h async.h posix_p.h) add_library(Cutefish::IO ALIAS cutefish-framework-io) set_target_properties(cutefish-framework-io PROPERTIES AUTOMOC ON POSITION_INDEPENDENT_CODE ON) @@ -41,6 +41,11 @@ if(BUILD_TESTING OR FILEMANAGER_BUILD_TESTS) target_link_libraries(tst_cutefish_io PRIVATE Cutefish::IO Qt6::Test Qt6::Gui) add_test(NAME cutefish-io COMMAND tst_cutefish_io) + add_executable(tst_thumbnailers tests/tst_thumbnailers.cpp) + set_target_properties(tst_thumbnailers PROPERTIES AUTOMOC ON) + target_link_libraries(tst_thumbnailers PRIVATE Cutefish::IO Qt6::Test Qt6::Gui) + add_test(NAME thumbnailers COMMAND tst_thumbnailers) + find_program(DBUS_RUN_SESSION dbus-run-session REQUIRED) add_executable(tst_volumemanager tests/tst_volumemanager.cpp) set_target_properties(tst_volumemanager PROPERTIES AUTOMOC ON) diff --git a/io/tests/tst_thumbnailers.cpp b/io/tests/tst_thumbnailers.cpp new file mode 100644 index 0000000..3dac0b0 --- /dev/null +++ b/io/tests/tst_thumbnailers.cpp @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: 2026 Reion Wong +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "thumbnailcache.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +void writeFile(const QString &path, const QByteArray &data) +{ + QFile file(path); + QVERIFY(file.open(QIODevice::WriteOnly)); + QCOMPARE(file.write(data), data.size()); +} + +QByteArray readFile(const QString &path) +{ + QFile file(path); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); +} + +QString localUrl(const QString &path) +{ + return QUrl::fromLocalFile(path).toString(); +} + +} // namespace + +class ThumbnailersTest : public QObject +{ + Q_OBJECT + + QTemporaryDir m_tools; + QTemporaryDir m_files; + QString m_thumbnailers; + QString m_cache; + + void installThumbnailer(const QString &name, const QByteArray &body) + { + writeFile(m_thumbnailers + u'/' + name, "[Thumbnailer Entry]\n" + body); + } + +private slots: + void initTestCase() + { + QStandardPaths::setTestModeEnabled(true); + QVERIFY(m_tools.isValid()); + QVERIFY(m_files.isValid()); + + m_thumbnailers = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + + QStringLiteral("/thumbnailers"); + m_cache = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + + QStringLiteral("/thumbnails"); + QDir(m_thumbnailers).removeRecursively(); + QDir(m_cache).removeRecursively(); + QVERIFY(QDir().mkpath(m_thumbnailers)); + + QImage source(300, 150, QImage::Format_RGB32); + source.fill(Qt::red); + QVERIFY(source.save(m_tools.filePath("source.png"))); + + // Records its arguments and writes the source image to the last one. + const QString script = m_tools.filePath("thumbnail.sh"); + writeFile(script, "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + m_tools.filePath("arguments").toUtf8() + + "\nfor last; do :; done\ncp " + m_tools.filePath("source.png").toUtf8() + + " \"$last\"\n"); + QVERIFY(QFile::setPermissions(script, QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + + installThumbnailer("record.thumbnailer", + "TryExec=" + script.toUtf8() + "\nExec=" + script.toUtf8() + + " -s %s %u literal%% %o\nMimeType=text/plain;\n"); + installThumbnailer("fail.thumbnailer", "Exec=false %i %o\nMimeType=text/csv;\n"); + installThumbnailer("hang.thumbnailer", "Exec=sh -c \"sleep 63\" sh %i %o\nMimeType=text/x-csrc;\n"); + installThumbnailer("missing.thumbnailer", + "TryExec=/nonexistent/thumbnailer\nExec=/nonexistent/thumbnailer %i %o\n" + "MimeType=application/json;\n"); + } + + void cleanupTestCase() + { + QDir(m_thumbnailers).removeRecursively(); + QDir(m_cache).removeRecursively(); + } + + void runsInstalledThumbnailer() + { + QVERIFY(ThumbnailCache::self()->canThumbnail(QStringLiteral("text/plain"))); + + const QString note = m_files.filePath("a note.txt"); + writeFile(note, "text"); + const Thumbnail thumbnail = ThumbnailCache::self()->requestThumbnail(localUrl(note), QSize(64, 64)); + + // The normal flavour is 128 pixels; the larger output is scaled into it. + QCOMPARE(thumbnail.image.size(), QSize(128, 64)); + QVERIFY(thumbnail.path.startsWith(m_cache + QStringLiteral("/normal/"))); + QVERIFY(QFileInfo::exists(thumbnail.path)); + + const QStringList arguments = + QString::fromUtf8(readFile(m_tools.filePath("arguments"))).split(u'\n', Qt::SkipEmptyParts); + QCOMPARE(arguments.size(), 5); + QCOMPARE(arguments.at(0), QStringLiteral("-s")); + QCOMPARE(arguments.at(1), QStringLiteral("128")); + QCOMPARE(arguments.at(2), QString::fromLatin1(QUrl::fromLocalFile(note).toEncoded())); + QCOMPARE(arguments.at(3), QStringLiteral("literal%")); + QVERIFY(arguments.at(4).endsWith(QStringLiteral("/thumbnail.png"))); + QVERIFY(!arguments.at(4).startsWith(m_cache)); + + // A second request reads the cache instead of running the thumbnailer again. + QFile::remove(m_tools.filePath("arguments")); + const Thumbnail cached = ThumbnailCache::self()->requestThumbnail(localUrl(note), QSize(64, 64)); + QCOMPARE(cached.path, thumbnail.path); + QVERIFY(!QFileInfo::exists(m_tools.filePath("arguments"))); + } + + void imagesStayInProcess() + { + QVERIFY(ThumbnailCache::self()->canThumbnail(QStringLiteral("image/png"))); + + const QString image = m_files.filePath("image.png"); + QImage source(40, 20, QImage::Format_RGB32); + source.fill(Qt::blue); + QVERIFY(source.save(image)); + + QFile::remove(m_tools.filePath("arguments")); + const Thumbnail thumbnail = ThumbnailCache::self()->requestThumbnail(localUrl(image), QSize(64, 64)); + QCOMPARE(thumbnail.image.size(), QSize(128, 64)); + QVERIFY(!QFileInfo::exists(m_tools.filePath("arguments"))); + } + + void failureIsRemembered() + { + QVERIFY(ThumbnailCache::self()->canThumbnail(QStringLiteral("text/csv"))); + + const QString table = m_files.filePath("table.csv"); + writeFile(table, "a,b\n"); + const Thumbnail thumbnail = ThumbnailCache::self()->requestThumbnail(localUrl(table), QSize(64, 64)); + QVERIFY(thumbnail.image.isNull()); + + const QString name = QString::fromLatin1( + QCryptographicHash::hash(QUrl::fromLocalFile(table).toEncoded(), QCryptographicHash::Md5) + .toHex()); + QVERIFY(QFileInfo::exists(m_cache + QStringLiteral("/fail/cutefish/") + name + QStringLiteral(".png"))); + } + + void hangingThumbnailerIsStopped() + { + const QString script = m_files.filePath("source.c"); + writeFile(script, "int x;\n"); + + QElapsedTimer timer; + timer.start(); + const Thumbnail thumbnail = ThumbnailCache::self()->requestThumbnail(localUrl(script), QSize(64, 64)); + QVERIFY(thumbnail.image.isNull()); + QVERIFY(timer.elapsed() >= 9000); + QVERIFY(timer.elapsed() < 15000); + + // The child of the wrapper shell goes with it. + QTRY_COMPARE(QProcess::execute(QStringLiteral("pgrep"), + {QStringLiteral("-u"), QString::number(::getuid()), + QStringLiteral("-fx"), QStringLiteral("sleep 63")}), + 1); + } + + void skipsMissingPrograms() + { + QVERIFY(!ThumbnailCache::self()->canThumbnail(QStringLiteral("application/json"))); + QVERIFY(!ThumbnailCache::self()->canThumbnail(QStringLiteral("application/x-cutefish-unknown"))); + } + + void picksUpNewThumbnailers() + { + QVERIFY(!ThumbnailCache::self()->canThumbnail(QStringLiteral("text/markdown"))); + installThumbnailer("markdown.thumbnailer", "Exec=true %i %o\nMimeType=text/markdown;\n"); + QTRY_VERIFY_WITH_TIMEOUT(ThumbnailCache::self()->canThumbnail(QStringLiteral("text/markdown")), + 6000); + } +}; + +QTEST_GUILESS_MAIN(ThumbnailersTest) +#include "tst_thumbnailers.moc" diff --git a/io/thumbnailcache.cpp b/io/thumbnailcache.cpp index 42eced9..4185f45 100644 --- a/io/thumbnailcache.cpp +++ b/io/thumbnailcache.cpp @@ -1,10 +1,12 @@ #include "thumbnailcache.h" +#include "thumbnailers_p.h" #include #include #include #include #include +#include #include #include #include @@ -66,6 +68,12 @@ bool isCurrent(const QImage &image, const QString &uri, qint64 mtime, qint64 siz return cachedSize.isEmpty() || cachedSize.toLongLong() == size; } +bool isReadableMimeType(const QString &mimeType) +{ + static const QList readable = QImageReader::supportedMimeTypes(); + return readable.contains(mimeType.toLatin1()); +} + void setMetadata(QImage &image, const QString &uri, qint64 mtime, qint64 size) { image.setText(uriKey, uri); @@ -80,6 +88,11 @@ ThumbnailCache *ThumbnailCache::self() return &cache; } +bool ThumbnailCache::canThumbnail(const QString &mimeType) +{ + return isReadableMimeType(mimeType) || Thumbnailers::self()->contains(mimeType); +} + Thumbnail ThumbnailCache::requestThumbnail(const QString &filePath, const QSize &requestedSize) { if (!requestedSize.isValid() || requestedSize.isEmpty()) @@ -143,10 +156,15 @@ Thumbnail ThumbnailCache::requestThumbnail(const QString &filePath, const QSize QImage ThumbnailCache::generateThumbnail(const QString &source, const QSize &requestedSize) { + if (requestedSize.isEmpty()) + return {}; + QImageReader reader(source); const QSize originalSize = reader.size(); - if (!originalSize.isValid() || requestedSize.isEmpty()) - return {}; + if (!originalSize.isValid()) { + const QString mimeType = QMimeDatabase().mimeTypeForFile(source).name(); + return Thumbnailers::self()->generate(source, mimeType, requestedSize); + } reader.setQuality(90); reader.setAutoTransform(true); diff --git a/io/thumbnailcache.h b/io/thumbnailcache.h index d7e97af..8652324 100644 --- a/io/thumbnailcache.h +++ b/io/thumbnailcache.h @@ -15,6 +15,9 @@ class ThumbnailCache public: static ThumbnailCache *self(); + /** Whether Qt's image readers or an installed thumbnailer handle @p mimeType. */ + bool canThumbnail(const QString &mimeType); + Thumbnail requestThumbnail(const QString &filePath, const QSize &requestedSize); QImage generateThumbnail(const QString &source, const QSize &requestedSize); QString writeCacheFile(const QString &path, const QImage &image); diff --git a/io/thumbnailers.cpp b/io/thumbnailers.cpp new file mode 100644 index 0000000..c3af6ae --- /dev/null +++ b/io/thumbnailers.cpp @@ -0,0 +1,234 @@ +// SPDX-FileCopyrightText: 2026 Reion Wong +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "thumbnailers_p.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +// Decoders of untrusted media can hang; a stuck one must not hold a pool thread for good. +constexpr int timeoutMs = 10000; +constexpr qint64 refreshIntervalMs = 3000; + +QString canonicalName(const QString &mimeType) +{ + const QMimeType type = QMimeDatabase().mimeTypeForName(mimeType); + return type.isValid() ? type.name() : mimeType; +} + +QStringList thumbnailerDirectories() +{ + QStringList directories; + for (const QString &data : QStandardPaths::standardLocations(QStandardPaths::GenericDataLocation)) + directories.append(data + QStringLiteral("/thumbnailers")); + + return directories; +} + +bool isRunnable(const QString &program) +{ + if (program.isEmpty()) + return false; + + if (QDir::isAbsolutePath(program)) + return QFileInfo(program).isExecutable(); + + return !QStandardPaths::findExecutable(program).isEmpty(); +} + +struct Entry +{ + QStringList command; + QStringList mimeTypes; +}; + +// The key file subset thumbnailers use: one group, plain keys, no localized values. +Entry parseEntry(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) + return {}; + + bool inGroup = false; + QString exec; + QString tryExec; + QString mimeTypes; + while (!file.atEnd()) { + const QString line = QString::fromUtf8(file.readLine()).trimmed(); + if (line.isEmpty() || line.startsWith(u'#')) + continue; + + if (line.startsWith(u'[')) { + inGroup = line == QStringLiteral("[Thumbnailer Entry]"); + continue; + } + + const qsizetype equals = line.indexOf(u'='); + if (!inGroup || equals <= 0) + continue; + + const QString key = line.left(equals).trimmed(); + const QString value = line.mid(equals + 1).trimmed(); + if (key == QStringLiteral("Exec")) + exec = value; + else if (key == QStringLiteral("TryExec")) + tryExec = value; + else if (key == QStringLiteral("MimeType")) + mimeTypes = value; + } + + const QStringList command = QProcess::splitCommand(exec); + if (command.isEmpty() || !isRunnable(command.first()) + || (!tryExec.isEmpty() && !isRunnable(tryExec))) + return {}; + + return {command, mimeTypes.split(u';', Qt::SkipEmptyParts)}; +} + +QStringList expand(const QStringList &command, const QString &input, const QString &output, + int size) +{ + QStringList arguments; + for (const QString &argument : command) { + QString expanded; + for (qsizetype i = 0; i < argument.size(); ++i) { + if (argument.at(i) != u'%' || i + 1 == argument.size()) { + expanded.append(argument.at(i)); + continue; + } + + const QChar code = argument.at(++i); + if (code == u'i') + expanded.append(input); + else if (code == u'u') + expanded.append(QString::fromLatin1(QUrl::fromLocalFile(input).toEncoded())); + else if (code == u'o') + expanded.append(output); + else if (code == u's') + expanded.append(QString::number(size)); + else if (code == u'%') + expanded.append(u'%'); + } + arguments.append(expanded); + } + + return arguments; +} +} + +Thumbnailers *Thumbnailers::self() +{ + static Thumbnailers thumbnailers; + return &thumbnailers; +} + +bool Thumbnailers::contains(const QString &mimeType) +{ + return !command(mimeType).isEmpty(); +} + +QStringList Thumbnailers::command(const QString &mimeType) +{ + const QString name = canonicalName(mimeType); + + QMutexLocker locker(&m_mutex); + if (!m_checked.isValid() || m_checked.elapsed() >= refreshIntervalMs) + refreshLocked(); + + return m_commands.value(name); +} + +void Thumbnailers::refreshLocked() +{ + m_checked.start(); + + QList> directories; + for (const QString &directory : thumbnailerDirectories()) + directories.append({directory, QFileInfo(directory).lastModified()}); + + // Package installs add or remove files, which touches the directory. + if (directories == m_directories) + return; + + m_directories = directories; + m_commands.clear(); + + QSet seen; + for (const auto &[directory, modified] : std::as_const(m_directories)) { + const QStringList names = QDir(directory).entryList({QStringLiteral("*.thumbnailer")}, + QDir::Files, QDir::Name); + for (const QString &name : names) { + // A file in an earlier directory hides the one of the same name after it. + if (seen.contains(name)) + continue; + seen.insert(name); + + const Entry entry = parseEntry(directory + u'/' + name); + for (const QString &mimeType : entry.mimeTypes) { + const QString canonical = canonicalName(mimeType); + if (!m_commands.contains(canonical)) + m_commands.insert(canonical, entry.command); + } + } + } +} + +QImage Thumbnailers::generate(const QString &path, const QString &mimeType, const QSize &size) +{ + const QStringList command = this->command(mimeType); + if (command.isEmpty()) + return {}; + + QTemporaryDir directory; + if (!directory.isValid()) + return {}; + + const QString output = directory.filePath(QStringLiteral("thumbnail.png")); + QStringList arguments = + expand(command, QFileInfo(path).absoluteFilePath(), output, qMax(size.width(), size.height())); + + QProcess process; + process.setProgram(arguments.takeFirst()); + process.setArguments(arguments); + process.setWorkingDirectory(directory.path()); + process.setStandardInputFile(QProcess::nullDevice()); + process.setStandardOutputFile(QProcess::nullDevice()); + process.setStandardErrorFile(QProcess::nullDevice()); + // Its own process group, so a timeout also stops what a wrapper script started. + process.setChildProcessModifier([] { ::setpgid(0, 0); }); + process.start(); + if (!process.waitForStarted()) + return {}; + + if (!process.waitForFinished(timeoutMs)) { + ::kill(-pid_t(process.processId()), SIGKILL); + process.waitForFinished(); + return {}; + } + + if (process.exitStatus() != QProcess::NormalExit || process.exitCode() != 0) + return {}; + + QImageReader reader(output); + const QSize outputSize = reader.size(); + if (!outputSize.isValid()) + return {}; + + // Thumbnailers may write a larger image than asked for. + if (outputSize.width() > size.width() || outputSize.height() > size.height()) + reader.setScaledSize(outputSize.scaled(size, Qt::KeepAspectRatio)); + + return reader.read(); +} diff --git a/io/thumbnailers_p.h b/io/thumbnailers_p.h new file mode 100644 index 0000000..2427c11 --- /dev/null +++ b/io/thumbnailers_p.h @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 Reion Wong +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * The external thumbnailers installed as `thumbnailers/*.thumbnailer` in the + * XDG data directories, as GNOME and Xfce use them. Entries in earlier + * directories win, and installing or removing one is picked up within seconds. + */ +class Thumbnailers +{ +public: + static Thumbnailers *self(); + + bool contains(const QString &mimeType); + + /** Runs the thumbnailer for @p mimeType on @p path; null on failure or timeout. */ + QImage generate(const QString &path, const QString &mimeType, const QSize &size); + +private: + QStringList command(const QString &mimeType); + void refreshLocked(); + + QMutex m_mutex; + QElapsedTimer m_checked; + QList> m_directories; + QHash m_commands; +};