feat(io): run installed freedesktop thumbnailers for types Qt cannot read

pull/18/head
reionwong 4 weeks ago
parent 2c80e2f1a1
commit 7495101293

@ -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)

@ -0,0 +1,190 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "thumbnailcache.h"
#include <QCryptographicHash>
#include <QDir>
#include <QElapsedTimer>
#include <QImage>
#include <QProcess>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QtTest>
#include <unistd.h>
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"

@ -1,10 +1,12 @@
#include "thumbnailcache.h"
#include "thumbnailers_p.h"
#include <QCryptographicHash>
#include <QDateTime>
#include <QDir>
#include <QFileInfo>
#include <QImageReader>
#include <QMimeDatabase>
#include <QSaveFile>
#include <QStandardPaths>
#include <QUrl>
@ -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<QByteArray> 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);

@ -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);

@ -0,0 +1,234 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "thumbnailers_p.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QImageReader>
#include <QMimeDatabase>
#include <QProcess>
#include <QSet>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QUrl>
#include <signal.h>
#include <unistd.h>
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<std::pair<QString, QDateTime>> 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<QString> 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();
}

@ -0,0 +1,38 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDateTime>
#include <QElapsedTimer>
#include <QHash>
#include <QImage>
#include <QList>
#include <QMutex>
#include <QSize>
#include <QStringList>
/**
* 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<std::pair<QString, QDateTime>> m_directories;
QHash<QString, QStringList> m_commands;
};
Loading…
Cancel
Save