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