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 ¤tActivity) 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"