1/*
2 SPDX-FileCopyrightText: 2023 Aleix Pol Gonzalez <aleixpol@kde.org>
3
4 SPDX-License-Identifier: LGPL-2.1-or-later
5*/
6
7#include "transaction.h"
8#include "enginebase.h"
9#include "enginebase_p.h"
10#include "provider.h"
11#include "question.h"
12
13#include <KLocalizedString>
14#include <KShell>
15#include <QDir>
16#include <QProcess>
17#include <QTimer>
18
19#include <knewstuffcore_debug.h>
20
21using namespace KNSCore;
22
23class KNSCore::TransactionPrivate
24{
25public:
26 TransactionPrivate(const KNSCore::Entry &entry, EngineBase *engine, Transaction *q)
27 : m_engine(engine)
28 , q(q)
29 , subject(entry)
30 {
31 }
32
33 void finish()
34 {
35 m_finished = true;
36 Q_EMIT q->finished();
37 q->deleteLater();
38 }
39
40 EngineBase *const m_engine;
41 Transaction *const q;
42 bool m_finished = false;
43 // Used for updating purposes - we ought to be saving this information, but we also have to deal with old stuff, and so... this will have to do for now
44 // TODO KF6: Installed state needs to move onto a per-downloadlink basis rather than per-entry
45 QMap<Entry, QStringList> payloads;
46 QMap<Entry, QString> payloadToIdentify;
47 const Entry subject;
48};
49
50/**
51 * we look for the directory where all the resources got installed.
52 * assuming it was extracted into a directory
53 */
54static QDir sharedDir(QStringList dirs, QString rootPath)
55{
56 // Ensure that rootPath definitely is a clean path with a slash at the end
57 rootPath = QDir::cleanPath(path: rootPath) + QStringLiteral("/");
58 qCInfo(KNEWSTUFFCORE) << Q_FUNC_INFO << dirs << rootPath;
59 while (!dirs.isEmpty()) {
60 QString thisDir(dirs.takeLast());
61 if (thisDir.endsWith(QStringLiteral("*"))) {
62 qCInfo(KNEWSTUFFCORE) << "Directory entry" << thisDir
63 << "ends in a *, indicating this was installed from an archive - see Installation::archiveEntries";
64 thisDir.chop(n: 1);
65 }
66
67 const QString currentPath = QDir::cleanPath(path: thisDir);
68 qCInfo(KNEWSTUFFCORE) << "Current path is" << currentPath;
69 if (!currentPath.startsWith(s: rootPath)) {
70 qCInfo(KNEWSTUFFCORE) << "Current path" << currentPath << "does not start with" << rootPath << "and should be ignored";
71 continue;
72 }
73
74 const QFileInfo current(currentPath);
75 qCInfo(KNEWSTUFFCORE) << "Current file info is" << current;
76 if (!current.isDir()) {
77 qCInfo(KNEWSTUFFCORE) << "Current path" << currentPath << "is not a directory, and should be ignored";
78 continue;
79 }
80
81 const QDir dir(currentPath);
82 if (dir.path() == (rootPath + dir.dirName())) {
83 qCDebug(KNEWSTUFFCORE) << "Found directory" << dir;
84 return dir;
85 }
86 }
87 qCWarning(KNEWSTUFFCORE) << "Failed to locate any shared installed directory in" << dirs << "and this is almost certainly very bad.";
88 return {};
89}
90
91static QString getAdoptionCommand(const QString &command, const KNSCore::Entry &entry, Installation *inst)
92{
93 auto adoption = command;
94 if (adoption.isEmpty()) {
95 return {};
96 }
97
98 const QLatin1String dirReplace("%d");
99 if (adoption.contains(s: dirReplace)) {
100 QString installPath = sharedDir(dirs: entry.installedFiles(), rootPath: inst->targetInstallationPath()).path();
101 adoption.replace(before: dirReplace, after: KShell::quoteArg(arg: installPath));
102 }
103
104 const QLatin1String fileReplace("%f");
105 if (adoption.contains(s: fileReplace)) {
106 if (entry.installedFiles().isEmpty()) {
107 qCWarning(KNEWSTUFFCORE) << "no installed files to adopt";
108 return {};
109 } else if (entry.installedFiles().count() != 1) {
110 qCWarning(KNEWSTUFFCORE) << "can only adopt one file, will be using the first" << entry.installedFiles().at(i: 0);
111 }
112
113 adoption.replace(before: fileReplace, after: KShell::quoteArg(arg: entry.installedFiles().at(i: 0)));
114 }
115 return adoption;
116}
117
118Transaction::Transaction(const KNSCore::Entry &entry, EngineBase *engine)
119 : QObject(engine)
120 , d(new TransactionPrivate(entry, engine, this))
121{
122 connect(sender: d->m_engine->d->installation, signal: &Installation::signalEntryChanged, context: this, slot: [this](const KNSCore::Entry &changedEntry) {
123 Q_EMIT signalEntryEvent(entry: changedEntry, event: Entry::StatusChangedEvent);
124 d->m_engine->cache()->registerChangedEntry(entry: changedEntry);
125 });
126 connect(sender: d->m_engine->d->installation, signal: &Installation::signalInstallationFailed, context: this, slot: [this](const QString &message, const KNSCore::Entry &entry) {
127 if (entry == d->subject) {
128 Q_EMIT signalErrorCode(errorCode: KNSCore::ErrorCode::InstallationError, message, metadata: {});
129 d->finish();
130 }
131 });
132}
133
134Transaction::~Transaction() = default;
135
136Transaction *Transaction::install(EngineBase *engine, const KNSCore::Entry &_entry, int _linkId)
137{
138 auto ret = new Transaction(_entry, engine);
139 connect(sender: engine->d->installation, signal: &Installation::signalInstallationError, context: ret, slot: [ret, _entry](const QString &msg, const KNSCore::Entry &entry) {
140 if (_entry.uniqueId() == entry.uniqueId()) {
141 Q_EMIT ret->signalErrorCode(errorCode: KNSCore::ErrorCode::InstallationError, message: msg, metadata: {});
142 }
143 });
144
145 QTimer::singleShot(interval: 0, receiver: ret, slot: [_entry, ret, _linkId, engine] {
146 int linkId = _linkId;
147 KNSCore::Entry entry = _entry;
148 if (entry.downloadLinkCount() == 0 && entry.payload().isEmpty()) {
149 // Turns out this happens sometimes, so we should deal with that and spit out an error
150 qCDebug(KNEWSTUFFCORE) << "There were no downloadlinks defined in the entry we were just asked to update: " << entry.uniqueId() << "on provider"
151 << entry.providerId();
152 Q_EMIT ret->signalErrorCode(
153 errorCode: KNSCore::ErrorCode::InstallationError,
154 i18n("Could not perform an installation of the entry %1 as it does not have any downloadable items defined. Please contact the "
155 "author so they can fix this.",
156 entry.name()),
157 metadata: entry.uniqueId());
158 ret->d->finish();
159 } else {
160 if (entry.status() == KNSCore::Entry::Updateable) {
161 entry.setStatus(KNSCore::Entry::Updating);
162 } else {
163 entry.setStatus(KNSCore::Entry::Installing);
164 }
165 Q_EMIT ret->signalEntryEvent(entry, event: Entry::StatusChangedEvent);
166
167 qCDebug(KNEWSTUFFCORE) << "Install " << entry.name() << " from: " << entry.providerId();
168 QSharedPointer<Provider> p = engine->d->providers.value(key: entry.providerId());
169 if (p) {
170 connect(sender: p.data(), signal: &Provider::payloadLinkLoaded, context: ret, slot: &Transaction::downloadLinkLoaded);
171 // If linkId is -1, assume that it's an update and that we don't know what to update
172 if (entry.status() == KNSCore::Entry::Updating && linkId == -1) {
173 if (entry.downloadLinkCount() == 1 || !entry.payload().isEmpty()) {
174 // If there is only one downloadable item (which also includes a predefined payload name), then we can fairly safely assume that's what
175 // we're wanting to update, meaning we can bypass some of the more expensive operations in downloadLinkLoaded
176 qCDebug(KNEWSTUFFCORE) << "Just the one download link, so let's use that";
177 ret->d->payloadToIdentify[entry] = QString{};
178 linkId = 1;
179 } else {
180 qCDebug(KNEWSTUFFCORE) << "Try and identify a download link to use from a total of" << entry.downloadLinkCount();
181 // While this seems silly, the payload gets reset when fetching the new download link information
182 ret->d->payloadToIdentify[entry] = entry.payload();
183 // Drop a fresh list in place so we've got something to work with when we get the links
184 ret->d->payloads[entry] = QStringList{};
185 linkId = 1;
186 }
187 } else {
188 qCDebug(KNEWSTUFFCORE) << "Link ID already known" << linkId;
189 // If there is no payload to identify, we will assume the payload is already known and just use that
190 ret->d->payloadToIdentify[entry] = QString{};
191 }
192
193 p->loadPayloadLink(entry, linkId);
194
195 ret->d->m_finished = false;
196 ret->d->m_engine->updateStatus();
197 }
198 }
199 });
200 return ret;
201}
202
203void Transaction::downloadLinkLoaded(const KNSCore::Entry &entry)
204{
205 if (entry.status() == KNSCore::Entry::Updating) {
206 if (d->payloadToIdentify[entry].isEmpty()) {
207 // If there's nothing to identify, and we've arrived here, then we know what the payload is
208 qCDebug(KNEWSTUFFCORE) << "If there's nothing to identify, and we've arrived here, then we know what the payload is";
209 d->m_engine->d->installation->install(entry);
210 d->payloadToIdentify.remove(key: entry);
211 d->finish();
212 } else if (d->payloads[entry].count() < entry.downloadLinkCount()) {
213 // We've got more to get before we can attempt to identify anything, so fetch the next one...
214 qCDebug(KNEWSTUFFCORE) << "We've got more to get before we can attempt to identify anything, so fetch the next one...";
215 QStringList payloads = d->payloads[entry];
216 payloads << entry.payload();
217 d->payloads[entry] = payloads;
218 QSharedPointer<Provider> p = d->m_engine->d->providers.value(key: entry.providerId());
219 if (p) {
220 // ok, so this should definitely always work, but... safety first, kids!
221 p->loadPayloadLink(entry, linkId: payloads.count());
222 }
223 } else {
224 // We now have all the links, so let's try and identify the correct one...
225 qCDebug(KNEWSTUFFCORE) << "We now have all the links, so let's try and identify the correct one...";
226 QString identifiedLink;
227 const QString payloadToIdentify = d->payloadToIdentify[entry];
228 const QList<Entry::DownloadLinkInformation> downloadLinks = entry.downloadLinkInformationList();
229 const QStringList &payloads = d->payloads[entry];
230
231 if (payloads.contains(str: payloadToIdentify)) {
232 // Simplest option, the link hasn't changed at all
233 qCDebug(KNEWSTUFFCORE) << "Simplest option, the link hasn't changed at all";
234 identifiedLink = payloadToIdentify;
235 } else {
236 // Next simplest option, filename is the same but in a different folder
237 qCDebug(KNEWSTUFFCORE) << "Next simplest option, filename is the same but in a different folder";
238 const QString fileName = payloadToIdentify.split(sep: QChar::fromLatin1(c: '/')).last();
239 for (const QString &payload : payloads) {
240 if (payload.endsWith(s: fileName)) {
241 identifiedLink = payload;
242 break;
243 }
244 }
245
246 // Possibly the payload itself is named differently (by a CDN, for example), but the link identifier is the same...
247 qCDebug(KNEWSTUFFCORE) << "Possibly the payload itself is named differently (by a CDN, for example), but the link identifier is the same...";
248 QStringList payloadNames;
249 for (const Entry::DownloadLinkInformation &downloadLink : downloadLinks) {
250 qCDebug(KNEWSTUFFCORE) << "Download link" << downloadLink.name << downloadLink.id << downloadLink.size << downloadLink.descriptionLink;
251 payloadNames << downloadLink.name;
252 if (downloadLink.name == fileName) {
253 identifiedLink = payloads[payloadNames.count() - 1];
254 qCDebug(KNEWSTUFFCORE) << "Found a suitable download link for" << fileName << "which should match" << identifiedLink;
255 }
256 }
257
258 if (identifiedLink.isEmpty()) {
259 // Least simple option, no match - ask the user to pick (and if we still haven't got one... that's us done, no installation)
260 qCDebug(KNEWSTUFFCORE)
261 << "Least simple option, no match - ask the user to pick (and if we still haven't got one... that's us done, no installation)";
262 auto question = std::make_unique<Question>(args: Question::SelectFromListQuestion);
263 question->setTitle(i18n("Pick Update Item"));
264 question->setQuestion(
265 i18n("Please pick the item from the list below which should be used to apply this update. We were unable to identify which item to "
266 "select, based on the original item, which was named %1",
267 fileName));
268 question->setList(payloadNames);
269 if (question->ask() == Question::OKResponse) {
270 identifiedLink = payloads.value(i: payloadNames.indexOf(str: question->response()));
271 }
272 }
273 }
274 if (!identifiedLink.isEmpty()) {
275 KNSCore::Entry theEntry(entry);
276 theEntry.setPayload(identifiedLink);
277 d->m_engine->d->installation->install(entry: theEntry);
278 connect(sender: d->m_engine->d->installation, signal: &Installation::signalInstallationFinished, context: this, slot: [this, entry](const KNSCore::Entry &finishedEntry) {
279 if (entry.uniqueId() == finishedEntry.uniqueId()) {
280 d->finish();
281 }
282 });
283 } else {
284 qCWarning(KNEWSTUFFCORE) << "We failed to identify a good link for updating" << entry.name() << "and are unable to perform the update";
285 KNSCore::Entry theEntry(entry);
286 theEntry.setStatus(KNSCore::Entry::Updateable);
287 Q_EMIT signalEntryEvent(entry: theEntry, event: Entry::StatusChangedEvent);
288 Q_EMIT signalErrorCode(errorCode: ErrorCode::InstallationError,
289 i18n("We failed to identify a good link for updating %1, and are unable to perform the update", entry.name()),
290 metadata: {entry.uniqueId()});
291 }
292 // As the serverside data may change before next time this is called, even in the same session,
293 // let's not make assumptions, and just get rid of this
294 d->payloads.remove(key: entry);
295 d->payloadToIdentify.remove(key: entry);
296 d->finish();
297 }
298 } else {
299 d->m_engine->d->installation->install(entry);
300 connect(sender: d->m_engine->d->installation, signal: &Installation::signalInstallationFinished, context: this, slot: [this, entry](const KNSCore::Entry &finishedEntry) {
301 if (entry.uniqueId() == finishedEntry.uniqueId()) {
302 d->finish();
303 }
304 });
305 }
306}
307
308Transaction *Transaction::uninstall(EngineBase *engine, const KNSCore::Entry &_entry)
309{
310 auto ret = new Transaction(_entry, engine);
311 const KNSCore::Entry::List list = ret->d->m_engine->cache()->registryForProvider(providerId: _entry.providerId());
312 // we have to use the cached entry here, not the entry from the provider
313 // since that does not contain the list of installed files
314 KNSCore::Entry actualEntryForUninstall;
315 for (const KNSCore::Entry &eInt : list) {
316 if (eInt.uniqueId() == _entry.uniqueId()) {
317 actualEntryForUninstall = eInt;
318 break;
319 }
320 }
321 if (!actualEntryForUninstall.isValid()) {
322 qCDebug(KNEWSTUFFCORE) << "could not find a cached entry with following id:" << _entry.uniqueId() << " -> using the non-cached version";
323 actualEntryForUninstall = _entry;
324 }
325
326 QTimer::singleShot(interval: 0, receiver: ret, slot: [actualEntryForUninstall, _entry, ret] {
327 KNSCore::Entry entry = _entry;
328 entry.setStatus(KNSCore::Entry::Installing);
329
330 Entry actualEntryForUninstall2 = actualEntryForUninstall;
331 actualEntryForUninstall2.setStatus(KNSCore::Entry::Installing);
332 Q_EMIT ret->signalEntryEvent(entry, event: Entry::StatusChangedEvent);
333
334 // We connect to/forward the relevant signals
335 qCDebug(KNEWSTUFFCORE) << "about to uninstall entry " << entry.uniqueId();
336 ret->d->m_engine->d->installation->uninstall(entry: actualEntryForUninstall2);
337
338 // Update the correct entry
339 entry.setStatus(actualEntryForUninstall2.status());
340 Q_EMIT ret->signalEntryEvent(entry, event: Entry::StatusChangedEvent);
341
342 ret->d->finish();
343 });
344
345 return ret;
346}
347
348Transaction *Transaction::adopt(EngineBase *engine, const Entry &entry)
349{
350 if (!engine->hasAdoptionCommand()) {
351 qCWarning(KNEWSTUFFCORE) << "no adoption command specified";
352 return nullptr;
353 }
354
355 auto ret = new Transaction(entry, engine);
356 const QString command = getAdoptionCommand(command: engine->d->adoptionCommand, entry, inst: engine->d->installation);
357
358 QTimer::singleShot(interval: 0, receiver: ret, slot: [command, entry, ret] {
359 QStringList split = KShell::splitArgs(cmd: command);
360 QProcess *process = new QProcess(ret);
361 process->setProgram(split.takeFirst());
362 process->setArguments(split);
363
364 QProcessEnvironment env = QProcessEnvironment::systemEnvironment();
365 // The debug output is too talkative to be useful
366 env.insert(QStringLiteral("QT_LOGGING_RULES"), QStringLiteral("*.debug=false"));
367 process->setProcessEnvironment(env);
368
369 process->start();
370
371 connect(sender: process, signal: &QProcess::finished, context: ret, slot: [ret, process, entry, command](int exitCode) {
372 if (exitCode == 0) {
373 Q_EMIT ret->signalEntryEvent(entry, event: Entry::EntryEvent::AdoptedEvent);
374
375 // Handle error output as warnings if the process hasn't crashed
376 const QString stdErr = QString::fromLocal8Bit(ba: process->readAllStandardError());
377 if (!stdErr.isEmpty()) {
378 Q_EMIT ret->signalMessage(message: stdErr);
379 }
380 } else {
381 const QString errorMsg = i18n("Failed to adopt '%1'\n%2", entry.name(), QString::fromLocal8Bit(process->readAllStandardError()));
382 Q_EMIT ret->signalErrorCode(errorCode: KNSCore::ErrorCode::AdoptionError, message: errorMsg, metadata: QVariantList{command});
383 }
384 ret->d->finish();
385 });
386 });
387 return ret;
388}
389
390bool Transaction::isFinished() const
391{
392 return d->m_finished;
393}
394
395#include "moc_transaction.cpp"
396

source code of knewstuff/src/core/transaction.cpp