File indexing completed on 2024-05-12 05:38:20

0001 /*
0002     SPDX-FileCopyrightText: 2006 Aaron Seigo <aseigo@kde.org>
0003     SPDX-FileCopyrightText: 2014 Vishesh Handa <vhanda@kde.org>
0004     SPDX-FileCopyrightText: 2016-2020 Harald Sitter <sitter@kde.org>
0005     SPDX-FileCopyrightText: 2022-2023 Alexander Lohnau <alexander.lohnau@gmx.de>
0006 
0007     SPDX-License-Identifier: LGPL-2.0-only
0008 */
0009 
0010 #include "servicerunner.h"
0011 
0012 #include <algorithm>
0013 
0014 #include <QMimeData>
0015 
0016 #include <QDebug>
0017 #include <QDir>
0018 #include <QIcon>
0019 #include <QStandardPaths>
0020 #include <QUrl>
0021 #include <QUrlQuery>
0022 
0023 #include <KApplicationTrader>
0024 #include <KLocalizedString>
0025 #include <KNotificationJobUiDelegate>
0026 #include <KServiceAction>
0027 #include <KShell>
0028 #include <KStringHandler>
0029 #include <KSycoca>
0030 #include <PlasmaActivities/ResourceInstance>
0031 #include <PlasmaActivities/Stats/Query>
0032 #include <PlasmaActivities/Stats/ResultSet>
0033 #include <PlasmaActivities/Stats/Terms>
0034 
0035 #include <KIO/ApplicationLauncherJob>
0036 #include <KIO/DesktopExecParser>
0037 
0038 #include "debug.h"
0039 
0040 int weightedLength(const QString &query)
0041 {
0042     return KStringHandler::logicalLength(query);
0043 }
0044 
0045 inline bool contains(const QString &result, const QStringList &queryList)
0046 {
0047     return std::all_of(queryList.cbegin(), queryList.cend(), [&result](const QString &query) {
0048         return result.contains(query, Qt::CaseInsensitive);
0049     });
0050 }
0051 
0052 inline bool contains(const QStringList &results, const QStringList &queryList)
0053 {
0054     return std::all_of(queryList.cbegin(), queryList.cend(), [&results](const QString &query) {
0055         return std::any_of(results.cbegin(), results.cend(), [&query](const QString &result) {
0056             return result.contains(query, Qt::CaseInsensitive);
0057         });
0058     });
0059 }
0060 
0061 /**
0062  * @brief Finds all KServices for a given runner query
0063  */
0064 class ServiceFinder
0065 {
0066 public:
0067     ServiceFinder(ServiceRunner *runner, const QList<KService::Ptr> &list, const QString &currentActivity)
0068         : m_runner(runner)
0069         , m_services(list)
0070         , m_currentActivity(currentActivity)
0071     {
0072     }
0073 
0074     void match(KRunner::RunnerContext &context)
0075     {
0076         query = context.query();
0077         // Splitting the query term to match using subsequences
0078         queryList = query.split(QLatin1Char(' '));
0079         weightedTermLength = weightedLength(query);
0080 
0081         matchNameKeywordAndGenericName();
0082         matchCategories();
0083         matchJumpListActions();
0084 
0085         context.addMatches(matches);
0086     }
0087 
0088 private:
0089     inline void seen(const KService::Ptr &service)
0090     {
0091         m_seen.insert(service->exec());
0092     }
0093 
0094     inline void seen(const KServiceAction &action)
0095     {
0096         m_seen.insert(action.exec());
0097     }
0098 
0099     inline bool hasSeen(const KService::Ptr &service)
0100     {
0101         return m_seen.contains(service->exec());
0102     }
0103 
0104     inline bool hasSeen(const KServiceAction &action)
0105     {
0106         return m_seen.contains(action.exec());
0107     }
0108 
0109     bool disqualify(const KService::Ptr &service)
0110     {
0111         auto ret = hasSeen(service);
0112         qCDebug(RUNNER_SERVICES) << service->name() << "disqualified?" << ret;
0113         seen(service);
0114         return ret;
0115     }
0116 
0117     enum class Category { Name, GenericName, Comment };
0118     qreal increaseMatchRelavance(const QString &serviceProperty, const QStringList &strList, Category category)
0119     {
0120         // Increment the relevance based on all the words (other than the first) of the query list
0121         qreal relevanceIncrement = 0;
0122 
0123         for (int i = 1; i < strList.size(); ++i) {
0124             const auto &str = strList.at(i);
0125             if (category == Category::Name) {
0126                 if (serviceProperty.contains(str, Qt::CaseInsensitive)) {
0127                     relevanceIncrement += 0.01;
0128                 }
0129             } else if (category == Category::GenericName) {
0130                 if (serviceProperty.contains(str, Qt::CaseInsensitive)) {
0131                     relevanceIncrement += 0.01;
0132                 }
0133             } else if (category == Category::Comment) {
0134                 if (serviceProperty.contains(str, Qt::CaseInsensitive)) {
0135                     relevanceIncrement += 0.01;
0136                 }
0137             }
0138         }
0139 
0140         return relevanceIncrement;
0141     }
0142 
0143     void setupMatch(const KService::Ptr &service, KRunner::QueryMatch &match)
0144     {
0145         const QString name = service->name();
0146         const QString exec = service->exec();
0147 
0148         match.setText(name);
0149 
0150         QUrl url(service->storageId());
0151         url.setScheme(QStringLiteral("applications"));
0152         match.setData(url);
0153         match.setUrls({QUrl::fromLocalFile(service->entryPath())});
0154 
0155         QString urlPath = resolvedArgs(exec);
0156         if (urlPath.isEmpty()) {
0157             // Otherwise we might filter out broken services. Rather than hiding them, it is better to show an error message on launch (as done by KIO's jobs)
0158             urlPath = exec;
0159         }
0160         match.setId(QStringLiteral("exec://") + urlPath);
0161         if (!service->genericName().isEmpty() && service->genericName() != name) {
0162             match.setSubtext(service->genericName());
0163         } else if (!service->comment().isEmpty()) {
0164             match.setSubtext(service->comment());
0165         }
0166 
0167         if (!service->icon().isEmpty()) {
0168             match.setIconName(service->icon());
0169         }
0170     }
0171 
0172     QString resolvedArgs(const QString &exec)
0173     {
0174         const KService syntheticService(QString(), exec, QString());
0175         KIO::DesktopExecParser parser(syntheticService, {});
0176         QStringList resultingArgs = parser.resultingArguments();
0177         if (const auto error = parser.errorMessage(); resultingArgs.isEmpty() && !error.isEmpty()) {
0178             qCWarning(RUNNER_SERVICES) << "Failed to resolve executable from service. Error:" << error;
0179         }
0180 
0181         // Remove any environment variables.
0182         if (KIO::DesktopExecParser::executableName(exec) == QLatin1String("env")) {
0183             resultingArgs.removeFirst(); // remove "env".
0184 
0185             while (!resultingArgs.isEmpty() && resultingArgs.first().contains(QLatin1Char('='))) {
0186                 resultingArgs.removeFirst();
0187             }
0188 
0189             // Now parse it again to resolve the path.
0190             resultingArgs = KIO::DesktopExecParser(KService(QString(), KShell::joinArgs(resultingArgs), QString()), {}).resultingArguments();
0191             return resultingArgs.join(QLatin1Char(' '));
0192         }
0193 
0194         // Remove special arguments that have no real impact on the application.
0195         static const auto specialArgs = {QStringLiteral("-qwindowtitle"), QStringLiteral("-qwindowicon"), QStringLiteral("--started-from-file")};
0196 
0197         for (const auto &specialArg : specialArgs) {
0198             int index = resultingArgs.indexOf(specialArg);
0199             if (index > -1) {
0200                 if (resultingArgs.count() > index) {
0201                     resultingArgs.removeAt(index);
0202                 }
0203                 if (resultingArgs.count() > index) {
0204                     resultingArgs.removeAt(index); // remove value, too, if any.
0205                 }
0206             }
0207         }
0208         return resultingArgs.join(QLatin1Char(' '));
0209     }
0210 
0211     void matchNameKeywordAndGenericName()
0212     {
0213         const auto nameKeywordAndGenericNameFilter = [this](const KService::Ptr &service) {
0214             // Name
0215             if (contains(service->name(), queryList)) {
0216                 return true;
0217             }
0218             // If the term length is < 3, no real point searching the untranslated Name, Keywords and GenericName
0219             if (weightedTermLength < 3) {
0220                 return false;
0221             }
0222             if (contains(service->untranslatedName(), queryList)) {
0223                 return true;
0224             }
0225 
0226             // Keywords
0227             if (contains(service->keywords(), queryList)) {
0228                 return true;
0229             }
0230             // GenericName
0231             if (contains(service->genericName(), queryList) || contains(service->untranslatedGenericName(), queryList)) {
0232                 return true;
0233             }
0234             // Comment
0235             if (contains(service->comment(), queryList)) {
0236                 return true;
0237             }
0238 
0239             return false;
0240         };
0241 
0242         for (const KService::Ptr &service : m_services) {
0243             if (!nameKeywordAndGenericNameFilter(service) || disqualify(service)) {
0244                 continue;
0245             }
0246 
0247             const QString id = service->storageId();
0248             const QString name = service->name();
0249 
0250             KRunner::QueryMatch::CategoryRelevance categoryRelevance = KRunner::QueryMatch::CategoryRelevance::Moderate;
0251             qreal relevance(0.6);
0252 
0253             // If the term was < 3 chars and NOT at the beginning of the App's name, then chances are the user doesn't want that app
0254             if (weightedTermLength < 3) {
0255                 if (name.startsWith(query, Qt::CaseInsensitive)) {
0256                     relevance = 0.9;
0257                 } else {
0258                     continue;
0259                 }
0260             } else if (name.compare(query, Qt::CaseInsensitive) == 0) {
0261                 relevance = 1;
0262                 categoryRelevance = KRunner::QueryMatch::CategoryRelevance::Highest;
0263             } else if (const int idx = name.indexOf(queryList[0], 0, Qt::CaseInsensitive); idx != -1) {
0264                 relevance = 0.8;
0265                 relevance += increaseMatchRelavance(name, queryList, Category::Name);
0266                 if (idx == 0) {
0267                     relevance += 0.1;
0268                 }
0269             } else if (const int idx = service->genericName().indexOf(queryList[0], 0, Qt::CaseInsensitive); idx != -1) {
0270                 relevance = 0.65;
0271                 relevance += increaseMatchRelavance(service->genericName(), queryList, Category::GenericName);
0272                 if (idx == 0) {
0273                     relevance += 0.05;
0274                 }
0275             } else if (const int idx = service->comment().indexOf(queryList[0], 0, Qt::CaseInsensitive); idx != -1) {
0276                 relevance = 0.5;
0277                 relevance += increaseMatchRelavance(service->comment(), queryList, Category::Comment);
0278                 if (idx == 0) {
0279                     relevance += 0.05;
0280                 }
0281             }
0282 
0283             KRunner::QueryMatch match(m_runner);
0284             match.setCategoryRelevance(categoryRelevance);
0285             setupMatch(service, match);
0286             if (service->categories().contains(QLatin1String("KDE"))) {
0287                 qCDebug(RUNNER_SERVICES) << "found a kde thing" << id << match.subtext() << relevance;
0288                 relevance += .09;
0289             }
0290 
0291             if (const auto foundIt = m_runner->m_favourites.constFind(service->desktopEntryName()); foundIt != m_runner->m_favourites.cend()) {
0292                 if (foundIt->isGlobal || foundIt->linkedActivities.contains(m_currentActivity)) {
0293                     qCDebug(RUNNER_SERVICES) << "entry is a favorite" << id << match.subtext() << relevance;
0294                     relevance *= 1.25; // Give favorites a relative boost,
0295                 }
0296             }
0297 
0298             qCDebug(RUNNER_SERVICES) << name << "is this relevant:" << relevance;
0299             match.setRelevance(relevance);
0300 
0301             matches << match;
0302         }
0303     }
0304 
0305     void matchCategories()
0306     {
0307         // Do not match categories for short queries, BUG: 469769
0308         if (weightedTermLength < 5) {
0309             return;
0310         }
0311         for (const KService::Ptr &service : m_services) {
0312             const QStringList categories = service->categories();
0313             if (disqualify(service) || !contains(categories, queryList)) {
0314                 continue;
0315             }
0316             qCDebug(RUNNER_SERVICES) << service->name() << "is an exact match!" << service->storageId() << service->exec();
0317 
0318             KRunner::QueryMatch match(m_runner);
0319             setupMatch(service, match);
0320 
0321             qreal relevance = 0.4;
0322             if (std::any_of(categories.begin(), categories.end(), [this](const QString &category) {
0323                     return category.compare(query, Qt::CaseInsensitive) == 0;
0324                 })) {
0325                 relevance = 0.6;
0326             }
0327 
0328             if (service->isApplication()) {
0329                 relevance += .04;
0330             }
0331 
0332             match.setRelevance(relevance);
0333             matches << match;
0334         }
0335     }
0336 
0337     void matchJumpListActions()
0338     {
0339         if (weightedTermLength < 3) {
0340             return;
0341         }
0342         for (const KService::Ptr &service : m_services) {
0343             const auto actions = service->actions();
0344             // Skip SystemSettings as we find KCMs already
0345             if (actions.isEmpty() || service->storageId() == QLatin1String("systemsettings.desktop")) {
0346                 continue;
0347             }
0348 
0349             for (const KServiceAction &action : actions) {
0350                 if (action.text().isEmpty() || action.exec().isEmpty() || hasSeen(action)) {
0351                     continue;
0352                 }
0353                 seen(action);
0354 
0355                 const int matchIndex = action.text().indexOf(query, 0, Qt::CaseInsensitive);
0356                 if (matchIndex < 0) {
0357                     continue;
0358                 }
0359 
0360                 KRunner::QueryMatch match(m_runner);
0361                 if (!action.icon().isEmpty()) {
0362                     match.setIconName(action.icon());
0363                 } else {
0364                     match.setIconName(service->icon());
0365                 }
0366                 match.setText(i18nc("Jump list search result, %1 is action (eg. open new tab), %2 is application (eg. browser)",
0367                                     "%1 - %2",
0368                                     action.text(),
0369                                     service->name()));
0370 
0371                 QUrl url(service->storageId());
0372                 url.setScheme(QStringLiteral("applications"));
0373 
0374                 QUrlQuery urlQuery;
0375                 urlQuery.addQueryItem(QStringLiteral("action"), action.name());
0376                 url.setQuery(urlQuery);
0377 
0378                 match.setData(url);
0379 
0380                 qreal relevance = 0.5;
0381                 if (action.text().compare(query, Qt::CaseInsensitive) == 0) {
0382                     relevance = 0.65;
0383                     match.setCategoryRelevance(KRunner::QueryMatch::CategoryRelevance::High); // Give it a higer match type to ensure it is shown, BUG: 455436
0384                 } else if (matchIndex == 0) {
0385                     relevance += 0.05;
0386                 }
0387 
0388                 match.setRelevance(relevance);
0389 
0390                 matches << match;
0391             }
0392         }
0393     }
0394 
0395     ServiceRunner *m_runner;
0396     QSet<QString> m_seen;
0397     const QList<KService::Ptr> m_services;
0398     const QString m_currentActivity;
0399 
0400     QList<KRunner::QueryMatch> matches;
0401     QString query;
0402     QStringList queryList;
0403     int weightedTermLength = -1;
0404 };
0405 
0406 ServiceRunner::ServiceRunner(QObject *parent, const KPluginMetaData &metaData)
0407     : KRunner::AbstractRunner(parent, metaData)
0408     , m_kactivitiesQuery(Terms::LinkedResources | Terms::Agent{QStringLiteral("org.kde.plasma.favorites.applications")} | Terms::Type::any()
0409                          | Terms::Activity::any() | Terms::Limit(25))
0410     , m_kactivitiesWatcher(m_kactivitiesQuery)
0411 {
0412     addSyntax(QStringLiteral(":q:"), i18n("Finds applications whose name or description match :q:"));
0413     connect(&m_kactivitiesWatcher, &ResultWatcher::resultLinked, [this](const QString &resource) {
0414         processActivitiesResults(ResultSet(m_kactivitiesQuery | Terms::Url::contains(resource)));
0415     });
0416 
0417     connect(&m_kactivitiesWatcher, &ResultWatcher::resultUnlinked, [this](const QString &resource) {
0418         m_favourites.remove(resource);
0419         // In case it was only unlinked from one activity
0420         processActivitiesResults(ResultSet(m_kactivitiesQuery | Terms::Url::contains(resource)));
0421     });
0422 
0423     connect(this, &KRunner::AbstractRunner::prepare, this, [this]() {
0424         m_matching = true;
0425         if (m_services.isEmpty()) {
0426             m_services = KApplicationTrader::query([](const KService::Ptr &service) {
0427                 return !service->noDisplay();
0428             });
0429         } else {
0430             KSycoca::self()->ensureCacheValid();
0431         }
0432     });
0433     connect(this, &KRunner::AbstractRunner::teardown, this, [this]() {
0434         m_matching = false;
0435     });
0436 }
0437 
0438 void ServiceRunner::init()
0439 {
0440     processActivitiesResults(ResultSet(m_kactivitiesQuery));
0441 
0442     //  connect to the thread-local singleton here
0443     connect(KSycoca::self(), &KSycoca::databaseChanged, this, [this]() {
0444         if (m_matching) {
0445             m_services = KApplicationTrader::query([](const KService::Ptr &service) {
0446                 return !service->noDisplay();
0447             });
0448         } else {
0449             // Invalidate for the next match session
0450             m_services.clear();
0451         }
0452     });
0453 }
0454 
0455 void ServiceRunner::processActivitiesResults(const ResultSet &results)
0456 {
0457     const static QLatin1String globalActivity(":global");
0458     const static QLatin1String applicationScheme("applications");
0459     for (const ResultSet::Result &result : results) {
0460         if (result.url().scheme() == applicationScheme) {
0461             m_favourites.insert(result.url().path().remove(QLatin1String(".desktop")),
0462                                 ActivityFavourite{
0463                                     result.linkedActivities(),
0464                                     result.linkedActivities().contains(globalActivity),
0465                                 });
0466         }
0467     }
0468 }
0469 
0470 void ServiceRunner::match(KRunner::RunnerContext &context)
0471 {
0472     ServiceFinder finder(this, m_services, m_activitiesConsuer.currentActivity());
0473     finder.match(context);
0474 }
0475 
0476 void ServiceRunner::run(const KRunner::RunnerContext & /*context*/, const KRunner::QueryMatch &match)
0477 {
0478     const QUrl dataUrl = match.data().toUrl();
0479 
0480     KService::Ptr service = KService::serviceByStorageId(dataUrl.path());
0481     if (!service) {
0482         return;
0483     }
0484 
0485     KActivities::ResourceInstance::notifyAccessed(QUrl(QStringLiteral("applications:") + service->storageId()), QStringLiteral("org.kde.krunner"));
0486 
0487     KIO::ApplicationLauncherJob *job = nullptr;
0488 
0489     const QString actionName = QUrlQuery(dataUrl).queryItemValue(QStringLiteral("action"));
0490     if (actionName.isEmpty()) {
0491         job = new KIO::ApplicationLauncherJob(service);
0492     } else {
0493         const auto actions = service->actions();
0494         auto it = std::find_if(actions.begin(), actions.end(), [&actionName](const KServiceAction &action) {
0495             return action.name() == actionName;
0496         });
0497         Q_ASSERT(it != actions.end());
0498 
0499         job = new KIO::ApplicationLauncherJob(*it);
0500     }
0501 
0502     auto *delegate = new KNotificationJobUiDelegate;
0503     delegate->setAutoErrorHandlingEnabled(true);
0504     job->setUiDelegate(delegate);
0505     job->start();
0506 }
0507 
0508 K_PLUGIN_CLASS_WITH_JSON(ServiceRunner, "plasma-runner-services.json")
0509 
0510 #include "servicerunner.moc"