/* SPDX-FileCopyrightText: 2020 Méven Car SPDX-License-Identifier: GPL-2.0-or-later */ #include "autostartmodel.h" #include "kcm_autostart_debug.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include using namespace Qt::StringLiterals; // FDO user autostart directories are // .config/autostart which has .desktop files executed by klaunch or systemd, some of which might be scripts // Then we have Plasma-specific locations which run scripts // .config/autostart-scripts which has scripts executed by plasma_session (now migrated to .desktop files) // .config/plasma-workspace/shutdown which has scripts executed by plasma-shutdown // .config/plasma-workspace/env which has scripts executed by startplasma // in the case of pre-startup they have to end in .sh // everywhere else it doesn't matter // the comment above describes how autostart *currently* works, it is not definitive documentation on how autostart *should* work // share/autostart shouldn't be an option as this should be reserved for global autostart entries std::optional AutostartModel::loadDesktopEntry(const QString &fileName) { KDesktopFile config(fileName); const KConfigGroup grp = config.desktopGroup(); const auto name = config.readName(); const bool hidden = grp.readEntry("Hidden", false); if (hidden) { return {}; } const QStringList notShowList = grp.readXdgListEntry("NotShowIn"); const QStringList onlyShowList = grp.readXdgListEntry("OnlyShowIn"); const bool enabled = !(notShowList.contains(QLatin1String("KDE")) || (!onlyShowList.isEmpty() && !onlyShowList.contains(QLatin1String("KDE")))); if (!enabled) { return {}; } const auto lstEntry = grp.readXdgListEntry("OnlyShowIn"); const bool onlyInPlasma = lstEntry.contains(QLatin1String("KDE")); const QString iconName = !config.readIcon().isEmpty() ? config.readIcon() : QStringLiteral("dialog-scripts"); const auto kind = AutostartScriptDesktopFile::isAutostartScript(config) ? XdgScripts : XdgAutoStart; // .config/autostart load desktop at startup const QString tryCommand = grp.readEntry("TryExec"); // Try to filter out entries that point to nonexistant programs // If TryExec is either found in $PATH or is an absolute file path that exists // This doesn't detect uninstalled Flatpaks for example though if (!tryCommand.isEmpty() && QStandardPaths::findExecutable(tryCommand).isEmpty() && !QFile::exists(tryCommand)) { return {}; } if (kind == XdgScripts) { const QString targetScriptPath = grp.readEntry("Exec"); const QString targetFileName = QUrl::fromLocalFile(targetScriptPath).fileName(); const QString targetScriptDir = QFileInfo(targetScriptPath).absoluteDir().path(); return AutostartEntry{targetFileName, targetScriptDir, kind, enabled, fileName, onlyInPlasma, iconName}; } return AutostartEntry{name, name, kind, enabled, fileName, onlyInPlasma, iconName}; } static const QString FALLBACK_ICON = QStringLiteral("application-x-executable-script"); AutostartModel::AutostartModel(QObject *parent) : QAbstractListModel(parent) , m_xdgConfigPath(QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation)) , m_xdgAutoStartPath(m_xdgConfigPath.filePath(QStringLiteral("autostart"))) { auto message = QDBusMessage::createMethodCall(u"org.freedesktop.systemd1"_s, u"/org/freedesktop/systemd1"_s, u"org.freedesktop.systemd1.Manager"_s, u"Subscribe"_s); QDBusConnection::sessionBus().send(message); } AutostartModel::~AutostartModel() { } void AutostartModel::load() { beginResetModel(); m_entries.clear(); // Creates if doesn't already exist m_xdgAutoStartPath.mkpath(QStringLiteral(".")); // Needed to add all script entries after application entries QList scriptEntries; const auto filesInfo = m_xdgAutoStartPath.entryInfoList(QDir::Files); for (const QFileInfo &fi : filesInfo) { if (!KDesktopFile::isDesktopFile(fi.fileName())) { continue; } const std::optional entry = loadDesktopEntry(fi.absoluteFilePath()); if (!entry) { continue; } if (entry->source == XdgScripts) { scriptEntries.push_back(entry.value()); } else { m_entries.push_back(entry.value()); } } m_entries.append(scriptEntries); loadScriptsFromDir(QStringLiteral("plasma-workspace/env/"), AutostartModel::AutostartEntrySource::PlasmaEnvScripts); loadScriptsFromDir(QStringLiteral("plasma-workspace/shutdown/"), AutostartModel::AutostartEntrySource::PlasmaShutdown); // Add unit objects for entries and set id to them for (AutostartEntry &entry : m_entries) { if (entry.source == AutostartModel::AutostartEntrySource::PlasmaShutdown || entry.source == AutostartModel::AutostartEntrySource::PlasmaEnvScripts) { continue; } const QUrl url{entry.fileName}; QString actualName = url.fileName(); // Remove .desktop part actualName.chop(8); const QString serviceName = u"app-" + systemdEscape(actualName) + u"@autostart.service"; auto unit = new Unit(this); // To show errors that occur when loading unit data in main page connect(unit, &Unit::error, this, &AutostartModel::error); unit->setId(serviceName); entry.systemdUnit = unit; } sort(); endResetModel(); } // Returns if systemd is available and systemdBoot is enabled. It used to determine if the autostart entries should be clickable in qml bool AutostartModel::usingSystemdBoot() const { if (!haveSystemd) { return false; } const KSharedConfig::Ptr config = KSharedConfig::openConfig(u"startkderc"_s); const KConfigGroup generalGroup(config, u"General"_s); return generalGroup.readEntry("systemdBoot", true); } QString AutostartModel::systemdEscape(const QString &name) const { QString newName = name; newName.replace(QLatin1Char('-'), QLatin1String("\\x2d")); newName.replace(QLatin1Char('/'), QLatin1String("\\xe2\\x81\\x84")); return newName; } void AutostartModel::loadScriptsFromDir(const QString &subDir, AutostartModel::AutostartEntrySource kind) { QDir dir(m_xdgConfigPath.filePath(subDir)); // Creates if doesn't already exist dir.mkpath(QStringLiteral(".")); const auto autostartDirFilesInfo = dir.entryInfoList(QDir::Files); for (const QFileInfo &fi : autostartDirFilesInfo) { QString targetFileDir = fi.absoluteDir().path(); QString fileName = fi.fileName(); QString iconName; const bool isSymlink = fi.isSymLink(); // logout scripts are saved as symlinks if (isSymlink) { QFileInfo symLinkTarget(fi.symLinkTarget()); iconName = m_iconProvider.icon(symLinkTarget).name(); targetFileDir = symLinkTarget.absoluteDir().path(); fileName = symLinkTarget.fileName(); } else { iconName = m_iconProvider.icon(fi).name(); } iconName = iconName == QLatin1String("text-plain") ? FALLBACK_ICON : iconName; m_entries.push_back({fileName, targetFileDir, kind, true, fi.absoluteFilePath(), false, iconName}); } } int AutostartModel::rowCount(const QModelIndex &parent) const { if (parent.isValid()) { return 0; } return m_entries.count(); } bool AutostartModel::reloadEntry(const QModelIndex &index, const QString &fileName) { if (!checkIndex(index)) { return false; } const std::optional newEntry = loadDesktopEntry(fileName); if (!newEntry) { return false; } m_entries.replace(index.row(), newEntry.value()); Q_EMIT dataChanged(index, index); return true; } QVariant AutostartModel::data(const QModelIndex &index, int role) const { if (!checkIndex(index)) { return QVariant(); } const auto &entry = m_entries.at(index.row()); switch (role) { case Name: return entry.name; case Enabled: return entry.enabled; case Source: return entry.source; case FileName: return entry.fileName; case OnlyInPlasma: return entry.onlyInPlasma; case IconName: return entry.iconName; case TargetFileDirPath: return entry.targetFileDirPath; case SystemdUnit: return QVariant::fromValue(entry.systemdUnit); } return QVariant(); } void AutostartModel::addApplication(const KService::Ptr &service) { QString desktopPath; // It is important to ensure that we make an exact copy of an existing // desktop file (if selected) to enable users to override global autostarts. // Also see // https://bugs.launchpad.net/ubuntu/+source/kde-workspace/+bug/923360 if (service->desktopEntryName().isEmpty() || service->entryPath().isEmpty()) { // create a new desktop file in s_desktopPath desktopPath = m_xdgAutoStartPath.filePath(service->name() + QStringLiteral(".desktop")); if (QFileInfo::exists(desktopPath)) { QUrl baseUrl = QUrl::fromLocalFile(m_xdgAutoStartPath.path()); QString newName = suggestName(baseUrl, service->name() + QStringLiteral(".desktop")); desktopPath = m_xdgAutoStartPath.filePath(newName); } KDesktopFile desktopFile(desktopPath); KConfigGroup kcg = desktopFile.desktopGroup(); kcg.writeEntry("Name", service->name()); kcg.writeEntry("Exec", service->exec()); kcg.writeEntry("Icon", service->icon()); kcg.writeEntry("Path", ""); kcg.writeEntry("Terminal", service->terminal() ? "True" : "False"); kcg.writeEntry("Type", "Application"); desktopFile.sync(); } else { desktopPath = m_xdgAutoStartPath.filePath(service->storageId()); KDesktopFile desktopFile(service->entryPath()); if (QFileInfo::exists(desktopPath)) { QUrl baseUrl = QUrl::fromLocalFile(m_xdgAutoStartPath.path()); QString newName = suggestName(baseUrl, service->storageId()); desktopPath = m_xdgAutoStartPath.filePath(newName); } // copy original desktop file to new path auto newDesktopFile = desktopFile.copyTo(desktopPath); newDesktopFile->sync(); } const QString iconName = !service->icon().isEmpty() ? service->icon() : FALLBACK_ICON; Unit *unit = new Unit(this, true); const auto entry = AutostartEntry{service->name(), service->name(), AutostartModel::AutostartEntrySource::XdgAutoStart, // .config/autostart load desktop at startup true, desktopPath, false, iconName, unit}; int lastApplication = -1; for (const AutostartEntry &e : std::as_const(m_entries)) { if (e.source == AutostartModel::AutostartEntrySource::XdgScripts) { break; } ++lastApplication; } // push before the script items const int index = lastApplication + 1; beginInsertRows(QModelIndex(), index, index); m_entries.insert(index, entry); endInsertRows(); sort(); } void AutostartModel::showApplicationDialog(QQuickItem *context) { KOpenWithDialog *owdlg = new KOpenWithDialog(); owdlg->setAttribute(Qt::WA_DeleteOnClose); if (context && context->window()) { if (QWindow *actualWindow = QQuickRenderControl::renderWindowFor(context->window())) { owdlg->winId(); // so it creates windowHandle owdlg->windowHandle()->setTransientParent(actualWindow); owdlg->setModal(true); } } connect(owdlg, &QDialog::finished, this, [this, owdlg](int result) { if (result != QDialog::Accepted) { return; } const KService::Ptr service = owdlg->service(); Q_ASSERT(service); if (!service) { return; // Don't crash if KOpenWith wasn't able to create service. } addApplication(service); }); owdlg->open(); } void AutostartModel::addScript(const QUrl &url, AutostartModel::AutostartEntrySource kind) { const QFileInfo file(url.toLocalFile()); if (!file.isAbsolute()) { Q_EMIT error(i18n("\"%1\" is not an absolute url.", url.toLocalFile())); return; } else if (!file.exists()) { Q_EMIT error(i18n("\"%1\" does not exist.", url.toLocalFile())); return; } else if (!file.isFile()) { Q_EMIT error(i18n("\"%1\" is not a file.", url.toLocalFile())); return; } else if (!file.isReadable()) { Q_EMIT error(i18n("\"%1\" is not readable.", url.toLocalFile())); return; } QFile scriptFile(url.toLocalFile()); if (!(scriptFile.permissions() & QFile::ExeUser)) { Q_EMIT nonExecutableScript(url.toLocalFile(), kind); } QString fileName = url.fileName(); if (kind == AutostartModel::AutostartEntrySource::XdgScripts) { int lastLoginScript = -1; for (const AutostartEntry &e : std::as_const(m_entries)) { if (e.source == AutostartModel::AutostartEntrySource::PlasmaShutdown) { break; } ++lastLoginScript; } // path of the desktop file that is about to be created const QString newFilePath = m_xdgAutoStartPath.absoluteFilePath(fileName + QStringLiteral(".desktop")); QIcon icon = m_iconProvider.icon(file); QString iconName = icon.name() == QLatin1String("text-plain") ? FALLBACK_ICON : icon.name(); if (QFileInfo::exists(newFilePath)) { const QUrl baseUrl = QUrl::fromLocalFile(m_xdgAutoStartPath.path()); fileName = suggestName(baseUrl, fileName + QStringLiteral(".desktop")); // remove the .desktop part from String fileName.chop(8); } AutostartScriptDesktopFile desktopFile(fileName, KShell::quoteArg(file.filePath()), iconName); insertScriptEntry(lastLoginScript + 1, file.fileName(), file.absoluteDir().path(), desktopFile.fileName(), kind); } else if (kind == AutostartModel::AutostartEntrySource::PlasmaShutdown) { const QUrl destinationScript = QUrl::fromLocalFile(QDir(m_xdgConfigPath.filePath(QStringLiteral("plasma-workspace/shutdown/"))).filePath(fileName)); KIO::CopyJob *job = KIO::link(url, destinationScript, KIO::HideProgressInfo); job->setAutoRename(true); job->setProperty("finalUrl", destinationScript); connect(job, &KIO::CopyJob::renamed, this, [](KIO::Job *job, const QUrl &from, const QUrl &to) { Q_UNUSED(from) // in case the destination filename had to be renamed job->setProperty("finalUrl", to); }); connect(job, &KJob::finished, this, [this, url, kind](KJob *theJob) { if (theJob->error()) { qCWarning(KCM_AUTOSTART_DEBUG) << "Could not add script entry" << theJob->errorString(); return; } const QUrl dest = theJob->property("finalUrl").toUrl(); const QFileInfo destFile(dest.path()); const QString symLinkFileName = QUrl::fromLocalFile(destFile.symLinkTarget()).fileName(); const QFileInfo symLinkTarget{destFile.symLinkTarget()}; const QString symLinkTargetDir = symLinkTarget.absoluteDir().path(); insertScriptEntry(m_entries.size(), symLinkFileName, symLinkTargetDir, dest.path(), kind); }); job->start(); } else { Q_ASSERT(0); } } void AutostartModel::insertScriptEntry(int index, const QString &name, const QString &targetFileDirPath, const QString &path, AutostartEntrySource kind) { beginInsertRows(QModelIndex(), index, index); QFileInfo targetFile{QDir(targetFileDirPath).filePath(name)}; const QIcon icon = m_iconProvider.icon(targetFile); const QString iconName = icon.name() == QLatin1String("text-plain") ? FALLBACK_ICON : icon.name(); Unit *unit = new Unit(this, true); // Plasma shutdown and Plasma env scripts don't have units if (kind == AutostartModel::AutostartEntrySource::PlasmaShutdown || kind == AutostartModel::AutostartEntrySource::PlasmaEnvScripts) { delete unit; unit = nullptr; } AutostartEntry entry = AutostartEntry{name, targetFileDirPath, kind, true, path, false, iconName, unit}; m_entries.insert(index, entry); endInsertRows(); sort(); } void AutostartModel::removeEntry(int row) { const auto entry = m_entries.at(row); KIO::DeleteJob *job = KIO::del(QUrl::fromLocalFile(entry.fileName), KIO::HideProgressInfo); connect(job, &KJob::finished, this, [this, row, entry](KJob *theJob) { if (theJob->error()) { qCWarning(KCM_AUTOSTART_DEBUG) << "Could not remove entry" << theJob->errorString(); return; } beginRemoveRows(QModelIndex(), row, row); m_entries.remove(row); delete entry.systemdUnit; endRemoveRows(); }); job->start(); } QHash AutostartModel::roleNames() const { QHash roleNames = QAbstractListModel::roleNames(); roleNames.insert(Name, QByteArrayLiteral("name")); roleNames.insert(Enabled, QByteArrayLiteral("enabled")); roleNames.insert(Source, QByteArrayLiteral("source")); roleNames.insert(FileName, QByteArrayLiteral("fileName")); roleNames.insert(OnlyInPlasma, QByteArrayLiteral("onlyInPlasma")); roleNames.insert(IconName, QByteArrayLiteral("iconName")); roleNames.insert(TargetFileDirPath, QByteArrayLiteral("targetFileDirPath")); roleNames.insert(SystemdUnit, QByteArrayLiteral("systemdUnit")); return roleNames; } void AutostartModel::editApplication(int row, QQuickItem *context) { const QModelIndex idx = index(row, 0); const QString fileName = data(idx, AutostartModel::Roles::FileName).toString(); KFileItem kfi(QUrl::fromLocalFile(fileName)); kfi.setDelayedMimeTypes(true); KPropertiesDialog *dlg = new KPropertiesDialog(kfi, nullptr); dlg->setAttribute(Qt::WA_DeleteOnClose); if (context && context->window()) { if (QWindow *actualWindow = QQuickRenderControl::renderWindowFor(context->window())) { dlg->winId(); // so it creates windowHandle dlg->windowHandle()->setTransientParent(actualWindow); dlg->setModal(true); } } connect(dlg, &QDialog::finished, this, [this, idx, dlg](int result) { if (result == QDialog::Accepted) { reloadEntry(idx, dlg->item().localPath()); } }); dlg->open(); } void AutostartModel::makeFileExecutable(const QString &fileName) { QFile file(fileName); file.setPermissions(file.permissions() | QFile::ExeUser); } // Use slightly modified code copied from frameworks KFileUtils because desktop filenames cannot contain '(' or ' '. QString AutostartModel::makeSuggestedName(const QString &oldName) { QString basename; // Extract the original file extension from the filename QMimeDatabase db; QString nameSuffix = db.suffixForFileName(oldName); if (oldName.lastIndexOf(QLatin1Char('.')) == 0) { basename = QStringLiteral("."); nameSuffix = oldName; } else if (nameSuffix.isEmpty()) { const int lastDot = oldName.lastIndexOf(QLatin1Char('.')); if (lastDot == -1) { basename = oldName; } else { basename = oldName.left(lastDot); nameSuffix = oldName.mid(lastDot); } } else { nameSuffix.prepend(QLatin1Char('.')); basename = oldName.left(oldName.length() - nameSuffix.length()); } // check if (number) exists at the end of the oldName and increment that number const QRegularExpression re(QStringLiteral("_(\\d+)_")); QRegularExpressionMatch rmatch; (void)oldName.lastIndexOf(re, -1, &rmatch); if (rmatch.hasMatch()) { const int currentNum = rmatch.captured(1).toInt(); const QString number = QString::number(currentNum + 1); basename.replace(rmatch.capturedStart(1), rmatch.capturedLength(1), number); } else { // number does not exist, so just append " _1_" to filename basename += QLatin1String("_1_"); } return basename + nameSuffix; } QString AutostartModel::suggestName(const QUrl &baseURL, const QString &oldName) { QString suggestedName = makeSuggestedName(oldName); if (baseURL.isLocalFile()) { const QString basePath = baseURL.toLocalFile() + QLatin1Char('/'); while (QFileInfo::exists(basePath + suggestedName)) { suggestedName = makeSuggestedName(suggestedName); } } return suggestedName; } void AutostartModel::sort(int column, Qt::SortOrder order) { Q_UNUSED(column) Q_UNUSED(order) Q_EMIT layoutAboutToBeChanged(); m_entries = sortedEntries(m_entries); Q_EMIT layoutChanged(); } QList AutostartModel::sortedEntries(const QList &entries) { QList sortedEntries = entries; QCollator collator; collator.setCaseSensitivity(Qt::CaseInsensitive); std::sort(sortedEntries.begin(), sortedEntries.end(), [&collator](const AutostartEntry &a, const AutostartEntry &b) { if (a.source != b.source) { return a.source < b.source; } return collator.compare(a.name, b.name) < 0; }); return sortedEntries; }