#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 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 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->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 NotificationStore::rows() { return QQmlListProperty(this, &m_rows); } NotificationRow* NotificationStore::insert( const QJSValue& notification, bool popup) { QElapsedTimer clock; clock.start(); 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); 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 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 = 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 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) { 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 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(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( engine->imageProvider(url.host())); provider = dynamic_cast(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 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, 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(); } 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(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; 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