1/*
2 SPDX-FileCopyrightText: 2014 Aleix Pol Gonzalez <aleixpol@blue-systems.com>
3
4 SPDX-License-Identifier: LGPL-2.1-or-later
5*/
6
7#include "alternativesmodel.h"
8
9#if HAVE_QTDBUS
10#include <QDBusConnection>
11#include <QDBusConnectionInterface>
12#endif
13
14#include <QDebug>
15#include <QDirIterator>
16#include <QIcon>
17#include <QJsonArray>
18#include <QMimeDatabase>
19#include <QMimeType>
20#include <QRegularExpression>
21#include <QStandardPaths>
22
23#include <KConfigGroup>
24#include <KJsonUtils>
25#include <KPluginMetaData>
26#include <KSharedConfig>
27
28#include "configuration.h"
29#include "helper.h"
30#include "purpose_external_process_debug.h"
31
32using namespace Purpose;
33
34static const QStringList s_defaultDisabledPlugins = {QStringLiteral("saveasplugin")};
35
36typedef bool (*matchFunction)(const QString &constraint, const QJsonValue &value);
37
38static bool defaultMatch(const QString &constraint, const QJsonValue &value)
39{
40 return value == QJsonValue(constraint);
41}
42
43static bool mimeTypeMatch(const QString &constraint, const QJsonValue &value)
44{
45 if (value.isArray()) {
46 const auto array = value.toArray();
47 for (const QJsonValue &val : array) {
48 if (mimeTypeMatch(constraint, value: val)) {
49 return true;
50 }
51 }
52 return false;
53 } else if (value.isObject()) {
54 for (const QJsonValue &val : value.toObject()) {
55 if (mimeTypeMatch(constraint, value: val)) {
56 return true;
57 }
58 }
59 return false;
60 } else if (constraint.contains(c: QLatin1Char('*'))) {
61 const QRegularExpression re(QRegularExpression::wildcardToRegularExpression(str: constraint), QRegularExpression::CaseInsensitiveOption);
62 return re.match(subject: value.toString()).hasMatch();
63 } else {
64 QMimeDatabase db;
65 QMimeType mime = db.mimeTypeForName(nameOrAlias: value.toString());
66 return mime.inherits(mimeTypeName: constraint);
67 }
68}
69
70static bool dbusMatch(const QString &constraint, const QJsonValue &value)
71{
72 Q_UNUSED(value)
73#if HAVE_QTDBUS
74 return QDBusConnection::sessionBus().interface()->isServiceRegistered(serviceName: constraint);
75#else
76 Q_UNUSED(constraint)
77 return false;
78#endif
79}
80
81static bool executablePresent(const QString &constraint, const QJsonValue &value)
82{
83 Q_UNUSED(value)
84 return !QStandardPaths::findExecutable(executableName: constraint).isEmpty();
85}
86
87static bool desktopFilePresent(const QString &constraint, const QJsonValue &value)
88{
89 Q_UNUSED(value)
90 return !QStandardPaths::locate(type: QStandardPaths::ApplicationsLocation, fileName: constraint).isEmpty();
91}
92
93static QMap<QString, matchFunction> s_matchFunctions = {
94 {QStringLiteral("mimeType"), mimeTypeMatch},
95 {QStringLiteral("dbus"), dbusMatch},
96 {QStringLiteral("application"), desktopFilePresent},
97 {QStringLiteral("exec"), executablePresent},
98};
99
100class Purpose::AlternativesModelPrivate
101{
102public:
103 QList<KPluginMetaData> m_plugins;
104 QJsonObject m_inputData;
105 QString m_pluginType;
106 QStringList m_disabledPlugins = s_defaultDisabledPlugins;
107 QJsonObject m_pluginTypeData;
108 const QRegularExpression constraintRx{QStringLiteral("(\\w+):(.*)")};
109
110 bool isPluginAcceptable(const KPluginMetaData &meta, const QStringList &disabledPlugins) const
111 {
112 const QJsonObject obj = meta.rawData();
113 if (!obj.value(key: QLatin1String("X-Purpose-PluginTypes")).toArray().contains(element: m_pluginType)) {
114 // qCDebug(PURPOSE_EXTERNAL_PROCESS_LOG) << "discarding" << meta.name() << KPluginMetaData::readStringList(meta.rawData(),
115 // QStringLiteral("X-Purpose-PluginTypes"));
116 return false;
117 }
118
119 if (disabledPlugins.contains(str: meta.pluginId()) || m_disabledPlugins.contains(str: meta.pluginId())) {
120 // qCDebug(PURPOSE_EXTERNAL_PROCESS_LOG) << "disabled plugin" << meta.name() << meta.pluginId();
121 return false;
122 }
123
124 // All constraints must match
125 const QJsonArray constraints = obj.value(key: QLatin1String("X-Purpose-Constraints")).toArray();
126 for (const QJsonValue &constraint : constraints) {
127 if (!constraintMatches(meta, constraint)) {
128 return false;
129 }
130 }
131 return true;
132 }
133
134 bool constraintMatches(const KPluginMetaData &meta, const QJsonValue &constraint) const
135 {
136 // Treat an array as an OR
137 if (constraint.isArray()) {
138 const QJsonArray options = constraint.toArray();
139 for (const auto &option : options) {
140 if (constraintMatches(meta, constraint: option)) {
141 return true;
142 }
143 }
144 return false;
145 }
146 Q_ASSERT(constraintRx.isValid());
147 QRegularExpressionMatch match = constraintRx.match(subject: constraint.toString());
148 if (!match.isValid() || !match.hasMatch()) {
149 qCWarning(PURPOSE_EXTERNAL_PROCESS_LOG) << "wrong constraint" << constraint.toString();
150 return false;
151 }
152 const QString propertyName = match.captured(nth: 1);
153 const QString constrainedValue = match.captured(nth: 2);
154
155 const auto it = m_inputData.constFind(key: propertyName);
156 if (it == m_inputData.end()) {
157 // The constraint doesn't pertain to this data type
158 return true;
159 }
160
161 const bool acceptable = s_matchFunctions.value(key: propertyName, defaultValue: defaultMatch)(constrainedValue, *it);
162 if (!acceptable) {
163 // qCDebug(PURPOSE_EXTERNAL_PROCESS_LOG) << "not accepted" << meta.name() << propertyName << constrainedValue << *it;
164 }
165 return acceptable;
166 }
167};
168
169AlternativesModel::AlternativesModel(QObject *parent)
170 : QAbstractListModel(parent)
171 , d_ptr(new AlternativesModelPrivate)
172{
173}
174
175AlternativesModel::~AlternativesModel()
176{
177 Q_D(AlternativesModel);
178 delete d;
179}
180
181QHash<int, QByteArray> AlternativesModel::roleNames() const
182{
183 QHash<int, QByteArray> roles = QAbstractListModel::roleNames();
184 roles.insert(key: IconNameRole, QByteArrayLiteral("iconName"));
185 roles.insert(key: PluginIdRole, QByteArrayLiteral("pluginId"));
186 roles.insert(key: ActionDisplayRole, QByteArrayLiteral("actionDisplay"));
187 return roles;
188}
189
190void AlternativesModel::setInputData(const QJsonObject &input)
191{
192 Q_D(AlternativesModel);
193 if (input == d->m_inputData) {
194 return;
195 }
196
197 d->m_inputData = input;
198 initializeModel();
199
200 Q_EMIT inputDataChanged();
201}
202
203void AlternativesModel::setPluginType(const QString &pluginType)
204{
205 Q_D(AlternativesModel);
206 if (pluginType == d->m_pluginType) {
207 return;
208 }
209
210 d->m_pluginTypeData = Purpose::readPluginType(pluginType);
211 d->m_pluginType = pluginType;
212 Q_ASSERT(d->m_pluginTypeData.isEmpty() == d->m_pluginType.isEmpty());
213
214 initializeModel();
215
216 Q_EMIT pluginTypeChanged();
217}
218
219QStringList AlternativesModel::disabledPlugins() const
220{
221 Q_D(const AlternativesModel);
222 return d->m_disabledPlugins;
223}
224
225void AlternativesModel::setDisabledPlugins(const QStringList &pluginIds)
226{
227 Q_D(AlternativesModel);
228 if (pluginIds == d->m_disabledPlugins) {
229 return;
230 }
231
232 d->m_disabledPlugins = pluginIds;
233
234 initializeModel();
235
236 Q_EMIT disabledPluginsChanged();
237}
238
239QString AlternativesModel::pluginType() const
240{
241 Q_D(const AlternativesModel);
242 return d->m_pluginType;
243}
244
245QJsonObject AlternativesModel::inputData() const
246{
247 Q_D(const AlternativesModel);
248 return d->m_inputData;
249}
250
251Purpose::Configuration *AlternativesModel::configureJob(int row)
252{
253 Q_D(AlternativesModel);
254 const KPluginMetaData pluginData = d->m_plugins.at(i: row);
255 return new Configuration(d->m_inputData, d->m_pluginType, d->m_pluginTypeData, pluginData, this);
256}
257
258int AlternativesModel::rowCount(const QModelIndex &parent) const
259{
260 Q_D(const AlternativesModel);
261 return parent.isValid() ? 0 : d->m_plugins.count();
262}
263
264QVariant AlternativesModel::data(const QModelIndex &index, int role) const
265{
266 Q_D(const AlternativesModel);
267 if (!index.isValid() || index.row() > d->m_plugins.count()) {
268 return QVariant();
269 }
270
271 KPluginMetaData data = d->m_plugins[index.row()];
272 switch (role) {
273 case Qt::DisplayRole:
274 return data.name();
275 case Qt::ToolTipRole:
276 return data.description();
277 case IconNameRole:
278 return data.iconName();
279 case Qt::DecorationRole:
280 return QIcon::fromTheme(name: data.iconName());
281 case PluginIdRole:
282 return data.pluginId();
283 case ActionDisplayRole: {
284 const QJsonObject pluginData = data.rawData().value(key: QLatin1String("KPlugin")).toObject();
285 QString action = KJsonUtils::readTranslatedString(jo: pluginData, QStringLiteral("X-Purpose-ActionDisplay"));
286 // We really don't want custom keys in the KPlugin object, but used to do this. No warnings due to different release cycles for now
287 if (action.isEmpty()) {
288 action = KJsonUtils::readTranslatedString(jo: data.rawData(), QStringLiteral("X-Purpose-ActionDisplay"));
289 }
290 return action.isEmpty() ? data.name() : action;
291 }
292 }
293 return QVariant();
294}
295
296static QList<KPluginMetaData> findScriptedPackages(std::function<bool(const KPluginMetaData &)> filter)
297{
298 QList<KPluginMetaData> ret;
299 QSet<QString> addedPlugins;
300 const QStringList dirs =
301 QStandardPaths::locateAll(type: QStandardPaths::GenericDataLocation, QStringLiteral("kpackage/Purpose"), options: QStandardPaths::LocateDirectory);
302 for (const QString &dir : dirs) {
303 QDirIterator dirIt(dir, QDir::Dirs | QDir::NoDotAndDotDot);
304
305 while (dirIt.hasNext()) {
306 QDir dir(dirIt.next());
307 Q_ASSERT(dir.exists());
308 if (!dir.exists(QStringLiteral("metadata.json"))) {
309 continue;
310 }
311
312 const KPluginMetaData info = Purpose::createMetaData(file: dir.absoluteFilePath(QStringLiteral("metadata.json")));
313 if (!addedPlugins.contains(value: info.pluginId()) && filter(info)) {
314 addedPlugins << info.pluginId();
315 ret += info;
316 }
317 }
318 }
319
320 return ret;
321}
322
323void AlternativesModel::initializeModel()
324{
325 Q_D(AlternativesModel);
326
327 const QList<KPluginMetaData> newPlugins = [&d] {
328 QList<KPluginMetaData> plugins;
329
330 if (d->m_pluginType.isEmpty() || d->m_inputData.isEmpty()) {
331 return plugins;
332 }
333
334 const QJsonArray inbound = d->m_pluginTypeData.value(key: QLatin1String("X-Purpose-InboundArguments")).toArray();
335 for (const QJsonValue &arg : inbound) {
336 const auto key = arg.toString();
337 if (!d->m_inputData.contains(key)) {
338 qCWarning(PURPOSE_EXTERNAL_PROCESS_LOG).nospace()
339 << "Cannot initialize model for plugin type " << d->m_pluginType << " with data " << d->m_inputData << ": missing key " << key;
340 return plugins;
341 }
342 }
343
344 const auto config = KSharedConfig::openConfig(QStringLiteral("purposerc"));
345 const auto group = config->group(QStringLiteral("plugins"));
346 const QStringList disabledPlugins = group.readEntry(key: "disabled", aDefault: QStringList());
347 auto pluginAcceptable = [d, disabledPlugins](const KPluginMetaData &meta) {
348 return d->isPluginAcceptable(meta, disabledPlugins);
349 };
350
351 plugins << KPluginMetaData::findPlugins(QStringLiteral("kf6/purpose"), filter: pluginAcceptable);
352 plugins += findScriptedPackages(filter: pluginAcceptable);
353
354 return plugins;
355 }();
356
357 if (d->m_plugins != newPlugins) {
358 beginResetModel();
359 d->m_plugins = newPlugins;
360 endResetModel();
361 }
362}
363
364#include "moc_alternativesmodel.cpp"
365

source code of purpose/src/alternativesmodel.cpp