1/*
2 * This file is part of the KDE libraries
3 * SPDX-FileCopyrightText: 2025 Akseli Lahtinen <akselmo@akselmo.dev>
4 * SPDX-FileCopyrightText: 2000 David Faure <faure@kde.org>
5 * SPDX-FileCopyrightText: 2000 Carsten Pfeiffer <pfeiffer@kde.org>
6 * SPDX-FileCopyrightText: 2001 Malte Starostik <malte.starostik@t-online.de>
7 *
8 * SPDX-License-Identifier: LGPL-2.0-or-later
9 */
10
11#include "filepreviewjob.h"
12#include "filecopyjob.h"
13#include "kiogui_debug.h"
14#include "previewjob.h"
15#include "standardthumbnailjob_p.h"
16#include "statjob.h"
17#include "transferjob.h"
18
19#if defined(Q_OS_UNIX) && !defined(Q_OS_ANDROID) && !defined(Q_OS_HAIKU)
20#define WITH_SHM 1
21#else
22#define WITH_SHM 0
23#endif
24
25#if WITH_SHM
26#include <sys/ipc.h>
27#include <sys/shm.h>
28#endif
29
30#include <KConfigGroup>
31#include <KFileUtils>
32#include <KProtocolInfo>
33#include <KSharedConfig>
34#include <Solid/Device>
35#include <Solid/StorageAccess>
36
37#include <QCoreApplication>
38#include <QCryptographicHash>
39#include <QJsonArray>
40#include <QMimeDatabase>
41#include <QSaveFile>
42#include <QTemporaryDir>
43#include <QtConcurrent/QtConcurrent>
44
45#ifdef WITH_QTDBUS
46#include <QDBusConnection>
47#include <QDBusError>
48
49#include "kiofuse_interface.h"
50#endif
51
52using namespace KIO;
53class FileDeviceJob : public KIO::Job
54{
55public:
56 FileDeviceJob(const QStringList paths);
57
58 void getDeviceId(const QString &path);
59 void slotResult(KJob *job) override;
60
61 QMap<QString, int> m_deviceIdMap;
62};
63
64FilePreviewJob::FilePreviewJob(const PreviewItem &item, const QString &thumbRoot, const QMap<QString, KPluginMetaData> &mimeMap)
65 : m_item(item)
66 , m_size(m_item.size)
67 , m_cacheSize(m_item.cacheSize)
68 , m_scaleType(m_item.scaleType)
69 , m_ignoreMaximumSize(m_item.ignoreMaximumSize)
70 , m_sequenceIndex(m_item.sequenceIndex)
71 , m_thumbRoot(thumbRoot)
72 , m_devicePixelRatio(m_item.devicePixelRatio)
73 , m_deviceIdMap(m_item.deviceIdMap)
74 , m_preview(QImage())
75 , m_mimeMap(mimeMap)
76{
77}
78
79FilePreviewJob::~FilePreviewJob()
80{
81 if (!m_tempName.isEmpty()) {
82 Q_ASSERT((!QFileInfo(m_tempName).isDir() && QFileInfo(m_tempName).isFile()) || QFileInfo(m_tempName).isSymLink());
83 QFile::remove(fileName: m_tempName);
84 m_tempName.clear();
85 }
86 if (!m_tempDirPath.isEmpty()) {
87 Q_ASSERT(m_tempDirPath.startsWith(QStandardPaths::writableLocation(QStandardPaths::TempLocation)));
88 QDir tempDir(m_tempDirPath);
89 tempDir.removeRecursively();
90 }
91}
92
93void FilePreviewJob::start()
94{
95 // If our deviceIdMap does not have these items, run FilePreviewStatJob to get them
96 auto parentDir = parentDirPath(path: m_item.item.localPath());
97 QStringList paths;
98 if (!m_deviceIdMap.contains(key: m_thumbRoot)) {
99 paths.append(t: m_thumbRoot);
100 }
101 if (!parentDir.isEmpty() && !m_deviceIdMap.contains(key: parentDir)) {
102 paths.append(t: parentDir);
103 }
104
105 if (!paths.isEmpty()) {
106 auto *firstJob = new FileDeviceJob(paths);
107 connect(sender: firstJob, signal: &KIO::Job::result, context: this, slot: [this](KJob *job) {
108 FileDeviceJob *previewStatJob = static_cast<FileDeviceJob *>(job);
109 for (auto item : previewStatJob->m_deviceIdMap.asKeyValueRange()) {
110 m_deviceIdMap.insert(key: item.first, value: item.second);
111 }
112 statFile();
113 });
114 firstJob->start();
115 } else {
116 statFile();
117 }
118}
119
120QString FilePreviewJob::parentDirPath(const QString &path) const
121{
122 if (!path.isEmpty()) {
123 // If checked file is directory on a different filesystem than its parent, we need to check it separately
124 int separatorIndex = path.lastIndexOf(c: QLatin1Char('/'));
125 // special case for root folders
126 const QString parentDirPath = separatorIndex == 0 ? path : path.left(n: separatorIndex);
127 return parentDirPath;
128 }
129 return path;
130}
131
132void FilePreviewJob::statFile()
133{
134 if (!m_item.item.targetUrl().isValid()) {
135 emitResult();
136 return;
137 }
138 // We need to first check the device id's so we can find out if the images can be cached
139 QFlags<KIO::StatDetail> details = KIO::StatDefaultDetails | KIO::StatInode;
140
141 if (!m_item.item.isMimeTypeKnown()) {
142 details.setFlag(flag: KIO::StatMimeType);
143 }
144
145 KIO::Job *statJob = KIO::stat(url: m_item.item.targetUrl(), side: StatJob::SourceSide, details, flags: KIO::HideProgressInfo);
146 statJob->addMetaData(QStringLiteral("thumbnail"), QStringLiteral("1"));
147 statJob->addMetaData(QStringLiteral("no-auth-prompt"), QStringLiteral("true"));
148 connect(sender: statJob, signal: &KIO::Job::result, context: this, slot: &FilePreviewJob::slotStatFile);
149 statJob->start();
150}
151
152void FilePreviewJob::preparePluginForMimetype(const QString &mimeType)
153{
154 auto setUpCaching = [this]() {
155 short cacheSize = 0;
156 const int longer = std::max(a: m_item.size.width(), b: m_item.size.height());
157 if (longer <= 128) {
158 cacheSize = 128;
159 } else if (longer <= 256) {
160 cacheSize = 256;
161 } else if (longer <= 512) {
162 cacheSize = 512;
163 } else {
164 cacheSize = 1024;
165 }
166
167 struct CachePool {
168 QString path;
169 int minSize;
170 };
171
172 const static auto pools = {
173 CachePool{QStringLiteral("normal/"), .minSize: 128},
174 CachePool{QStringLiteral("large/"), .minSize: 256},
175 CachePool{QStringLiteral("x-large/"), .minSize: 512},
176 CachePool{QStringLiteral("xx-large/"), .minSize: 1024},
177 };
178
179 QString thumbDir;
180 int wants = m_item.devicePixelRatio * cacheSize;
181 for (const auto &p : pools) {
182 if (p.minSize < wants) {
183 continue;
184 } else {
185 thumbDir = p.path;
186 break;
187 }
188 }
189 QString thumbPath = m_thumbRoot + thumbDir;
190 QDir().mkpath(dirPath: m_thumbRoot);
191 if (!QDir(thumbPath).exists() && !QDir(m_thumbRoot).mkdir(dirName: thumbDir, permissions: QFile::ReadUser | QFile::WriteUser | QFile::ExeUser)) { // 0700
192 qCWarning(KIO_GUI) << "couldn't create thumbnail dir " << thumbPath;
193 }
194 m_thumbPath = thumbPath;
195 m_cacheSize = cacheSize;
196 };
197
198 auto pluginIt = m_mimeMap.constFind(key: mimeType);
199 if (pluginIt == m_mimeMap.constEnd()) {
200 // check MIME type inheritance, resolve aliases
201 QMimeDatabase db;
202 const QMimeType mimeInfo = db.mimeTypeForName(nameOrAlias: mimeType);
203 if (mimeInfo.isValid()) {
204 const QStringList parentMimeTypes = mimeInfo.allAncestors();
205 for (const QString &parentMimeType : parentMimeTypes) {
206 pluginIt = m_mimeMap.constFind(key: parentMimeType);
207 if (pluginIt != m_mimeMap.constEnd()) {
208 break;
209 }
210 }
211 }
212
213 if (pluginIt == m_mimeMap.constEnd()) {
214 // Check the wildcards last, see BUG 453480
215 QString groupMimeType = mimeType;
216 const int slashIdx = groupMimeType.indexOf(ch: QLatin1Char('/'));
217 if (slashIdx != -1) {
218 // Replace everything after '/' with '*'
219 groupMimeType.truncate(pos: slashIdx + 1);
220 groupMimeType += QLatin1Char('*');
221 }
222 pluginIt = m_mimeMap.constFind(key: groupMimeType);
223 }
224 }
225
226 if (pluginIt != m_mimeMap.constEnd()) {
227 const KPluginMetaData plugin = *pluginIt;
228
229 if (!plugin.isValid()) {
230 qCDebug(KIO_GUI) << "Plugin for item " << m_item.item << " is not valid. Emitting result.";
231 emitResult();
232 return;
233 }
234
235 m_standardThumbnailer = plugin.description() == QStringLiteral("standardthumbnailer");
236 m_plugin = plugin;
237 m_thumbnailWorkerMetaData.insert(QStringLiteral("handlesSequences"), value: QString::number(m_plugin.value(QStringLiteral("HandleSequences"), defaultValue: false)));
238
239 if (m_item.scaleType == PreviewJob::ScaleType::ScaledAndCached && plugin.value(QStringLiteral("CacheThumbnail"), defaultValue: true)) {
240 const QUrl url = m_item.item.targetUrl();
241 if (!url.isLocalFile() || !url.adjusted(options: QUrl::RemoveFilename).toLocalFile().startsWith(s: m_thumbRoot)) {
242 setUpCaching();
243 }
244 }
245 } else {
246 qCDebug(KIO_GUI) << "Could not get plugin for, " << m_item.item << " - emitting result.";
247 emitResult();
248 return;
249 }
250}
251
252void FilePreviewJob::slotStatFile(KJob *job)
253{
254 if (job->error()) {
255 qCDebug(KIO_GUI) << "Job stat failed" << job->errorString();
256 emitResult();
257 return;
258 }
259 bool isLocal;
260
261 const QUrl itemUrl = m_item.item.mostLocalUrl(local: &isLocal);
262 const KIO::StatJob *statJob = static_cast<KIO::StatJob *>(job);
263 const KIO::UDSEntry statResult = statJob->statResult();
264 m_currentDeviceId = statResult.numberValue(field: KIO::UDSEntry::UDS_DEVICE_ID, defaultValue: 0);
265 m_tOrig = QDateTime::fromSecsSinceEpoch(secs: statResult.numberValue(field: KIO::UDSEntry::UDS_MODIFICATION_TIME, defaultValue: 0));
266
267 if (m_item.item.isMimeTypeKnown()) {
268 preparePluginForMimetype(mimeType: m_item.item.mimetype());
269 } else {
270 preparePluginForMimetype(mimeType: statResult.stringValue(field: KIO::UDSEntry::UDS_MIME_TYPE));
271 }
272
273 if (isLocal) {
274 const QFileInfo localFile(itemUrl.toLocalFile());
275 const QString canonicalPath = localFile.canonicalFilePath();
276 m_origName = QUrl::fromLocalFile(localfile: canonicalPath).toEncoded(options: QUrl::RemovePassword | QUrl::FullyEncoded);
277 if (m_origName.isEmpty()) {
278 qCDebug(KIO_GUI) << "Failed to convert" << itemUrl << "to canonical path, possibly a broken symlink";
279 emitResult();
280 }
281 } else {
282 // Don't include the password if any
283 m_origName = m_item.item.targetUrl().toEncoded(options: QUrl::RemovePassword);
284 }
285
286 QCryptographicHash md5(QCryptographicHash::Md5);
287 md5.addData(data: m_origName);
288 m_thumbName = QString::fromLatin1(ba: md5.result().toHex()) + QLatin1String(".png");
289
290 const KIO::filesize_t size = static_cast<KIO::filesize_t>(statResult.numberValue(field: KIO::UDSEntry::UDS_SIZE, defaultValue: 0));
291 if (size == 0) {
292 qCDebug(KIO_GUI) << "FilePreviewJob: skipping an empty file, migth be a broken symlink" << m_item.item.url();
293 emitResult();
294 return;
295 }
296
297 bool skipCurrentItem = false;
298 const KConfigGroup cg(KSharedConfig::openConfig(), QStringLiteral("PreviewSettings"));
299 if ((itemUrl.isLocalFile() || KProtocolInfo::protocolClass(protocol: itemUrl.scheme()) == QLatin1String(":local")) && !m_item.item.isSlow()) {
300 const KIO::filesize_t maximumLocalSize = cg.readEntry(key: "MaximumSize", defaultValue: std::numeric_limits<KIO::filesize_t>::max());
301 skipCurrentItem = !m_ignoreMaximumSize && size > maximumLocalSize && !m_plugin.value(QStringLiteral("IgnoreMaximumSize"), defaultValue: false);
302 } else {
303 // For remote items the "IgnoreMaximumSize" plugin property is not respected
304 // Also we need to check if remote (but locally mounted) folder preview is enabled
305 const KIO::filesize_t maximumRemoteSize = cg.readEntry<KIO::filesize_t>(key: "MaximumRemoteSize", defaultValue: 0);
306 const bool enableRemoteFolderThumbnail = cg.readEntry(key: "EnableRemoteFolderThumbnail", defaultValue: false);
307 skipCurrentItem = (!m_ignoreMaximumSize && size > maximumRemoteSize) || (m_item.item.isDir() && !enableRemoteFolderThumbnail);
308 }
309 if (skipCurrentItem) {
310 emitResult();
311 return;
312 }
313
314 bool pluginHandlesSequences = m_plugin.value(QStringLiteral("HandleSequences"), defaultValue: false);
315 if (!m_plugin.value(QStringLiteral("CacheThumbnail"), defaultValue: true) || (m_sequenceIndex && pluginHandlesSequences) || m_thumbPath.isEmpty()) {
316 // This preview will not be cached, no need to look for a saved thumbnail
317 // Just create it, and be done
318 getOrCreateThumbnail();
319 return;
320 }
321
322 auto watcher = new QFutureWatcher<QImage>(this);
323 connect(sender: watcher, signal: &QFutureWatcher<QImage>::finished, context: this, slot: [this, watcher]() {
324 watcher->deleteLater();
325 QImage thumb = watcher->result();
326 if (isCacheValid(thumb)) {
327 emitPreview(thumb);
328 emitResult();
329 } else {
330 getOrCreateThumbnail();
331 }
332
333 });
334 QFuture<QImage> future = QtConcurrent::run(f&: loadThumbnailFromCache, args: QString(m_thumbPath + m_thumbName), args&: m_devicePixelRatio);
335
336 watcher->setFuture(future);
337}
338
339QImage FilePreviewJob::loadThumbnailFromCache(const QString &path, qreal dpr)
340{
341 QImage thumb;
342 QFile thumbFile(path);
343 if (!thumbFile.open(flags: QIODevice::ReadOnly) || !thumb.load(device: &thumbFile, format: "png")) {
344 return QImage();
345 }
346 // The DPR of the loaded thumbnail is unspecified (and typically irrelevant).
347 // When a thumbnail is DPR-invariant, use the DPR passed in the request.
348 thumb.setDevicePixelRatio(dpr);
349 return thumb;
350}
351
352bool FilePreviewJob::isCacheValid(const QImage &thumb)
353{
354 if (thumb.isNull()) {
355 return false;
356 }
357 if (thumb.text(QStringLiteral("Thumb::URI")) != QString::fromUtf8(ba: m_origName)
358 || thumb.text(QStringLiteral("Thumb::MTime")).toLongLong() != m_tOrig.toSecsSinceEpoch()) {
359 return false;
360 }
361
362 const QString origSize = thumb.text(QStringLiteral("Thumb::Size"));
363 if (!origSize.isEmpty() && origSize.toULongLong() != m_item.item.size()) {
364 // Thumb::Size is not required, but if it is set it should match
365 return false;
366 }
367
368 QString thumbnailerVersion = m_plugin.value(QStringLiteral("ThumbnailerVersion"));
369
370 if (!thumbnailerVersion.isEmpty() && thumb.text(QStringLiteral("Software")).startsWith(s: QLatin1String("KDE Thumbnail Generator"))) {
371 // Check if the version matches
372 // The software string should read "KDE Thumbnail Generator pluginName (vX)"
373 QString softwareString = thumb.text(QStringLiteral("Software")).remove(QStringLiteral("KDE Thumbnail Generator")).trimmed();
374 if (softwareString.isEmpty()) {
375 // The thumbnail has been created with an older version, recreating
376 return false;
377 }
378 int versionIndex = softwareString.lastIndexOf(s: QLatin1String("(v"));
379 if (versionIndex < 0) {
380 return false;
381 }
382
383 QString cachedVersion = softwareString.remove(i: 0, len: versionIndex + 2);
384 cachedVersion.chop(n: 1);
385 uint thumbnailerMajor = thumbnailerVersion.toInt();
386 uint cachedMajor = cachedVersion.toInt();
387 if (thumbnailerMajor > cachedMajor) {
388 return false;
389 }
390 }
391 return true;
392}
393
394void FilePreviewJob::getOrCreateThumbnail()
395{
396 // We still need to load the orig file ! (This is getting tedious) :)
397 const KFileItem &item = m_item.item;
398 const QString localPath = item.localPath();
399 if (!localPath.isEmpty()) {
400 createThumbnail(localPath);
401 return;
402 }
403
404 if (item.isDir() || !KProtocolInfo::isKnownProtocol(protocol: item.targetUrl().scheme())) {
405 // Skip remote dirs (bug 208625)
406 emitResult();
407 return;
408 }
409 // The plugin does not support this remote content, either copy the
410 // file, or try to get a local path using KIOFuse
411 if (m_tryKioFuse) {
412 createThumbnailViaFuse(item.targetUrl(), item.mostLocalUrl());
413 return;
414 }
415
416 createThumbnailViaLocalCopy(item.mostLocalUrl());
417}
418
419void FilePreviewJob::createThumbnailViaFuse(const QUrl &fileUrl, const QUrl &localUrl)
420{
421#if defined(WITH_QTDBUS) && !defined(Q_OS_ANDROID)
422 org::kde::KIOFuse::VFS kiofuse_iface(QStringLiteral("org.kde.KIOFuse"), QStringLiteral("/org/kde/KIOFuse"), QDBusConnection::sessionBus());
423 kiofuse_iface.setTimeout(s_kioFuseMountTimeout);
424 QDBusPendingReply<QString> reply = kiofuse_iface.mountUrl(remoteUrl: fileUrl.toString());
425 QDBusPendingCallWatcher *watcher = new QDBusPendingCallWatcher(reply, this);
426
427 QObject::connect(sender: watcher, signal: &QDBusPendingCallWatcher::finished, context: this, slot: [this, localUrl](QDBusPendingCallWatcher *watcher) {
428 QDBusPendingReply<QString> reply = *watcher;
429 watcher->deleteLater();
430
431 if (reply.isError()) {
432 // Don't try kio-fuse again if it is not available
433 if (reply.error().type() == QDBusError::ServiceUnknown || reply.error().type() == QDBusError::NoReply) {
434 m_tryKioFuse = false;
435 }
436
437 // Fall back to copying the file to the local machine
438 createThumbnailViaLocalCopy(localUrl);
439 } else {
440 // Use file exposed via the local fuse mount point
441 createThumbnail(reply.value());
442 }
443 });
444#else
445 createThumbnailViaLocalCopy(localUrl);
446#endif
447}
448
449void FilePreviewJob::slotGetOrCreateThumbnail(KJob *job)
450{
451 auto fileCopyJob = static_cast<KIO::FileCopyJob *>(job);
452 if (fileCopyJob) {
453 auto pixPath = fileCopyJob->destUrl().toLocalFile();
454 if (!pixPath.isEmpty()) {
455 createThumbnail(pixPath);
456 return;
457 }
458 }
459 emitResult();
460}
461
462void FilePreviewJob::createThumbnailViaLocalCopy(const QUrl &url)
463{
464 // Only download for the first sequence
465 if (m_sequenceIndex) {
466 emitResult();
467 return;
468 }
469 // No plugin support access to this remote content, copy the file
470 // to the local machine, then create the thumbnail
471 const KFileItem &item = m_item.item;
472
473 // Build the destination filename: ~/.cache/app/kpreviewjob/pid/UUID.extension
474 QString krun_writable =
475 QStandardPaths::writableLocation(type: QStandardPaths::CacheLocation) + QStringLiteral("/kpreviewjob/%1/").arg(a: QCoreApplication::applicationPid());
476 if (!QDir().mkpath(dirPath: krun_writable)) {
477 qCWarning(KIO_GUI) << "Could not create a cache folder for preview creation:" << krun_writable;
478 emitResult();
479 return;
480 }
481 m_tempName = QStringLiteral("%1%2.%3").arg(args&: krun_writable, args: QUuid(item.mostLocalUrl().toString()).createUuid().toString(mode: QUuid::WithoutBraces), args: item.suffix());
482
483 KIO::Job *job = KIO::file_copy(src: url, dest: QUrl::fromLocalFile(localfile: m_tempName), permissions: -1, flags: KIO::Overwrite | KIO::HideProgressInfo /* No GUI */);
484 job->addMetaData(QStringLiteral("thumbnail"), QStringLiteral("1"));
485 connect(sender: job, signal: &KIO::FileCopyJob::result, context: this, slot: &FilePreviewJob::slotGetOrCreateThumbnail);
486 job->start();
487}
488
489FilePreviewJob::CachePolicy FilePreviewJob::canBeCached(const QString &path)
490{
491 const QString parentDir = parentDirPath(path);
492
493 int parentId = getDeviceId(path: parentDir);
494 if (parentId == m_idUnknown) {
495 return CachePolicy::Unknown;
496 }
497
498 bool isDifferentSystem = !parentId || parentId != m_currentDeviceId;
499 if (!isDifferentSystem && m_currentDeviceCachePolicy != CachePolicy::Unknown) {
500 return m_currentDeviceCachePolicy;
501 }
502 int checkedId;
503 QString checkedPath;
504 if (isDifferentSystem) {
505 checkedId = m_currentDeviceId;
506 checkedPath = path;
507 } else {
508 checkedId = getDeviceId(path: parentDir);
509 checkedPath = parentDir;
510 if (checkedId == m_idUnknown) {
511 return CachePolicy::Unknown;
512 }
513 }
514 // If we're checking different filesystem or haven't checked yet see if filesystem matches thumbRoot
515 int thumbRootId = getDeviceId(path: m_thumbRoot);
516 if (thumbRootId == m_idUnknown) {
517 return CachePolicy::Unknown;
518 }
519 bool shouldAllow = checkedId && checkedId == thumbRootId;
520 if (!shouldAllow) {
521 Solid::Device device = Solid::Device::storageAccessFromPath(path: checkedPath);
522 if (device.isValid()) {
523 // If the checked device is encrypted, allow thumbnailing if the thumbnails are stored in an encrypted location.
524 // Or, if the checked device is unencrypted, allow thumbnailing.
525 if (device.as<Solid::StorageAccess>()->isEncrypted()) {
526 const Solid::Device thumbRootDevice = Solid::Device::storageAccessFromPath(path: m_thumbRoot);
527 shouldAllow = thumbRootDevice.isValid() && thumbRootDevice.as<Solid::StorageAccess>()->isEncrypted();
528 } else {
529 shouldAllow = true;
530 }
531 }
532 }
533 if (!isDifferentSystem) {
534 m_currentDeviceCachePolicy = shouldAllow ? CachePolicy::Allow : CachePolicy::Prevent;
535 }
536 return shouldAllow ? CachePolicy::Allow : CachePolicy::Prevent;
537}
538
539int FilePreviewJob::getDeviceId(const QString &path)
540{
541 auto iter = m_deviceIdMap.find(key: path);
542 if (iter != m_deviceIdMap.end()) {
543 return iter.value();
544 }
545 QUrl url = QUrl::fromLocalFile(localfile: path);
546 if (!url.isValid()) {
547 qCWarning(KIO_GUI) << "Could not get device id for file preview, Invalid url" << path;
548 return 0;
549 }
550 return m_idUnknown;
551}
552
553void FilePreviewJob::createThumbnail(const QString &pixPath)
554{
555 QFileInfo info(pixPath);
556 Q_ASSERT_X(info.isAbsolute(), "PreviewJobPrivate::createThumbnail", qPrintable(QLatin1String("path is not absolute: ") + info.path()));
557
558 bool save = m_scaleType == PreviewJob::ScaledAndCached && m_plugin.value(QStringLiteral("CacheThumbnail"), defaultValue: true) && !m_sequenceIndex;
559
560 bool isRemoteProtocol = m_item.item.localPath().isEmpty();
561 m_currentDeviceCachePolicy = isRemoteProtocol ? CachePolicy::Allow : canBeCached(path: pixPath);
562
563 if (m_currentDeviceCachePolicy == CachePolicy::Unknown) {
564 emitResult();
565 return;
566 }
567
568 if (m_standardThumbnailer) {
569 if (m_tempDirPath.isEmpty()) {
570 auto tempDir = QTemporaryDir();
571 Q_ASSERT(tempDir.isValid());
572
573 tempDir.setAutoRemove(false);
574 // restrict read access to current User
575 QFile::setPermissions(filename: tempDir.path(), permissionSpec: QFile::Permission::ReadOwner | QFile::Permission::WriteOwner | QFile::Permission::ExeOwner);
576
577 m_tempDirPath = tempDir.path();
578 }
579
580 if (pixPath.startsWith(s: m_tempDirPath)) {
581 // don't generate thumbnails for images already in temporary directory
582 emitResult();
583 return;
584 }
585
586 KIO::StandardThumbnailJob *job = new KIO::StandardThumbnailJob(m_plugin.value(key: u"Exec"), m_size.width() * m_devicePixelRatio, pixPath, m_tempDirPath);
587 connect(sender: job, signal: &KIO::StandardThumbnailJob::data, context: this, slot: [=, this](KIO::Job *job, const QImage &thumb) {
588 slotStandardThumbData(job, thumb);
589 });
590 connect(sender: job, signal: &KIO::StandardThumbnailJob::result, context: this, slot: &FilePreviewJob::emitResult);
591 job->start();
592 return;
593 }
594
595 // Using thumbnailer plugin
596 QUrl thumbURL;
597 thumbURL.setScheme(QStringLiteral("thumbnail"));
598 thumbURL.setPath(path: pixPath);
599 KIO::TransferJob *job = KIO::get(url: thumbURL, reload: NoReload, flags: HideProgressInfo);
600 connect(sender: job, signal: &KIO::TransferJob::data, context: this, slot: [this](KIO::Job *job, const QByteArray &data) {
601 slotThumbData(job, data);
602 });
603 connect(sender: job, signal: &KIO::TransferJob::result, context: this, slot: &FilePreviewJob::emitResult);
604 int thumb_width = m_size.width();
605 int thumb_height = m_size.height();
606 if (save) {
607 thumb_width = thumb_height = m_cacheSize;
608 }
609
610 job->addMetaData(QStringLiteral("mimeType"), value: m_item.item.mimetype());
611 job->addMetaData(QStringLiteral("width"), value: QString::number(thumb_width));
612 job->addMetaData(QStringLiteral("height"), value: QString::number(thumb_height));
613 job->addMetaData(QStringLiteral("plugin"), value: m_plugin.fileName());
614 job->addMetaData(QStringLiteral("enabledPlugins"), value: m_enabledPlugins.join(sep: QLatin1Char(',')));
615 job->addMetaData(QStringLiteral("devicePixelRatio"), value: QString::number(m_devicePixelRatio));
616 job->addMetaData(QStringLiteral("cache"), value: QString::number(m_currentDeviceCachePolicy == CachePolicy::Allow));
617 if (m_sequenceIndex) {
618 job->addMetaData(QStringLiteral("sequence-index"), value: QString::number(m_sequenceIndex));
619 }
620
621 size_t requiredSize = thumb_width * m_devicePixelRatio * thumb_height * m_devicePixelRatio * 4;
622 m_shm = SHM::create(size: requiredSize);
623
624 if (m_shm) {
625 job->addMetaData(QStringLiteral("shmid"), value: QString::number(m_shm->id()));
626 }
627
628 job->start();
629}
630
631void FilePreviewJob::slotStandardThumbData(KIO::Job *job, const QImage &thumbData)
632{
633 m_thumbnailWorkerMetaData = job->metaData();
634
635 if (thumbData.isNull()) {
636 // let succeeded in false state
637 // failed will get called in determineNextFile()
638 emitResult();
639 return;
640 }
641
642 QImage thumb = thumbData;
643 saveThumbnailData(thumb);
644
645 emitPreview(thumb);
646}
647
648void FilePreviewJob::slotThumbData(KIO::Job *job, const QByteArray &data)
649{
650 QImage thumb;
651 // Keep this in sync with kio-extras|thumbnail/thumbnail.cpp
652 QDataStream str(data);
653
654 int width;
655 int height;
656 QImage::Format format;
657 qreal imgDevicePixelRatio;
658 // TODO KF7: add a version number as first parameter
659 // always read those, even when !WITH_SHM, because the other side always writes them
660 str >> width >> height >> format >> imgDevicePixelRatio;
661
662 if (m_shm) {
663 thumb = QImage(m_shm->address(), width, height, format).copy();
664 thumb.setDevicePixelRatio(imgDevicePixelRatio);
665 }
666
667 if (thumb.isNull()) {
668 // fallback a raw QImage
669 str >> thumb;
670 thumb.setDevicePixelRatio(imgDevicePixelRatio);
671 }
672
673 slotStandardThumbData(job, thumbData: thumb);
674}
675
676void FilePreviewJob::saveThumbnailData(QImage &thumb)
677{
678 const bool save = m_scaleType == PreviewJob::ScaledAndCached && !m_sequenceIndex && m_currentDeviceCachePolicy == CachePolicy::Allow
679 && m_plugin.value(QStringLiteral("CacheThumbnail"), defaultValue: true)
680 && (!m_item.item.targetUrl().isLocalFile() || !m_item.item.targetUrl().adjusted(options: QUrl::RemoveFilename).toLocalFile().startsWith(s: m_thumbRoot));
681
682 if (save) {
683 thumb.setText(QStringLiteral("Thumb::URI"), value: QString::fromUtf8(ba: m_origName));
684 thumb.setText(QStringLiteral("Thumb::MTime"), value: QString::number(m_tOrig.toSecsSinceEpoch()));
685 thumb.setText(QStringLiteral("Thumb::Size"), value: number(size: m_item.item.size()));
686 thumb.setText(QStringLiteral("Thumb::Mimetype"), value: m_item.item.mimetype());
687 QString thumbnailerVersion = m_plugin.value(QStringLiteral("ThumbnailerVersion"));
688 QString signature = QLatin1String("KDE Thumbnail Generator ") + m_plugin.name();
689 if (!thumbnailerVersion.isEmpty()) {
690 signature.append(s: QLatin1String(" (v") + thumbnailerVersion + QLatin1Char(')'));
691 }
692 thumb.setText(QStringLiteral("Software"), value: signature);
693 // we don't need to block for the saving to complete, it can run in it's own time
694 QFuture<void> future = QtConcurrent::run(f&: saveThumbnailToCache, args&: thumb, args: QString(m_thumbPath + m_thumbName));
695 }
696}
697
698void FilePreviewJob::saveThumbnailToCache(const QImage &thumb, const QString &path)
699{
700 QEventLoopLocker lock; // stop the application from quitting until we finish
701 QSaveFile saveFile(path);
702 if (saveFile.open(flags: QIODevice::WriteOnly)) {
703 if (thumb.save(device: &saveFile, format: "PNG")) {
704 saveFile.commit();
705 }
706 }
707}
708
709void FilePreviewJob::emitPreview(const QImage &thumb)
710{
711 const qreal ratio = thumb.devicePixelRatio();
712
713 QImage preview = thumb;
714 if (preview.width() > m_size.width() * ratio || preview.height() > m_size.height() * ratio) {
715 preview = preview.scaled(s: QSize(m_size.width() * ratio, m_size.height() * ratio), aspectMode: Qt::KeepAspectRatio, mode: Qt::SmoothTransformation);
716 }
717
718 m_preview = preview;
719 emitResult();
720}
721
722QList<KPluginMetaData> FilePreviewJob::loadAvailablePlugins()
723{
724 static QList<KPluginMetaData> jsonMetaDataPlugins;
725 if (jsonMetaDataPlugins.isEmpty()) {
726 // Insert plugins first so they take precedence over standard thumbnailers
727 jsonMetaDataPlugins = KPluginMetaData::findPlugins(QStringLiteral("kf6/thumbcreator"));
728 jsonMetaDataPlugins << standardThumbnailers();
729 }
730 return jsonMetaDataPlugins;
731}
732
733QList<KPluginMetaData> FilePreviewJob::standardThumbnailers()
734{
735 static QList<KPluginMetaData> standardThumbs;
736 if (standardThumbs.empty()) {
737 const QStringList dirs =
738 QStandardPaths::locateAll(type: QStandardPaths::GenericDataLocation, QStringLiteral("thumbnailers/"), options: QStandardPaths::LocateDirectory);
739 const auto thumbnailerPaths = KFileUtils::findAllUniqueFiles(dirs, nameFilters: QStringList{QStringLiteral("*.thumbnailer")});
740 for (const QString &thumbnailerPath : thumbnailerPaths) {
741 const KConfig thumbnailerFile(thumbnailerPath);
742 const KConfigGroup thumbnailerConfig = thumbnailerFile.group(QStringLiteral("Thumbnailer Entry"));
743 const QStringList mimetypes = thumbnailerConfig.readXdgListEntry(key: "MimeType");
744 const QString exec = thumbnailerConfig.readEntry(key: "Exec", aDefault: QString{});
745
746 if (exec.isEmpty() || mimetypes.isEmpty()) {
747 continue;
748 }
749
750 QMimeDatabase db;
751 // We only need the first mimetype since the names/comments are often shared between multiple types
752 auto mime = db.mimeTypeForName(nameOrAlias: mimetypes.first());
753 auto name = mime.name().isEmpty() ? mimetypes.first() : mime.name();
754 if (!mime.comment().isEmpty()) {
755 name = mime.comment();
756 }
757
758 // the plugin metadata
759 QJsonObject kplugin;
760 kplugin[QStringLiteral("Id")] = QFileInfo(thumbnailerPath).completeBaseName();
761 kplugin[QStringLiteral("MimeTypes")] = QJsonArray::fromStringList(list: mimetypes);
762 kplugin[QStringLiteral("Name")] = name;
763 kplugin[QStringLiteral("Description")] = QStringLiteral("standardthumbnailer");
764
765 QJsonObject root;
766 root[QStringLiteral("CacheThumbnail")] = true;
767 root[QStringLiteral("Exec")] = exec;
768 root[QStringLiteral("KPlugin")] = kplugin;
769
770 KPluginMetaData standardThumbnailerPlugin(root, QString());
771 standardThumbs.append(t: standardThumbnailerPlugin);
772 }
773 }
774 return standardThumbs;
775}
776
777QMap<QString, QString> FilePreviewJob::thumbnailWorkerMetaData() const
778{
779 return m_thumbnailWorkerMetaData;
780}
781
782QMap<QString, int> FilePreviewJob::deviceIdMap() const
783{
784 return m_deviceIdMap;
785}
786
787QImage FilePreviewJob::previewImage() const
788{
789 return m_preview;
790}
791
792PreviewItem FilePreviewJob::item() const
793{
794 return m_item;
795}
796
797// Stat multiple files at same time
798FileDeviceJob::FileDeviceJob(const QStringList paths)
799{
800 for (const QString &path : paths) {
801 getDeviceId(path);
802 }
803}
804
805void FileDeviceJob::slotResult(KJob *job)
806{
807 KIO::StatJob *statJob = static_cast<KIO::StatJob *>(job);
808 int id;
809 QString path = statJob->url().toLocalFile();
810 if (job->error()) {
811 // We set id to 0 to know we tried getting it
812 qCDebug(KIO_GUI) << "Cannot read information about filesystem under path" << path;
813 id = 0;
814 } else {
815 id = statJob->statResult().numberValue(field: KIO::UDSEntry::UDS_DEVICE_ID, defaultValue: 0);
816 }
817 if (!path.isEmpty()) {
818 m_deviceIdMap[path] = id;
819 }
820 removeSubjob(job);
821 if (!hasSubjobs()) {
822 emitResult();
823 }
824}
825
826void FileDeviceJob::getDeviceId(const QString &path)
827{
828 QUrl url = QUrl::fromLocalFile(localfile: path);
829 KIO::Job *job = KIO::stat(url, side: StatJob::SourceSide, details: KIO::StatDefaultDetails | KIO::StatInode, flags: KIO::HideProgressInfo);
830 job->addMetaData(QStringLiteral("no-auth-prompt"), QStringLiteral("true"));
831 addSubjob(job);
832}
833
834std::unique_ptr<SHM> SHM::create(int size)
835{
836#if WITH_SHM
837 int id = shmget(IPC_PRIVATE, size: size, IPC_CREAT | 0600);
838
839 if (id == -1) {
840 return nullptr;
841 }
842
843 uchar *address = (uchar *)(shmat(shmid: id, shmaddr: nullptr, SHM_RDONLY));
844
845 if (address == (uchar *)-1) {
846 shmctl(shmid: id, IPC_RMID, buf: nullptr);
847 return nullptr;
848 }
849
850 return std::make_unique<SHM>(args&: id, args&: address);
851#else
852 return nullptr;
853#endif
854}
855
856int SHM::id() const
857{
858 return m_id;
859}
860
861uchar *SHM::address() const
862{
863 return m_address;
864}
865
866SHM::~SHM()
867{
868#if WITH_SHM
869 shmdt(shmaddr: (char *)m_address);
870 shmctl(shmid: m_id, IPC_RMID, buf: nullptr);
871#endif
872}
873
874SHM::SHM(int id, uchar *address)
875 : m_id(id)
876 , m_address(address)
877{
878}
879
880#include "moc_filepreviewjob.cpp"
881

source code of kio/src/gui/filepreviewjob.cpp