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