diff --git a/Daemons/NotifServer.qml b/Daemons/NotifServer.qml index 3360c8b..483a923 100644 --- a/Daemons/NotifServer.qml +++ b/Daemons/NotifServer.qml @@ -4,14 +4,11 @@ pragma ComponentBehavior: Bound import Quickshell import Quickshell.Io import Quickshell.Services.Notifications -import Quickshell.Hyprland -import QtQuick -import ZShell +import ZShell.Services import qs.Components -import qs.Modules import qs.Helpers -import qs.Services import qs.Paths +import qs.Services import ZShell.Config Singleton { @@ -19,11 +16,9 @@ Singleton { readonly property var appCooldownMap: new Map() property alias dnd: props.dnd - property list list: [] - property bool loaded - readonly property list notClosed: list.filter(n => !n.closed) - readonly property list popups: list.filter(n => n.popup) - property alias server: server + property alias list: store.rows + readonly property list notClosed: store.rows.filter(n => !n.closed) + readonly property list popups: store.rows.filter(n => n.popup) function shouldThrottle(appName: string): bool { if (props.dnd || ShellState.anySidebarOpen()) @@ -46,33 +41,6 @@ Singleton { return true; } - onListChanged: { - if (loaded) { - saveTimer.restart(); - } - } - - Timer { - id: saveTimer - - interval: 1000 - - onTriggered: storage.setText(JSON.stringify(root.notClosed.map(n => ({ - time: n.time, - id: n.id, - summary: n.summary, - body: n.body, - appIcon: n.appIcon, - appName: n.appName, - image: n.image, - expireTimeout: n.expireTimeout, - urgency: n.urgency, - resident: n.resident, - hasActionIcons: n.hasActionIcons, - actions: n.actions - })))) - } - PersistentProperties { id: props @@ -81,6 +49,12 @@ Singleton { reloadableId: "notifs" } + NotificationStore { + id: store + + path: `${Paths.state}/notifs.sqlite` + } + NotificationServer { id: server @@ -89,40 +63,12 @@ Singleton { bodyImagesSupported: true bodyMarkupSupported: true imageSupported: true - keepOnReload: false + keepOnReload: true persistenceSupported: true onNotification: notif => { notif.tracked = true; - - const is_popup = root.shouldThrottle(notif.appName); - - const comp = notifComp.createObject(root, { - popup: is_popup, - notification: notif - }); - root.list = [comp, ...root.list]; - } - } - - FileView { - id: storage - - path: `${Paths.state}/notifs.json` - printErrors: false - - onLoadFailed: err => { - if (err === FileViewError.FileNotFound) { - root.loaded = true; - Qt.callLater(() => setText("[]")); - } - } - onLoaded: { - const data = JSON.parse(text()); - for (const notif of data) - root.list.push(notifComp.createObject(root, notif)); - root.list.sort((a, b) => b.time - a.time); - root.loaded = true; + store.insert(notif, root.shouldThrottle(notif.appName)); } } @@ -130,16 +76,12 @@ Singleton { description: "Clear all notifications" name: "clearnotifs" - onPressed: { - for (const notif of root.list.slice()) - notif.close(); - } + onPressed: store.clear() } IpcHandler { function clear(): void { - for (const notif of root.list.slice()) - notif.close(); + store.clear(); } function disableDnd(): void { @@ -160,192 +102,4 @@ Singleton { target: "notifs" } - - Component { - id: notifComp - - Notif { - } - } - - component Notif: QtObject { - id: notif - - property list actions - property string appIcon - property string appName - property string body - property string cachedImageSource: "" - property bool cachingImage: false - property bool closed - readonly property Connections conn: Connections { - function onActionsChanged(): void { - notif.actions = notif.notification.actions.map(a => ({ - identifier: a.identifier, - text: a.text, - invoke: () => a.invoke() - })); - } - - function onAppIconChanged(): void { - notif.appIcon = notif.notification.appIcon; - } - - function onAppNameChanged(): void { - notif.appName = notif.notification.appName; - } - - function onBodyChanged(): void { - notif.body = notif.notification.body; - } - - function onClosed(): void { - notif.close(); - } - - function onExpireTimeoutChanged(): void { - notif.expireTimeout = notif.notification.expireTimeout; - } - - function onHasActionIconsChanged(): void { - notif.hasActionIcons = notif.notification.hasActionIcons; - } - - function onHintsChanged(): void { - notif.hints = notif.notification.hints; - } - - function onImageChanged(): void { - notif.imageSource = notif.notification.image || ""; - notif.image = notif.imageSource; - notif.cacheImageIfNeeded(); - } - - function onResidentChanged(): void { - notif.resident = notif.notification.resident; - } - - function onSummaryChanged(): void { - notif.summary = notif.notification.summary; - } - - function onUrgencyChanged(): void { - notif.urgency = notif.notification.urgency; - } - - target: notif.notification - } - property real expireTimeout: 5 - property bool hasActionIcons - property var hints - property string image - property string imageSource - property var locks: new Set() - property string notifId - property Notification notification - property bool popup - property bool resident - property string summary - property date time: new Date() - readonly property string timeStr: { - const diff = Time.date.getTime() - time.getTime(); - const m = Math.floor(diff / 60000); - - if (m < 1) - return qsTr("now"); - - const h = Math.floor(m / 60); - const d = Math.floor(h / 24); - - if (d > 0) - return `${d}d`; - if (h > 0) - return `${h}h`; - return `${m}m`; - } - readonly property Timer timer: Timer { - property bool paused: false - property int remainingTime: totalTime - property int totalTime: Config.notifs.defaultExpireTimeout - - interval: 50 - repeat: true - running: !paused - - onTriggered: { - remainingTime -= interval; - - if (remainingTime <= 0) { - remainingTime = 0; - notif.popup = false; - stop(); - } - } - } - property int urgency: NotificationUrgency.Normal - - function cacheImageIfNeeded(): void { - const source = imageSource; - - if (!source || cachingImage) - return; - - if (cachedImageSource === source) - return; - - cachingImage = true; - - ZShellIo.cacheImage(Qt.resolvedUrl(source), Paths.notifimagecache, (path, url) => { - cachedImageSource = source; - image = path; - cachingImage = false; - }, () => { - cachingImage = false; - }); - } - - function close(): void { - closed = true; - if (locks.size === 0 && root.list.includes(this)) { - root.list = root.list.filter(n => n !== this); - notification?.dismiss(); - destroy(); - } - } - - function lock(item: Item): void { - locks.add(item); - } - - function unlock(item: Item): void { - locks.delete(item); - if (closed) - close(); - } - - Component.onCompleted: { - if (!notification) - return; - - notifId = notification.id; - summary = notification.summary; - body = notification.body; - appIcon = notification.appIcon; - appName = notification.appName; - imageSource = notification.image || ""; - image = imageSource; - hints = notification.hints; - expireTimeout = notification.expireTimeout; - urgency = notification.urgency; - resident = notification.resident; - hasActionIcons = notification.hasActionIcons; - actions = notification.actions.map(a => ({ - identifier: a.identifier, - text: a.text, - invoke: () => a.invoke() - })); - - cacheImageIfNeeded(); - } - } } diff --git a/Modules/Lock/NotifGroup.qml b/Modules/Lock/NotifGroup.qml index 12eeab1..2619ae1 100644 --- a/Modules/Lock/NotifGroup.qml +++ b/Modules/Lock/NotifGroup.qml @@ -5,6 +5,7 @@ import Quickshell.Widgets import Quickshell.Services.Notifications import QtQuick import QtQuick.Layouts +import ZShell.Services import qs.Modules import qs.Components import qs.Helpers @@ -277,7 +278,7 @@ CustomRect { component NotifLine: CustomText { id: notifLine - required property NotifServer.Notif modelData + required property NotificationRow modelData Layout.fillWidth: true color: root.urgency === "critical" ? Colors.palette.m3onSecondaryContainer : Colors.palette.m3onSurface diff --git a/Modules/Notifications/Content.qml b/Modules/Notifications/Content.qml index f80fda7..1ee7ff9 100644 --- a/Modules/Notifications/Content.qml +++ b/Modules/Notifications/Content.qml @@ -5,6 +5,7 @@ import Quickshell.Widgets import QtQuick import ZShell.Config import ZShell.Components +import ZShell.Services import qs.Components import qs.Daemons import qs.Helpers @@ -97,7 +98,7 @@ Item { property int idx required property int index - required property NotifServer.Notif modelData + required property NotificationRow modelData readonly property alias nonAnimHeight: notif.nonAnimHeight implicitHeight: notif.implicitHeight + (idx === 0 ? 0 : Tokens.spacing.small) diff --git a/Modules/Notifications/Notification.qml b/Modules/Notifications/Notification.qml index 96c1bfc..bd8cf9b 100644 --- a/Modules/Notifications/Notification.qml +++ b/Modules/Notifications/Notification.qml @@ -5,7 +5,7 @@ import QtQuick.Shapes import Quickshell import Quickshell.Services.Notifications import ZShell.Components -import qs.Daemons +import ZShell.Services import qs.Helpers import ZShell.Config import qs.Components @@ -17,7 +17,7 @@ CustomRect { property bool expanded: Config.notifs.openExpanded readonly property bool hasAppIcon: modelData.appIcon.length > 0 readonly property bool hasImage: modelData.image.length > 0 - required property NotifServer.Notif modelData + required property NotificationRow modelData readonly property int nonAnimHeight: summary.implicitHeight + (root.expanded ? Tokens.spacing.extraSmall * 2 + appName.height + body.height + actions.height + actions.anchors.topMargin : bodyPreview.height) + inner.anchors.margins * 2 color: root.modelData.urgency === NotificationUrgency.Critical ? Colors.palette.m3secondaryContainer : Colors.tPalette.m3surfaceContainer @@ -56,10 +56,10 @@ CustomRect { if (actions.length === 1) actions[0].invoke(); } - onEntered: root.modelData.timer.stop() + onEntered: root.modelData.pausePopup() onExited: { if (!pressed) - root.modelData.timer.start(); + root.modelData.resumePopup(); } onPositionChanged: event => { if (pressed) { @@ -69,14 +69,14 @@ CustomRect { } } onPressed: event => { - root.modelData.timer.stop(); + root.modelData.pausePopup(); startY = event.y; if (event.button === Qt.MiddleButton) root.modelData.close(); } onReleased: event => { if (!containsMouse) - root.modelData.timer.start(); + root.modelData.resumePopup(); if (Math.abs(root.x) < root.implicitWidth * Config.notifs.clearThreshold) root.x = 0; @@ -200,7 +200,7 @@ CustomRect { radiusX: progressIndicator.width / 2 - Tokens.padding.extraSmall / 2 radiusY: progressIndicator.height / 2 - Tokens.padding.extraSmall / 2 startAngle: -90 - sweepAngle: ((root.modelData.hints.value ?? 0) / 100) * 360 + sweepAngle: (root.modelData.progress / 100) * 360 Behavior on sweepAngle { Anim { @@ -477,7 +477,12 @@ CustomRect { shapeMorph: true text: modelData.text - onClicked: modelData.invoke() + onClicked: { + if (modelData.invokable) + modelData.invoke(); + else if (!root.modelData.resident) + root.modelData.close(); + } } } diff --git a/Modules/Sidebar/Notif.qml b/Modules/Sidebar/Notif.qml index ae7a016..9a7b5c2 100644 --- a/Modules/Sidebar/Notif.qml +++ b/Modules/Sidebar/Notif.qml @@ -4,16 +4,17 @@ import QtQuick import QtQuick.Layouts import Quickshell import ZShell.Config +import ZShell.Components +import ZShell.Services import qs.Components import qs.Services -import qs.Daemons CustomRect { id: root readonly property CustomText body: (expandedContent.item as ExpandedBody)?.body ?? null required property bool expanded - required property NotifServer.Notif modelData + required property NotificationRow modelData readonly property real nonAnimHeight: expanded ? summary.implicitHeight + expandedContent.implicitHeight + expandedContent.anchors.topMargin + Tokens.padding.medium * 2 : summaryHeightMetrics.height required property ScreenState screenState diff --git a/Modules/Sidebar/NotifActionList.qml b/Modules/Sidebar/NotifActionList.qml index 1bd33b5..e753620 100644 --- a/Modules/Sidebar/NotifActionList.qml +++ b/Modules/Sidebar/NotifActionList.qml @@ -5,15 +5,15 @@ import QtQuick.Layouts import Quickshell import Quickshell.Widgets import ZShell.Config +import ZShell.Services import qs.Components import qs.Effects import qs.Services -import qs.Daemons Item { id: root - required property NotifServer.Notif notif + required property NotificationRow notif Layout.fillWidth: true implicitHeight: flickable.contentHeight @@ -36,7 +36,7 @@ Item { { isClose: true }, - ...(root.notif?.actions ?? []), + ...Array.from(root.notif?.actions ?? []), { isCopy: true } @@ -84,7 +84,7 @@ Item { Quickshell.clipboardText = root.notif.body; actionInner.item.text = "inventory"; copyTimer.start(); - } else if (action.modelData.invoke) { + } else if (action.modelData.invokable) { action.modelData.invoke(); } else if (!root.notif.resident) { root.notif.close(); diff --git a/Modules/Sidebar/NotifDock.qml b/Modules/Sidebar/NotifDock.qml index 5ef82e6..87757ca 100644 --- a/Modules/Sidebar/NotifDock.qml +++ b/Modules/Sidebar/NotifDock.qml @@ -81,9 +81,8 @@ Item { Behavior on opacity { Anim {} } - sourceComponent: SnapLayout { + sourceComponent: ColumnLayout { spacing: 20 - orientation: Qt.Vertical CustomText { Layout.alignment: Qt.AlignHCenter diff --git a/Modules/Sidebar/NotifGroupList.qml b/Modules/Sidebar/NotifGroupList.qml index b047878..109ce8d 100644 --- a/Modules/Sidebar/NotifGroupList.qml +++ b/Modules/Sidebar/NotifGroupList.qml @@ -3,8 +3,8 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import ZShell.Components +import ZShell.Services import ZShell.Config -import qs.Daemons import qs.Components import qs.Services @@ -37,7 +37,7 @@ LazyListView { id: notif required property int index - required property NotifServer.Notif modelData + required property NotificationRow modelData property int startY LazyListView.preferredHeight: modelData?.closed || LazyListView.removing ? 0 : notifInner.nonAnimHeight diff --git a/Plugins/ZShell/Services/CMakeLists.txt b/Plugins/ZShell/Services/CMakeLists.txt index 1090516..a7f710f 100644 --- a/Plugins/ZShell/Services/CMakeLists.txt +++ b/Plugins/ZShell/Services/CMakeLists.txt @@ -23,11 +23,14 @@ qml_module(ZShell-services lyriccandidate.hpp lyriccandidate.cpp lyrics.hpp lyrics.cpp dropimporter.hpp dropimporter.cpp + notificationstore.hpp notificationstore.cpp LIBRARIES Qt6::Core Qt6::Qml + Qt6::Quick Qt6::Concurrent Qt6::Network + Qt6::Sql PkgConfig::Pipewire PkgConfig::Aubio PkgConfig::Cava diff --git a/Plugins/ZShell/Services/notificationstore.cpp b/Plugins/ZShell/Services/notificationstore.cpp new file mode 100644 index 0000000..20cd9d1 --- /dev/null +++ b/Plugins/ZShell/Services/notificationstore.cpp @@ -0,0 +1,1374 @@ +#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 diff --git a/Plugins/ZShell/Services/notificationstore.hpp b/Plugins/ZShell/Services/notificationstore.hpp new file mode 100644 index 0000000..f9af698 --- /dev/null +++ b/Plugins/ZShell/Services/notificationstore.hpp @@ -0,0 +1,248 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ZShell::config { +class Notifs; +} + +namespace ZShell::Services { + +class NotificationStore; +class NotifImageProvider; + +class NotificationActionRow : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE( + "NotificationActionRow instances come from a NotificationStore") + + Q_PROPERTY(bool invokable READ invokable NOTIFY invokableChanged) + Q_PROPERTY(QString identifier READ identifier CONSTANT) + Q_PROPERTY(QString text READ text CONSTANT) + + public: + NotificationActionRow( + QObject* action, + const QString& identifier, + const QString& text, + QObject* parent = nullptr); + + [[nodiscard]] bool invokable() const; + [[nodiscard]] QString identifier() const; + [[nodiscard]] QString text() const; + + Q_INVOKABLE void invoke(); + + signals: + void invokableChanged(); + + private: + QPointer m_action; + QString m_identifier; + QString m_text; +}; + +class NotificationRow : public QObject { + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("NotificationRow instances come from a NotificationStore") + + Q_PROPERTY(QQmlListProperty actions READ actions NOTIFY actionsChanged) + Q_PROPERTY(QString appIcon READ appIcon NOTIFY appIconChanged) + Q_PROPERTY(QString appName READ appName NOTIFY appNameChanged) + Q_PROPERTY(QString body READ body NOTIFY bodyChanged) + Q_PROPERTY(bool closed READ closed NOTIFY closedChanged) + Q_PROPERTY(bool hasActionIcons READ hasActionIcons NOTIFY hasActionIconsChanged) + Q_PROPERTY(QString image READ image NOTIFY imageChanged) + Q_PROPERTY(QString imageSource READ imageSource NOTIFY imageSourceChanged) + Q_PROPERTY(int notificationId READ notificationId CONSTANT) + Q_PROPERTY(int progress READ progress NOTIFY progressChanged) + Q_PROPERTY(bool popup READ popup WRITE setPopup NOTIFY popupChanged) + Q_PROPERTY(bool resident READ resident NOTIFY residentChanged) + Q_PROPERTY(QString summary READ summary NOTIFY summaryChanged) + Q_PROPERTY(QDateTime time READ time CONSTANT) + Q_PROPERTY(QString timeStr READ timeStr NOTIFY timeStrChanged) + Q_PROPERTY(int urgency READ urgency NOTIFY urgencyChanged) + + public: + explicit NotificationRow(QObject* parent = nullptr); + + [[nodiscard]] QQmlListProperty actions(); + [[nodiscard]] QString appIcon() const; + [[nodiscard]] QString appName() const; + [[nodiscard]] QString body() const; + [[nodiscard]] bool closed() const; + [[nodiscard]] bool hasActionIcons() const; + [[nodiscard]] QString image() const; + [[nodiscard]] QString imageSource() const; + [[nodiscard]] int notificationId() const; + [[nodiscard]] int progress() const; + [[nodiscard]] bool popup() const; + void setPopup(bool popup); + [[nodiscard]] bool resident() const; + [[nodiscard]] QString summary() const; + [[nodiscard]] QDateTime time() const; + [[nodiscard]] QString timeStr() const; + [[nodiscard]] int urgency() const; + + Q_INVOKABLE void close(); + Q_INVOKABLE void lock(QObject* item); + Q_INVOKABLE void pausePopup(); + Q_INVOKABLE void resumePopup(); + Q_INVOKABLE void unlock(QObject* item); + + signals: + void actionsChanged(); + void appIconChanged(); + void appNameChanged(); + void bodyChanged(); + void closedChanged(); + void hasActionIconsChanged(); + void imageChanged(); + void imageSourceChanged(); + void popupChanged(); + void progressChanged(); + void residentChanged(); + void summaryChanged(); + void timeStrChanged(); + void urgencyChanged(); + + private: + friend NotificationStore; + + [[nodiscard]] bool expired(qint64 now) const; + void refreshTimeStr(); + + QList m_actions; + NotificationStore* m_store = nullptr; + + QString m_appIcon; + QString m_appName; + QString m_body; + QString m_digest; + QString m_image; + QString m_imageSource; + QString m_summary; + QString m_timeStr; + + qint64 m_deadline = 0; + qint64 m_rowId = -1; + qint64 m_timeMs = 0; + + QPointer m_notification; + QSet m_locks; + QJSValue m_jsNotification; + + bool m_closed = false; + bool m_hasActionIcons = false; + bool m_liveConnected = false; + bool m_popup = false; + bool m_popupPaused = false; + bool m_resident = false; + + int m_notificationId = 0; + int m_progress = 0; + int m_urgency = 1; +}; + +class NotificationStore : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(bool autoExpire READ autoExpire WRITE setAutoExpire NOTIFY autoExpireChanged) + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged REQUIRED) + Q_PROPERTY(int popupTimeout READ popupTimeout WRITE setPopupTimeout NOTIFY + popupTimeoutChanged) + Q_PROPERTY(QQmlListProperty rows READ rows NOTIFY rowsChanged) + + public: + explicit NotificationStore(QObject* parent = nullptr); + ~NotificationStore() override; + + [[nodiscard]] QString path() const; + void setPath(const QString& path); + + [[nodiscard]] bool autoExpire() const; + void setAutoExpire(bool autoExpire); + + [[nodiscard]] int popupTimeout() const; + void setPopupTimeout(int timeout); + + [[nodiscard]] QQmlListProperty rows(); + + Q_INVOKABLE NotificationRow* insert(const QJSValue& notification, bool popup); + Q_INVOKABLE void clear(); + + signals: + void autoExpireChanged(); + void pathChanged(); + void popupTimeoutChanged(); + void rowsChanged(); + + private slots: + void handleLiveUpdate(); + + private: + friend NotificationRow; + + void attachNotification( + NotificationRow* row, QObject* notification, const QJSValue& js); + void applyNotifConfig(const config::Notifs* notifs); + void closeDb(); + void copyFields(NotificationRow* row, QObject* notification); + void handleClosed(NotificationRow* row); + void insertDbRow(NotificationRow* row); + void loadRows(); + void migrateLegacyJson(); + [[nodiscard]] bool openDb(); + void orphanCleanup(); + void persistPopup(const NotificationRow* row); + void prune(); + void registerProvider(); + void removeRow(NotificationRow* row); + // Coalesced rowsChanged: a burst of inserts/removes triggers one QML + // rebind on the next event loop turn instead of one per notification. + void scheduleRowsChanged(); + [[nodiscard]] NotificationRow* rowForNotificationId(int id) const; + void startImageCache(NotificationRow* row); + void storeImageBlob( + const QPointer& row, + const QString& digest, + const QByteArray& png); + void tick(); + void trimRows(); + void updateDbRow(NotificationRow* row); + + static QList parseActions( + const QJSValue& actions, NotificationRow* row); + + int m_popupTimeout = 5000; + + qint64 m_nextPrune = 0; + qint64 m_nextTimeStrRefresh = 0; + bool m_autoExpire = true; + bool m_clearing = false; + bool m_rowsSignalPending = false; + + QString m_uuid; + QString m_path; + + QList m_rows; + QHash m_liveRows; + + NotifImageProvider* m_provider = nullptr; + QTimer* m_tick = nullptr; +}; + +} // namespace ZShell::Services