File indexing completed on 2024-04-28 05:36:49

0001 /*
0002  * SPDX-FileCopyrightText: 2017-2019 Red Hat Inc
0003  * SPDX-FileCopyrightText: 2020-2022 Harald Sitter <sitter@kde.org>
0004  *
0005  * SPDX-License-Identifier: LGPL-2.0-or-later
0006  *
0007  * SPDX-FileCopyrightText:  2017-2019 Jan Grulich <jgrulich@redhat.com>
0008  */
0009 
0010 #include "appchooserdialog.h"
0011 #include "appchooser_debug.h"
0012 
0013 #include <algorithm>
0014 #include <iterator>
0015 
0016 #include <QQmlContext>
0017 #include <QQmlEngine>
0018 #include <QQuickItem>
0019 #include <QQuickWidget>
0020 
0021 #include <QApplication>
0022 #include <QDir>
0023 #include <QMimeDatabase>
0024 #include <QSettings>
0025 #include <QStandardPaths>
0026 
0027 #include <KApplicationTrader>
0028 #include <KBuildSycocaProgressDialog>
0029 #include <KIO/MimeTypeFinderJob>
0030 #include <KLocalizedString>
0031 #include <KProcess>
0032 
0033 AppChooserDialog::AppChooserDialog(const QStringList &choices, const QString &lastUsedApp, const QString &fileName, const QString &mimeName, QObject *parent)
0034     : QuickDialog(parent)
0035     , m_model(new AppModel(this))
0036     , m_appChooserData(new AppChooserData(this))
0037 {
0038     QVariantMap props = {
0039         {"title", i18nc("@title:window", "Choose Application")},
0040         // fileName is actually the full path, confusingly enough. But showing the
0041         // whole thing is overkill; let's just show the user the file itself
0042         {"mainText", xi18nc("@info", "Choose an application to open <filename>%1</filename>", QUrl::fromLocalFile(fileName).fileName())},
0043     };
0044 
0045     auto filterModel = new AppFilterModel(this);
0046     filterModel->setSourceModel(m_model);
0047 
0048     m_appChooserData->setFileName(fileName);
0049     m_appChooserData->setLastUsedApp(lastUsedApp);
0050     filterModel->setLastUsedApp(lastUsedApp);
0051 
0052     auto findDefaultApp = [this, filterModel]() {
0053         KService::Ptr defaultService = KApplicationTrader::preferredService(m_appChooserData->mimeName());
0054         if (defaultService && defaultService->isValid()) {
0055             QString id = defaultService->desktopEntryName();
0056             m_appChooserData->setDefaultApp(id);
0057             filterModel->setDefaultApp(id);
0058         }
0059     };
0060 
0061     auto findPreferredApps = [this, choices]() {
0062         if (!choices.isEmpty()) {
0063             m_model->setPreferredApps(choices);
0064             return;
0065         }
0066         QStringList choices;
0067         const KService::List appServices = KApplicationTrader::queryByMimeType(m_appChooserData->mimeName(), [](const KService::Ptr &service) -> bool {
0068             return service->isValid();
0069         });
0070         std::transform(appServices.begin(), appServices.end(), std::back_inserter(choices), [](const KService::Ptr &service) {
0071             return service ? service->desktopEntryName() : QString();
0072         });
0073         m_model->setPreferredApps(choices);
0074     };
0075 
0076     if (mimeName.isEmpty()) {
0077         auto job = new KIO::MimeTypeFinderJob(QUrl::fromUserInput(fileName));
0078         job->setAuthenticationPromptEnabled(false);
0079         connect(job, &KIO::MimeTypeFinderJob::result, this, [this, job, findDefaultApp, findPreferredApps]() {
0080             if (job->error() == KJob::NoError) {
0081                 m_appChooserData->setMimeName(job->mimeType());
0082                 findDefaultApp();
0083                 findPreferredApps();
0084             } else {
0085                 qCWarning(XdgDesktopPortalKdeAppChooser) << "couldn't get mimetype:" << job->errorString();
0086             }
0087         });
0088         job->start();
0089     } else {
0090         m_appChooserData->setMimeName(mimeName);
0091         findDefaultApp();
0092         findPreferredApps();
0093     }
0094 
0095     qmlRegisterSingletonInstance<AppFilterModel>("org.kde.xdgdesktopportal", 1, 0, "AppModel", filterModel);
0096     qmlRegisterSingletonInstance<AppChooserData>("org.kde.xdgdesktopportal", 1, 0, "AppChooserData", m_appChooserData);
0097 
0098     create(QStringLiteral("qrc:/AppChooserDialog.qml"), props);
0099 
0100     connect(m_appChooserData, &AppChooserData::openDiscover, this, &AppChooserDialog::onOpenDiscover);
0101     connect(m_appChooserData, &AppChooserData::applicationSelected, this, &AppChooserDialog::onApplicationSelected);
0102 }
0103 
0104 QString AppChooserDialog::selectedApplication() const
0105 {
0106     return m_selectedApplication;
0107 }
0108 
0109 void AppChooserDialog::onApplicationSelected(const QString &desktopFile, const bool remember)
0110 {
0111     m_selectedApplication = desktopFile;
0112 
0113     if (remember && !m_appChooserData->mimeName().isEmpty()) {
0114         KService::Ptr serv = KService::serviceByDesktopName(desktopFile);
0115         KApplicationTrader::setPreferredService(m_appChooserData->mimeName(), serv);
0116         // kbuildsycoca is the one reading mimeapps.list, so we need to run it now
0117         KBuildSycocaProgressDialog::rebuildKSycoca(QApplication::activeWindow());
0118     }
0119 
0120     accept();
0121 }
0122 
0123 void AppChooserDialog::onOpenDiscover()
0124 {
0125     QStringList args;
0126     if (!m_appChooserData->mimeName().isEmpty()) {
0127         args << QStringLiteral("--mime") << m_appChooserData->mimeName();
0128     }
0129     KProcess::startDetached(QStringLiteral("plasma-discover"), args);
0130 }
0131 
0132 void AppChooserDialog::updateChoices(const QStringList &choices)
0133 {
0134     m_model->setPreferredApps(choices);
0135 }
0136 
0137 ApplicationItem::ApplicationItem(const QString &name, const KService::Ptr &service)
0138     : m_applicationName(name)
0139     , m_applicationService(service)
0140     , m_applicationCategory(AllApplications)
0141 {
0142     const QStringList names = service->mimeTypes();
0143     const QMimeDatabase database;
0144     for (const QString &name : names) {
0145         QMimeType mime = database.mimeTypeForName(name);
0146         if (mime.isValid()) {
0147             m_supportedMimeTypes.append(mime);
0148         }
0149     }
0150 }
0151 
0152 QString ApplicationItem::applicationName() const
0153 {
0154     return m_applicationName;
0155 }
0156 
0157 QString ApplicationItem::applicationGenericName() const
0158 {
0159     return m_applicationService->genericName();
0160 }
0161 
0162 QString ApplicationItem::applicationUntranslatedGenericName() const
0163 {
0164     return m_applicationService->untranslatedGenericName();
0165 }
0166 
0167 QString ApplicationItem::applicationIcon() const
0168 {
0169     return m_applicationService->icon();
0170 }
0171 
0172 QString ApplicationItem::applicationDesktopFile() const
0173 {
0174     return m_applicationService->desktopEntryName();
0175 }
0176 
0177 QList<QMimeType> ApplicationItem::supportedMimeTypes() const
0178 {
0179     return m_supportedMimeTypes;
0180 }
0181 
0182 void ApplicationItem::setApplicationCategory(ApplicationItem::ApplicationCategory category)
0183 {
0184     m_applicationCategory = category;
0185 }
0186 
0187 ApplicationItem::ApplicationCategory ApplicationItem::applicationCategory() const
0188 {
0189     return m_applicationCategory;
0190 }
0191 
0192 bool ApplicationItem::operator==(const ApplicationItem &item) const
0193 {
0194     return item.applicationDesktopFile() == applicationDesktopFile();
0195 }
0196 
0197 AppFilterModel::AppFilterModel(QObject *parent)
0198     : QSortFilterProxyModel(parent)
0199 {
0200     setDynamicSortFilter(true);
0201     setFilterCaseSensitivity(Qt::CaseInsensitive);
0202     sort(0, Qt::DescendingOrder);
0203 }
0204 
0205 void AppFilterModel::setShowOnlyPreferredApps(bool show)
0206 {
0207     if (m_showOnlyPreferredApps == show) {
0208         return;
0209     }
0210 
0211     m_showOnlyPreferredApps = show;
0212     Q_EMIT showOnlyPreferredAppsChanged();
0213     invalidate();
0214 }
0215 
0216 bool AppFilterModel::showOnlyPreferredApps() const
0217 {
0218     return m_showOnlyPreferredApps;
0219 }
0220 
0221 void AppFilterModel::setDefaultApp(const QString &defaultApp)
0222 {
0223     m_defaultApp = defaultApp;
0224 
0225     invalidate();
0226 }
0227 
0228 QString AppFilterModel::defaultApp() const
0229 {
0230     return m_defaultApp;
0231 }
0232 
0233 void AppFilterModel::setLastUsedApp(const QString &lastUsedApp)
0234 {
0235     if (m_lastUsedApp == lastUsedApp) {
0236         return;
0237     }
0238 
0239     m_lastUsedApp = lastUsedApp;
0240     invalidate();
0241 }
0242 
0243 QString AppFilterModel::lastUsedApp() const
0244 {
0245     return m_lastUsedApp;
0246 }
0247 
0248 void AppFilterModel::setFilter(const QString &text)
0249 {
0250     m_filter = text;
0251 
0252     if (!m_filter.isEmpty() && m_showOnlyPreferredApps) {
0253         m_showOnlyPreferredApps = false;
0254         emit showOnlyPreferredAppsChanged();
0255     }
0256 
0257     invalidate();
0258 }
0259 
0260 QString AppFilterModel::filter() const
0261 {
0262     return m_filter;
0263 }
0264 
0265 bool AppFilterModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const
0266 {
0267     const QModelIndex index = sourceModel()->index(source_row, 0, source_parent);
0268 
0269     ApplicationItem::ApplicationCategory category =
0270         static_cast<ApplicationItem::ApplicationCategory>(sourceModel()->data(index, AppModel::ApplicationCategoryRole).toInt());
0271 
0272     const bool canShowInList = m_showOnlyPreferredApps ? (category == ApplicationItem::PreferredApplication) : true;
0273     if (!canShowInList) {
0274         return false;
0275     }
0276 
0277     if (m_filter.isEmpty()) {
0278         return true;
0279     }
0280 
0281     const QString appName = index.data(AppModel::ApplicationNameRole).toString();
0282     if (appName.contains(m_filter, Qt::CaseInsensitive)) {
0283         return true;
0284     }
0285 
0286     // Match in GenericName
0287     const QString genericName = index.data(AppModel::ApplicationGenericNameRole).toString();
0288     if (genericName.contains(m_filter, Qt::CaseInsensitive)) {
0289         return true;
0290     }
0291 
0292     // Match in untranslated GenericName
0293     const QString untranslatedGenericName = index.data(AppModel::ApplicationUntranslatedGenericNameRole).toString();
0294     if (untranslatedGenericName.contains(m_filter, Qt::CaseInsensitive)) {
0295         return true;
0296     }
0297 
0298     // Match in MimeTypes
0299     const auto supportedMimeTypes = index.data(AppModel::ApplicationSupportedMimeTypesRole).value<QList<QMimeType>>();
0300     return std::any_of(supportedMimeTypes.cbegin(), supportedMimeTypes.cend(), [this, category](const QMimeType &type) {
0301         if (type.name().contains(m_filter, Qt::CaseInsensitive)) {
0302             return true;
0303         }
0304 
0305         const QStringList aliases = type.aliases();
0306         const bool aliasesMatched = std::any_of(aliases.cbegin(), aliases.cend(), [this](const QString &name) {
0307             return name.contains(m_filter, Qt::CaseInsensitive);
0308         });
0309         if (aliasesMatched) {
0310             return true;
0311         }
0312 
0313         const QStringList suffixes = type.suffixes();
0314         if (suffixes.contains(m_filter) || suffixes.contains(m_filter.mid(m_filter.lastIndexOf(QLatin1Char('.')) + 1))) {
0315             return true;
0316         }
0317 
0318         return false;
0319     });
0320 }
0321 
0322 bool AppFilterModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
0323 {
0324     if (sourceModel()->data(left, AppModel::ApplicationDesktopFileRole) == m_defaultApp) {
0325         return false;
0326     }
0327     if (sourceModel()->data(right, AppModel::ApplicationDesktopFileRole) == m_defaultApp) {
0328         return true;
0329     }
0330 
0331     const QString leftName = left.data(AppModel::ApplicationNameRole).toString();
0332     const QString rightName = right.data(AppModel::ApplicationNameRole).toString();
0333 
0334     // Prioritize name match when filter is not empty
0335     if (!m_filter.isEmpty()) {
0336         const bool leftMatched = leftName.contains(m_filter, Qt::CaseInsensitive);
0337         const bool rightMatched = rightName.contains(m_filter, Qt::CaseInsensitive);
0338         if (leftMatched && !rightMatched) {
0339             return false;
0340         } else if (!leftMatched && rightMatched) {
0341             return true;
0342         }
0343     }
0344 
0345     if (sourceModel()->data(left, AppModel::ApplicationDesktopFileRole) == m_lastUsedApp) {
0346         return false;
0347     }
0348     if (sourceModel()->data(right, AppModel::ApplicationDesktopFileRole) == m_lastUsedApp) {
0349         return true;
0350     }
0351     ApplicationItem::ApplicationCategory leftCategory =
0352         static_cast<ApplicationItem::ApplicationCategory>(sourceModel()->data(left, AppModel::ApplicationCategoryRole).toInt());
0353     ApplicationItem::ApplicationCategory rightCategory =
0354         static_cast<ApplicationItem::ApplicationCategory>(sourceModel()->data(right, AppModel::ApplicationCategoryRole).toInt());
0355 
0356     if (int comp = leftCategory - rightCategory; comp != 0) {
0357         return comp > 0;
0358     }
0359 
0360     return QString::localeAwareCompare(leftName, rightName) > 0;
0361 }
0362 
0363 QString AppChooserData::defaultApp() const
0364 {
0365     return m_defaultApp;
0366 }
0367 
0368 void AppChooserData::setDefaultApp(const QString &defaultApp)
0369 {
0370     m_defaultApp = defaultApp;
0371     Q_EMIT defaultAppChanged();
0372 }
0373 
0374 QString AppChooserData::lastUsedApp() const
0375 {
0376     return m_lastUsedApp;
0377 }
0378 
0379 void AppChooserData::setLastUsedApp(const QString &lastUsedApp)
0380 {
0381     if (m_lastUsedApp == lastUsedApp) {
0382         return;
0383     }
0384 
0385     m_lastUsedApp = lastUsedApp;
0386     Q_EMIT lastUsedAppChanged();
0387 }
0388 
0389 AppChooserData::AppChooserData(QObject *parent)
0390     : QObject(parent)
0391 {
0392 }
0393 
0394 QString AppChooserData::fileName() const
0395 {
0396     return m_fileName;
0397 }
0398 
0399 void AppChooserData::setFileName(const QString &fileName)
0400 {
0401     m_fileName = fileName;
0402     Q_EMIT fileNameChanged();
0403 }
0404 
0405 QString AppChooserData::mimeName() const
0406 {
0407     return m_mimeName;
0408 }
0409 
0410 void AppChooserData::setMimeName(const QString &mimeName)
0411 {
0412     if (m_mimeName != mimeName) {
0413         m_mimeName = mimeName;
0414         Q_EMIT mimeNameChanged();
0415         Q_EMIT mimeDescChanged();
0416     }
0417 }
0418 
0419 QString AppChooserData::mimeDesc() const
0420 {
0421     return QMimeDatabase().mimeTypeForName(m_mimeName).comment();
0422 }
0423 
0424 AppModel::AppModel(QObject *parent)
0425     : QAbstractListModel(parent)
0426 {
0427     loadApplications();
0428 }
0429 
0430 void AppModel::setPreferredApps(const QStringList &possiblyAliasedList)
0431 {
0432     m_hasPreferredApps = false;
0433     Q_EMIT hasPreferredAppsChanged();
0434 
0435     // In the event that we get incoming NoDisplay entries that are AliasFor another desktop file,
0436     // switch the NoDisplay name for the aliased name.
0437     QStringList list;
0438     for (const auto &entry : possiblyAliasedList) {
0439         if (const auto value = m_noDisplayAliasesFor.value(entry); !value.isEmpty()) {
0440             list << value;
0441         } else {
0442             list << entry;
0443         }
0444     }
0445 
0446     for (ApplicationItem &item : m_list) {
0447         bool changed = false;
0448 
0449         // First reset to initial type
0450         if (item.applicationCategory() != ApplicationItem::AllApplications) {
0451             item.setApplicationCategory(ApplicationItem::AllApplications);
0452             changed = true;
0453         }
0454 
0455         if (list.contains(item.applicationDesktopFile())) {
0456             item.setApplicationCategory(ApplicationItem::PreferredApplication);
0457             changed = true;
0458             m_hasPreferredApps = true;
0459             Q_EMIT hasPreferredAppsChanged();
0460         }
0461 
0462         if (changed) {
0463             const int row = m_list.indexOf(item);
0464             if (row >= 0) {
0465                 QModelIndex index = createIndex(row, 0, AppModel::ApplicationCategoryRole);
0466                 Q_EMIT dataChanged(index, index);
0467             }
0468         }
0469     }
0470 }
0471 
0472 QVariant AppModel::data(const QModelIndex &index, int role) const
0473 {
0474     const int row = index.row();
0475 
0476     if (row >= 0 && row < m_list.count()) {
0477         const ApplicationItem &item = m_list.at(row);
0478 
0479         switch (role) {
0480         case ApplicationNameRole:
0481             return item.applicationName();
0482         case ApplicationGenericNameRole:
0483             return item.applicationGenericName();
0484         case ApplicationUntranslatedGenericNameRole:
0485             return item.applicationUntranslatedGenericName();
0486         case ApplicationIconRole:
0487             return item.applicationIcon();
0488         case ApplicationDesktopFileRole:
0489             return item.applicationDesktopFile();
0490         case ApplicationCategoryRole:
0491             return static_cast<int>(item.applicationCategory());
0492         case ApplicationSupportedMimeTypesRole:
0493             return QVariant::fromValue(item.supportedMimeTypes());
0494         default:
0495             break;
0496         }
0497     }
0498 
0499     return {};
0500 }
0501 
0502 int AppModel::rowCount(const QModelIndex &parent) const
0503 {
0504     return parent.isValid() ? 0 : m_list.count();
0505 }
0506 
0507 QHash<int, QByteArray> AppModel::roleNames() const
0508 {
0509     return {
0510         {ApplicationNameRole, QByteArrayLiteral("applicationName")},
0511         {ApplicationIconRole, QByteArrayLiteral("applicationIcon")},
0512         {ApplicationDesktopFileRole, QByteArrayLiteral("applicationDesktopFile")},
0513         {ApplicationCategoryRole, QByteArrayLiteral("applicationCategory")},
0514     };
0515 }
0516 
0517 void AppModel::loadApplications()
0518 {
0519     const KService::List appServices = KApplicationTrader::query([](const KService::Ptr &service) -> bool {
0520         return service->isValid();
0521     });
0522     for (const KService::Ptr &service : appServices) {
0523         if (service->noDisplay()) {
0524             if (const auto alias = service->aliasFor(); !alias.isEmpty()) {
0525                 m_noDisplayAliasesFor.insert(service->desktopEntryName(), service->aliasFor());
0526             }
0527             continue; // no display after all
0528         }
0529 
0530         const QString fullName = service->property<QString>(QStringLiteral("X-GNOME-FullName"));
0531         const QString name = fullName.isEmpty() ? service->name() : fullName;
0532         ApplicationItem appItem(name, service);
0533 
0534         if (!m_list.contains(appItem)) {
0535             m_list.append(appItem);
0536         }
0537     }
0538 }