#include "notificationstore.hpp" #include "config/notifs.hpp" #include "config/rootnodes.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include Q_LOGGING_CATEGORY(notifLog, "ZShell.Services.notifs", QtInfoMsg) namespace { constexpr int MAX_ROWS = 500; constexpr int MAX_PER_GROUP = 50; constexpr qint64 MAX_AGE_MS = 30LL * 24 * 60 * 60 * 1000; constexpr int TICK_MS = 250; constexpr int MAX_IMAGE_DIM = 512; // Memoized icon theme file lookups (hits and misses) per theme configuration. constexpr int MAX_ICON_PATHS = 256; constexpr qint64 PRUNE_INTERVAL_MS = 4LL * 60 * 60 * 1000; constexpr qint64 TIME_STR_REFRESH_MS = 2000; // Coalesces content updates (live replaces_id edits, popup state) into one // transaction instead of one UPDATE per changed field. constexpr int FLUSH_MS = 300; // Quickshell serves inline notification pictures through image://qsimage/ // (and qspixmap) handles that belong to the live notification, not to the // shell: once that notification is replaced or dismissed, the handle is gone // and the url fails to resolve. Such a source is only ever useful as the raw // url long enough to read the bytes, so the UI must never see it. bool transientImageSource(const QString& source) { if (source.isEmpty()) { return false; } const QUrl url(source); return url.scheme() == QLatin1String("image") && (url.host() == QLatin1String("qsimage") || url.host() == QLatin1String("qspixmap")); } // The url the UI should use: the cached blob when there is one, and the raw // source only while that source keeps resolving. QString displayImage(const QString& source, const QString& digest) { if (!digest.isEmpty()) { return QStringLiteral("image://notifimg/") + digest; } return transientImageSource(source) ? QString() : source; } // Icon theme state, captured on the GUI thread. `QIcon::themeName()`, // `fallbackThemeName()` and `themeSearchPaths()` touch GUI-thread only state, // but the strings they return are safe to hand to a worker. struct IconTheme { QStringList searchPaths; QStringList themes; // Changes when the theme or the search paths change, so the path cache // below can be dropped after a theme switch. QString signature; }; IconTheme iconTheme() { IconTheme theme; theme.searchPaths = QIcon::themeSearchPaths(); for (const auto& name : {QIcon::themeName(), QIcon::fallbackThemeName(), QStringLiteral("hicolor"), QStringLiteral("Adwaita")}) { if (!name.isEmpty() && !theme.themes.contains(name)) { theme.themes.append(name); } } theme.signature = theme.searchPaths.join(QLatin1Char('\n')) + QLatin1Char('\n') + theme.themes.join(QLatin1Char('\n')); return theme; } // Resolve an icon name to a file the theme actually ships, so notification // images that only name an icon can still be cached. KF6's KIconLoader is not // thread-safe (it crashed the shell when the icon provider was called from a // worker) and QIcon painting is GUI-thread only, so this only builds candidate // paths and stats them - which makes it worker-thread safe when the theme // strings come from iconTheme(). Hits and misses are both memoized: a sender // repeats the same icon name, and a name the theme lacks costs thousands of // stat calls to discover. QString iconFileForName(const QString& name, const IconTheme& theme) { if (name.isEmpty()) { return {}; } // Stat results only change when a theme is installed or removed, which the // signature covers; the cap just keeps a stream of odd names bounded. static std::mutex cacheMutex; static QHash pathCache; static QString cacheSignature; std::lock_guard lock(cacheMutex); if (cacheSignature != theme.signature) { cacheSignature = theme.signature; pathCache.clear(); } const auto cached = pathCache.constFind(name); if (cached != pathCache.constEnd()) { return cached.value(); } const QString found = [&]() { if (QFileInfo::exists(name)) { return name; } const QFileInfo info(name); const QStringList names = info.suffix().isEmpty() ? QStringList{name} : QStringList{name, info.completeBaseName()}; static const QStringList sizes = {"256x256", "192x192", "128x128", "96x96", "64x64", "48x48", "32x32", "24x24", "22x22", "16x16", "scalable", "256", "192", "128", "96", "64", "48", "32", "24", "22", "16"}; static const QStringList contexts = { "apps", "actions", "devices", "places", "status"}; static const QStringList extensions = {"svg", "svgz", "png"}; for (const auto& searchPath : theme.searchPaths) { for (const auto& themeName : theme.themes) { const QDir themeDir(QDir(searchPath).filePath(themeName)); if (!themeDir.exists()) { continue; } for (const auto& candidateName : names) { for (const auto& size : sizes) { for (const auto& context : contexts) { for (const auto& extension : extensions) { const QString suffix = candidateName + "." + extension; // hicolor: ///. const QString canonical = themeDir.filePath( size + "/" + context + "/" + extension + "/" + suffix); if (QFileInfo::exists(canonical)) { return canonical; } // hicolor and Papirus both also ship // //. const QString sized = themeDir.filePath( size + "/" + context + "/" + suffix); if (QFileInfo::exists(sized)) { return sized; } // themes ordering size under context const QString nested = themeDir.filePath( context + "/" + size + "/" + suffix); if (QFileInfo::exists(nested)) { return nested; } } } } // theme without size directories: /. for (const auto& context : contexts) { for (const auto& extension : extensions) { const QString plain = themeDir.filePath( context + "/" + candidateName + "." + extension); if (QFileInfo::exists(plain)) { return plain; } } } } } } return QString(); }(); if (pathCache.size() >= MAX_ICON_PATHS) { pathCache.clear(); } pathCache.insert(name, found); return found; } } // namespace namespace ZShell::Services { class NotifImageProvider : public QQuickImageProvider { public: explicit NotifImageProvider(const QString& dbPath) : QQuickImageProvider(QQuickImageProvider::Image), m_dbPath(dbPath) {} void setDbPath(const QString& dbPath) { std::lock_guard lock(m_mutex); if (m_dbPath == dbPath) { return; } m_dbPath = dbPath; m_generation.fetch_add(1, std::memory_order_relaxed); } QImage requestImage( const QString& id, QSize* size, const QSize& requestedSize) override { if (id.isEmpty() || !openThreadDb(currentDbPath())) { return {}; } QSqlQuery query(QSqlDatabase::database(m_threadUuid)); query.prepare("SELECT png FROM images WHERE digest = ?"); query.addBindValue(id); if (!query.exec() || !query.next()) { return {}; } const QByteArray bytes = query.value(0).toByteArray(); if (bytes.isEmpty()) { return {}; } QImage image = QImage::fromData(bytes, "PNG"); if (image.isNull()) { return {}; } if (size) { *size = image.size(); } if (requestedSize.isValid() && !requestedSize.isEmpty() && (image.width() > requestedSize.width() || image.height() > requestedSize.height())) { image = image.scaled( requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); } return image; } private: QString currentDbPath() const { std::lock_guard lock(m_mutex); return m_dbPath; } bool openThreadDb(const QString& dbPath) { const quint64 generation = m_generation.load(std::memory_order_relaxed); if (!m_threadUuid.isEmpty() && m_threadGeneration == generation) { return true; } if (!m_threadUuid.isEmpty()) { { auto db = QSqlDatabase::database(m_threadUuid, false); if (db.isValid()) { db.close(); } } QSqlDatabase::removeDatabase(m_threadUuid); m_threadUuid.clear(); } if (dbPath.isEmpty()) { return false; } const QString uuid = QUuid::createUuid().toString(QUuid::WithoutBraces); auto db = QSqlDatabase::addDatabase("QSQLITE", uuid); db.setConnectOptions("QSQLITE_OPEN_READONLY"); db.setDatabaseName(dbPath); if (!db.open()) { return false; } m_threadUuid = uuid; m_threadGeneration = generation; return true; } mutable std::mutex m_mutex; QString m_dbPath; std::atomic m_generation = 0; thread_local static QString m_threadUuid; thread_local static quint64 m_threadGeneration; }; thread_local QString NotifImageProvider::m_threadUuid; thread_local quint64 NotifImageProvider::m_threadGeneration; NotificationActionRow::NotificationActionRow( QObject* action, const QString& identifier, const QString& text, QObject* parent) : QObject(parent) , m_action(action) , m_identifier(identifier) , m_text(text) { if (action) { QObject::connect(action, &QObject::destroyed, this, [this]() { emit invokableChanged(); }); } } bool NotificationActionRow::invokable() const { return m_action != nullptr; } QString NotificationActionRow::identifier() const { return m_identifier; } QString NotificationActionRow::text() const { return m_text; } void NotificationActionRow::invoke() { if (auto* action = m_action.data()) { QMetaObject::invokeMethod(action, "invoke"); } } NotificationRow::NotificationRow(QObject* parent) : QObject(parent) {} QQmlListProperty NotificationRow::actions() { return QQmlListProperty(this, &m_actions); } QString NotificationRow::appIcon() const { return m_appIcon; } QString NotificationRow::appName() const { return m_appName; } QString NotificationRow::body() const { return m_body; } bool NotificationRow::closed() const { return m_closed; } bool NotificationRow::hasActionIcons() const { return m_hasActionIcons; } QString NotificationRow::image() const { return m_image; } QString NotificationRow::imageSource() const { return m_imageSource; } int NotificationRow::notificationId() const { return m_notificationId; } int NotificationRow::progress() const { return m_progress; } bool NotificationRow::popup() const { return m_popup; } void NotificationRow::setPopup(bool popup) { if (m_popup == popup) { return; } m_popup = popup; m_popupPaused = false; m_deadline = popup ? QDateTime::currentMSecsSinceEpoch() + m_store->popupTimeout() : 0; emit popupChanged(); if (m_store != nullptr) { m_store->markDirty(this); m_store->updatePopupGroup(this); } } bool NotificationRow::resident() const { return m_resident; } QString NotificationRow::summary() const { return m_summary; } QDateTime NotificationRow::time() const { return QDateTime::fromMSecsSinceEpoch(m_timeMs); } QString NotificationRow::timeStr() const { return m_timeStr; } int NotificationRow::urgency() const { return m_urgency; } void NotificationRow::close() { if (m_closed) { return; } m_closed = true; emit closedChanged(); if (auto* notification = m_notification.data()) { QMetaObject::invokeMethod(notification, "dismiss"); } m_store->handleClosed(this); } void NotificationRow::lock(QObject* item) { if (item == nullptr || m_locks.contains(item)) { return; } m_locks.insert(item); // Goes through unlock() so releasing the last lock on an already-closed // row finishes the removal the close started. The destroyed signal // carries the dying object, which is the lock holder. QObject::connect(item, &QObject::destroyed, this, [this](QObject* obj) { unlock(obj); }); } void NotificationRow::unlock(QObject* item) { m_locks.remove(item); if (!m_locks.isEmpty()) { return; } if (m_deleteAfterUnlock) { m_deleteAfterUnlock = false; deleteLater(); return; } if (m_closed) { m_store->removeRow(this); } } void NotificationRow::pausePopup() { m_popupPaused = true; } void NotificationRow::resumePopup() { m_popupPaused = false; } // The v2 notification UI uses caelestia's naming; these are thin aliases of the // existing behaviour so both spellings work. void NotificationRow::dismiss() { close(); } void NotificationRow::dismissPopup() { if (m_popup) { setPopup(false); } } void NotificationRow::holdPopup() { pausePopup(); } void NotificationRow::releasePopup() { resumePopup(); } void NotificationRow::ref(QObject* item) { lock(item); } void NotificationRow::unref(QObject* item) { unlock(item); } // True when path is this row's image, in either its raw or cached form, so the // UI can tell "the same picture changed url" from "a different picture". bool NotificationRow::isSameImage(const QString& path) const { return path == m_image || path == m_imageSource; } bool NotificationRow::isSameAppIcon(const QString& path) const { return path == m_appIcon; } bool NotificationRow::expired(qint64 now) const { return m_popup && !m_popupPaused && !m_closed && m_deadline > 0 && now >= m_deadline; } void NotificationRow::refreshTimeStr() { const qint64 diff = QDateTime::currentMSecsSinceEpoch() - m_timeMs; const qint64 m = diff / 60000; QString next; if (m < 1) { next = tr("now"); } else { const qint64 h = m / 60; const qint64 d = h / 24; if (d > 0) { next = QStringLiteral("%1d").arg(d); } else if (h > 0) { next = QStringLiteral("%1h").arg(h); } else { next = QStringLiteral("%1m").arg(m); } } if (next != m_timeStr) { m_timeStr = next; emit timeStrChanged(); } } NotificationGroup::NotificationGroup(const QString& appName, QObject* parent) : QObject(parent), m_appName(appName) {} QQmlListProperty NotificationGroup::activeNotifs() { return QQmlListProperty(this, &m_active); } QString NotificationGroup::appIcon() const { return m_iconSource ? m_iconSource->m_appIcon : QString(); } QString NotificationGroup::appName() const { return m_appName; } int NotificationGroup::count() const { return m_count; } QString NotificationGroup::firstSummary() const { return m_firstSummary; } QString NotificationGroup::image() const { return m_imageSource ? m_imageSource->m_image : QString(); } QQmlListProperty NotificationGroup::notifs() { return QQmlListProperty(this, &m_notifs); } QString NotificationGroup::timeStr() const { return m_timeStr; } int NotificationGroup::urgency() const { return m_urgency; } void NotificationGroup::addRow(NotificationRow* row) { m_notifs.prepend(row); QObject::connect( row, &NotificationRow::imageChanged, this, &NotificationGroup::sync); QObject::connect( row, &NotificationRow::appIconChanged, this, &NotificationGroup::sync); QObject::connect( row, &NotificationRow::urgencyChanged, this, &NotificationGroup::sync); emit notifsChanged(); refreshActive(); sync(); } void NotificationGroup::removeRow(NotificationRow* row) { const bool had = m_notifs.removeOne(row); if (!had) { return; } QObject::disconnect(row, nullptr, this, nullptr); emit notifsChanged(); // Must not drop the row from m_active here: refreshActive() only emits // activeNotifsChanged when the list it rebuilds differs from the stored // one, and a silently pre-edited m_active is equal to the rebuild. The // popup stack reads activeNotifs, so skipping the emit left a closed row // occupying its slot - an invisible gap other entries did not move into. refreshActive(); sync(); } void NotificationGroup::refreshActive() { QList active; active.reserve(m_notifs.size()); for (auto* row : std::as_const(m_notifs)) { if (!row->m_closed) { active.append(row); } } if (active != m_active) { m_active = active; emit activeNotifsChanged(); } if (m_count != static_cast(m_active.size())) { m_count = static_cast(m_active.size()); emit countChanged(); } NotificationRow* first = m_active.isEmpty() ? nullptr : m_active.first(); const QString timeStr = first != nullptr ? first->m_timeStr : QString(); if (timeStr != m_timeStr) { m_timeStr = timeStr; emit timeStrChanged(); } const QString summary = first != nullptr ? first->m_summary : QString(); if (summary != m_firstSummary) { m_firstSummary = summary; emit firstSummaryChanged(); } } void NotificationGroup::sync() { NotificationRow* imageSource = nullptr; NotificationRow* iconSource = nullptr; int urgency = 0; for (auto* row : std::as_const(m_active)) { if (imageSource == nullptr && !row->m_image.isEmpty()) { imageSource = row; } if (iconSource == nullptr && !row->m_appIcon.isEmpty()) { iconSource = row; } urgency = (std::max)(urgency, row->m_urgency); } if (m_imageSource != imageSource) { m_imageSource = imageSource; emit imageChanged(); } if (m_iconSource != iconSource) { m_iconSource = iconSource; emit appIconChanged(); } if (urgency != m_urgency) { m_urgency = urgency; emit urgencyChanged(); } } // Delegates compare their own shown url against the group's current source row, // so a row swapping raw source <-> cached blob does not re-trigger the fade. bool NotificationGroup::isSameImage(const QString& path) const { return m_imageSource != nullptr && m_imageSource->isSameImage(path); } bool NotificationGroup::isSameAppIcon(const QString& path) const { return m_iconSource != nullptr && m_iconSource->isSameAppIcon(path); } void NotificationGroup::lock(QObject* item) { if (item != nullptr && !m_locks.contains(item)) { m_locks.insert(item); // No Qt::UniqueConnection here: it requires a member-function pointer, // and the contains() check above already keeps this one-shot per item. QObject::connect( item, &QObject::destroyed, this, [this, item](QObject*) { unlock(item); }); } } void NotificationGroup::unlock(QObject* item) { m_locks.remove(item); if (m_releasing && m_locks.isEmpty()) { deleteLater(); } } // Called by the store once the group has no rows left. A delegate may still be // animating it out, so the delete waits for its last unlock. void NotificationGroup::release() { m_releasing = true; if (m_locks.isEmpty()) { deleteLater(); } } NotificationStore::NotificationStore(QObject* parent) : QObject(parent) , m_uuid(QUuid::createUuid().toString(QUuid::WithoutBraces)) { m_tick = new QTimer(this); m_tick->setInterval(TICK_MS); QObject::connect(m_tick, &QTimer::timeout, this, &NotificationStore::tick); m_flush = new QTimer(this); m_flush->setSingleShot(true); m_flush->setInterval(FLUSH_MS); QObject::connect(m_flush, &QTimer::timeout, this, &NotificationStore::flush); // Config-backed settings, same pattern as TickingService: read the // config node directly and update live on its change signals. QML may // still write these properties; the next config change re-applies. auto* notifs = config::ConfigSingleton::instance()->notifs(); applyNotifConfig(notifs); QObject::connect( notifs, &config::Notifs::defaultExpireTimeoutChanged, this, [this, notifs] { applyNotifConfig(notifs); }); QObject::connect( notifs, &config::Notifs::expireChanged, this, [this, notifs] { applyNotifConfig(notifs); }); } NotificationStore::~NotificationStore() { flush(); closeDb(); } QString NotificationStore::path() const { return m_path; } void NotificationStore::setPath(const QString& path) { if (m_path == path || path.isEmpty()) { return; } m_path = path; emit pathChanged(); if (!openDb()) { return; } migrateLegacyJson(); prune(); loadRows(); qCInfo(notifLog) << "NotificationStore: ready with" << m_rows.size() << "stored rows from" << m_path; const qint64 now = QDateTime::currentMSecsSinceEpoch(); m_nextPrune = now + PRUNE_INTERVAL_MS; m_tick->start(); } int NotificationStore::popupTimeout() const { return m_popupTimeout; } void NotificationStore::setPopupTimeout(int timeout) { if (m_popupTimeout == timeout || timeout <= 0) { return; } m_popupTimeout = timeout; emit popupTimeoutChanged(); } bool NotificationStore::autoExpire() const { return m_autoExpire; } void NotificationStore::setAutoExpire(bool autoExpire) { if (m_autoExpire == autoExpire) { return; } m_autoExpire = autoExpire; emit autoExpireChanged(); } void NotificationStore::applyNotifConfig(const config::Notifs* notifs) { setPopupTimeout(notifs->defaultExpireTimeout()); setAutoExpire(notifs->expire()); } QQmlListProperty NotificationStore::rows() { return QQmlListProperty(this, &m_rows); } QQmlListProperty NotificationStore::groups() { return QQmlListProperty(this, &m_groups); } QQmlListProperty NotificationStore::popupGroups() { return QQmlListProperty(this, &m_popupGroups); } NotificationRow* NotificationStore::insert( const QJSValue& notification, bool popup) { const auto wrapped = notification.toVariant().value(); if (wrapped == nullptr) { qWarning(notifLog) << "NotificationStore: expected a Notification"; return nullptr; } const int notificationId = wrapped->property("id").toInt(); if (wrapped->property("lastGeneration").toBool()) { if (auto* row = rowForNotificationId(notificationId)) { attachNotification(row, wrapped, notification); return row; } } auto* row = new NotificationRow(this); row->m_store = this; row->m_timeMs = QDateTime::currentMSecsSinceEpoch(); row->m_notificationId = notificationId; copyFields(row, wrapped); const auto hints = notification.property("hints"); if (hints.isObject()) { row->m_progress = hints.property("value").toVariant().toInt(); } attachNotification(row, wrapped, notification); row->m_popup = popup; if (popup) { row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout; } insertDbRow(row); startImageCache(row); m_rows.prepend(row); addGroup(row); if (popup) { addPopupGroup(row); } row->refreshTimeStr(); scheduleRowsChanged(); return row; } void NotificationStore::scheduleRowsChanged() { if (m_rowsSignalPending) { return; } m_rowsSignalPending = true; QTimer::singleShot(0, this, [this] { if (!m_rowsSignalPending) { return; // an immediate emit (e.g. clear()) already covered it } m_rowsSignalPending = false; emit rowsChanged(); }); } void NotificationStore::clear() { m_flush->stop(); m_dirty.clear(); const auto db = QSqlDatabase::database(m_uuid, false); if (db.isOpen()) { QSqlQuery query(db); query.prepare("DELETE FROM notifications"); if (!query.exec()) { qWarning(notifLog) << "NotificationStore: could not clear notifications" << query.lastError(); } } // The bulk delete above already removed every DB row; the per-row // cleanup would just re-delete no-ops, so batch it instead. m_clearing = true; const auto rows = m_rows; for (auto* row : rows) { row->close(); } m_clearing = false; orphanCleanup(); m_rowsSignalPending = false; // supersede any pending coalesced emit emit rowsChanged(); } void NotificationStore::copyFields(NotificationRow* row, QObject* notification) { row->m_summary = notification->property("summary").toString(); row->m_body = notification->property("body").toString(); row->m_appName = notification->property("appName").toString(); row->m_appIcon = notification->property("appIcon").toString(); row->m_imageSource = notification->property("image").toString(); row->m_image = displayImage(row->m_imageSource, row->m_digest); row->m_resident = notification->property("resident").toBool(); row->m_hasActionIcons = notification->property("hasActionIcons").toBool(); row->m_urgency = notification->property("urgency").toInt(); } void NotificationStore::attachNotification( NotificationRow* row, QObject* notification, const QJSValue& js) { row->m_notification = notification; row->m_jsNotification = js; m_liveRows.insert(notification, row); if (!row->m_liveConnected) { row->m_liveConnected = true; const QPointer rowGuard(row); QObject::connect( notification, &QObject::destroyed, this, [this, rowGuard, notification]() { m_liveRows.remove(notification); if (rowGuard == nullptr || rowGuard->m_closed) { return; } rowGuard->m_closed = true; emit rowGuard->closedChanged(); rowGuard->m_store->handleClosed(rowGuard); }); // Quickshell applies replaces_id updates to the object we already // hold; mirror those changes onto the row (in UI and in the DB). // Connected straight to the slot (not through an intermediate // signal) so sender() in handleLiveUpdate is the Notification. static const QMetaMethod slot = [] { const QMetaObject& storeMeta = NotificationStore::staticMetaObject; return storeMeta.method( storeMeta.indexOfSlot("handleLiveUpdate()")); }(); static const QList mirrored = { "summaryChanged", "bodyChanged", "appNameChanged", "appIconChanged", "imageChanged", "urgencyChanged", "residentChanged", "hasActionIconsChanged", "actionsChanged", "hintsChanged", }; const auto* meta = notification->metaObject(); for (int i = 0; i < meta->methodCount(); i++) { const auto method = meta->method(i); if (method.methodType() != QMetaMethod::Signal) { continue; } if (mirrored.contains(method.name())) { QObject::connect(notification, method, this, slot); } } } qDeleteAll(row->m_actions); row->m_actions.clear(); row->m_actions = parseActions(js.property("actions"), row); emit row->actionsChanged(); } void NotificationStore::handleLiveUpdate() { const auto it = m_liveRows.constFind(sender()); if (it == m_liveRows.constEnd()) { qWarning(notifLog) << "NotificationStore: live update for untracked " "notification object"; return; } NotificationRow* row = it.value(); const QString summary = it.key()->property("summary").toString(); const QString body = it.key()->property("body").toString(); const QString appName = it.key()->property("appName").toString(); const QString appIcon = it.key()->property("appIcon").toString(); const QString imageSource = it.key()->property("image").toString(); const bool resident = it.key()->property("resident").toBool(); const bool hasActionIcons = it.key()->property("hasActionIcons").toBool(); const int urgency = it.key()->property("urgency").toInt(); const bool imageChanged = imageSource != row->m_imageSource; if (summary != row->m_summary) { row->m_summary = summary; emit row->summaryChanged(); } if (body != row->m_body) { row->m_body = body; emit row->bodyChanged(); } if (appName != row->m_appName) { row->m_appName = appName; emit row->appNameChanged(); } if (appIcon != row->m_appIcon) { row->m_appIcon = appIcon; emit row->appIconChanged(); } if (urgency != row->m_urgency) { row->m_urgency = urgency; emit row->urgencyChanged(); } if (resident != row->m_resident) { row->m_resident = resident; emit row->residentChanged(); } if (hasActionIcons != row->m_hasActionIcons) { row->m_hasActionIcons = hasActionIcons; emit row->hasActionIconsChanged(); } const auto hints = row->m_jsNotification.property("hints"); if (hints.isObject()) { const int progress = hints.property("value").toVariant().toInt(); if (progress != row->m_progress) { row->m_progress = progress; emit row->progressChanged(); } } qDeleteAll(row->m_actions); row->m_actions.clear(); row->m_actions = parseActions(row->m_jsNotification.property("actions"), row); emit row->actionsChanged(); if (imageChanged) { row->m_imageSource = imageSource; row->m_digest.clear(); row->m_image = displayImage(imageSource, row->m_digest); emit row->imageSourceChanged(); emit row->imageChanged(); startImageCache(row); } // New content deserves a full look: restart the popup countdown. if (row->m_popup) { row->m_deadline = QDateTime::currentMSecsSinceEpoch() + m_popupTimeout; } // The row may have moved to another app name (replaces_id can change it). if (row->m_group != nullptr && row->m_group->m_appName != row->m_appName) { removeGroupRow(row); addGroup(row); if (row->m_popupGroup != nullptr) { removePopupGroupRow(row); addPopupGroup(row); } } markDirty(row); } void NotificationStore::updateDbRow(NotificationRow* row) { if (row->m_rowId < 0) { return; } const auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen()) { return; } QJsonArray actions; for (auto* action : std::as_const(row->m_actions)) { actions.append( QJsonObject{ {"identifier", action->identifier()}, {"text", action->text()}}); } QSqlQuery query(db); query.prepare( "UPDATE notifications SET" " app_name = ?, app_icon = ?, summary = ?, body = ?," " urgency = ?, resident = ?, has_action_icons = ?, popup = ?," " actions = ?, image_source = ?, image_digest = ?, progress = ?" " WHERE id = ?"); query.addBindValue(row->m_appName); query.addBindValue(row->m_appIcon); query.addBindValue(row->m_summary); query.addBindValue(row->m_body); query.addBindValue(row->m_urgency); query.addBindValue(row->m_resident ? 1 : 0); query.addBindValue(row->m_hasActionIcons ? 1 : 0); query.addBindValue(row->m_popup ? 1 : 0); query.addBindValue( QString::fromUtf8( QJsonDocument(actions).toJson(QJsonDocument::Compact))); query.addBindValue(row->m_imageSource); query.addBindValue(row->m_digest); query.addBindValue(row->m_progress); query.addBindValue(row->m_rowId); if (!query.exec()) { qWarning(notifLog) << "NotificationStore: could not update row" << query.lastError(); } } QList NotificationStore::parseActions( const QJSValue& actions, NotificationRow* row) { QList result; if (!actions.isObject()) { return result; } const int length = actions.property("length").toVariant().toInt(); for (int i = 0; i < length; i++) { auto* object = actions.property(static_cast(i)) .toVariant() .value(); if (object == nullptr) { continue; } result.append(new NotificationActionRow( object, object->property("identifier").toString(), object->property("text").toString(), row)); } return result; } NotificationRow* NotificationStore::rowForNotificationId(int id) const { for (auto* row : m_rows) { if (row->m_notificationId == id && !row->m_closed) { return row; } } return nullptr; } void NotificationStore::handleClosed(NotificationRow* row) { // During clear() the bulk DELETE already removed every row. if (m_clearing) { removeRow(row); return; } const auto db = QSqlDatabase::database(m_uuid, false); if (db.isOpen()) { QSqlQuery query(db); query.prepare("DELETE FROM notifications WHERE id = ?"); query.addBindValue(row->m_rowId); query.exec(); } m_dirty.remove(row); removePopupGroupRow(row); if (row->m_group != nullptr) { row->m_group->refreshActive(); row->m_group->sync(); } // A closed row leaves the store immediately, so its group and the sidebar // order update at once; a delegate that still holds it only delays the // delete, never the removal. removeRow(row); } void NotificationStore::removeRow(NotificationRow* row) { if (!m_rows.removeOne(row)) { return; } removePopupGroupRow(row); removeGroupRow(row); m_dirty.remove(row); // The row is gone; drop any live-notification mapping to it so // handleLiveUpdate can never resolve a freed pointer. erase_if(m_liveRows, [row](std::pair e) { return e.second == row; }); // A delegate animating this row out still holds it; unlock() deletes once // the last lock is gone. if (row->m_locks.isEmpty()) { row->deleteLater(); } else { row->m_deleteAfterUnlock = true; } if (!m_clearing) { orphanCleanup(); scheduleRowsChanged(); } } void NotificationStore::markDirty(NotificationRow* row) { if (row->m_rowId < 0) { return; } m_dirty.insert(row); m_flush->start(); } void NotificationStore::flush() { m_flush->stop(); if (m_dirty.isEmpty()) { return; } auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen()) { m_dirty.clear(); return; } if (!db.transaction()) { qWarning(notifLog) << "NotificationStore: flush transaction failed" << db.lastError(); } for (auto* row : std::as_const(m_dirty)) { updateDbRow(row); } m_dirty.clear(); if (!db.commit()) { qWarning(notifLog) << "NotificationStore: flush commit failed" << db.lastError(); } } void NotificationStore::addGroup(NotificationRow* row) { auto* group = m_groupsByName.value(row->m_appName, nullptr); if (group == nullptr) { group = new NotificationGroup(row->m_appName, this); m_groupsByName.insert(row->m_appName, group); m_groups.prepend(group); emit groupsChanged(); } else { // Most-recently-active group first, matching the sidebar order. const qsizetype index = m_groups.indexOf(group); if (index > 0) { m_groups.move(index, 0); emit groupsChanged(); } } row->m_group = group; group->addRow(row); } void NotificationStore::removeGroupRow(NotificationRow* row) { auto* group = row->m_group; if (group == nullptr) { return; } row->m_group = nullptr; group->removeRow(row); if (!group->m_notifs.isEmpty()) { return; } m_groups.removeOne(group); m_groupsByName.remove(group->m_appName); group->release(); emit groupsChanged(); } // Popup groups mirror m_groups over the rows currently showing as popups, so // the popup stack can render one card per app instead of one per notification. void NotificationStore::addPopupGroup(NotificationRow* row) { if (row->m_popupGroup != nullptr) { return; } auto* group = m_popupGroupsByName.value(row->m_appName, nullptr); if (group == nullptr) { group = new NotificationGroup(row->m_appName, this); m_popupGroupsByName.insert(row->m_appName, group); m_popupGroups.prepend(group); emit popupGroupsChanged(); } else { const qsizetype index = m_popupGroups.indexOf(group); if (index > 0) { m_popupGroups.move(index, 0); emit popupGroupsChanged(); } } row->m_popupGroup = group; group->addRow(row); } void NotificationStore::removePopupGroupRow(NotificationRow* row) { auto* group = row->m_popupGroup; if (group == nullptr) { return; } row->m_popupGroup = nullptr; group->removeRow(row); if (!group->m_notifs.isEmpty()) { return; } m_popupGroups.removeOne(group); m_popupGroupsByName.remove(group->m_appName); group->release(); emit popupGroupsChanged(); } void NotificationStore::updatePopupGroup(NotificationRow* row) { if (row->m_popup && !row->m_closed) { addPopupGroup(row); return; } removePopupGroupRow(row); } void NotificationStore::insertDbRow(NotificationRow* row) { const auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen()) { return; } QJsonArray actions; for (auto* action : std::as_const(row->m_actions)) { actions.append( QJsonObject{ {"identifier", action->identifier()}, {"text", action->text()}}); } QSqlQuery query(db); query.prepare( "INSERT INTO notifications" " (notification_id, app_name, app_icon, summary, body, time," " urgency, resident, has_action_icons, popup, actions," " image_source, image_digest, progress)" " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"); query.addBindValue(row->m_notificationId); query.addBindValue(row->m_appName); query.addBindValue(row->m_appIcon); query.addBindValue(row->m_summary); query.addBindValue(row->m_body); query.addBindValue(row->m_timeMs); query.addBindValue(row->m_urgency); query.addBindValue(row->m_resident ? 1 : 0); query.addBindValue(row->m_hasActionIcons ? 1 : 0); query.addBindValue(row->m_popup ? 1 : 0); query.addBindValue( QString::fromUtf8( QJsonDocument(actions).toJson(QJsonDocument::Compact))); query.addBindValue(row->m_imageSource); query.addBindValue(row->m_digest); query.addBindValue(row->m_progress); if (!query.exec()) { qWarning(notifLog) << "NotificationStore: could not insert row" << query.lastError(); return; } row->m_rowId = query.lastInsertId().toLongLong(); } bool NotificationStore::openDb() { closeDb(); QDir().mkpath(QFileInfo(m_path).absolutePath()); auto db = QSqlDatabase::addDatabase("QSQLITE", m_uuid); db.setDatabaseName(m_path); if (!db.open()) { qWarning(notifLog) << "NotificationStore: could not open database" << m_path << db.lastError(); return false; } QSqlQuery query(db); const QStringList statements = { // WAL + NORMAL: commits don't fsync per insert (the fsync is what // made every notification insert 5-8ms). Safe with WAL: an // application crash still recovers the WAL; only an OS/power // failure can lose the most recent commits. "PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", "CREATE TABLE IF NOT EXISTS notifications(" "id INTEGER PRIMARY KEY AUTOINCREMENT," " notification_id INTEGER, app_name TEXT, app_icon TEXT," " summary TEXT, body TEXT, time INTEGER, urgency INTEGER," " resident INTEGER, has_action_icons INTEGER, popup INTEGER," " actions TEXT, image_source TEXT, image_digest TEXT," " progress INTEGER)", "CREATE TABLE IF NOT EXISTS images(" "digest TEXT PRIMARY KEY, png BLOB)", "CREATE INDEX IF NOT EXISTS notifications_time" " ON notifications(time DESC)", }; for (const auto& statement : statements) { if (!query.exec(statement)) { qWarning(notifLog) << "NotificationStore: could not prepare database schema" << statement << query.lastError(); closeDb(); return false; } } // Databases created before progress was stored lack the column; ALTER adds // it instead of dropping history (caelestia drops and recreates). if (!db.record("notifications").contains("progress")) { QSqlQuery alter(db); if (!alter.exec( "ALTER TABLE notifications ADD COLUMN progress INTEGER")) { qWarning(notifLog) << "NotificationStore: could not add progress column" << alter.lastError(); closeDb(); return false; } } if (m_provider != nullptr) { m_provider->setDbPath(m_path); return true; } registerProvider(); return true; } void NotificationStore::orphanCleanup() { const auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen()) { return; } QSqlQuery query(db); if (!query.exec( "DELETE FROM images WHERE digest NOT IN" " (SELECT DISTINCT image_digest FROM notifications" " WHERE image_digest != '')")) { qWarning(notifLog) << "NotificationStore: could not clean up orphaned images" << query.lastError(); } } void NotificationStore::registerProvider() { if (m_provider != nullptr) { return; } auto* engine = qmlEngine(this); if (engine == nullptr) { return; } m_provider = new NotifImageProvider(m_path); engine->addImageProvider(QStringLiteral("notifimg"), m_provider); } void NotificationStore::closeDb() { if (!QSqlDatabase::contains(m_uuid)) { return; } { auto db = QSqlDatabase::database(m_uuid, false); if (db.isValid()) { db.close(); } } QSqlDatabase::removeDatabase(m_uuid); } void NotificationStore::loadRows() { const auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen()) { return; } QSqlQuery query(db); query.exec( "SELECT id, notification_id, app_name, app_icon, summary, body, time," " urgency, resident, has_action_icons, popup, actions," " image_source, image_digest, progress" " FROM notifications ORDER BY time DESC"); while (query.next()) { auto* row = new NotificationRow(this); row->m_store = this; row->m_rowId = query.value(0).toLongLong(); row->m_notificationId = query.value(1).toInt(); row->m_appName = query.value(2).toString(); row->m_appIcon = query.value(3).toString(); row->m_summary = query.value(4).toString(); row->m_body = query.value(5).toString(); row->m_timeMs = query.value(6).toLongLong(); row->m_urgency = query.value(7).toInt(); row->m_resident = query.value(8).toInt() != 0; row->m_hasActionIcons = query.value(9).toInt() != 0; // Popup state is deliberately not restored: showing stored // notifications as toasts again after a shell restart would // replay an old burst, and with autoExpire off the flag never // clears itself. The rows stay in the sidebar instead. row->m_popup = false; row->m_imageSource = query.value(12).toString(); row->m_digest = query.value(13).toString(); row->m_progress = query.value(14).toInt(); const auto actions = QJsonDocument::fromJson(query.value(11).toString().toUtf8()); if (actions.isArray()) { for (const auto& action : actions.array()) { const auto object = action.toObject(); row->m_actions.append(new NotificationActionRow( nullptr, object.value("identifier").toString(), object.value("text").toString(), row)); } } row->m_image = displayImage(row->m_imageSource, row->m_digest); row->refreshTimeStr(); if (row->m_digest.isEmpty()) { startImageCache(row); } m_rows.append(row); addGroup(row); } emit rowsChanged(); } void NotificationStore::migrateLegacyJson() { const QString legacy = QDir(QFileInfo(m_path).absolutePath()).filePath("notifs.json"); const auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen() || !QFile::exists(legacy)) { return; } QSqlQuery count(db); count.exec("SELECT COUNT(*) FROM notifications"); if (count.next() && count.value(0).toInt() != 0) { return; } QFile file(legacy); if (!file.open(QIODevice::ReadOnly)) { return; } const auto doc = QJsonDocument::fromJson(file.readAll()); if (!doc.isArray()) { return; } for (const auto& value : doc.array()) { const auto object = value.toObject(); auto* row = new NotificationRow(this); row->m_store = this; row->m_appName = object.value("appName").toString(); row->m_appIcon = object.value("appIcon").toString(); row->m_summary = object.value("summary").toString(); row->m_body = object.value("body").toString(); row->m_imageSource = object.value("image").toString(); row->m_notificationId = object.value("id").toInt(); row->m_urgency = object.value("urgency").toInt(1); row->m_resident = object.value("resident").toBool(); row->m_hasActionIcons = object.value("hasActionIcons").toBool(); // Same reasoning as loadRows(): never re-toast migrated rows. row->m_popup = false; const auto time = object.value("time"); if (time.isDouble()) { row->m_timeMs = static_cast(time.toDouble()); } else { const auto parsed = QDateTime::fromString(time.toString(), Qt::ISODate); row->m_timeMs = parsed.isValid() ? parsed.toMSecsSinceEpoch() : QDateTime::currentMSecsSinceEpoch(); } for (const auto& action : object.value("actions").toArray()) { const auto actionObject = action.toObject(); row->m_actions.append(new NotificationActionRow( nullptr, actionObject.value("identifier").toString(), actionObject.value("text").toString(), row)); } row->m_image = displayImage(row->m_imageSource, row->m_digest); row->refreshTimeStr(); insertDbRow(row); startImageCache(row); m_rows.append(row); addGroup(row); } if (!m_rows.isEmpty()) { emit rowsChanged(); } file.close(); QFile::remove(legacy + ".migrated"); QFile::rename(legacy, legacy + ".migrated"); qCInfo(notifLog) << "NotificationStore: migrated legacy notifs.json"; } void NotificationStore::prune() { flush(); const auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen()) { return; } QSqlQuery query(db); query.prepare("DELETE FROM notifications WHERE time < ?"); query.addBindValue(QDateTime::currentMSecsSinceEpoch() - MAX_AGE_MS); if (!query.exec()) { qWarning(notifLog) << "NotificationStore: could not prune old notifications" << query.lastError(); } // Total cap, preferring higher urgency so a chatty app cannot push out // an important notification. if (!query.exec( "DELETE FROM notifications WHERE id NOT IN" " (SELECT id FROM notifications ORDER BY urgency DESC, time DESC" " LIMIT " + QString::number(MAX_ROWS) + ")")) { qWarning(notifLog) << "NotificationStore: could not trim notification rows" << query.lastError(); } // Per-app cap: one app flooding must not crowd out the other groups. QStringList appNames; if (query.exec("SELECT DISTINCT app_name FROM notifications")) { while (query.next()) { appNames.append(query.value(0).toString()); } } for (const auto& appName : std::as_const(appNames)) { QSqlQuery perGroup(db); perGroup.prepare( "DELETE FROM notifications WHERE app_name = ? AND id NOT IN" " (SELECT id FROM notifications WHERE app_name = ?" " ORDER BY urgency DESC, time DESC LIMIT " + QString::number(MAX_PER_GROUP) + ")"); perGroup.addBindValue(appName); perGroup.addBindValue(appName); if (!perGroup.exec()) { qWarning(notifLog) << "NotificationStore: could not trim group" << appName << perGroup.lastError(); } } orphanCleanup(); trimRows(); } void NotificationStore::trimRows() { if (m_rows.size() <= MAX_ROWS) { return; } // Only closed, unlocked rows are droppable (open rows may still be // acted on by the sender). Weakest first: lowest urgency, then oldest. const auto db = QSqlDatabase::database(m_uuid, false); while (m_rows.size() > MAX_ROWS) { NotificationRow* victim = nullptr; for (auto* row : std::as_const(m_rows)) { if (!row->m_closed || !row->m_locks.isEmpty()) { continue; } if (victim == nullptr) { victim = row; continue; } const bool weaker = row->m_urgency < victim->m_urgency || (row->m_urgency == victim->m_urgency && row->m_timeMs <= victim->m_timeMs); if (weaker) { victim = row; } } if (victim == nullptr) { return; } if (victim->m_rowId >= 0 && db.isOpen()) { QSqlQuery query(db); query.prepare("DELETE FROM notifications WHERE id = ?"); query.addBindValue(victim->m_rowId); query.exec(); } removeRow(victim); } } void NotificationStore::startImageCache(NotificationRow* row) { if (row->m_imageSource.isEmpty()) { return; } registerProvider(); const QUrl url(row->m_imageSource); // Resolve *what* to read on the main thread (engine provider lookups and // QIcon's theme state belong here), but do no file scanning and no // decoding here: providers are designed to serve requestImage from non-GUI // threads, and SVG rasterization was blocking the shell 3-9ms per // notification. QString localPath; QQuickImageProvider* provider = nullptr; QString providerId; bool usePixmap = false; QImage inlineImage; QString iconName; IconTheme theme; if (url.isLocalFile()) { localPath = url.toLocalFile(); } else if (url.scheme() == QLatin1String("image")) { const QString id = url.path().startsWith(QLatin1Char('/')) ? url.path().mid(1) : url.path(); const QString host = url.host(); if (host != QLatin1String("qsimage") && host != QLatin1String("qspixmap")) { // Provider urls such as image://icon/... are served by KF6's // KIconLoader, which is not thread-safe (calling it from a // worker crashed the shell). Only QIcon's theme state is read // here, because the GUI thread owns it; the candidate scanning it // feeds is pure filesystem work and runs in the worker below. A // name the theme does not have means thousands of stat calls, and // doing those here stalled inserts for milliseconds. If nothing // matches, the row keeps rendering the url directly. iconName = id; theme = iconTheme(); } else { // Transient inline notification images (image://qsimage/...). // Provider objects are engine-owned and outlive this task; // ids owned by a destroyed notification resolve to nothing. auto* engine = qmlEngine(this); if (engine == nullptr) { return; } auto* base = const_cast( engine->imageProvider(url.host())); provider = dynamic_cast(base); if (provider == nullptr) { return; } providerId = id; switch (provider->imageType()) { case QQuickImageProvider::Image: usePixmap = false; break; case QQuickImageProvider::Pixmap: usePixmap = true; break; default: return; } // Copy the pixels here instead of in the worker: the handle // belongs to the sender's notification, and it can be gone by the // time the task runs, which silently dropped the picture. This is // a memory copy of an already decoded image, not a rasterization. inlineImage = usePixmap ? provider->requestPixmap(providerId, nullptr, QSize()) .toImage() : provider->requestImage(providerId, nullptr, QSize()); if (inlineImage.isNull()) { return; } } } const QPointer guard(row); auto* watcher = new QFutureWatcher>(this); QObject::connect( watcher, &QFutureWatcher>::finished, this, [this, watcher, guard]() { const auto result = watcher->result(); if (!result.first.isEmpty()) { storeImageBlob(guard, result.first, result.second); } watcher->deleteLater(); }); watcher->setFuture( QtConcurrent::run([localPath, iconName, theme, provider, providerId, usePixmap, inlineImage]() { QImage image = inlineImage; QString path = localPath; if (image.isNull() && path.isEmpty() && !iconName.isEmpty()) { path = iconFileForName(iconName, theme); } if (image.isNull() && !path.isEmpty()) { QImageReader reader(path); reader.setAutoTransform(true); image = reader.read(); } else if (image.isNull() && provider != nullptr) { image = usePixmap ? provider->requestPixmap(providerId, nullptr, QSize()) .toImage() : provider->requestImage(providerId, nullptr, QSize()); } if (image.isNull()) { return std::pair(); } QImage normalized = image; if (normalized.width() > MAX_IMAGE_DIM || normalized.height() > MAX_IMAGE_DIM) { normalized = normalized.scaled( MAX_IMAGE_DIM, MAX_IMAGE_DIM, Qt::KeepAspectRatio, Qt::SmoothTransformation); } normalized = normalized.convertToFormat(QImage::Format_RGBA8888); // Cache the avatar content already cut to a circle, so the view // does not have to mask it with a layer effect every frame. const int side = (std::min)(normalized.width(), normalized.height()); const QRect square( (normalized.width() - side) / 2, (normalized.height() - side) / 2, side, side); QImage circle(side, side, QImage::Format_RGBA8888); circle.fill(Qt::transparent); QImage mask(side, side, QImage::Format_ARGB32); mask.fill(Qt::transparent); { QPainter maskPainter(&mask); maskPainter.setRenderHint(QPainter::Antialiasing); maskPainter.setPen(Qt::NoPen); maskPainter.setBrush(QColor(0, 0, 0, 255)); maskPainter.drawEllipse(QRect(0, 0, side, side)); } { QPainter painter(&circle); painter.setCompositionMode(QPainter::CompositionMode_Source); painter.drawImage(QRect(0, 0, side, side), normalized, square); painter.setCompositionMode( QPainter::CompositionMode_DestinationIn); painter.drawImage(0, 0, mask); } normalized = circle; const QByteArray bytes( reinterpret_cast(normalized.constBits()), normalized.sizeInBytes()); const QString digest = QString::fromLatin1( QCryptographicHash::hash(bytes, QCryptographicHash::Sha256) .toHex()); QByteArray png; QBuffer buffer(&png); if (!buffer.open(QIODevice::WriteOnly) || !normalized.save(&buffer, "PNG")) { return std::pair(); } return std::make_pair(digest, png); })); } void NotificationStore::storeImageBlob( const QPointer& row, const QString& digest, const QByteArray& png) { const auto db = QSqlDatabase::database(m_uuid, false); if (!db.isOpen()) { return; } QSqlQuery query(db); query.prepare( "INSERT INTO images (digest, png) VALUES (?, ?)" " ON CONFLICT(digest) DO NOTHING"); query.addBindValue(digest); query.addBindValue(png); query.exec(); if (row != nullptr) { if (row->m_rowId >= 0) { QSqlQuery update(db); update.prepare( "UPDATE notifications SET image_digest = ?" " WHERE id = ?"); update.addBindValue(digest); update.addBindValue(row->m_rowId); update.exec(); } row->m_digest = digest; row->m_image = QStringLiteral("image://notifimg/") + digest; // The group reads its image through the source row and listens to // imageChanged, so this emit is enough to keep the card in sync. emit row->imageChanged(); } } void NotificationStore::tick() { const qint64 now = QDateTime::currentMSecsSinceEpoch(); for (auto* row : std::as_const(m_rows)) { if (m_autoExpire && row->expired(now)) { row->setPopup(false); } } if (m_nextTimeStrRefresh <= now) { m_nextTimeStrRefresh = now + TIME_STR_REFRESH_MS; for (auto* row : std::as_const(m_rows)) { row->refreshTimeStr(); } for (auto* group : std::as_const(m_groups)) { group->refreshActive(); } } if (m_nextPrune <= now) { m_nextPrune = now + PRUNE_INTERVAL_MS; prune(); trimRows(); } } } // namespace ZShell::Services