KNewStuff

installation.cpp
1 /*
2  This file is part of KNewStuff2.
3  SPDX-FileCopyrightText: 2007 Josef Spillner <[email protected]>
4  SPDX-FileCopyrightText: 2009 Frederik Gladhorn <[email protected]>
5 
6  SPDX-License-Identifier: LGPL-2.1-or-later
7 */
8 
9 #include "installation.h"
10 
11 #include <QDesktopServices>
12 #include <QDir>
13 #include <QFile>
14 #include <QProcess>
15 #include <QTemporaryFile>
16 #include <QUrlQuery>
17 
18 #include "karchive.h"
19 #include "qmimedatabase.h"
20 #include <KRandom>
21 #include <KShell>
22 #include <KTar>
23 #include <KZip>
24 
25 #include "jobs/kpackagejob.h"
26 #include <KPackage/Package>
27 #include <KPackage/PackageLoader>
28 #include <KPackage/PackageStructure>
29 
30 #include <KLocalizedString>
31 #include <knewstuffcore_debug.h>
32 #include <qstandardpaths.h>
33 
34 #include "jobs/filecopyjob.h"
35 #include "question.h"
36 #ifdef Q_OS_WIN
37 #include <shlobj.h>
38 #include <windows.h>
39 #endif
40 
41 using namespace KNSCore;
42 
44  : QObject(parent)
45 {
46  // TODO KF6 Make these real properties, when we can refactor this and add a proper dptr
47  setProperty("kpackageType", QLatin1String(""));
48  setProperty("uncompressSetting", UncompressionOptions::NeverUncompress);
49 }
50 
51 bool Installation::readConfig(const KConfigGroup &group)
52 {
53  // FIXME: add support for several categories later on
54  // FIXME: read out only when actually installing as a performance improvement?
55  uncompression = group.readEntry("Uncompress", QStringLiteral("never"));
57  // support old value of true as equivalent of always
58  if (uncompression == QLatin1String("true")) {
59  uncompression = QStringLiteral("always");
60  }
61  if (uncompression == QLatin1String("always")) {
62  opt = AlwaysUncompress;
63  } else if (uncompression == QLatin1String("archive")) {
64  opt = UncompressIfArchive;
65  } else if (uncompression == QLatin1String("subdir")) {
67  } else if (uncompression == QLatin1String("kpackage")) {
69  } else if (uncompression == QLatin1String("subdir-archive")) {
71  } else if (uncompression == QLatin1String("never")) {
72  opt = NeverUncompress;
73  } else {
74  qCCritical(KNEWSTUFFCORE) << "invalid Uncompress setting chosen, must be one of: subdir, always, archive, never, or kpackage";
75  return false;
76  }
77  setProperty("uncompressSetting", opt);
78  setProperty("kpackageType", group.readEntry("KPackageType"));
79  postInstallationCommand = group.readEntry("InstallationCommand");
80  uninstallCommand = group.readEntry("UninstallCommand");
81  standardResourceDirectory = group.readEntry("StandardResource");
82  targetDirectory = group.readEntry("TargetDir");
83  xdgTargetDirectory = group.readEntry("XdgTargetDir");
84 
85 #if KNEWSTUFFCORE_BUILD_DEPRECATED_SINCE(5, 77)
86  // Provide some compatibility
87  if (standardResourceDirectory == QLatin1String("wallpaper")) {
88  xdgTargetDirectory = QStringLiteral("wallpapers");
89  }
90 
91  // also, ensure wallpapers are decompressed into subdirectories
92  // this ensures that wallpapers with multiple resolutions continue to function
93  // as expected
94  if (xdgTargetDirectory == QLatin1String("wallpapers")) {
95  uncompression = QStringLiteral("subdir");
96  }
97 
98  // A touch of special treatment for the various old kpackage based knsrc files, so they work
99  // with the new, internal stuff. The result unfortunately is that all entries marked as
100  // installed in knewstuff no longer will be, but since it never worked right anyway... we'll
101  // simply have to live with that.
102  // clang-format off
103  if (postInstallationCommand.startsWith(QLatin1String("kpackagetool5 -t")) &&
104  postInstallationCommand.endsWith(QLatin1String("-i %f")) &&
105  uninstallCommand.startsWith(QLatin1String("kpackagetool5 -t")) &&
106  uninstallCommand.endsWith(QLatin1String("-r %f"))) {
107  uncompression = QStringLiteral("kpackage");
108  postInstallationCommand = QLatin1String("");
109  // Not clearing uninstallCommand, as this is used for the fallback situation
110  setProperty("kpackageType", uninstallCommand.mid(17, uninstallCommand.length() - 17 - 6));
111  qCWarning(KNEWSTUFFCORE) << "Your configuration file uses an old version of the kpackage support, and should be converted. Please report this to the author of the software you are currently using. The package type, we assume, is" << property("kpackageType").toString();
112  } else if (postInstallationCommand.startsWith(QLatin1String("kpackagetool5 --type")) &&
113  postInstallationCommand.endsWith(QLatin1String("--install %f")) &&
114  uninstallCommand.startsWith(QLatin1String("kpackagetool5 --type")) &&
115  uninstallCommand.endsWith(QLatin1String("--remove %f"))) {
116  uncompression = QStringLiteral("kpackage");
117  postInstallationCommand = QLatin1String("");
118  // Not clearing uninstallCommand, as this is used for the fallback situation
119  setProperty("kpackageType", uninstallCommand.mid(21, uninstallCommand.length() - 21 - 12));
120  qCWarning(KNEWSTUFFCORE) << "Your configuration file uses an old version of the kpackage support, and should be converted. Please report this to the author of the software you are currently using. The package type, we assume, is" << property("kpackageType").toString();
121  }
122  // clang-format on
123 #endif
124 #if KNEWSTUFFCORE_BUILD_DEPRECATED_SINCE(5, 79)
125  customName = group.readEntry("CustomName", false);
126  if (customName) {
127  qWarning(KNEWSTUFFCORE) << "The CustomName property is deprecated and will be removed in KF6";
128  }
129  QString scopeString = group.readEntry("Scope");
130  if (!scopeString.isEmpty()) {
131  qWarning(KNEWSTUFFCORE) << "Setting the scope is deprecated, it will default to user";
132  if (scopeString == QLatin1String("user")) {
133  scope = ScopeUser;
134  } else if (scopeString == QLatin1String("system")) {
135  scope = ScopeSystem;
136  } else {
137  qCCritical(KNEWSTUFFCORE) << QStringLiteral("The scope '") + scopeString + QStringLiteral("' is unknown.");
138  return false;
139  }
140 
141  if (scope == ScopeSystem) {
142  if (!installPath.isEmpty()) {
143  qCCritical(KNEWSTUFFCORE) << "System installation cannot be mixed with InstallPath.";
144  return false;
145  }
146  }
147  }
148 
149  QString checksumpolicy = group.readEntry("ChecksumPolicy");
150  if (!checksumpolicy.isEmpty()) {
151  qWarning(KNEWSTUFFCORE) << "The ChecksumPolicy feature is defunct";
152  if (checksumpolicy == QLatin1String("never")) {
153  checksumPolicy = Installation::CheckNever;
154  } else if (checksumpolicy == QLatin1String("ifpossible")) {
155  checksumPolicy = Installation::CheckIfPossible;
156  } else if (checksumpolicy == QLatin1String("always")) {
157  checksumPolicy = Installation::CheckAlways;
158  } else {
159  qCCritical(KNEWSTUFFCORE) << QStringLiteral("The checksum policy '") + checksumpolicy + QStringLiteral("' is unknown.");
160  return false;
161  }
162  }
163 
164  QString signaturepolicy = group.readEntry("SignaturePolicy");
165  if (!signaturepolicy.isEmpty()) {
166  qWarning(KNEWSTUFFCORE) << "The SignaturePolicy feature is defunct";
167  if (signaturepolicy == QLatin1String("never")) {
168  signaturePolicy = Installation::CheckNever;
169  } else if (signaturepolicy == QLatin1String("ifpossible")) {
170  signaturePolicy = Installation::CheckIfPossible;
171  } else if (signaturepolicy == QLatin1String("always")) {
172  signaturePolicy = Installation::CheckAlways;
173  } else {
174  qCCritical(KNEWSTUFFCORE) << QStringLiteral("The signature policy '") + signaturepolicy + QStringLiteral("' is unknown.");
175  return false;
176  }
177  }
178  acceptHtml = group.readEntry("AcceptHtmlDownloads", false);
179  if (acceptHtml) {
180  qWarning(KNEWSTUFFCORE) << "The AcceptHtmlDownload property is deprecated and will default to false. If there"
181  "is a HTML download link the user will be prompted if the installation should proceed";
182  }
183 #endif
184 
185  installPath = group.readEntry("InstallPath");
186  absoluteInstallPath = group.readEntry("AbsoluteInstallPath");
187 
188  if (standardResourceDirectory.isEmpty() && targetDirectory.isEmpty() && xdgTargetDirectory.isEmpty() && installPath.isEmpty()
189  && absoluteInstallPath.isEmpty()) {
190  qCCritical(KNEWSTUFFCORE) << "No installation target set";
191  return false;
192  }
193  return true;
194 }
195 
196 #if KNEWSTUFFCORE_BUILD_DEPRECATED_SINCE(5, 71)
197 bool Installation::isRemote() const
198 {
199  return false;
200 }
201 #endif
202 
204 {
205  downloadPayload(entry);
206 }
207 
209 {
210  if (!entry.isValid()) {
211  Q_EMIT signalInstallationFailed(i18n("Invalid item."));
212  return;
213  }
214  QUrl source = QUrl(entry.payload());
215 
216  if (!source.isValid()) {
217  qCCritical(KNEWSTUFFCORE) << "The entry doesn't have a payload.";
218  Q_EMIT signalInstallationFailed(i18n("Download of item failed: no download URL for \"%1\".", entry.name()));
219  return;
220  }
221 
222  QString fileName(source.fileName());
223  QTemporaryFile tempFile(QDir::tempPath() + QStringLiteral("/XXXXXX-") + fileName);
224  if (!tempFile.open()) {
225  return; // ERROR
226  }
227  QUrl destination = QUrl::fromLocalFile(tempFile.fileName());
228  qCDebug(KNEWSTUFFCORE) << "Downloading payload" << source << "to" << destination;
229 
230  // FIXME: check for validity
231  FileCopyJob *job = FileCopyJob::file_copy(source, destination, -1, JobFlag::Overwrite | JobFlag::HideProgressInfo);
232  connect(job, &KJob::result, this, &Installation::slotPayloadResult);
233 
234  entry_jobs[job] = entry;
235 }
236 
237 void Installation::slotPayloadResult(KJob *job)
238 {
239  // for some reason this slot is getting called 3 times on one job error
240  if (entry_jobs.contains(job)) {
241  EntryInternal entry = entry_jobs[job];
242  entry_jobs.remove(job);
243 
244  if (job->error()) {
245  const QString errorMessage = i18n("Download of \"%1\" failed, error: %2", entry.name(), job->errorString());
246  qCWarning(KNEWSTUFFCORE) << errorMessage;
247  Q_EMIT signalInstallationFailed(errorMessage);
248  } else {
249  FileCopyJob *fcjob = static_cast<FileCopyJob *>(job);
250 #if KNEWSTUFFCORE_BUILD_DEPRECATED_SINCE(5, 79)
251  // check if the app likes html files - disabled by default as too many bad links have been submitted to opendesktop.org
252  if (!acceptHtml) {
253 #endif
254  QMimeDatabase db;
255  QMimeType mimeType = db.mimeTypeForFile(fcjob->destUrl().toLocalFile());
256  if (mimeType.inherits(QStringLiteral("text/html")) || mimeType.inherits(QStringLiteral("application/x-php"))) {
257  Question question;
258  question.setQuestion(
259  i18n("The downloaded file is a html file. This indicates a link to a website instead of the actual download. Would you like to open "
260  "the site with a browser instead?"));
261  question.setTitle(i18n("Possibly bad download link"));
262  if (question.ask() == Question::YesResponse) {
263  QDesktopServices::openUrl(fcjob->srcUrl());
264  Q_EMIT signalInstallationFailed(i18n("Downloaded file was a HTML file. Opened in browser."));
265  entry.setStatus(KNS3::Entry::Invalid);
266  Q_EMIT signalEntryChanged(entry);
267  return;
268  }
269  }
270 #if KNEWSTUFFCORE_BUILD_DEPRECATED_SINCE(5, 79)
271  }
272 #endif
273 
274  Q_EMIT signalPayloadLoaded(fcjob->destUrl());
275  install(entry, fcjob->destUrl().toLocalFile());
276  }
277  }
278 }
279 
280 void KNSCore::Installation::install(KNSCore::EntryInternal entry, const QString &downloadedFile)
281 {
282  qCDebug(KNEWSTUFFCORE) << "Install: " << entry.name() << " from " << downloadedFile;
283 
284  if (entry.payload().isEmpty()) {
285  qCDebug(KNEWSTUFFCORE) << "No payload associated with: " << entry.name();
286  return;
287  }
288 
289  // TODO Add async checksum verification
290 
291  QString targetPath = targetInstallationPath();
292  QStringList installedFiles = installDownloadedFileAndUncompress(entry, downloadedFile, targetPath);
293 
295  if (installedFiles.isEmpty()) {
296  if (entry.status() == KNS3::Entry::Installing) {
297  entry.setStatus(KNS3::Entry::Downloadable);
298  } else if (entry.status() == KNS3::Entry::Updating) {
299  entry.setStatus(KNS3::Entry::Updateable);
300  }
301  Q_EMIT signalEntryChanged(entry);
302  Q_EMIT signalInstallationFailed(i18n("Could not install \"%1\": file not found.", entry.name()));
303  return;
304  }
305 
306  entry.setInstalledFiles(installedFiles);
307 
308  auto installationFinished = [this, entry]() {
309  EntryInternal newentry = entry;
310  if (!newentry.updateVersion().isEmpty()) {
311  newentry.setVersion(newentry.updateVersion());
312  }
313  if (newentry.updateReleaseDate().isValid()) {
314  newentry.setReleaseDate(newentry.updateReleaseDate());
315  }
316  newentry.setStatus(KNS3::Entry::Installed);
317  Q_EMIT signalEntryChanged(newentry);
318  Q_EMIT signalInstallationFinished();
319  };
320  if (!postInstallationCommand.isEmpty()) {
321  QString scriptArgPath = !installedFiles.isEmpty() ? installedFiles.first() : targetPath;
322  if (scriptArgPath.endsWith(QLatin1Char('*'))) {
323  scriptArgPath = scriptArgPath.left(scriptArgPath.lastIndexOf(QLatin1Char('*')));
324  }
325  QProcess *p = runPostInstallationCommand(scriptArgPath);
326  connect(p,
327  qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
328  this,
329  [entry, installationFinished, this](int exitCode, QProcess::ExitStatus) {
330  if (exitCode) {
331  EntryInternal newEntry = entry;
332  newEntry.setStatus(KNS3::Entry::Invalid);
333  Q_EMIT signalEntryChanged(newEntry);
334  } else {
335  installationFinished();
336  }
337  });
338  } else {
339  installationFinished();
340  }
341  }
342 }
343 
345 {
346  // installdir is the target directory
347  QString installdir;
348 
349 #if KNEWSTUFFCORE_BUILD_DEPRECATED_SINCE(5, 79)
350  const bool userScope = scope == ScopeUser;
351 #else
352  const bool userScope = true;
353 #endif
354  // installpath also contains the file name if it's a single file, otherwise equal to installdir
355  int pathcounter = 0;
356  // wallpaper is already managed in the case of !xdgTargetDirectory.isEmpty()
357  if (!standardResourceDirectory.isEmpty() && standardResourceDirectory != QLatin1String("wallpaper")) {
359  // crude translation KStandardDirs names -> QStandardPaths enum
360  if (standardResourceDirectory == QLatin1String("tmp")) {
361  location = QStandardPaths::TempLocation;
362  } else if (standardResourceDirectory == QLatin1String("config")) {
364  }
365 
366  if (userScope) {
367  installdir = QStandardPaths::writableLocation(location);
368  } else { // system scope
369  installdir = QStandardPaths::standardLocations(location).constLast();
370  }
371  pathcounter++;
372  }
373  if (!targetDirectory.isEmpty() && targetDirectory != QLatin1String("/")) {
374  if (userScope) {
376  } else { // system scope
378  }
379  pathcounter++;
380  }
381  if (!xdgTargetDirectory.isEmpty() && xdgTargetDirectory != QLatin1String("/")) {
382  installdir = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation) + QLatin1Char('/') + xdgTargetDirectory + QLatin1Char('/');
383  pathcounter++;
384  }
385  if (!installPath.isEmpty()) {
386 #if defined(Q_OS_WIN)
387  WCHAR wPath[MAX_PATH + 1];
388  if (SHGetFolderPathW(NULL, CSIDL_APPDATA, NULL, SHGFP_TYPE_CURRENT, wPath) == S_OK) {
389  installdir = QString::fromUtf16((const ushort *)wPath) + QLatin1Char('/') + installPath + QLatin1Char('/');
390  } else {
391  installdir = QDir::homePath() + QLatin1Char('/') + installPath + QLatin1Char('/');
392  }
393 #else
394  installdir = QDir::homePath() + QLatin1Char('/') + installPath + QLatin1Char('/');
395 #endif
396  pathcounter++;
397  }
398  if (!absoluteInstallPath.isEmpty()) {
399  installdir = absoluteInstallPath + QLatin1Char('/');
400  pathcounter++;
401  }
402 
403  if (pathcounter != 1) {
404  qCCritical(KNEWSTUFFCORE) << "Wrong number of installation directories given.";
405  return QString();
406  }
407 
408  qCDebug(KNEWSTUFFCORE) << "installdir: " << installdir;
409 
410  // create the dir if it doesn't exist (QStandardPaths doesn't create it, unlike KStandardDirs!)
411  QDir().mkpath(installdir);
412 
413  return installdir;
414 }
415 
416 QStringList Installation::installDownloadedFileAndUncompress(const KNSCore::EntryInternal &entry, const QString &payloadfile, const QString installdir)
417 {
418  // Collect all files that were installed
419  QStringList installedFiles;
420  bool isarchive = true;
421  UncompressionOptions uncompressionOpt = uncompressionSetting();
422 
423  // respect the uncompress flag in the knsrc
424  if (uncompressionOpt == UseKPackageUncompression) {
425  qCDebug(KNEWSTUFFCORE) << "Using KPackage for installation";
426  KPackage::PackageStructure structure;
427  KPackage::Package package(&structure);
428  QString serviceType;
429  package.setPath(payloadfile);
430  auto resetEntryStatus = [this, entry]() {
431  KNSCore::EntryInternal changedEntry(entry);
432  if (changedEntry.status() == KNS3::Entry::Installing || changedEntry.status() == KNS3::Entry::Installed) {
433  changedEntry.setStatus(KNS3::Entry::Downloadable);
434  } else if (changedEntry.status() == KNS3::Entry::Updating) {
435  changedEntry.setStatus(KNS3::Entry::Updateable);
436  }
437  Q_EMIT signalEntryChanged(changedEntry);
438  };
439  if (package.isValid() && package.metadata().isValid()) {
440  qCDebug(KNEWSTUFFCORE) << "Package metadata is valid";
441  serviceType = package.metadata().value(QStringLiteral("X-Plasma-ServiceType"));
442  if (serviceType.isEmpty() && !package.metadata().serviceTypes().isEmpty()) {
443  serviceType = package.metadata().serviceTypes().first();
444  }
445  if (serviceType.isEmpty()) {
446  serviceType = property("kpackageType").toString();
447  }
448  if (!serviceType.isEmpty()) {
449  qCDebug(KNEWSTUFFCORE) << "Service type discovered as" << serviceType;
451  if (structure) {
452  KPackage::Package installer = KPackage::Package(structure);
453  if (installer.hasValidStructure()) {
454  QString packageRoot =
456  qCDebug(KNEWSTUFFCORE) << "About to attempt to install" << package.metadata().pluginId() << "into" << packageRoot;
457  const QString expectedDir{packageRoot + package.metadata().pluginId()};
458  KJob *installJob = KPackageJob::update(payloadfile, packageRoot, serviceType);
459  // TODO KF6 Really, i would prefer to make more functions to handle this, but as this is
460  // an exported class, i'd rather not pollute the public namespace with internal functions,
461  // and we don't have a pimpl, so... we'll just have to deal with it for now
462  connect(installJob, &KJob::result, this, [this, entry, payloadfile, expectedDir, resetEntryStatus](KJob *job) {
463  if (job->error() == KJob::NoError) {
464  if (QFile::exists(expectedDir)) {
465  EntryInternal newentry = entry;
466  newentry.setInstalledFiles(QStringList{expectedDir});
467  // update version and release date to the new ones
468  if (newentry.status() == KNS3::Entry::Updating) {
469  if (!newentry.updateVersion().isEmpty()) {
470  newentry.setVersion(newentry.updateVersion());
471  }
472  if (newentry.updateReleaseDate().isValid()) {
473  newentry.setReleaseDate(newentry.updateReleaseDate());
474  }
475  }
476  newentry.setStatus(KNS3::Entry::Installed);
477  // We can remove the downloaded file, because we don't save its location and don't need it to uninstall the entry
478  QFile::remove(payloadfile);
479  Q_EMIT signalEntryChanged(newentry);
480  Q_EMIT signalInstallationFinished();
481  qCDebug(KNEWSTUFFCORE) << "Install job finished with no error and we now have files" << expectedDir;
482  } else {
483  Q_EMIT signalInstallationFailed(
484  i18n("The installation of %1 failed to create the expected new directory %2").arg(payloadfile, expectedDir));
485  resetEntryStatus();
486  qCDebug(KNEWSTUFFCORE)
487  << "Install job finished with no error, but we do not have the expected new directory" << expectedDir;
488  }
489  } else {
490  if (job->error() == KPackage::Package::JobError::NewerVersionAlreadyInstalledError) {
491  EntryInternal newentry = entry;
492  newentry.setStatus(KNS3::Entry::Installed);
493  newentry.setInstalledFiles(QStringList{expectedDir});
494  // update version and release date to the new ones
495  if (!newentry.updateVersion().isEmpty()) {
496  newentry.setVersion(newentry.updateVersion());
497  }
498  if (newentry.updateReleaseDate().isValid()) {
499  newentry.setReleaseDate(newentry.updateReleaseDate());
500  }
501  Q_EMIT signalEntryChanged(newentry);
502  Q_EMIT signalInstallationFinished();
503  qCDebug(KNEWSTUFFCORE) << "Install job finished telling us this item was already installed with this version, so... let's "
504  "just make a small fib and say we totally installed that, honest, and we now have files"
505  << expectedDir;
506  } else {
507  Q_EMIT signalInstallationFailed(i18n("Installation of %1 failed: %2", payloadfile, job->errorText()));
508  resetEntryStatus();
509  qCDebug(KNEWSTUFFCORE) << "Install job finished with error state" << job->error() << "and description" << job->error();
510  }
511  }
512  });
513  installJob->start();
514  } else {
515  Q_EMIT signalInstallationFailed(
516  i18n("The installation of %1 failed, as the service type %2 was not accepted by the system (did you forget to install the KPackage "
517  "support plugin for this type of package?)",
518  payloadfile,
519  serviceType));
520  resetEntryStatus();
521  qCWarning(KNEWSTUFFCORE) << "Package serviceType" << serviceType << "not found";
522  }
523  } else {
524  // no package structure
525  Q_EMIT signalInstallationFailed(
526  i18n("The installation of %1 failed, as the downloaded package does not contain a correct KPackage structure.", payloadfile));
527  resetEntryStatus();
528  qCWarning(KNEWSTUFFCORE) << "Could not load the package structure for KPackage service type" << serviceType;
529  }
530  } else {
531  // no service type
532  Q_EMIT signalInstallationFailed(i18n("The installation of %1 failed, as the downloaded package does not list a service type.", payloadfile));
533  resetEntryStatus();
534  qCWarning(KNEWSTUFFCORE) << "No service type listed in" << payloadfile;
535  }
536  } else {
537  // package or package metadata is invalid
538  Q_EMIT signalInstallationFailed(
539  i18n("The installation of %1 failed, as the downloaded package does not contain any useful meta information, which means it is not a valid "
540  "KPackage.",
541  payloadfile));
542  resetEntryStatus();
543  qCWarning(KNEWSTUFFCORE) << "No valid meta information (which suggests no valid KPackage) found in" << payloadfile;
544  }
545  } else {
546  if (uncompressionOpt == AlwaysUncompress || uncompressionOpt == UncompressIntoSubdirIfArchive || uncompressionOpt == UncompressIfArchive
547  || uncompressionOpt == UncompressIntoSubdir) {
548  // this is weird but a decompression is not a single name, so take the path instead
549  QMimeDatabase db;
550  QMimeType mimeType = db.mimeTypeForFile(payloadfile);
551  qCDebug(KNEWSTUFFCORE) << "Postinstallation: uncompress the file";
552 
553  // FIXME: check for overwriting, malicious archive entries (../foo) etc.
554  // FIXME: KArchive should provide "safe mode" for this!
555  QScopedPointer<KArchive> archive;
556 
557  if (mimeType.inherits(QStringLiteral("application/zip"))) {
558  archive.reset(new KZip(payloadfile));
559  // clang-format off
560  } else if (mimeType.inherits(QStringLiteral("application/tar"))
561  || mimeType.inherits(QStringLiteral("application/x-gzip"))
562  || mimeType.inherits(QStringLiteral("application/x-bzip"))
563  || mimeType.inherits(QStringLiteral("application/x-lzma"))
564  || mimeType.inherits(QStringLiteral("application/x-xz"))
565  || mimeType.inherits(QStringLiteral("application/x-bzip-compressed-tar"))
566  || mimeType.inherits(QStringLiteral("application/x-compressed-tar"))) {
567  // clang-format on
568  archive.reset(new KTar(payloadfile));
569  } else {
570  qCCritical(KNEWSTUFFCORE) << "Could not determine type of archive file '" << payloadfile << "'";
571  if (uncompressionOpt == AlwaysUncompress) {
572  Q_EMIT signalInstallationError(i18n("Could not determine the type of archive of the downloaded file %1", payloadfile));
573  return QStringList();
574  }
575  isarchive = false;
576  }
577 
578  if (isarchive) {
579  bool success = archive->open(QIODevice::ReadOnly);
580  if (!success) {
581  qCCritical(KNEWSTUFFCORE) << "Cannot open archive file '" << payloadfile << "'";
582  if (uncompressionOpt == AlwaysUncompress) {
584  i18n("Failed to open the archive file %1. The reported error was: %2", payloadfile, archive->errorString()));
585  return QStringList();
586  }
587  // otherwise, just copy the file
588  isarchive = false;
589  }
590 
591  if (isarchive) {
592  const KArchiveDirectory *dir = archive->directory();
593  // if there is more than an item in the file, and we are requested to do so
594  // put contents in a subdirectory with the same name as the file
595  QString installpath;
596  const bool isSubdir =
597  (uncompressionOpt == UncompressIntoSubdir || uncompressionOpt == UncompressIntoSubdirIfArchive) && dir->entries().count() > 1;
598  if (isSubdir) {
599  installpath = installdir + QLatin1Char('/') + QFileInfo(archive->fileName()).baseName();
600  } else {
601  installpath = installdir;
602  }
603 
604  if (dir->copyTo(installpath)) {
605  // If we extracted the subdir we want to save it using the /* notation like we would when using the "archive" option
606  // Also if we use an (un)install command we only call it once with the folder as argument and not for each file
607  if (isSubdir) {
608  installedFiles << QDir(installpath).absolutePath() + QLatin1String("/*");
609  } else {
610  installedFiles << archiveEntries(installpath, dir);
611  }
612  } else {
613  qCWarning(KNEWSTUFFCORE) << "could not install" << entry.name() << "to" << installpath;
614  }
615 
616  archive->close();
617  QFile::remove(payloadfile);
618  }
619  }
620  }
621 
622  qCDebug(KNEWSTUFFCORE) << "isarchive: " << isarchive;
623 
624  // some wallpapers are compressed, some aren't
625  if ((!isarchive && standardResourceDirectory == QLatin1String("wallpaper"))
626  || (uncompressionOpt == NeverUncompress || (uncompressionOpt == UncompressIfArchive && !isarchive)
627  || (uncompressionOpt == UncompressIntoSubdirIfArchive && !isarchive))) {
628  // no decompress but move to target
629 
630  /// @todo when using KIO::get the http header can be accessed and it contains a real file name.
631  // FIXME: make naming convention configurable through *.knsrc? e.g. for kde-look.org image names
632  QUrl source = QUrl(entry.payload());
633  qCDebug(KNEWSTUFFCORE) << "installing non-archive from " << source.url();
634 #if KNEWSTUFF_BUILD_DEPRECATED_SINCE(5, 79)
635  QString installfile;
636  QString ext = source.fileName().section(QLatin1Char('.'), -1);
637  if (customName) {
638  // Otherwise name can be interpreted as path
639  installfile = entry.name().remove(QLatin1Char('/'));
640  if (!entry.version().isEmpty()) {
641  installfile += QLatin1Char('-') + entry.version();
642  }
643  if (!ext.isEmpty()) {
644  installfile += QLatin1Char('.') + ext;
645  }
646  } else {
647  installfile = source.fileName();
648  }
649  const QString installpath = QDir(installdir).filePath(installfile);
650 #else
651  const QString installpath = QDir(installdir).filePath(source.fileName());
652 #endif
653 
654  qCDebug(KNEWSTUFFCORE) << "Install to file " << installpath;
655  // FIXME: copy goes here (including overwrite checking)
656  // FIXME: what must be done now is to update the cache *again*
657  // in order to set the new payload filename (on root tag only)
658  // - this might or might not need to take uncompression into account
659  // FIXME: for updates, we might need to force an overwrite (that is, deleting before)
660  QFile file(payloadfile);
661  bool success = true;
662  const bool update = ((entry.status() == KNS3::Entry::Updateable) || (entry.status() == KNS3::Entry::Updating));
663 
664  if (QFile::exists(installpath) && QDir::tempPath() != installdir) {
665  if (!update) {
666  Question question(Question::YesNoQuestion);
667  question.setQuestion(i18n("This file already exists on disk (possibly due to an earlier failed download attempt). Continuing means "
668  "overwriting it. Do you wish to overwrite the existing file?")
669  + QStringLiteral("\n'") + installpath + QLatin1Char('\''));
670  question.setTitle(i18n("Overwrite File"));
671  if (question.ask() != Question::YesResponse) {
672  return QStringList();
673  }
674  }
675  success = QFile::remove(installpath);
676  }
677  if (success) {
678  // remove in case it's already present and in a temporary directory, so we get to actually use the path again
679  if (installpath.startsWith(QDir::tempPath())) {
680  file.remove(installpath);
681  }
682  success = file.rename(installpath);
683  qCDebug(KNEWSTUFFCORE) << "move: " << file.fileName() << " to " << installpath;
684  }
685  if (!success) {
686  Q_EMIT signalInstallationError(i18n("Unable to move the file %1 to the intended destination %2", payloadfile, installpath));
687  qCCritical(KNEWSTUFFCORE) << "Cannot move file '" << payloadfile << "' to destination '" << installpath << "'";
688  return QStringList();
689  }
690  installedFiles << installpath;
691  }
692  }
693 
694  return installedFiles;
695 }
696 
697 QProcess *Installation::runPostInstallationCommand(const QString &installPath)
698 {
699  QString command(postInstallationCommand);
700  QString fileArg(KShell::quoteArg(installPath));
701  command.replace(QLatin1String("%f"), fileArg);
702 
703  qCDebug(KNEWSTUFFCORE) << "Run command: " << command;
704 
705  QProcess *ret = new QProcess(this);
706  auto onProcessFinished = [this, command, ret](int exitcode, QProcess::ExitStatus status) {
708  if (status == QProcess::CrashExit) {
709  QString errorMessage = i18n("The installation failed while attempting to run the command:\n%1\n\nThe returned output was:\n%2", command, output);
710  Q_EMIT signalInstallationError(errorMessage);
711  qCCritical(KNEWSTUFFCORE) << "Process crashed with command: " << command;
712  } else if (exitcode) {
713  // 130 means Ctrl+C as an exit code this is interpreted by KNewStuff as cancel operation
714  // and no error will be displayed to the user, BUG: 436355
715  if (exitcode == 130) {
716  qCCritical(KNEWSTUFFCORE) << "Command '" << command << "' failed was aborted by the user";
717  } else {
719  i18n("The installation failed with code %1 while attempting to run the command:\n%2\n\nThe returned output was:\n%3",
720  exitcode,
721  command,
722  output));
723  qCCritical(KNEWSTUFFCORE) << "Command '" << command << "' failed with code" << exitcode;
724  }
725  }
726  sender()->deleteLater();
727  };
728  connect(ret, static_cast<void (QProcess::*)(int, QProcess::ExitStatus)>(&QProcess::finished), this, onProcessFinished);
729 
730  QStringList args = KShell::splitArgs(command);
731  ret->setProgram(args.takeFirst());
732  ret->setArguments(args);
733  ret->start();
734  return ret;
735 }
736 
738 {
739  // TODO Put this in pimpl or job
740  const auto deleteFilesAndMarkAsUninstalled = [entry, this]() {
741  KNS3::Entry::Status newStatus{KNS3::Entry::Deleted};
742  const auto lst = entry.installedFiles();
743  for (const QString &file : lst) {
744  // This is used to delete the download location if there are no more entries
745  QFileInfo info(file);
746  if (info.isDir()) {
747  QDir().rmdir(file);
748  } else if (file.endsWith(QLatin1String("/*"))) {
749  QDir dir(file.left(file.size() - 2));
750  bool worked = dir.removeRecursively();
751  if (!worked) {
752  qCWarning(KNEWSTUFFCORE) << "Couldn't remove" << dir.path();
753  continue;
754  }
755  } else {
756  if (info.exists() || info.isSymLink()) {
757  bool worked = QFile::remove(file);
758  if (!worked) {
759  qWarning() << "unable to delete file " << file;
760  Q_EMIT signalInstallationFailed(
761  i18n("The removal of %1 failed, as the installed file %2 could not be automatically removed. You can attempt to manually delete "
762  "this file, if you believe this is an error.",
763  entry.name(),
764  file));
765  // Assume that the uninstallation has failed, and reset the entry to an installed state
766  newStatus = KNS3::Entry::Installed;
767  break;
768  }
769  } else {
770  qWarning() << "unable to delete file " << file << ". file does not exist.";
771  }
772  }
773  }
774  EntryInternal newEntry = entry;
775  if (newStatus == KNS3::Entry::Deleted) {
776  newEntry.setUnInstalledFiles(entry.installedFiles());
777  newEntry.setInstalledFiles(QStringList());
778  }
779  newEntry.setStatus(newStatus);
780  Q_EMIT signalEntryChanged(newEntry);
781  };
782 
784  const auto lst = entry.installedFiles();
785  if (lst.length() == 1) {
786  const QString installedFile{lst.first()};
787  if (QFileInfo(installedFile).isDir()) {
788  KPackage::PackageStructure structure;
789  KPackage::Package package(&structure);
790  package.setPath(installedFile);
791  if (package.isValid() && package.metadata().isValid()) {
792  QString serviceType = package.metadata().value(QStringLiteral("X-Plasma-ServiceType"));
793  if (serviceType.isEmpty() && !package.metadata().serviceTypes().isEmpty()) {
794  serviceType = package.metadata().serviceTypes().first();
795  }
796  if (serviceType.isEmpty()) {
797  serviceType = property("kpackageType").toString();
798  }
799  if (!serviceType.isEmpty()) {
801  if (structure) {
802  KPackage::Package installer = KPackage::Package(structure);
803  if (!installer.hasValidStructure()) {
804  qWarning() << "Package serviceType" << serviceType << "not found";
805  }
806  QString packageRoot =
808  KJob *removalJob = KPackageJob::uninstall(package.metadata().pluginId(), packageRoot, serviceType);
809  connect(removalJob, &KJob::result, this, [this, installedFile, installer, entry](KJob *job) {
810  EntryInternal newEntry = entry;
811  if (job->error() == KJob::NoError) {
812  newEntry.setStatus(KNS3::Entry::Deleted);
813  newEntry.setUnInstalledFiles(newEntry.installedFiles());
814  newEntry.setInstalledFiles(QStringList());
815  Q_EMIT signalEntryChanged(newEntry);
816  } else {
817  Q_EMIT signalInstallationFailed(i18n("Installation of %1 failed: %2", installedFile, job->errorText()));
818  }
819  });
820  removalJob->start();
821  } else {
822  // no package structure
823  Q_EMIT signalInstallationFailed(
824  i18n("The removal of %1 failed, as the installed package does not contain a correct KPackage structure.", installedFile));
825  }
826  } else {
827  // no service type
828  Q_EMIT signalInstallationFailed(
829  i18n("The removal of %1 failed, as the installed package is not a supported type (did you forget to install the KPackage support "
830  "plugin for this type of package?)",
831  installedFile));
832  }
833  } else {
834  // package or package metadata is invalid
835  Q_EMIT signalInstallationFailed(
836  i18n("The removal of %1 failed, as the installed package does not contain any useful meta information, which means it is not a valid "
837  "KPackage.",
838  entry.name()));
839  }
840  } else {
841  QMimeDatabase db;
842  QMimeType mimeType = db.mimeTypeForFile(installedFile);
843  if (mimeType.inherits(QStringLiteral("application/zip")) || mimeType.inherits(QStringLiteral("application/x-compressed-tar"))
844  || mimeType.inherits(QStringLiteral("application/x-gzip")) || mimeType.inherits(QStringLiteral("application/x-tar"))
845  || mimeType.inherits(QStringLiteral("application/x-bzip-compressed-tar")) || mimeType.inherits(QStringLiteral("application/x-xz"))
846  || mimeType.inherits(QStringLiteral("application/x-lzma"))) {
847  // Then it's one of the downloaded files installed with an old version of knewstuff prior to
848  // the native kpackage support being added, and we need to do some inspection-and-removal work...
849  KPackage::PackageStructure structure;
850  KPackage::Package package(&structure);
851  const QString serviceType{property("kpackageType").toString()};
852  package.setPath(installedFile);
853  if (package.isValid() && package.metadata().isValid()) {
854  // try and load the kpackage and sniff the expected location of its installation, and ask KPackage to remove that thing, if it's there
856  if (structure) {
857  KPackage::Package installer = KPackage::Package(structure);
858  if (installer.hasValidStructure()) {
859  QString packageRoot =
861  qCDebug(KNEWSTUFFCORE) << "About to attempt to uninstall" << package.metadata().pluginId() << "from" << packageRoot;
862  // Frankly, we don't care whether or not this next step succeeds, and it can just fizzle if it wants
863  // to. This is a cleanup step, and if it fails, it's just not really important.
864  KPackageJob::uninstall(package.metadata().pluginId(), packageRoot, serviceType);
865  }
866  }
867  }
868  // Also get rid of the downloaded file, and tell everything they've gone
869  if (QFile::remove(installedFile)) {
870  entry.setStatus(KNS3::Entry::Deleted);
871  entry.setUnInstalledFiles(entry.installedFiles());
873  Q_EMIT signalEntryChanged(entry);
874  } else {
875  Q_EMIT signalInstallationFailed(
876  i18n("The removal of %1 failed, as the downloaded file %2 could not be automatically removed.", entry.name(), installedFile));
877  }
878  } else {
879  // Not sure what's installed, but it's not a KPackage, not a lot we can do with this...
880  Q_EMIT signalInstallationFailed(
881  i18n("The removal of %1 failed, due to the installed file not being a KPackage. The file in question was %2, and you can attempt to "
882  "delete it yourself, if you are certain that it is not needed.",
883  entry.name(),
884  installedFile));
885  }
886  }
887  } else {
888  Q_EMIT signalInstallationFailed(
889  i18n("The removal of %1 failed, as there seems to somehow be more than one thing installed, which is not supposed to be possible for KPackage "
890  "based entries.",
891  entry.name()));
892  }
893  deleteFilesAndMarkAsUninstalled();
894  } else {
895  const auto lst = entry.installedFiles();
896  // If there is an uninstall script, make sure it runs without errors
897  if (!uninstallCommand.isEmpty()) {
898  bool validFileExisted = false;
899  for (const QString &file : lst) {
900  QString filePath = file;
901  bool validFile = QFileInfo::exists(filePath);
902  // If we have uncompressed a subdir we write <path>/* in the config, but when calling a script
903  // we want to convert this to a normal path
904  if (!validFile && file.endsWith(QLatin1Char('*'))) {
905  filePath = filePath.left(filePath.lastIndexOf(QLatin1Char('*')));
906  validFile = QFileInfo::exists(filePath);
907  }
908  if (validFile) {
909  validFileExisted = true;
910  QString fileArg(KShell::quoteArg(filePath));
911  QString command(uninstallCommand);
912  command.replace(QLatin1String("%f"), fileArg);
913 
914  QStringList args = KShell::splitArgs(command);
915  const QString program = args.takeFirst();
916  QProcess *process = new QProcess(this);
917  process->start(program, args);
918  auto onProcessFinished = [this, command, process, entry, deleteFilesAndMarkAsUninstalled](int, QProcess::ExitStatus status) {
919  if (status == QProcess::CrashExit) {
920  const QString processOutput = QString::fromLocal8Bit(process->readAllStandardError());
921  const QString err = i18n(
922  "The uninstallation process failed to successfully run the command %1\n"
923  "The output of was: \n%2\n"
924  "If you think this is incorrect, you can continue or cancel the uninstallation process",
925  KShell::quoteArg(command),
926  processOutput);
928  // Ask the user if he wants to continue, even though the script failed
929  Question question(Question::ContinueCancelQuestion);
930  question.setQuestion(err);
931  Question::Response response = question.ask();
932  if (response == Question::CancelResponse) {
933  // Use can delete files manually
934  EntryInternal newEntry = entry;
935  newEntry.setStatus(KNS3::Entry::Installed);
936  Q_EMIT signalEntryChanged(newEntry);
937  return;
938  }
939  } else {
940  qCDebug(KNEWSTUFFCORE) << "Command executed successfully: " << command;
941  }
942  deleteFilesAndMarkAsUninstalled();
943  };
944  connect(process, qOverload<int, QProcess::ExitStatus>(&QProcess::finished), this, onProcessFinished);
945  }
946  }
947  // If the entry got deleted, but the RemoveDeadEntries option was not selected this case can happen
948  if (!validFileExisted) {
949  deleteFilesAndMarkAsUninstalled();
950  }
951  } else {
952  deleteFilesAndMarkAsUninstalled();
953  }
954  }
955 }
956 
958 {
959  return property("uncompressSetting").value<UncompressionOptions>();
960 }
961 
962 #if KNEWSTUFFCORE_BUILD_DEPRECATED_SINCE(5, 31)
963 void Installation::slotInstallationVerification(int result)
964 {
965  Q_UNUSED(result)
966  // Deprecated, was wired up to defunct Security class.
967 }
968 #endif
969 
970 QStringList Installation::archiveEntries(const QString &path, const KArchiveDirectory *dir)
971 {
972  QStringList files;
973  const auto lst = dir->entries();
974  for (const QString &entry : lst) {
975  const auto currentEntry = dir->entry(entry);
976 
977  const QString childPath = QDir(path).filePath(entry);
978  if (currentEntry->isFile()) {
979  files << childPath;
980  } else if (currentEntry->isDirectory()) {
981  files << childPath + QStringLiteral("/*");
982  }
983  }
984  return files;
985 }
QString url(QUrl::FormattingOptions options) const const
QStringList serviceTypes() const
static KPackageJob * uninstall(const QString &packageName, const QString &packageRoot, const QString &serviceType)
Create a job for removing the given installed package.
A way to ask a user a question from inside a GUI-less library (like KNewStuffCore) ...
Definition: question.h:41
QString value(const QString &key, const QString &defaultValue=QString()) const
QString writableLocation(QStandardPaths::StandardLocation type)
bool remove()
QObject * sender() const const
virtual QString errorString() const
< As Archive, except that if there is more than an item in the file, put contents in a subdirectory w...
Definition: installation.h:67
UncompressionOptions uncompressionSetting() const
Returns the uncompression setting, in a computer-readable format.
bool rename(const QString &newName)
virtual QString fileName() const const override
Contains the core functionality for handling interaction with NewStuff providers. ...
void setUnInstalledFiles(const QStringList &files)
Set the files that have been uninstalled by the uninstall command.
bool removeRecursively()
QString filePath(const QString &fileName) const const
bool inherits(const QString &mimeTypeName) const const
T value() const const
static KPackageJob * update(const QString &sourcePackage, const QString &packageRoot, const QString &serviceType)
Create a job for updating the given package, or installing it if it is not already, the given package into the package root, and treat it as the given service type.
QString defaultPackageRoot() const
bool exists() const const
QString & remove(int position, int n)
void setArguments(const QStringList &arguments)
QString homePath()
bool hasValidStructure() const
< Never attempt to decompress a file, whatever format it is. Matches "never" knsrc setting ...
Definition: installation.h:60
QStringList standardLocations(QStandardPaths::StandardLocation type)
int lastIndexOf(QChar ch, int from, Qt::CaseSensitivity cs) const const
void reset(T *other)
bool copyTo(const QString &dest, bool recursive=true) const
QMimeType mimeTypeForFile(const QString &fileName, QMimeDatabase::MatchMode mode) const const
static PackageLoader * self()
void setInstalledFiles(const QStringList &files)
Set the files that have been installed by the install command.
QString payload() const
Retrieve the file name of the object.
const T & constLast() const const
int count(const T &value) const const
QString fromLocal8Bit(const char *str, int size)
QString version() const
Retrieve the version string of the object.
bool rmdir(const QString &dirName) const const
QVariant property(const char *name) const const
QString path() const const
QString tempPath()
QString fromUtf16(const ushort *unicode, int size)
bool isDir() const const
void setPath(const QString &path)
bool isEmpty() const const
QStringList installedFiles() const
Retrieve the locally installed files.
bool isEmpty() const const
bool startsWith(const QString &s, Qt::CaseSensitivity cs) const const
void finished(int exitCode)
bool isValid() const const
bool endsWith(const QString &s, Qt::CaseSensitivity cs) const const
void deleteLater()
T & first()
QDate updateReleaseDate() const
Retrieve the date of the newer version that is available as update.
void setStatus(KNS3::Entry::Status status)
Sets the entry&#39;s status.
KCOREADDONS_EXPORT QStringList splitArgs(const QString &cmd, Options flags=NoOptions, Errors *err=nullptr)
Installation(QObject *parent=nullptr)
Constructor.
KPackage::PackageStructure * loadPackageStructure(const QString &packageFormat)
void setProgram(const QString &program)
bool exists() const const
KCOREADDONS_EXPORT QString quoteArg(const QString &arg)
KCALUTILS_EXPORT QString errorMessage(const KCalendarCore::Exception &exception)
Status
Status of the entry.
Definition: entry.h:49
void signalInstallationError(const QString &message)
An informational signal fired when a serious error occurs during the installation.
< If the file is an archive, decompress it in a subdirectory if it contains multiple files...
Definition: installation.h:65
const KArchiveEntry * entry(const QString &name) const
QString targetInstallationPath() const
void uninstall(KNSCore::EntryInternal entry)
Uninstalls an entry.
void setVersion(const QString &version)
Sets the version number.
QString i18n(const char *text, const TYPE &arg...)
QString & replace(int position, int n, QChar after)
< Assume all downloaded files are archives, and attempt to decompress them.
Definition: installation.h:62
bool isValid() const const
QString mid(int position, int n) const const
KPluginMetaData metadata() const
T takeFirst()
KNewStuff data entry container.
Definition: entryinternal.h:49
QString absolutePath() const const
virtual Q_SCRIPTABLE void start()=0
int length() const const
bool isValid() const
QString section(QChar sep, int start, int end, QString::SectionFlags flags) const const
QString left(int n) const const
QString updateVersion() const
Retrieve the version string of the object that is available as update.
bool setProperty(const char *name, const QVariant &value)
void result(KJob *job)
KNS3::Entry::Status status() const
Retrieves the entry&#39;s status.
void install(const KNSCore::EntryInternal &entry)
Installs an entry&#39;s payload file.
bool openUrl(const QUrl &url)
QMetaObject::Connection connect(const QObject *sender, const char *signal, const QObject *receiver, const char *method, Qt::ConnectionType type)
QString name() const
Retrieve the name of the data object.
QString toString() const const
T readEntry(const QString &key, const T &aDefault) const
void downloadPayload(const KNSCore::EntryInternal &entry)
Downloads a payload file.
QString fileName(QUrl::ComponentFormattingOptions options) const const
QStringList entries() const
void start(const QString &program, const QStringList &arguments, QIODevice::OpenMode mode)
bool isValid() const
Q_EMITQ_EMIT
QString pluginId() const
void setReleaseDate(const QDate &releasedate)
Sets the release date.
QByteArray readAllStandardError()
bool mkpath(const QString &dirPath) const const
QString errorText() const
QUrl fromLocalFile(const QString &localFile)
< If the file is an archive, decompress it, otherwise just pass it on. Matches "archive" knsrc settin...
Definition: installation.h:63
QString locate(QStandardPaths::StandardLocation type, const QString &fileName, QStandardPaths::LocateOptions options)
int error() const
This file is part of the KDE documentation.
Documentation copyright © 1996-2021 The KDE developers.
Generated on Thu Sep 23 2021 22:42:45 by doxygen 1.8.11 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.