Warning, file /plasma/plasma-workspace/kcms/autostart/autostartmodel.cpp was not indexed or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).

0001 /*
0002     SPDX-FileCopyrightText: 2020 Méven Car <meven.car@enioka.com>
0003 
0004     SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 
0007 #include "autostartmodel.h"
0008 #include "kcm_autostart_debug.h"
0009 
0010 #include <KConfigGroup>
0011 #include <KDesktopFile>
0012 #include <KShell>
0013 #include <QDebug>
0014 #include <QQuickItem>
0015 #include <QQuickRenderControl>
0016 #include <QStandardPaths>
0017 #include <QWindow>
0018 
0019 #include <QDirIterator>
0020 #include <QFileInfo>
0021 #include <QMimeDatabase>
0022 #include <QRegularExpression>
0023 
0024 #include <set>
0025 
0026 #include <KFileItem>
0027 #include <KFileUtils>
0028 #include <KIO/CopyJob>
0029 #include <KIO/DeleteJob>
0030 #include <KLocalizedString>
0031 #include <KOpenWithDialog>
0032 #include <KPropertiesDialog>
0033 #include <autostartscriptdesktopfile.h>
0034 
0035 // FDO user autostart directories are
0036 // .config/autostart which has .desktop files executed by klaunch or systemd, some of which might be scripts
0037 
0038 // Then we have Plasma-specific locations which run scripts
0039 // .config/autostart-scripts which has scripts executed by plasma_session (now migrated to .desktop files)
0040 // .config/plasma-workspace/shutdown which has scripts executed by plasma-shutdown
0041 // .config/plasma-workspace/env which has scripts executed by startplasma
0042 
0043 // in the case of pre-startup they have to end in .sh
0044 // everywhere else it doesn't matter
0045 
0046 // the comment above describes how autostart *currently* works, it is not definitive documentation on how autostart *should* work
0047 
0048 // share/autostart shouldn't be an option as this should be reserved for global autostart entries
0049 
0050 std::optional<AutostartEntry> AutostartModel::loadDesktopEntry(const QString &fileName)
0051 {
0052     KDesktopFile config(fileName);
0053     const KConfigGroup grp = config.desktopGroup();
0054     const auto name = config.readName();
0055     const bool hidden = grp.readEntry("Hidden", false);
0056 
0057     if (hidden) {
0058         return {};
0059     }
0060 
0061     const QStringList notShowList = grp.readXdgListEntry("NotShowIn");
0062     const QStringList onlyShowList = grp.readXdgListEntry("OnlyShowIn");
0063     const bool enabled = !(notShowList.contains(QLatin1String("KDE")) || (!onlyShowList.isEmpty() && !onlyShowList.contains(QLatin1String("KDE"))));
0064 
0065     if (!enabled) {
0066         return {};
0067     }
0068 
0069     const auto lstEntry = grp.readXdgListEntry("OnlyShowIn");
0070     const bool onlyInPlasma = lstEntry.contains(QLatin1String("KDE"));
0071     const QString iconName = !config.readIcon().isEmpty() ? config.readIcon() : QStringLiteral("dialog-scripts");
0072     const auto kind = AutostartScriptDesktopFile::isAutostartScript(config) ? XdgScripts : XdgAutoStart; // .config/autostart load desktop at startup
0073     const QString tryCommand = grp.readEntry("TryExec");
0074 
0075     // Try to filter out entries that point to nonexistant programs
0076     // If TryExec is either found in $PATH or is an absolute file path that exists
0077     // This doesn't detect uninstalled Flatpaks for example though
0078     if (!tryCommand.isEmpty() && QStandardPaths::findExecutable(tryCommand).isEmpty() && !QFile::exists(tryCommand)) {
0079         return {};
0080     }
0081 
0082     if (kind == XdgScripts) {
0083         const QString targetScriptPath = grp.readEntry("Exec");
0084         const QString targetFileName = QUrl::fromLocalFile(targetScriptPath).fileName();
0085         const QString targetScriptDir = QFileInfo(targetScriptPath).absoluteDir().path();
0086 
0087         return AutostartEntry{targetFileName, targetScriptDir, kind, enabled, fileName, onlyInPlasma, iconName};
0088     }
0089 
0090     return AutostartEntry{name, name, kind, enabled, fileName, onlyInPlasma, iconName};
0091 }
0092 
0093 AutostartModel::AutostartModel(QObject *parent)
0094     : QAbstractListModel(parent)
0095     , m_xdgConfigPath(QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation))
0096     , m_xdgAutoStartPath(m_xdgConfigPath.filePath(QStringLiteral("autostart")))
0097 {
0098 }
0099 
0100 void AutostartModel::load()
0101 {
0102     beginResetModel();
0103 
0104     m_entries.clear();
0105 
0106     // Creates if doesn't already exist
0107     m_xdgAutoStartPath.mkpath(QStringLiteral("."));
0108 
0109     // Needed to add all script entries after application entries
0110     QVector<AutostartEntry> scriptEntries;
0111     const auto filesInfo = m_xdgAutoStartPath.entryInfoList(QDir::Files);
0112     for (const QFileInfo &fi : filesInfo) {
0113         if (!KDesktopFile::isDesktopFile(fi.fileName())) {
0114             continue;
0115         }
0116 
0117         const std::optional<AutostartEntry> entry = loadDesktopEntry(fi.absoluteFilePath());
0118 
0119         if (!entry) {
0120             continue;
0121         }
0122 
0123         if (entry->source == XdgScripts) {
0124             scriptEntries.push_back(entry.value());
0125         } else {
0126             m_entries.push_back(entry.value());
0127         }
0128     }
0129 
0130     m_entries.append(scriptEntries);
0131 
0132     loadScriptsFromDir(QStringLiteral("plasma-workspace/env/"), AutostartModel::AutostartEntrySource::PlasmaEnvScripts);
0133 
0134     loadScriptsFromDir(QStringLiteral("plasma-workspace/shutdown/"), AutostartModel::AutostartEntrySource::PlasmaShutdown);
0135 
0136     endResetModel();
0137 }
0138 
0139 void AutostartModel::loadScriptsFromDir(const QString &subDir, AutostartModel::AutostartEntrySource kind)
0140 {
0141     QDir dir(m_xdgConfigPath.filePath(subDir));
0142     // Creates if doesn't already exist
0143     dir.mkpath(QStringLiteral("."));
0144 
0145     const auto autostartDirFilesInfo = dir.entryInfoList(QDir::Files);
0146     for (const QFileInfo &fi : autostartDirFilesInfo) {
0147         QString targetFileDir = fi.absoluteDir().path();
0148         QString targetFilePath = fi.absoluteFilePath();
0149         QString fileName = QUrl::fromLocalFile(targetFilePath).fileName();
0150         const bool isSymlink = fi.isSymLink();
0151         if (isSymlink) {
0152             targetFilePath = fi.symLinkTarget();
0153             QFileInfo symLinkTarget(targetFilePath);
0154             targetFileDir = symLinkTarget.absoluteDir().path();
0155             fileName = symLinkTarget.fileName();
0156         }
0157 
0158         m_entries.push_back({fileName, targetFileDir, kind, true, fi.absoluteFilePath(), false, QStringLiteral("dialog-scripts")});
0159     }
0160 }
0161 
0162 int AutostartModel::rowCount(const QModelIndex &parent) const
0163 {
0164     if (parent.isValid()) {
0165         return 0;
0166     }
0167 
0168     return m_entries.count();
0169 }
0170 
0171 bool AutostartModel::reloadEntry(const QModelIndex &index, const QString &fileName)
0172 {
0173     if (!checkIndex(index)) {
0174         return false;
0175     }
0176 
0177     const std::optional<AutostartEntry> newEntry = loadDesktopEntry(fileName);
0178 
0179     if (!newEntry) {
0180         return false;
0181     }
0182 
0183     m_entries.replace(index.row(), newEntry.value());
0184     Q_EMIT dataChanged(index, index);
0185     return true;
0186 }
0187 
0188 QVariant AutostartModel::data(const QModelIndex &index, int role) const
0189 {
0190     if (!checkIndex(index)) {
0191         return QVariant();
0192     }
0193 
0194     const auto &entry = m_entries.at(index.row());
0195 
0196     switch (role) {
0197     case Name:
0198         return entry.name;
0199     case Enabled:
0200         return entry.enabled;
0201     case Source:
0202         return entry.source;
0203     case FileName:
0204         return entry.fileName;
0205     case OnlyInPlasma:
0206         return entry.onlyInPlasma;
0207     case IconName:
0208         return entry.iconName;
0209     case TargetFileDirPath:
0210         return entry.targetFileDirPath;
0211     }
0212 
0213     return QVariant();
0214 }
0215 
0216 void AutostartModel::addApplication(const KService::Ptr &service)
0217 {
0218     QString desktopPath;
0219     // It is important to ensure that we make an exact copy of an existing
0220     // desktop file (if selected) to enable users to override global autostarts.
0221     // Also see
0222     // https://bugs.launchpad.net/ubuntu/+source/kde-workspace/+bug/923360
0223     if (service->desktopEntryName().isEmpty() || service->entryPath().isEmpty()) {
0224         // create a new desktop file in s_desktopPath
0225         desktopPath = m_xdgAutoStartPath.filePath(service->name() + QStringLiteral(".desktop"));
0226 
0227         if (QFileInfo::exists(desktopPath)) {
0228             QUrl baseUrl = QUrl::fromLocalFile(m_xdgAutoStartPath.path());
0229             QString newName = suggestName(baseUrl, service->name() + QStringLiteral(".desktop"));
0230             desktopPath = m_xdgAutoStartPath.filePath(newName);
0231         }
0232 
0233         KDesktopFile desktopFile(desktopPath);
0234         KConfigGroup kcg = desktopFile.desktopGroup();
0235         kcg.writeEntry("Name", service->name());
0236         kcg.writeEntry("Exec", service->exec());
0237         kcg.writeEntry("Icon", service->icon());
0238         kcg.writeEntry("Path", "");
0239         kcg.writeEntry("Terminal", service->terminal() ? "True" : "False");
0240         kcg.writeEntry("Type", "Application");
0241         desktopFile.sync();
0242 
0243     } else {
0244         desktopPath = m_xdgAutoStartPath.filePath(service->storageId());
0245 
0246         KDesktopFile desktopFile(service->entryPath());
0247 
0248         if (QFileInfo::exists(desktopPath)) {
0249             QUrl baseUrl = QUrl::fromLocalFile(m_xdgAutoStartPath.path());
0250             QString newName = suggestName(baseUrl, service->storageId());
0251             desktopPath = m_xdgAutoStartPath.filePath(newName);
0252         }
0253 
0254         // copy original desktop file to new path
0255         auto newDesktopFile = desktopFile.copyTo(desktopPath);
0256         newDesktopFile->sync();
0257     }
0258 
0259     const QString iconName = !service->icon().isEmpty() ? service->icon() : QStringLiteral("dialog-scripts");
0260 
0261     const auto entry = AutostartEntry{service->name(),
0262                                       service->name(),
0263                                       AutostartModel::AutostartEntrySource::XdgAutoStart, // .config/autostart load desktop at startup
0264                                       true,
0265                                       desktopPath,
0266                                       false,
0267                                       iconName};
0268 
0269     int lastApplication = -1;
0270     for (const AutostartEntry &e : qAsConst(m_entries)) {
0271         if (e.source == AutostartModel::AutostartEntrySource::XdgScripts) {
0272             break;
0273         }
0274         ++lastApplication;
0275     }
0276 
0277     // push before the script items
0278     const int index = lastApplication + 1;
0279 
0280     beginInsertRows(QModelIndex(), index, index);
0281 
0282     m_entries.insert(index, entry);
0283 
0284     endInsertRows();
0285 }
0286 
0287 void AutostartModel::showApplicationDialog(QQuickItem *context)
0288 {
0289     KOpenWithDialog *owdlg = new KOpenWithDialog();
0290     owdlg->setAttribute(Qt::WA_DeleteOnClose);
0291 
0292     if (context && context->window()) {
0293         if (QWindow *actualWindow = QQuickRenderControl::renderWindowFor(context->window())) {
0294             owdlg->winId(); // so it creates windowHandle
0295             owdlg->windowHandle()->setTransientParent(actualWindow);
0296             owdlg->setModal(true);
0297         }
0298     }
0299 
0300     connect(owdlg, &QDialog::finished, this, [this, owdlg](int result) {
0301         if (result != QDialog::Accepted) {
0302             return;
0303         }
0304 
0305         const KService::Ptr service = owdlg->service();
0306 
0307         Q_ASSERT(service);
0308         if (!service) {
0309             return; // Don't crash if KOpenWith wasn't able to create service.
0310         }
0311 
0312         addApplication(service);
0313     });
0314     owdlg->open();
0315 }
0316 
0317 void AutostartModel::addScript(const QUrl &url, AutostartModel::AutostartEntrySource kind)
0318 {
0319     const QFileInfo file(url.toLocalFile());
0320 
0321     if (!file.isAbsolute()) {
0322         Q_EMIT error(i18n("\"%1\" is not an absolute url.", url.toLocalFile()));
0323         return;
0324     } else if (!file.exists()) {
0325         Q_EMIT error(i18n("\"%1\" does not exist.", url.toLocalFile()));
0326         return;
0327     } else if (!file.isFile()) {
0328         Q_EMIT error(i18n("\"%1\" is not a file.", url.toLocalFile()));
0329         return;
0330     } else if (!file.isReadable()) {
0331         Q_EMIT error(i18n("\"%1\" is not readable.", url.toLocalFile()));
0332         return;
0333     }
0334 
0335     QFile scriptFile(url.toLocalFile());
0336 
0337     if (!(scriptFile.permissions() & QFile::ExeUser)) {
0338         Q_EMIT nonExecutableScript(url.toLocalFile(), kind);
0339     }
0340 
0341     QString fileName = url.fileName();
0342 
0343     if (kind == AutostartModel::AutostartEntrySource::XdgScripts) {
0344         int lastLoginScript = -1;
0345         for (const AutostartEntry &e : qAsConst(m_entries)) {
0346             if (e.source == AutostartModel::AutostartEntrySource::PlasmaShutdown) {
0347                 break;
0348             }
0349             ++lastLoginScript;
0350         }
0351 
0352         // path of the desktop file that is about to be created
0353         const QString newFilePath = m_xdgAutoStartPath.absoluteFilePath(fileName + QStringLiteral(".desktop"));
0354 
0355         if (QFileInfo::exists(newFilePath)) {
0356             const QUrl baseUrl = QUrl::fromLocalFile(m_xdgAutoStartPath.path());
0357             fileName = suggestName(baseUrl, fileName + QStringLiteral(".desktop"));
0358 
0359             // remove the .desktop part from String
0360             fileName.chop(8);
0361         }
0362         AutostartScriptDesktopFile desktopFile(fileName, KShell::quoteArg(file.filePath()));
0363         insertScriptEntry(lastLoginScript + 1, file.fileName(), file.absoluteDir().path(), desktopFile.fileName(), kind);
0364 
0365     } else if (kind == AutostartModel::AutostartEntrySource::PlasmaShutdown) {
0366         const QUrl destinationScript = QUrl::fromLocalFile(QDir(m_xdgConfigPath.filePath(QStringLiteral("plasma-workspace/shutdown/"))).filePath(fileName));
0367         KIO::CopyJob *job = KIO::link(url, destinationScript, KIO::HideProgressInfo);
0368         job->setAutoRename(true);
0369         job->setProperty("finalUrl", destinationScript);
0370 
0371         connect(job, &KIO::CopyJob::renamed, this, [](KIO::Job *job, const QUrl &from, const QUrl &to) {
0372             Q_UNUSED(from)
0373             // in case the destination filename had to be renamed
0374             job->setProperty("finalUrl", to);
0375         });
0376 
0377         connect(job, &KJob::finished, this, [this, url, kind](KJob *theJob) {
0378             if (theJob->error()) {
0379                 qCWarning(KCM_AUTOSTART_DEBUG) << "Could not add script entry" << theJob->errorString();
0380                 return;
0381             }
0382             const QUrl dest = theJob->property("finalUrl").toUrl();
0383             const QFileInfo destFile(dest.path());
0384             const QString symLinkFileName = QUrl::fromLocalFile(destFile.symLinkTarget()).fileName();
0385             const QFileInfo symLinkTarget{destFile.symLinkTarget()};
0386             const QString symLinkTargetDir = symLinkTarget.absoluteDir().path();
0387             insertScriptEntry(m_entries.size(), symLinkFileName, symLinkTargetDir, dest.path(), kind);
0388         });
0389 
0390         job->start();
0391     } else {
0392         Q_ASSERT(0);
0393     }
0394 }
0395 
0396 void AutostartModel::insertScriptEntry(int index, const QString &name, const QString &targetFileDirPath, const QString &path, AutostartEntrySource kind)
0397 {
0398     beginInsertRows(QModelIndex(), index, index);
0399 
0400     AutostartEntry entry = AutostartEntry{name, targetFileDirPath, kind, true, path, false, QStringLiteral("dialog-scripts")};
0401 
0402     m_entries.insert(index, entry);
0403 
0404     endInsertRows();
0405 }
0406 
0407 void AutostartModel::removeEntry(int row)
0408 {
0409     const auto entry = m_entries.at(row);
0410 
0411     KIO::DeleteJob *job = KIO::del(QUrl::fromLocalFile(entry.fileName), KIO::HideProgressInfo);
0412 
0413     connect(job, &KJob::finished, this, [this, row, entry](KJob *theJob) {
0414         if (theJob->error()) {
0415             qCWarning(KCM_AUTOSTART_DEBUG) << "Could not remove entry" << theJob->errorString();
0416             return;
0417         }
0418 
0419         beginRemoveRows(QModelIndex(), row, row);
0420         m_entries.remove(row);
0421 
0422         endRemoveRows();
0423     });
0424 
0425     job->start();
0426 }
0427 
0428 QHash<int, QByteArray> AutostartModel::roleNames() const
0429 {
0430     QHash<int, QByteArray> roleNames = QAbstractListModel::roleNames();
0431 
0432     roleNames.insert(Name, QByteArrayLiteral("name"));
0433     roleNames.insert(Enabled, QByteArrayLiteral("enabled"));
0434     roleNames.insert(Source, QByteArrayLiteral("source"));
0435     roleNames.insert(FileName, QByteArrayLiteral("fileName"));
0436     roleNames.insert(OnlyInPlasma, QByteArrayLiteral("onlyInPlasma"));
0437     roleNames.insert(IconName, QByteArrayLiteral("iconName"));
0438     roleNames.insert(TargetFileDirPath, QByteArrayLiteral("targetFileDirPath"));
0439 
0440     return roleNames;
0441 }
0442 
0443 void AutostartModel::editApplication(int row, QQuickItem *context)
0444 {
0445     const QModelIndex idx = index(row, 0);
0446 
0447     const QString fileName = data(idx, AutostartModel::Roles::FileName).toString();
0448     KFileItem kfi(QUrl::fromLocalFile(fileName));
0449     kfi.setDelayedMimeTypes(true);
0450 
0451     KPropertiesDialog *dlg = new KPropertiesDialog(kfi, nullptr);
0452     dlg->setAttribute(Qt::WA_DeleteOnClose);
0453 
0454     if (context && context->window()) {
0455         if (QWindow *actualWindow = QQuickRenderControl::renderWindowFor(context->window())) {
0456             dlg->winId(); // so it creates windowHandle
0457             dlg->windowHandle()->setTransientParent(actualWindow);
0458             dlg->setModal(true);
0459         }
0460     }
0461 
0462     connect(dlg, &QDialog::finished, this, [this, idx, dlg](int result) {
0463         if (result == QDialog::Accepted) {
0464             reloadEntry(idx, dlg->item().localPath());
0465         }
0466     });
0467     dlg->open();
0468 }
0469 
0470 void AutostartModel::makeFileExecutable(const QString &fileName)
0471 {
0472     QFile file(fileName);
0473 
0474     file.setPermissions(file.permissions() | QFile::ExeUser);
0475 }
0476 
0477 // Use slightly modified code copied from frameworks KFileUtils because desktop filenames cannot contain '(' or ' '.
0478 QString AutostartModel::makeSuggestedName(const QString &oldName)
0479 {
0480     QString basename;
0481 
0482     // Extract the original file extension from the filename
0483     QMimeDatabase db;
0484     QString nameSuffix = db.suffixForFileName(oldName);
0485 
0486     if (oldName.lastIndexOf(QLatin1Char('.')) == 0) {
0487         basename = QStringLiteral(".");
0488         nameSuffix = oldName;
0489     } else if (nameSuffix.isEmpty()) {
0490         const int lastDot = oldName.lastIndexOf(QLatin1Char('.'));
0491         if (lastDot == -1) {
0492             basename = oldName;
0493         } else {
0494             basename = oldName.left(lastDot);
0495             nameSuffix = oldName.mid(lastDot);
0496         }
0497     } else {
0498         nameSuffix.prepend(QLatin1Char('.'));
0499         basename = oldName.left(oldName.length() - nameSuffix.length());
0500     }
0501 
0502     // check if (number) exists at the end of the oldName and increment that number
0503     const QRegularExpression re(QStringLiteral("_(\\d+)_"));
0504     QRegularExpressionMatch rmatch;
0505     oldName.lastIndexOf(re, -1, &rmatch);
0506     if (rmatch.hasMatch()) {
0507         const int currentNum = rmatch.captured(1).toInt();
0508         const QString number = QString::number(currentNum + 1);
0509         basename.replace(rmatch.capturedStart(1), rmatch.capturedLength(1), number);
0510     } else {
0511         // number does not exist, so just append " _1_" to filename
0512         basename += QLatin1String("_1_");
0513     }
0514 
0515     return basename + nameSuffix;
0516 }
0517 
0518 QString AutostartModel::suggestName(const QUrl &baseURL, const QString &oldName)
0519 {
0520     QString suggestedName = makeSuggestedName(oldName);
0521 
0522     if (baseURL.isLocalFile()) {
0523         const QString basePath = baseURL.toLocalFile() + QLatin1Char('/');
0524         while (QFileInfo::exists(basePath + suggestedName)) {
0525             suggestedName = makeSuggestedName(suggestedName);
0526         }
0527     }
0528 
0529     return suggestedName;
0530 }