1 /*
2     SPDX-FileCopyrightText: 2019 Kai Uwe Broulik <kde@privat.broulik.de>
3 
4     SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL
5 */
6 
7 #include "jobsmodel_p.h"
8 
9 #include "debug.h"
10 
11 #include "job.h"
12 #include "job_p.h"
13 
14 #include "utils_p.h"
15 
16 #include "jobviewserveradaptor.h"
17 #include "jobviewserverv2adaptor.h"
18 #include "kuiserveradaptor.h"
19 
20 #include <QDBusConnection>
21 #include <QDBusConnectionInterface>
22 #include <QDBusMessage>
23 #include <QDBusServiceWatcher>
24 
25 #include <KJob>
26 #include <KLocalizedString>
27 #include <KService>
28 
29 #include <kio/global.h>
30 
31 #include <algorithm>
32 #include <chrono>
33 
34 using namespace NotificationManager;
35 using namespace std::literals::chrono_literals;
36 
JobsModelPrivate(QObject * parent)37 JobsModelPrivate::JobsModelPrivate(QObject *parent)
38     : QObject(parent)
39     , m_serviceWatcher(new QDBusServiceWatcher(this))
40     , m_compressUpdatesTimer(new QTimer(this))
41 {
42     m_serviceWatcher->setConnection(QDBusConnection::sessionBus());
43     m_serviceWatcher->setWatchMode(QDBusServiceWatcher::WatchForUnregistration);
44     connect(m_serviceWatcher, &QDBusServiceWatcher::serviceUnregistered, this, &JobsModelPrivate::onServiceUnregistered);
45 
46     m_compressUpdatesTimer->setInterval(0);
47     m_compressUpdatesTimer->setSingleShot(true);
48     connect(m_compressUpdatesTimer, &QTimer::timeout, this, [this] {
49         for (auto it = m_pendingDirtyRoles.constBegin(), end = m_pendingDirtyRoles.constEnd(); it != end; ++it) {
50             Job *job = it.key();
51             const QVector<int> roles = it.value();
52             const int row = m_jobViews.indexOf(job);
53             if (row == -1) {
54                 continue;
55             }
56 
57             emit jobViewChanged(row, job, roles);
58 
59             // This is updated here and not the percentageChanged signal so we also get some batching out of it
60             if (roles.contains(Notifications::PercentageRole)) {
61                 updateApplicationPercentage(job->desktopEntry());
62             }
63         }
64 
65         m_pendingDirtyRoles.clear();
66     });
67 }
68 
~JobsModelPrivate()69 JobsModelPrivate::~JobsModelPrivate()
70 {
71     QDBusConnection sessionBus = QDBusConnection::sessionBus();
72     sessionBus.unregisterService(QStringLiteral("org.kde.JobViewServer"));
73     sessionBus.unregisterService(QStringLiteral("org.kde.kuiserver"));
74     sessionBus.unregisterObject(QStringLiteral("/JobViewServer"));
75 
76     // Remember which services we had running and clear their progress
77     QStringList desktopEntries;
78     for (Job *job : qAsConst(m_jobViews)) {
79         if (!desktopEntries.contains(job->desktopEntry())) {
80             desktopEntries.append(job->desktopEntry());
81         }
82     }
83 
84     qDeleteAll(m_jobViews);
85     m_jobViews.clear();
86     qDeleteAll(m_pendingJobViews);
87     m_pendingJobViews.clear();
88 
89     m_pendingDirtyRoles.clear();
90 
91     for (const QString &desktopEntry : desktopEntries) {
92         updateApplicationPercentage(desktopEntry);
93     }
94 }
95 
init()96 bool JobsModelPrivate::init()
97 {
98     if (m_valid) {
99         return true;
100     }
101 
102     new KuiserverAdaptor(this);
103     new JobViewServerAdaptor(this);
104     new JobViewServerV2Adaptor(this);
105 
106     QDBusConnection sessionBus = QDBusConnection::sessionBus();
107 
108     if (!sessionBus.registerObject(QStringLiteral("/JobViewServer"), this)) {
109         qCWarning(NOTIFICATIONMANAGER) << "Failed to register JobViewServer DBus object";
110         return false;
111     }
112 
113     // Only the "dbus master" (effectively plasmashell) should be the true owner of job progress reporting
114     const bool master = Utils::isDBusMaster();
115     const auto queueOptions = master ? QDBusConnectionInterface::ReplaceExistingService : QDBusConnectionInterface::DontQueueService;
116     const auto replacementOptions = master ? QDBusConnectionInterface::DontAllowReplacement : QDBusConnectionInterface::AllowReplacement;
117 
118     const QString jobViewServerService = QStringLiteral("org.kde.JobViewServer");
119     const QString kuiserverService = QStringLiteral("org.kde.kuiserver");
120 
121     QDBusConnectionInterface *dbusIface = QDBusConnection::sessionBus().interface();
122 
123     if (!master) {
124         connect(dbusIface, &QDBusConnectionInterface::serviceUnregistered, this, [=](const QString &serviceName) {
125             // Close all running jobs as we're defunct now
126             if (serviceName == jobViewServerService || serviceName == kuiserverService) {
127                 qCDebug(NOTIFICATIONMANAGER) << "Lost ownership of" << serviceName << "service";
128 
129                 const auto pendingJobs = m_pendingJobViews;
130                 for (Job *job : pendingJobs) {
131                     remove(job);
132                 }
133 
134                 const auto jobs = m_jobViews;
135                 for (Job *job : jobs) {
136                     // We can keep the finished ones as they're non-interactive anyway
137                     if (job->state() != Notifications::JobStateStopped) {
138                         remove(job);
139                     }
140                 }
141 
142                 m_valid = false;
143                 emit serviceOwnershipLost();
144             }
145         });
146     }
147 
148     auto registration = dbusIface->registerService(jobViewServerService, queueOptions, replacementOptions);
149     if (registration.value() == QDBusConnectionInterface::ServiceRegistered) {
150         qCDebug(NOTIFICATIONMANAGER) << "Registered JobViewServer service on DBus";
151     } else {
152         qCWarning(NOTIFICATIONMANAGER) << "Failed to register JobViewServer service on DBus, is kuiserver running?";
153         return false;
154     }
155 
156     registration = dbusIface->registerService(kuiserverService, queueOptions, replacementOptions);
157     if (registration.value() != QDBusConnectionInterface::ServiceRegistered) {
158         qCWarning(NOTIFICATIONMANAGER) << "Failed to register org.kde.kuiserver service on DBus, is kuiserver running?";
159         return false;
160     }
161 
162     m_valid = true;
163     return true;
164 }
165 
registerService(const QString & service,const QString & objectPath)166 void JobsModelPrivate::registerService(const QString &service, const QString &objectPath)
167 {
168     qCWarning(NOTIFICATIONMANAGER) << "Request to register JobView service" << service << "on" << objectPath;
169     qCWarning(NOTIFICATIONMANAGER) << "org.kde.kuiserver registerService is deprecated and defunct.";
170     sendErrorReply(QDBusError::NotSupported, QStringLiteral("kuiserver proxying capabilities are deprecated and defunct."));
171 }
172 
jobUrls() const173 QStringList JobsModelPrivate::jobUrls() const
174 {
175     QStringList jobUrls;
176     for (Job *job : m_jobViews) {
177         if (job->state() != Notifications::JobStateStopped && job->destUrl().isValid()) {
178             jobUrls.append(job->destUrl().toString());
179         }
180     }
181     for (Job *job : m_pendingJobViews) {
182         if (job->state() != Notifications::JobStateStopped && job->destUrl().isValid()) {
183             jobUrls.append(job->destUrl().toString());
184         }
185     }
186     return jobUrls;
187 }
188 
emitJobUrlsChanged()189 void JobsModelPrivate::emitJobUrlsChanged()
190 {
191     emit jobUrlsChanged(jobUrls());
192 }
193 
requiresJobTracker() const194 bool JobsModelPrivate::requiresJobTracker() const
195 {
196     return false;
197 }
198 
registeredJobContacts() const199 QStringList JobsModelPrivate::registeredJobContacts() const
200 {
201     return QStringList();
202 }
203 
requestView(const QString & appName,const QString & appIconName,int capabilities)204 QDBusObjectPath JobsModelPrivate::requestView(const QString &appName, const QString &appIconName, int capabilities)
205 {
206     QString desktopEntry;
207     QVariantMap hints;
208 
209     QString applicationName = appName;
210     QString applicationIconName = appIconName;
211 
212     // JobViewServerV1 only sends application name, try to look it up as a service
213     KService::Ptr service = KService::serviceByStorageId(applicationName);
214     if (!service) {
215         // HACK :)
216         service = KService::serviceByStorageId(QLatin1String("org.kde.") + appName);
217     }
218 
219     if (service) {
220         desktopEntry = service->desktopEntryName();
221         applicationName = service->name();
222         applicationIconName = service->icon();
223     }
224 
225     if (!applicationName.isEmpty()) {
226         hints.insert(QStringLiteral("application-display-name"), applicationName);
227     }
228     if (!applicationIconName.isEmpty()) {
229         hints.insert(QStringLiteral("application-icon-name"), applicationIconName);
230     }
231 
232     return requestView(desktopEntry, capabilities, hints);
233 }
234 
requestView(const QString & desktopEntry,int capabilities,const QVariantMap & hints)235 QDBusObjectPath JobsModelPrivate::requestView(const QString &desktopEntry, int capabilities, const QVariantMap &hints)
236 {
237     qCDebug(NOTIFICATIONMANAGER) << "JobView requested by" << desktopEntry;
238 
239     if (!m_highestJobId) {
240         ++m_highestJobId;
241     }
242 
243     Job *job = new Job(m_highestJobId);
244     ++m_highestJobId;
245 
246     QString applicationName = hints.value(QStringLiteral("application-display-name")).toString();
247     QString applicationIconName = hints.value(QStringLiteral("application-icon-name")).toString();
248 
249     job->setDesktopEntry(desktopEntry);
250 
251     KService::Ptr service = KService::serviceByDesktopName(desktopEntry);
252     if (service) {
253         if (applicationName.isEmpty()) {
254             applicationName = service->name();
255         }
256         if (applicationIconName.isEmpty()) {
257             applicationIconName = service->icon();
258         }
259     }
260 
261     job->setApplicationName(applicationName);
262     job->setApplicationIconName(applicationIconName);
263 
264     // No application name? Try to figure out the process name using the sender's PID
265     const QString serviceName = message().service();
266     if (job->applicationName().isEmpty()) {
267         qCInfo(NOTIFICATIONMANAGER) << "JobView request from" << serviceName << "didn't contain any identification information, this is an application bug!";
268 
269         QDBusReply<uint> pidReply = connection().interface()->servicePid(serviceName);
270         if (pidReply.isValid()) {
271             const auto pid = pidReply.value();
272 
273             const QString processName = Utils::processNameFromPid(pid);
274             if (!processName.isEmpty()) {
275                 qCDebug(NOTIFICATIONMANAGER) << "Resolved JobView request to be from" << processName;
276                 job->setApplicationName(processName);
277             }
278         }
279     }
280 
281     job->setSuspendable(capabilities & KJob::Suspendable);
282     job->setKillable(capabilities & KJob::Killable);
283 
284     connect(job->d, &JobPrivate::showRequested, this, [this, job] {
285         if (job->state() == Notifications::JobStateStopped) {
286             // Stop finished or canceled in the meantime, remove
287             qCDebug(NOTIFICATIONMANAGER) << "By the time we wanted to show JobView" << job->id() << "from" << job->applicationName()
288                                          << ", it was already stopped";
289             remove(job);
290             return;
291         }
292 
293         const int pendingRow = m_pendingJobViews.indexOf(job);
294         Q_ASSERT(pendingRow > -1);
295         m_pendingJobViews.removeAt(pendingRow);
296 
297         const int newRow = m_jobViews.count();
298         Q_EMIT jobViewAboutToBeAdded(newRow, job);
299         m_jobViews.append(job);
300         Q_EMIT jobViewAdded(newRow, job);
301         updateApplicationPercentage(job->desktopEntry());
302     });
303 
304     m_pendingJobViews.append(job);
305 
306     if (hints.value(QStringLiteral("immediate")).toBool()) {
307         // Slightly delay showing the job so that the first update() call with a
308         // summary will be shown atomically to the user.
309         job->d->delayedShow(50ms, JobPrivate::ShowCondition::OnTimeout | JobPrivate::ShowCondition::OnSummary | JobPrivate::ShowCondition::OnTermination);
310     } else {
311         // Delay showing a job view to avoid showing really short stat jobs and other useless stuff.
312         job->d->delayedShow(500ms, JobPrivate::ShowCondition::OnTimeout);
313     }
314 
315     m_jobServices.insert(job, serviceName);
316     m_serviceWatcher->addWatchedService(serviceName);
317 
318     // Apply initial properties
319     job->d->update(hints);
320 
321     connect(job, &Job::updatedChanged, this, [this, job] {
322         scheduleUpdate(job, Notifications::UpdatedRole);
323     });
324     connect(job, &Job::summaryChanged, this, [this, job] {
325         scheduleUpdate(job, Notifications::SummaryRole);
326     });
327     connect(job, &Job::textChanged, this, [this, job] {
328         scheduleUpdate(job, Notifications::BodyRole);
329     });
330     connect(job, &Job::stateChanged, this, [this, job] {
331         scheduleUpdate(job, Notifications::JobStateRole);
332         // Timeout and Closable depend on state, signal a change for those, too
333         scheduleUpdate(job, Notifications::TimeoutRole);
334         scheduleUpdate(job, Notifications::ClosableRole);
335 
336         if (job->state() == Notifications::JobStateStopped) {
337             unwatchJob(job);
338             updateApplicationPercentage(job->desktopEntry());
339             emitJobUrlsChanged();
340         }
341     });
342     connect(job, &Job::percentageChanged, this, [this, job] {
343         scheduleUpdate(job, Notifications::PercentageRole);
344     });
345     connect(job, &Job::errorChanged, this, [this, job] {
346         scheduleUpdate(job, Notifications::JobErrorRole);
347     });
348     connect(job, &Job::expiredChanged, this, [this, job] {
349         scheduleUpdate(job, Notifications::ExpiredRole);
350     });
351     connect(job, &Job::dismissedChanged, this, [this, job] {
352         scheduleUpdate(job, Notifications::DismissedRole);
353     });
354 
355     connect(job, &Job::destUrlChanged, this, &JobsModelPrivate::emitJobUrlsChanged);
356 
357     connect(job->d, &JobPrivate::closed, this, [this, job] {
358         remove(job);
359     });
360 
361     if (!connection().interface()->isServiceRegistered(serviceName)) {
362         qCWarning(NOTIFICATIONMANAGER) << "Service that requested the view wasn't registered anymore by the time the request was being processed";
363         QMetaObject::invokeMethod(
364             this,
365             [this, serviceName] {
366                 onServiceUnregistered(serviceName);
367             },
368             Qt::QueuedConnection);
369     }
370 
371     return job->d->objectPath();
372 }
373 
remove(Job * job)374 void JobsModelPrivate::remove(Job *job)
375 {
376     const int activeRow = m_jobViews.indexOf(job);
377     const int pendingRow = m_pendingJobViews.indexOf(job);
378 
379     Job *jobToBeRemoved = nullptr;
380 
381     if (activeRow > -1) {
382         emit jobViewAboutToBeRemoved(activeRow);
383         jobToBeRemoved = m_jobViews.takeAt(activeRow);
384     } else if (pendingRow > -1) {
385         jobToBeRemoved = m_pendingJobViews.takeAt(pendingRow);
386     }
387     Q_ASSERT(jobToBeRemoved);
388 
389     m_pendingDirtyRoles.remove(jobToBeRemoved);
390 
391     const QString desktopEntry = jobToBeRemoved->desktopEntry();
392 
393     unwatchJob(jobToBeRemoved);
394 
395     delete jobToBeRemoved;
396     if (activeRow > -1) {
397         emit jobViewRemoved(activeRow);
398     }
399 
400     updateApplicationPercentage(desktopEntry);
401 }
402 
removeAt(int row)403 void JobsModelPrivate::removeAt(int row)
404 {
405     Q_ASSERT(row >= 0 && row < m_jobViews.count());
406     remove(m_jobViews.at(row));
407 }
408 
409 // This will forward overall application process via Unity API.
410 // This way users of that like Task Manager and Latte Dock still get basic job information.
updateApplicationPercentage(const QString & desktopEntry)411 void JobsModelPrivate::updateApplicationPercentage(const QString &desktopEntry)
412 {
413     if (desktopEntry.isEmpty()) {
414         return;
415     }
416 
417     int jobsPercentages = 0;
418     int jobsCount = 0;
419 
420     for (int i = 0; i < m_jobViews.count(); ++i) {
421         Job *job = m_jobViews.at(i);
422         if (job->state() == Notifications::JobStateStopped || job->desktopEntry() != desktopEntry) {
423             continue;
424         }
425 
426         jobsPercentages += job->percentage();
427         ++jobsCount;
428     }
429 
430     int percentage = 0;
431     if (jobsCount > 0) {
432         percentage = jobsPercentages / jobsCount;
433     }
434 
435     const QVariantMap properties = {{QStringLiteral("count-visible"), jobsCount > 0},
436                                     {QStringLiteral("count"), jobsCount},
437                                     {QStringLiteral("progress-visible"), jobsCount > 0},
438                                     {QStringLiteral("progress"), percentage / 100.0},
439                                     // so Task Manager knows this is a job progress and can ignore it if disabled in settings
440                                     {QStringLiteral("proxied-for"), QStringLiteral("kuiserver")}};
441 
442     QDBusMessage message = QDBusMessage::createSignal(QStringLiteral("/org/kde/notificationmanager/jobs"),
443                                                       QStringLiteral("com.canonical.Unity.LauncherEntry"),
444                                                       QStringLiteral("Update"));
445     message.setArguments({QStringLiteral("application://") + desktopEntry, properties});
446     QDBusConnection::sessionBus().send(message);
447 }
448 
unwatchJob(Job * job)449 void JobsModelPrivate::unwatchJob(Job *job)
450 {
451     const QString serviceName = m_jobServices.take(job);
452     // Check if there's any jobs left for this service, otherwise stop watching it
453     auto it = std::find_if(m_jobServices.constBegin(), m_jobServices.constEnd(), [&serviceName](const QString &item) {
454         return item == serviceName;
455     });
456     if (it == m_jobServices.constEnd()) {
457         m_serviceWatcher->removeWatchedService(serviceName);
458     }
459 }
460 
onServiceUnregistered(const QString & serviceName)461 void JobsModelPrivate::onServiceUnregistered(const QString &serviceName)
462 {
463     qCDebug(NOTIFICATIONMANAGER) << "JobView service unregistered" << serviceName;
464 
465     const QList<Job *> jobs = m_jobServices.keys(serviceName);
466     for (Job *job : jobs) {
467         // Mark all non-finished jobs as failed
468         if (job->state() == Notifications::JobStateStopped) {
469             continue;
470         }
471 
472         job->d->terminate(KIO::ERR_OWNER_DIED, i18n("Application closed unexpectedly."), {} /*hints*/);
473     }
474 
475     Q_ASSERT(!m_serviceWatcher->watchedServices().contains(serviceName));
476 }
477 
scheduleUpdate(Job * job,Notifications::Roles role)478 void JobsModelPrivate::scheduleUpdate(Job *job, Notifications::Roles role)
479 {
480     m_pendingDirtyRoles[job].append(role);
481     m_compressUpdatesTimer->start();
482 }
483