diff --git a/libtaskmanager/tasksmodel.cpp b/libtaskmanager/tasksmodel.cpp index 4607b5a..942ff3b 100644 --- a/libtaskmanager/tasksmodel.cpp +++ b/libtaskmanager/tasksmodel.cpp @@ -15,6 +15,7 @@ #include "launchertasksmodel.h" #include "startuptasksmodel.h" +#include "waylandstartuptasksmodel.h" #include "windowtasksmodel.h" #include "launchertasksmodel_p.h" @@ -189,6 +190,45 @@ void TasksModel::Private::initModels() concatProxyModel->addSourceModel(startupTasksModel); + // Quine Linux: tell the startup feedback about windows (with their pid), + // so it can follow a launch past KWin's own notion of it ending. Once per + // window model: it and the startup models are shared by all TasksModels. + if (auto *startups = qobject_cast(startupTasksModel->sourceModel()); + startups && !windowTasksModel->property("_quine_startupHook").toBool()) { + windowTasksModel->setProperty("_quine_startupHook", true); + auto *windows = windowTasksModel; + // Queued: removing startup rows while the proxies are still taking in + // the window's row corrupts the task order (launchers move, take + // other apps' icons). + auto report = [startups, windows](int first, int last) { + for (int i = first; i <= last; ++i) { + const QModelIndex index = windows->index(i, 0); + const QString appId = index.data(AbstractTasksModel::AppId).toString(); + const quint32 pid = index.data(AbstractTasksModel::AppPid).toUInt(); + QMetaObject::invokeMethod( + startups, + [startups, appId, pid] { + startups->windowAppeared(appId, pid); + }, + Qt::QueuedConnection); + } + }; + QObject::connect(windowTasksModel, &QAbstractItemModel::rowsInserted, startups, [report](const QModelIndex &parent, int first, int last) { + if (!parent.isValid()) { + report(first, last); + } + }); + QObject::connect(windowTasksModel, + &QAbstractItemModel::dataChanged, + startups, + [report](const QModelIndex &topLeft, const QModelIndex &bottomRight, const QList &roles) { + if (!topLeft.parent().isValid() + && (roles.isEmpty() || roles.contains(AbstractTasksModel::AppId) || roles.contains(AbstractTasksModel::AppPid))) { + report(topLeft.row(), bottomRight.row()); + } + }); + } + // If we're in manual sort mode, we need to seed the sort map on pending row // insertions. QObject::connect(concatProxyModel, &QAbstractItemModel::rowsAboutToBeInserted, q, [this](const QModelIndex &parent, int start, int end) { diff --git a/libtaskmanager/waylandstartuptasksmodel.cpp b/libtaskmanager/waylandstartuptasksmodel.cpp index 760d91d..345ebbd 100644 --- a/libtaskmanager/waylandstartuptasksmodel.cpp +++ b/libtaskmanager/waylandstartuptasksmodel.cpp @@ -13,9 +13,19 @@ #include +#include + +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include #include #include @@ -77,23 +87,199 @@ protected: } }; +// Quine Linux: the launch feedback follows the launched app instead of a +// fixed timer. +// +// KDE starts an app (KIO) in a systemd unit named after it, as systemd's +// desktop conventions say: app-@.service, and a Flatpak moves it +// on into app-flatpak--.scope. While the feedback is up, this polls +// systemd for the app's units, and tells the ones that started since the +// launch (its own) from ones already running (another copy of the app), by +// when systemd started them: a launcher in another process (KRunner) can +// start its unit before this hears of the launch. Then: +// - a window of the app, by app id or by its pid being in one of those +// units, ends it (windowAppeared(), from TasksModel); +// - so does the app registering a tray icon (it started in the background); +// - KWin saying the startup finished doesn't end it while the units run: +// KWin keeps one startup at a time, and ends it when any window opens; +// - the units ending without a window ends it with a notification, " +// didn't open" (and that another copy is running, if one is: a launch +// that hands over to a hung instance looks like this); +// - after StillStartingTimeout (klaunchrc [TaskbarButtonSettings], default +// 180 s) a notification asks: Keep Waiting, or Stop ; +// - a launch with no unit to follow (not started through systemd) ends +// after the usual Timeout, as before. +namespace +{ +const QString s_systemd = QStringLiteral("org.freedesktop.systemd1"); +const QString s_systemdPath = QStringLiteral("/org/freedesktop/systemd1"); +const QString s_systemdManager = QStringLiteral("org.freedesktop.systemd1.Manager"); +const QString s_notifications = QStringLiteral("org.freedesktop.Notifications"); +const QString s_notificationsPath = QStringLiteral("/org/freedesktop/Notifications"); +const QString s_sniWatcher = QStringLiteral("org.kde.StatusNotifierWatcher"); +const QString s_sniWatcherPath = QStringLiteral("/StatusNotifierWatcher"); +constexpr std::chrono::seconds s_pollInterval{1}; +// A unit started this long before the launch was heard of is still its own. +constexpr qint64 s_startSlackUsec = 2'000'000; + +// CLOCK_MONOTONIC in µs, systemd's *TimestampMonotonic clock. +qint64 monotonicUsec() +{ + timespec ts{}; + clock_gettime(CLOCK_MONOTONIC, &ts); + return qint64(ts.tv_sec) * 1'000'000 + ts.tv_nsec / 1'000; +} + +struct UnitInfo { + QString name, description, loadState, activeState, subState, following; + QDBusObjectPath path; + quint32 jobId = 0; + QString jobType; + QDBusObjectPath jobPath; +}; + +const QDBusArgument &operator>>(const QDBusArgument &arg, UnitInfo &unit) +{ + arg.beginStructure(); + arg >> unit.name >> unit.description >> unit.loadState >> unit.activeState >> unit.subState >> unit.following >> unit.path >> unit.jobId >> unit.jobType + >> unit.jobPath; + arg.endStructure(); + return arg; +} + +// As KIO escapes an app id into a unit name (KProcessRunner::escapeUnitName). +QString escapeUnitName(const QString &input) +{ + QString res; + const QByteArray bytes = input.toUtf8(); + for (const unsigned char c : bytes) { + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == ':' || c == '_' || c == '.') { + res += QLatin1Char(c); + } else { + res += QStringLiteral("\\x%1").arg(c, 2, 16, QLatin1Char('0')); + } + } + return res; +} + +// Whether a unit is one of the app's: app[-]-[@].service +// or app[-]--.scope. +bool unitBelongsTo(const QString &unit, const QString &appId) +{ + if (!unit.startsWith(QLatin1String("app-"))) { + return false; + } + QString rest = unit.mid(4); + if (rest.endsWith(QLatin1String(".service"))) { + rest.chop(8); + if (const int at = rest.indexOf(QLatin1Char('@')); at >= 0) { + rest.truncate(at); + } + } else if (rest.endsWith(QLatin1String(".scope"))) { + rest.chop(6); + const int dash = rest.lastIndexOf(QLatin1Char('-')); + if (dash < 0) { + return false; + } + rest.truncate(dash); + } else { + return false; + } + for (const QString &id : {escapeUnitName(appId), appId}) { + if (rest == id || rest.endsWith(QLatin1Char('-') + id)) { + return true; + } + } + return false; +} + +// The cgroup path of a process, split: its units are among the parts. +QStringList cgroupOf(quint32 pid) +{ + QFile file(QStringLiteral("/proc/%1/cgroup").arg(pid)); + if (pid == 0 || !file.open(QIODevice::ReadOnly)) { + return {}; + } + const auto lines = QString::fromUtf8(file.readAll()).split(QLatin1Char('\n')); + for (const QString &line : lines) { + if (line.startsWith(QLatin1String("0::"))) { // cgroup v2 + return line.mid(3).split(QLatin1Char('/'), Qt::SkipEmptyParts); + } + } + return {}; +} + +bool isAlive(const QString &activeState) +{ + return activeState != QLatin1String("inactive") && activeState != QLatin1String("failed"); +} +} + +// Relays the notification server's signals. +class StartupNotifications : public QObject +{ + Q_OBJECT +public: + using QObject::QObject; +public Q_SLOTS: + void onActionInvoked(uint id, const QString &action) + { + Q_EMIT actionInvoked(id, action); + } + void onClosed(uint id, uint reason) + { + Q_UNUSED(reason) + Q_EMIT closed(id); + } + void onTrayItemRegistered(const QString &service) + { + Q_EMIT trayItemRegistered(service); + } +Q_SIGNALS: + void actionInvoked(uint id, const QString &action); + void closed(uint id); + void trayItemRegistered(const QString &service); +}; + class Q_DECL_HIDDEN WaylandStartupTasksModel::Private { public: Private(WaylandStartupTasksModel *q); + struct Startup; + void addActivation(PlasmaActivation *activation); void removeActivation(PlasmaActivation *activation); + Startup *find(PlasmaActivation *activation); void init(); void loadConfig(); + void pollUnits(PlasmaActivation *activation); + void unitsPolled(PlasmaActivation *activation, const QHash &alive); + void endIfOwns(const QStringList &cgroup); + void notifyStillStarting(PlasmaActivation *activation); + void notifyDidNotOpen(const Startup &startup, bool anotherRunning); + void stopUnits(const QStringList &units); + void trayItemRegistered(const QString &service); + struct Startup { QString name; QIcon icon; + QString iconName; QString applicationId; QUrl launcherUrl; std::unique_ptr activation; + // Quine Linux: following the launch (above). + qint64 launchedUsec = 0; // when this heard of the launch (monotonic) + QHash unitStarts; // the app's units: when each started (-1: asking) + QStringList units; // the launch's own units, as last seen + bool sawUnit = false; + bool kwinFinished = false; // KWin said done; trusted once the units end + bool stopping = false; // the user chose Stop + uint notificationId = 0; // the "still starting" one + QTimer *pollTimer = nullptr; + QTimer *stillStartingTimer = nullptr; }; WaylandStartupTasksModel *const q; @@ -101,6 +287,8 @@ public: std::unique_ptr feedback = nullptr; std::vector startups; std::chrono::seconds startupTimeout = std::chrono::seconds::zero(); + std::chrono::seconds stillStartingTimeout{180}; + StartupNotifications *notifications = nullptr; }; WaylandStartupTasksModel::Private::Private(WaylandStartupTasksModel *q) @@ -115,6 +303,54 @@ void WaylandStartupTasksModel::Private::init() loadConfig(); }); + notifications = new StartupNotifications(q); + auto bus = QDBusConnection::sessionBus(); + bus.connect(s_notifications, + s_notificationsPath, + s_notifications, + QStringLiteral("ActionInvoked"), + notifications, + SLOT(onActionInvoked(uint, QString))); + bus.connect(s_notifications, + s_notificationsPath, + s_notifications, + QStringLiteral("NotificationClosed"), + notifications, + SLOT(onClosed(uint, uint))); + bus.connect(s_sniWatcher, + s_sniWatcherPath, + s_sniWatcher, + QStringLiteral("StatusNotifierItemRegistered"), + notifications, + SLOT(onTrayItemRegistered(QString))); + + QObject::connect(notifications, &StartupNotifications::actionInvoked, q, [this](uint id, const QString &action) { + for (auto &startup : startups) { + if (id == 0 || startup.notificationId != id) { + continue; + } + startup.notificationId = 0; + if (action == QLatin1String("stop")) { + startup.stopping = true; + stopUnits(startup.units); + } else if (action == QLatin1String("wait")) { + startup.stillStartingTimer->start(stillStartingTimeout); + } + return; + } + }); + QObject::connect(notifications, &StartupNotifications::closed, q, [this](uint id) { + // Dismissed: keep waiting, without asking again. + for (auto &startup : startups) { + if (id != 0 && startup.notificationId == id) { + startup.notificationId = 0; + } + } + }); + QObject::connect(notifications, &StartupNotifications::trayItemRegistered, q, [this](const QString &service) { + trayItemRegistered(service); + }); + loadConfig(); } @@ -132,6 +368,7 @@ void WaylandStartupTasksModel::Private::loadConfig() const KConfigGroup taskbarButtonConfig(configWatcher->config(), u"TaskbarButtonSettings"_s); startupTimeout = std::chrono::seconds(taskbarButtonConfig.readEntry("Timeout", 5)); + stillStartingTimeout = std::chrono::seconds(taskbarButtonConfig.readEntry("StillStartingTimeout", 180)); feedback = std::make_unique(); @@ -167,26 +404,239 @@ void WaylandStartupTasksModel::Private::addActivation(PlasmaActivation *activati startups.push_back(Startup{ .name = appData.name, .icon = appData.icon, + .iconName = appData.icon.name(), .applicationId = appId, .launcherUrl = launcherUrl, .activation = std::unique_ptr(activation), + .launchedUsec = monotonicUsec(), }); q->endInsertRows(); - // Remove the activation if it doesn't finish within certain time interval. + // Without a unit to follow, remove the activation if it doesn't + // finish within certain time interval, as before. auto *timeoutTimer = new QTimer(activation); QObject::connect(timeoutTimer, &QTimer::timeout, q, [this, activation]() { - removeActivation(activation); + if (auto *startup = find(activation); startup && !startup->sawUnit) { + qCDebug(TASKMANAGER_DEBUG) << "Startup" << startup->applicationId << "has no unit to follow: timed out"; + removeActivation(activation); + } }); timeoutTimer->setSingleShot(true); timeoutTimer->start(startupTimeout); + + auto &startup = startups.back(); + startup.stillStartingTimer = new QTimer(activation); + startup.stillStartingTimer->setSingleShot(true); + QObject::connect(startup.stillStartingTimer, &QTimer::timeout, q, [this, activation]() { + notifyStillStarting(activation); + }); + startup.pollTimer = new QTimer(activation); + startup.pollTimer->setInterval(s_pollInterval); + QObject::connect(startup.pollTimer, &QTimer::timeout, q, [this, activation]() { + pollUnits(activation); + }); + pollUnits(activation); + startup.pollTimer->start(); }); QObject::connect(activation, &PlasmaActivation::finished, q, [this, activation]() { + auto *startup = find(activation); + if (startup && startup->sawUnit && !startup->units.isEmpty()) { + // KWin ends its startup when any window opens, or another app + // starts: keep following the units; trust KWin once they end. + qCDebug(TASKMANAGER_DEBUG) << "Startup" << startup->applicationId << "still running, though KWin finished it"; + startup->kwinFinished = true; + return; + } + removeActivation(activation); + }); +} + +WaylandStartupTasksModel::Private::Startup *WaylandStartupTasksModel::Private::find(PlasmaActivation *activation) +{ + auto it = std::ranges::find_if(startups, [activation](const Startup &startup) { + return startup.activation.get() == activation; + }); + return it == startups.end() ? nullptr : &*it; +} + +void WaylandStartupTasksModel::Private::pollUnits(PlasmaActivation *activation) +{ + auto message = QDBusMessage::createMethodCall(s_systemd, s_systemdPath, s_systemdManager, QStringLiteral("ListUnitsByPatterns")); + message << QStringList{} << QStringList{QStringLiteral("app-*")}; + // Parented to the activation: gone, and not answered, once it ends. + auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), activation); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, q, [this, activation](QDBusPendingCallWatcher *watcher) { + watcher->deleteLater(); + const QDBusMessage reply = watcher->reply(); + auto *startup = find(activation); + if (!startup || reply.type() != QDBusMessage::ReplyMessage || reply.arguments().isEmpty()) { + return; + } + QHash alive; + const auto arg = reply.arguments().constFirst().value(); + arg.beginArray(); + while (!arg.atEnd()) { + UnitInfo unit; + arg >> unit; + if (isAlive(unit.activeState) && unitBelongsTo(unit.name, startup->applicationId)) { + alive.insert(unit.name, unit.path); + } + } + arg.endArray(); + unitsPolled(activation, alive); + }); +} + +void WaylandStartupTasksModel::Private::unitsPolled(PlasmaActivation *activation, const QHash &alive) +{ + auto *startup = find(activation); + if (!startup) { + return; + } + + // When each unit started, asked once per unit. + for (auto it = alive.cbegin(); it != alive.cend(); ++it) { + if (startup->unitStarts.contains(it.key())) { + continue; + } + startup->unitStarts.insert(it.key(), -1); + auto message = QDBusMessage::createMethodCall(s_systemd, it.value().path(), QStringLiteral("org.freedesktop.DBus.Properties"), QStringLiteral("Get")); + message << QStringLiteral("org.freedesktop.systemd1.Unit") << QStringLiteral("InactiveExitTimestampMonotonic"); + auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), activation); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, q, [this, activation, unit = it.key()](QDBusPendingCallWatcher *watcher) { + watcher->deleteLater(); + const QDBusPendingReply reply = *watcher; + if (auto *startup = find(activation)) { + // Unknown counts as already running: never mistaken for the launch. + startup->unitStarts.insert(unit, reply.isValid() ? qint64(reply.value().variant().toULongLong()) : 0); + } + }); + } + + QStringList launched; + bool anotherRunning = false; + bool asking = false; + for (auto it = alive.cbegin(); it != alive.cend(); ++it) { + const qint64 started = startup->unitStarts.value(it.key(), -1); + if (started < 0) { + asking = true; + } else if (started >= startup->launchedUsec - s_startSlackUsec) { + launched << it.key(); + } else { + anotherRunning = true; + } + } + if (!launched.isEmpty()) { + startup->units = launched; + if (!startup->sawUnit) { + qCDebug(TASKMANAGER_DEBUG) << "Startup" << startup->applicationId << "follows" << launched; + startup->sawUnit = true; + startup->stillStartingTimer->start(stillStartingTimeout); + } + return; + } + if (startup->sawUnit && !asking) { + // The launch's units ended, and no window of the app appeared. + qCDebug(TASKMANAGER_DEBUG) << "Startup" << startup->applicationId << "ended without a window; KWin finished:" << startup->kwinFinished; + if (!startup->kwinFinished && !startup->stopping) { + notifyDidNotOpen(*startup, anotherRunning); + } removeActivation(activation); + } +} + +void WaylandStartupTasksModel::Private::endIfOwns(const QStringList &cgroup) +{ + if (cgroup.isEmpty()) { + return; + } + std::vector ended; + for (const auto &startup : startups) { + if (std::ranges::any_of(startup.units, [&cgroup](const QString &unit) { + return cgroup.contains(unit); + })) { + ended.push_back(startup.activation.get()); + } + } + for (auto *activation : ended) { + qCDebug(TASKMANAGER_DEBUG) << "Startup" << find(activation)->applicationId << "has a window or tray icon in its units"; + removeActivation(activation); + } +} + +void WaylandStartupTasksModel::Private::trayItemRegistered(const QString &service) +{ + if (startups.empty()) { + return; + } + // "org.kde.StatusNotifierItem--" or ":1.23", maybe with "/". + const QString busName = service.section(QLatin1Char('/'), 0, 0); + auto message = QDBusMessage::createMethodCall(QStringLiteral("org.freedesktop.DBus"), + QStringLiteral("/org/freedesktop/DBus"), + QStringLiteral("org.freedesktop.DBus"), + QStringLiteral("GetConnectionUnixProcessID")); + message << busName; + auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), q); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, q, [this](QDBusPendingCallWatcher *watcher) { + watcher->deleteLater(); + const QDBusPendingReply reply = *watcher; + if (reply.isValid()) { + // Started in the background, in the tray: done. + endIfOwns(cgroupOf(reply.value())); + } + }); +} + +void WaylandStartupTasksModel::Private::notifyStillStarting(PlasmaActivation *activation) +{ + auto *startup = find(activation); + if (!startup || startup->units.isEmpty() || startup->notificationId != 0) { + return; + } + const int seconds = int(stillStartingTimeout.count()); + const QString body = seconds < 120 ? i18ncp("@info", "It hasn't shown a window after %1 second.", "It hasn't shown a window after %1 seconds.", seconds) + : i18ncp("@info", "It hasn't shown a window after %1 minute.", "It hasn't shown a window after %1 minutes.", seconds / 60); + auto message = QDBusMessage::createMethodCall(s_notifications, s_notificationsPath, s_notifications, QStringLiteral("Notify")); + message << startup->name << uint(0) << startup->iconName << i18nc("@title", "%1 is still starting", startup->name) + << body + << QStringList{QStringLiteral("wait"), + i18nc("@action:button", "Keep Waiting"), + QStringLiteral("stop"), + i18nc("@action:button %1 is an app's name", "Stop %1", startup->name)} + << QVariantMap{{QStringLiteral("urgency"), QVariant::fromValue(uchar(1))}} << int(0); + auto *watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), activation); + QObject::connect(watcher, &QDBusPendingCallWatcher::finished, q, [this, activation](QDBusPendingCallWatcher *watcher) { + watcher->deleteLater(); + const QDBusPendingReply reply = *watcher; + if (auto *startup = find(activation); startup && reply.isValid()) { + startup->notificationId = reply.value(); + } }); } +void WaylandStartupTasksModel::Private::notifyDidNotOpen(const Startup &startup, bool anotherRunning) +{ + const QString body = anotherRunning + ? i18nc("@info %1 is an app's name", + "It exited without showing a window. Another %1 is already running, and may not be responding.", + startup.name) + : i18nc("@info", "It exited without showing a window."); + auto message = QDBusMessage::createMethodCall(s_notifications, s_notificationsPath, s_notifications, QStringLiteral("Notify")); + message << startup.name << uint(0) << startup.iconName << i18nc("@title %1 is an app's name", "%1 didn't open", startup.name) << body + << QStringList{} << QVariantMap{} << int(-1); + QDBusConnection::sessionBus().asyncCall(message); +} + +void WaylandStartupTasksModel::Private::stopUnits(const QStringList &units) +{ + for (const QString &unit : units) { + auto message = QDBusMessage::createMethodCall(s_systemd, s_systemdPath, s_systemdManager, QStringLiteral("StopUnit")); + message << unit << QStringLiteral("replace"); + QDBusConnection::sessionBus().asyncCall(message); + } +} + void WaylandStartupTasksModel::Private::removeActivation(PlasmaActivation *activation) { auto it = std::ranges::find_if(startups, [activation](const Startup &startup) { @@ -195,6 +645,11 @@ void WaylandStartupTasksModel::Private::removeActivation(PlasmaActivation *activ if (it == startups.end()) { return; } + if (it->notificationId != 0) { + auto message = QDBusMessage::createMethodCall(s_notifications, s_notificationsPath, s_notifications, QStringLiteral("CloseNotification")); + message << it->notificationId; + QDBusConnection::sessionBus().asyncCall(message); + } const int position = std::distance(startups.begin(), it); q->beginRemoveRows(QModelIndex(), position, position); startups.erase(it); @@ -244,6 +699,23 @@ int WaylandStartupTasksModel::rowCount(const QModelIndex &parent) const return parent.isValid() ? 0 : d->startups.size(); } +void WaylandStartupTasksModel::windowAppeared(const QString &appId, quint32 pid) +{ + if (d->startups.empty()) { + return; + } + std::vector ended; + for (const auto &startup : d->startups) { + if (!appId.isEmpty() && startup.applicationId == appId) { + ended.push_back(startup.activation.get()); + } + } + for (auto *activation : ended) { + d->removeActivation(activation); + } + d->endIfOwns(cgroupOf(pid)); +} + } // namespace TaskManager #include "waylandstartuptasksmodel.moc" diff --git a/libtaskmanager/waylandstartuptasksmodel.h b/libtaskmanager/waylandstartuptasksmodel.h index 0028c3b..9ee4af2 100644 --- a/libtaskmanager/waylandstartuptasksmodel.h +++ b/libtaskmanager/waylandstartuptasksmodel.h @@ -23,6 +23,13 @@ public: QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; int rowCount(const QModelIndex &parent = QModelIndex()) const override; + /** + * A window appeared, or got its app id or pid: ends the startup it + * belongs to, by app id or by the pid being in the startup's systemd + * units. TasksModel calls it for each window. (Quine Linux) + */ + void windowAppeared(const QString &appId, quint32 pid); + private: class Private; std::unique_ptr d;