feat(io): run installed freedesktop thumbnailers for types Qt cannot read
parent
2c80e2f1a1
commit
7495101293
@ -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"
|
||||
@ -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…
Reference in New Issue