/* SPDX-FileCopyrightText: 2019 Kai Uwe Broulik SPDX-License-Identifier: LGPL-2.1-only OR LGPL-3.0-only OR LicenseRef-KDE-Accepted-LGPL */ #include "jobsmodel_p.h" #include "debug.h" #include "job.h" #include "job_p.h" #include "utils_p.h" #include "settings.h" #include "jobviewserveradaptor.h" #include "jobviewserverv2adaptor.h" #include "kuiserveradaptor.h" #include #include #include #include #include #include #include #include #include #include using namespace NotificationManager; using namespace std::literals::chrono_literals; JobsModelPrivate::JobsModelPrivate(QObject *parent) : QObject(parent) , m_serviceWatcher(new QDBusServiceWatcher(this)) , m_compressUpdatesTimer(new QTimer(this)) , m_settings(new Settings(this)) { m_serviceWatcher->setConnection(QDBusConnection::sessionBus()); m_serviceWatcher->setWatchMode(QDBusServiceWatcher::WatchForUnregistration); connect(m_serviceWatcher, &QDBusServiceWatcher::serviceUnregistered, this, &JobsModelPrivate::onServiceUnregistered); m_compressUpdatesTimer->setInterval(0); m_compressUpdatesTimer->setSingleShot(true); connect(m_compressUpdatesTimer, &QTimer::timeout, this, [this] { for (auto it = m_pendingDirtyRoles.constBegin(), end = m_pendingDirtyRoles.constEnd(); it != end; ++it) { Job *job = it.key(); const QList roles = it.value(); const int row = m_jobViews.indexOf(job); if (row == -1) { continue; } Q_EMIT jobViewChanged(row, job, roles); // This is updated here and not the percentageChanged signal so we also get some batching out of it if (roles.contains(Notifications::PercentageRole)) { updateApplicationPercentage(job->desktopEntry()); } } m_pendingDirtyRoles.clear(); }); connect(m_settings, &Settings::settingsChanged, this, [this] { Q_EMIT requiresJobTrackerChanged(!m_settings->jobsInNotifications()); }); } JobsModelPrivate::~JobsModelPrivate() { QDBusConnection sessionBus = QDBusConnection::sessionBus(); sessionBus.unregisterService(QStringLiteral("org.kde.JobViewServer")); sessionBus.unregisterService(QStringLiteral("org.kde.kuiserver")); sessionBus.unregisterObject(QStringLiteral("/JobViewServer")); // Remember which services we had running and clear their progress QStringList desktopEntries; for (Job *job : std::as_const(m_jobViews)) { if (!desktopEntries.contains(job->desktopEntry())) { desktopEntries.append(job->desktopEntry()); } } qDeleteAll(m_jobViews); m_jobViews.clear(); qDeleteAll(m_pendingJobViews); m_pendingJobViews.clear(); m_pendingDirtyRoles.clear(); for (const QString &desktopEntry : desktopEntries) { updateApplicationPercentage(desktopEntry); } } bool JobsModelPrivate::init() { if (m_valid) { return true; } new KuiserverAdaptor(this); new JobViewServerAdaptor(this); new JobViewServerV2Adaptor(this); QDBusConnection sessionBus = QDBusConnection::sessionBus(); if (!sessionBus.registerObject(QStringLiteral("/JobViewServer"), this)) { qCWarning(NOTIFICATIONMANAGER) << "Failed to register JobViewServer DBus object"; return false; } // Only the "dbus master" (effectively plasmashell) should be the true owner of job progress reporting const bool master = Utils::isDBusMaster(); const auto queueOptions = master ? QDBusConnectionInterface::ReplaceExistingService : QDBusConnectionInterface::DontQueueService; const auto replacementOptions = master ? QDBusConnectionInterface::DontAllowReplacement : QDBusConnectionInterface::AllowReplacement; const QString jobViewServerService = QStringLiteral("org.kde.JobViewServer"); const QString kuiserverService = QStringLiteral("org.kde.kuiserver"); QDBusConnectionInterface *dbusIface = QDBusConnection::sessionBus().interface(); if (!master) { connect(dbusIface, &QDBusConnectionInterface::serviceUnregistered, this, [=, this](const QString &serviceName) { // Close all running jobs as we're defunct now if (serviceName == jobViewServerService || serviceName == kuiserverService) { qCDebug(NOTIFICATIONMANAGER) << "Lost ownership of" << serviceName << "service"; const auto pendingJobs = m_pendingJobViews; for (Job *job : pendingJobs) { remove(job); } const auto jobs = m_jobViews; for (Job *job : jobs) { // We can keep the finished ones as they're non-interactive anyway if (job->state() != Notifications::JobStateStopped) { remove(job); } } m_valid = false; Q_EMIT serviceOwnershipLost(); } }); } auto registration = dbusIface->registerService(jobViewServerService, queueOptions, replacementOptions); if (registration.value() == QDBusConnectionInterface::ServiceRegistered) { qCDebug(NOTIFICATIONMANAGER) << "Registered JobViewServer service on DBus"; } else { qCWarning(NOTIFICATIONMANAGER) << "Failed to register JobViewServer service on DBus, is kuiserver running?"; return false; } registration = dbusIface->registerService(kuiserverService, queueOptions, replacementOptions); if (registration.value() != QDBusConnectionInterface::ServiceRegistered) { qCWarning(NOTIFICATIONMANAGER) << "Failed to register org.kde.kuiserver service on DBus, is kuiserver running?"; return false; } m_valid = true; return true; } void JobsModelPrivate::registerService(const QString &service, const QString &objectPath) { qCWarning(NOTIFICATIONMANAGER) << "Request to register JobView service" << service << "on" << objectPath; qCWarning(NOTIFICATIONMANAGER) << "org.kde.kuiserver registerService is deprecated and defunct."; sendErrorReply(QDBusError::NotSupported, QStringLiteral("kuiserver proxying capabilities are deprecated and defunct.")); } QStringList JobsModelPrivate::jobUrls() const { QStringList jobUrls; for (Job *job : m_jobViews) { if (job->state() != Notifications::JobStateStopped && job->destUrl().isValid()) { jobUrls.append(job->destUrl().toString()); } } for (Job *job : m_pendingJobViews) { if (job->state() != Notifications::JobStateStopped && job->destUrl().isValid()) { jobUrls.append(job->destUrl().toString()); } } return jobUrls; } void JobsModelPrivate::emitJobUrlsChanged() { Q_EMIT jobUrlsChanged(jobUrls()); } bool JobsModelPrivate::requiresJobTracker() const { return !m_settings->jobsInNotifications(); } QStringList JobsModelPrivate::registeredJobContacts() const { return QStringList(); } QDBusObjectPath JobsModelPrivate::requestView(const QString &appName, const QString &appIconName, int capabilities) { QString desktopEntry; QVariantMap hints; QString applicationName = appName; QString applicationIconName = appIconName; // JobViewServerV1 only sends application name, try to look it up as a service KService::Ptr service = KService::serviceByStorageId(applicationName); if (!service) { // HACK :) service = KService::serviceByStorageId(QLatin1String("org.kde.") + appName); } if (service) { desktopEntry = service->desktopEntryName(); applicationName = service->name(); applicationIconName = service->icon(); } if (!applicationName.isEmpty()) { hints.insert(QStringLiteral("application-display-name"), applicationName); } if (!applicationIconName.isEmpty()) { hints.insert(QStringLiteral("application-icon-name"), applicationIconName); } return requestView(desktopEntry, capabilities, hints); } QDBusObjectPath JobsModelPrivate::requestView(const QString &desktopEntry, int capabilities, const QVariantMap &hints) { qCDebug(NOTIFICATIONMANAGER) << "JobView requested by" << desktopEntry; if (!m_highestJobId) { ++m_highestJobId; } Job *job = new Job(m_highestJobId); ++m_highestJobId; QString applicationName = hints.value(QStringLiteral("application-display-name")).toString(); QString applicationIconName = hints.value(QStringLiteral("application-icon-name")).toString(); job->setDesktopEntry(desktopEntry); KService::Ptr service = KService::serviceByDesktopName(desktopEntry); if (service) { if (applicationName.isEmpty()) { applicationName = service->name(); } if (applicationIconName.isEmpty()) { applicationIconName = service->icon(); } } job->setApplicationName(applicationName); job->setApplicationIconName(applicationIconName); // No application name? Try to figure out the process name using the sender's PID const QString serviceName = message().service(); if (job->applicationName().isEmpty()) { qCInfo(NOTIFICATIONMANAGER) << "JobView request from" << serviceName << "didn't contain any identification information, this is an application bug!"; QDBusReply pidReply = connection().interface()->servicePid(serviceName); if (pidReply.isValid()) { const auto pid = pidReply.value(); const QString processName = Utils::processNameFromPid(pid); if (!processName.isEmpty()) { qCDebug(NOTIFICATIONMANAGER) << "Resolved JobView request to be from" << processName; job->setApplicationName(processName); } } } job->setSuspendable(capabilities & KJob::Suspendable); job->setKillable(capabilities & KJob::Killable); connect(job->d, &JobPrivate::showRequested, this, [this, job] { if (job->state() == Notifications::JobStateStopped) { // Stop finished or canceled in the meantime, remove qCDebug(NOTIFICATIONMANAGER) << "By the time we wanted to show JobView" << job->id() << "from" << job->applicationName() << ", it was already stopped"; remove(job); return; } const int pendingRow = m_pendingJobViews.indexOf(job); Q_ASSERT(pendingRow > -1); m_pendingJobViews.removeAt(pendingRow); const int newRow = m_jobViews.count(); Q_EMIT jobViewAboutToBeAdded(newRow, job); m_jobViews.append(job); Q_EMIT jobViewAdded(newRow, job); updateApplicationPercentage(job->desktopEntry()); }); m_pendingJobViews.append(job); if (hints.value(QStringLiteral("immediate")).toBool()) { // Slightly delay showing the job so that the first update() call with a // summary will be shown atomically to the user. job->d->delayedShow(50ms, JobPrivate::ShowCondition::OnTimeout | JobPrivate::ShowCondition::OnSummary | JobPrivate::ShowCondition::OnTermination); } else { // Delay showing a job view to avoid showing really short stat jobs and other useless stuff. job->d->delayedShow(500ms, JobPrivate::ShowCondition::OnTimeout); } if (hints.value(QStringLiteral("transient")).toBool()) { job->setTransient(true); } m_jobServices.insert(job, serviceName); m_serviceWatcher->addWatchedService(serviceName); // Apply initial properties job->d->update(hints); connect(job, &Job::updatedChanged, this, [this, job] { scheduleUpdate(job, Notifications::UpdatedRole); }); connect(job, &Job::summaryChanged, this, [this, job] { scheduleUpdate(job, Notifications::SummaryRole); }); connect(job, &Job::textChanged, this, [this, job] { scheduleUpdate(job, Notifications::BodyRole); scheduleUpdate(job, Qt::AccessibleDescriptionRole); }); connect(job, &Job::stateChanged, this, [this, job] { scheduleUpdate(job, Notifications::JobStateRole); // Timeout and Closable depend on state, signal a change for those, too scheduleUpdate(job, Notifications::TimeoutRole); scheduleUpdate(job, Notifications::ClosableRole); if (job->state() == Notifications::JobStateStopped) { unwatchJob(job); updateApplicationPercentage(job->desktopEntry()); emitJobUrlsChanged(); } }); connect(job, &Job::percentageChanged, this, [this, job] { scheduleUpdate(job, Notifications::PercentageRole); }); connect(job, &Job::errorChanged, this, [this, job] { scheduleUpdate(job, Notifications::JobErrorRole); }); connect(job, &Job::expiredChanged, this, [this, job] { scheduleUpdate(job, Notifications::ExpiredRole); }); connect(job, &Job::dismissedChanged, this, [this, job] { scheduleUpdate(job, Notifications::DismissedRole); }); connect(job, &Job::destUrlChanged, this, &JobsModelPrivate::emitJobUrlsChanged); connect(job->d, &JobPrivate::closed, this, [this, job] { remove(job); }); if (!connection().interface()->isServiceRegistered(serviceName)) { qCWarning(NOTIFICATIONMANAGER) << "Service that requested the view wasn't registered anymore by the time the request was being processed"; QMetaObject::invokeMethod( this, [this, serviceName] { onServiceUnregistered(serviceName); }, Qt::QueuedConnection); } return job->d->objectPath(); } void JobsModelPrivate::remove(Job *job) { const int activeRow = m_jobViews.indexOf(job); const int pendingRow = m_pendingJobViews.indexOf(job); Job *jobToBeRemoved = nullptr; if (activeRow > -1) { Q_EMIT jobViewAboutToBeRemoved(activeRow); jobToBeRemoved = m_jobViews.takeAt(activeRow); } else if (pendingRow > -1) { jobToBeRemoved = m_pendingJobViews.takeAt(pendingRow); } Q_ASSERT(jobToBeRemoved); m_pendingDirtyRoles.remove(jobToBeRemoved); const QString desktopEntry = jobToBeRemoved->desktopEntry(); unwatchJob(jobToBeRemoved); delete jobToBeRemoved; if (activeRow > -1) { Q_EMIT jobViewRemoved(activeRow); } updateApplicationPercentage(desktopEntry); } void JobsModelPrivate::removeAt(int row) { Q_ASSERT(row >= 0 && row < m_jobViews.count()); remove(m_jobViews.at(row)); } // This will forward overall application process via Unity API. // This way users of that like Task Manager and Latte Dock still get basic job information. void JobsModelPrivate::updateApplicationPercentage(const QString &desktopEntry) { if (desktopEntry.isEmpty()) { return; } int jobsPercentages = 0; int jobsCount = 0; for (int i = 0; i < m_jobViews.count(); ++i) { Job *job = m_jobViews.at(i); if (job->state() == Notifications::JobStateStopped || job->desktopEntry() != desktopEntry) { continue; } jobsPercentages += job->percentage(); ++jobsCount; } int percentage = 0; if (jobsCount > 0) { percentage = jobsPercentages / jobsCount; } const QVariantMap properties = {{QStringLiteral("count-visible"), jobsCount > 0}, {QStringLiteral("count"), jobsCount}, {QStringLiteral("progress-visible"), jobsCount > 0}, {QStringLiteral("progress"), percentage / 100.0}, // so Task Manager knows this is a job progress and can ignore it if disabled in settings {QStringLiteral("proxied-for"), QStringLiteral("kuiserver")}}; QDBusMessage message = QDBusMessage::createSignal(QStringLiteral("/org/kde/notificationmanager/jobs"), QStringLiteral("com.canonical.Unity.LauncherEntry"), QStringLiteral("Update")); message.setArguments({QString(u"application://" + desktopEntry), properties}); QDBusConnection::sessionBus().send(message); } void JobsModelPrivate::unwatchJob(Job *job) { const QString serviceName = m_jobServices.take(job); // Check if there's any jobs left for this service, otherwise stop watching it auto it = std::find_if(m_jobServices.constBegin(), m_jobServices.constEnd(), [&serviceName](const QString &item) { return item == serviceName; }); if (it == m_jobServices.constEnd()) { m_serviceWatcher->removeWatchedService(serviceName); } } void JobsModelPrivate::onServiceUnregistered(const QString &serviceName) { qCDebug(NOTIFICATIONMANAGER) << "JobView service unregistered" << serviceName; const QList jobs = m_jobServices.keys(serviceName); for (Job *job : jobs) { // Mark all non-finished jobs as failed if (job->state() == Notifications::JobStateStopped) { continue; } job->d->terminate(KIO::ERR_OWNER_DIED, i18n("Application closed unexpectedly."), {} /*hints*/); } Q_ASSERT(!m_serviceWatcher->watchedServices().contains(serviceName)); } void JobsModelPrivate::scheduleUpdate(Job *job, int role) { m_pendingDirtyRoles[job].append(role); m_compressUpdatesTimer->start(); }