1375 lines
34 KiB
C++
1375 lines
34 KiB
C++
#include "notificationstore.hpp"
|
|
|
|
#include "config/notifs.hpp"
|
|
#include "config/rootnodes.hpp"
|
|
|
|
#include <QCryptographicHash>
|
|
#include <QDateTime>
|
|
#include <QDir>
|
|
#include <QElapsedTimer>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QFutureWatcher>
|
|
#include <QImage>
|
|
#include <QImageReader>
|
|
#include <QJsonArray>
|
|
#include <QJsonDocument>
|
|
#include <QJsonObject>
|
|
#include <QLoggingCategory>
|
|
#include <QBuffer>
|
|
#include <QQmlEngine>
|
|
#include <QQuickImageProvider>
|
|
#include <QSqlDatabase>
|
|
#include <QSqlError>
|
|
#include <QSqlQuery>
|
|
#include <QtConcurrent/qtconcurrentrun.h>
|
|
#include <QUuid>
|
|
|
|
#include <mutex>
|
|
|
|
Q_LOGGING_CATEGORY(notifLog, "ZShell.Services.notifs", QtInfoMsg)
|
|
|
|
namespace {
|
|
constexpr int MAX_ROWS = 500;
|
|
constexpr qint64 MAX_AGE_MS = 30LL * 24 * 60 * 60 * 1000;
|
|
constexpr int TICK_MS = 250;
|
|
constexpr int MAX_IMAGE_DIM = 512;
|
|
constexpr qint64 PRUNE_INTERVAL_MS = 4LL * 60 * 60 * 1000;
|
|
constexpr qint64 TIME_STR_REFRESH_MS = 2000;
|
|
} // 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<std::mutex> 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<std::mutex> 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<quint64> 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<NotificationActionRow> NotificationRow::actions() {
|
|
return QQmlListProperty<NotificationActionRow>(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->persistPopup(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);
|
|
QObject::connect(item, &QObject::destroyed, this, [this, item]() {
|
|
m_locks.remove(item);
|
|
});
|
|
}
|
|
|
|
void NotificationRow::unlock(QObject* item) {
|
|
m_locks.remove(item);
|
|
|
|
if (m_closed && m_locks.isEmpty()) {
|
|
m_store->removeRow(this);
|
|
}
|
|
}
|
|
|
|
void NotificationRow::pausePopup() {
|
|
m_popupPaused = true;
|
|
}
|
|
|
|
void NotificationRow::resumePopup() {
|
|
m_popupPaused = false;
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
// 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() {
|
|
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<NotificationRow> NotificationStore::rows() {
|
|
return QQmlListProperty<NotificationRow>(this, &m_rows);
|
|
}
|
|
|
|
NotificationRow* NotificationStore::insert(
|
|
const QJSValue& notification, bool popup) {
|
|
QElapsedTimer clock;
|
|
clock.start();
|
|
|
|
const auto wrapped = notification.toVariant().value<QObject*>();
|
|
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);
|
|
const qint64 dbMs = clock.elapsed();
|
|
startImageCache(row);
|
|
const qint64 imageMs = clock.elapsed() - dbMs;
|
|
|
|
m_rows.prepend(row);
|
|
row->refreshTimeStr();
|
|
scheduleRowsChanged();
|
|
|
|
// rowsChanged is coalesced onto the next event loop turn, so the QML
|
|
// reaction (filter re-evaluation + delegate rebind) runs once for a burst
|
|
// of inserts instead of once per notification. This timing therefore
|
|
// covers the synchronous part: attach + db + image cache start.
|
|
if (const qint64 totalMs = clock.elapsed(); totalMs >= 3) {
|
|
qCDebug(notifLog) << "perf: insert total" << totalMs
|
|
<< "ms | attach+db" << dbMs << "ms | image" << imageMs << "ms";
|
|
}
|
|
|
|
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() {
|
|
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 = row->m_imageSource;
|
|
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<NotificationRow> 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<QByteArray> 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 = imageSource;
|
|
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;
|
|
}
|
|
|
|
updateDbRow(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 = ?,"
|
|
" actions = ?, image_source = ?, image_digest = ?"
|
|
" 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(
|
|
QString::fromUtf8(QJsonDocument(actions).toJson(QJsonDocument::Compact)));
|
|
query.addBindValue(row->m_imageSource);
|
|
query.addBindValue(row->m_digest);
|
|
query.addBindValue(row->m_rowId);
|
|
|
|
if (!query.exec()) {
|
|
qWarning(notifLog)
|
|
<< "NotificationStore: could not update row" << query.lastError();
|
|
}
|
|
}
|
|
|
|
QList<NotificationActionRow*> NotificationStore::parseActions(
|
|
const QJSValue& actions, NotificationRow* row) {
|
|
QList<NotificationActionRow*> 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<quint32>(i)).toVariant().value<QObject*>();
|
|
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) {
|
|
if (row->m_locks.isEmpty()) {
|
|
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();
|
|
}
|
|
|
|
if (row->m_locks.isEmpty()) {
|
|
removeRow(row);
|
|
}
|
|
}
|
|
|
|
void NotificationStore::removeRow(NotificationRow* row) {
|
|
if (!m_rows.removeOne(row)) {
|
|
return;
|
|
}
|
|
|
|
// 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<const QObject*, NotificationRow*> e) {
|
|
return e.second == row;
|
|
});
|
|
|
|
row->deleteLater();
|
|
|
|
if (!m_clearing) {
|
|
orphanCleanup();
|
|
scheduleRowsChanged();
|
|
}
|
|
}
|
|
|
|
void NotificationStore::persistPopup(const NotificationRow* row) {
|
|
if (row->m_rowId < 0) {
|
|
return;
|
|
}
|
|
|
|
const auto db = QSqlDatabase::database(m_uuid, false);
|
|
if (!db.isOpen()) {
|
|
return;
|
|
}
|
|
|
|
QSqlQuery query(db);
|
|
query.prepare("UPDATE notifications SET popup = ? WHERE id = ?");
|
|
query.addBindValue(row->m_popup ? 1 : 0);
|
|
query.addBindValue(row->m_rowId);
|
|
|
|
if (!query.exec()) {
|
|
qWarning(notifLog)
|
|
<< "NotificationStore: could not persist popup state"
|
|
<< query.lastError();
|
|
}
|
|
}
|
|
|
|
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)"
|
|
" 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);
|
|
|
|
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)",
|
|
"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;
|
|
}
|
|
}
|
|
|
|
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"
|
|
" 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;
|
|
row->m_popup = query.value(10).toInt() != 0;
|
|
row->m_imageSource = query.value(12).toString();
|
|
row->m_digest = query.value(13).toString();
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
if (row->m_popup) {
|
|
row->m_deadline =
|
|
QDateTime::currentMSecsSinceEpoch() + m_popupTimeout;
|
|
}
|
|
|
|
row->m_image = row->m_digest.isEmpty() ? row->m_imageSource
|
|
: QStringLiteral("image://notifimg/") +
|
|
row->m_digest;
|
|
row->refreshTimeStr();
|
|
|
|
if (row->m_digest.isEmpty()) {
|
|
startImageCache(row);
|
|
}
|
|
|
|
m_rows.append(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();
|
|
row->m_popup = object.value("popup").toBool();
|
|
|
|
const auto time = object.value("time");
|
|
if (time.isDouble()) {
|
|
row->m_timeMs = static_cast<qint64>(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 = row->m_imageSource;
|
|
|
|
if (row->m_popup) {
|
|
row->m_deadline =
|
|
QDateTime::currentMSecsSinceEpoch() + m_popupTimeout;
|
|
}
|
|
|
|
row->refreshTimeStr();
|
|
insertDbRow(row);
|
|
startImageCache(row);
|
|
m_rows.append(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() {
|
|
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();
|
|
}
|
|
|
|
if (!query.exec(
|
|
"DELETE FROM notifications WHERE id NOT IN"
|
|
" (SELECT id FROM notifications ORDER BY time DESC"
|
|
" LIMIT " +
|
|
QString::number(MAX_ROWS) + ")")) {
|
|
qWarning(notifLog)
|
|
<< "NotificationStore: could not trim notification rows"
|
|
<< query.lastError();
|
|
}
|
|
|
|
orphanCleanup();
|
|
trimRows();
|
|
}
|
|
|
|
void NotificationStore::trimRows() {
|
|
if (m_rows.size() <= MAX_ROWS) {
|
|
return;
|
|
}
|
|
|
|
// m_rows is newest-first. Drop closed rows from the oldest end until
|
|
// the cap is met; open rows stay (few, and the sender may act on
|
|
// them). Their DB rows are removed in case the DB trim above kept a
|
|
// different set (closed rows already deleted their DB row on close,
|
|
// so the two views can differ).
|
|
const auto db = QSqlDatabase::database(m_uuid, false);
|
|
|
|
for (qsizetype i = m_rows.size() - 1;
|
|
i >= 0 && m_rows.size() > MAX_ROWS; i--) {
|
|
NotificationRow* row = m_rows.at(i);
|
|
|
|
if (!row->m_closed || !row->m_locks.isEmpty()) {
|
|
continue;
|
|
}
|
|
|
|
if (row->m_rowId >= 0 && db.isOpen()) {
|
|
QSqlQuery query(db);
|
|
query.prepare("DELETE FROM notifications WHERE id = ?");
|
|
query.addBindValue(row->m_rowId);
|
|
query.exec();
|
|
}
|
|
|
|
removeRow(row);
|
|
}
|
|
}
|
|
|
|
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
|
|
// belong here), but do 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;
|
|
|
|
if (url.isLocalFile()) {
|
|
localPath = url.toLocalFile();
|
|
} else if (url.scheme() == QLatin1String("image")) {
|
|
const QString host = url.host();
|
|
if (host != QLatin1String("qsimage") && host != QLatin1String("qspixmap")) {
|
|
// Persistent urls (e.g. image://icon/...) resolve at any
|
|
// shell start; caching them is pointless, and calling the
|
|
// icon provider from this thread races KF6's KIconLoader,
|
|
// which is not thread-safe (crashed the shell). The row
|
|
// keeps rendering the url directly.
|
|
return;
|
|
}
|
|
|
|
// 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<QQmlImageProviderBase*>(
|
|
engine->imageProvider(url.host()));
|
|
provider = dynamic_cast<QQuickImageProvider*>(base);
|
|
if (provider == nullptr) {
|
|
return;
|
|
}
|
|
|
|
providerId = url.path().startsWith(QLatin1Char('/'))
|
|
? url.path().mid(1)
|
|
: url.path();
|
|
|
|
switch (provider->imageType()) {
|
|
case QQuickImageProvider::Image:
|
|
usePixmap = false;
|
|
break;
|
|
case QQuickImageProvider::Pixmap:
|
|
usePixmap = true;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
const QPointer<NotificationRow> guard(row);
|
|
|
|
auto* watcher = new QFutureWatcher<std::pair<QString, QByteArray>>(this);
|
|
QObject::connect(
|
|
watcher,
|
|
&QFutureWatcher<std::pair<QString, QByteArray>>::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, provider, providerId, usePixmap]() {
|
|
QImage image;
|
|
|
|
if (!localPath.isEmpty()) {
|
|
QImageReader reader(localPath);
|
|
reader.setAutoTransform(true);
|
|
image = reader.read();
|
|
} else if (provider != nullptr) {
|
|
image = usePixmap
|
|
? provider->requestPixmap(providerId, nullptr, QSize())
|
|
.toImage()
|
|
: provider->requestImage(providerId, nullptr, QSize());
|
|
}
|
|
|
|
if (image.isNull()) {
|
|
return std::pair<QString, QByteArray>();
|
|
}
|
|
|
|
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);
|
|
|
|
const QByteArray bytes(
|
|
reinterpret_cast<const char*>(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<QString, QByteArray>();
|
|
}
|
|
|
|
return std::make_pair(digest, png);
|
|
}));
|
|
}
|
|
|
|
void NotificationStore::storeImageBlob(
|
|
const QPointer<NotificationRow>& 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;
|
|
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();
|
|
}
|
|
}
|
|
|
|
if (m_nextPrune <= now) {
|
|
m_nextPrune = now + PRUNE_INTERVAL_MS;
|
|
prune();
|
|
trimRows();
|
|
}
|
|
}
|
|
|
|
} // namespace ZShell::Services
|