Files
noctalia-shell/Services/System/NotificationService.qml
T

737 lines
21 KiB
QML

pragma Singleton
import QtQuick
import QtQuick.Window
import Quickshell
import Quickshell.Io
import Quickshell.Services.Notifications
import qs.Commons
import qs.Services.Power
import qs.Services.UI
import "../../Helpers/sha256.js" as Checksum
Singleton {
id: root
// Configuration
property int maxVisible: 5
property int maxHistory: 100
property string historyFile: Quickshell.env("NOCTALIA_NOTIF_HISTORY_FILE") || (Settings.cacheDir + "notifications.json")
property string stateFile: Settings.cacheDir + "notifications-state.json"
// State
property real lastSeenTs: 0
// Volatile property that doesn't persist to settings (similar to noctaliaPerformanceMode)
property bool doNotDisturb: false
// Models
property ListModel activeList: ListModel {}
property ListModel historyList: ListModel {}
// Internal state
property var activeNotifications: ({}) // Maps internal ID to {notification, watcher, metadata}
property var quickshellIdToInternalId: ({})
property var imageQueue: []
PanelWindow {
implicitHeight: 1
implicitWidth: 1
color: Color.transparent
mask: Region {}
Image {
id: cacher
width: 64
height: 64
visible: true
cache: false
asynchronous: true
mipmap: true
antialiasing: true
onStatusChanged: {
if (imageQueue.length === 0)
return
const req = imageQueue[0]
if (status === Image.Ready) {
Quickshell.execDetached(["mkdir", "-p", Settings.cacheDirImagesNotifications])
grabToImage(result => {
if (result.saveToFile(req.dest))
updateImagePath(req.imageId, req.dest)
processNextImage()
})
} else if (status === Image.Error) {
processNextImage()
}
}
function processNextImage() {
imageQueue.shift()
if (imageQueue.length > 0) {
source = imageQueue[0].src
} else {
source = ""
}
}
}
}
// Notification server
property var notificationServerLoader: null
Component {
id: notificationServerComponent
NotificationServer {
keepOnReload: false
imageSupported: true
actionsSupported: true
onNotification: notification => handleNotification(notification)
}
}
Component {
id: notificationWatcherComponent
Connections {
property var targetNotification
property var targetDataId
target: targetNotification
function onSummaryChanged() {
updateNotificationFromObject(targetDataId)
}
function onBodyChanged() {
updateNotificationFromObject(targetDataId)
}
function onAppNameChanged() {
updateNotificationFromObject(targetDataId)
}
function onUrgencyChanged() {
updateNotificationFromObject(targetDataId)
}
function onAppIconChanged() {
updateNotificationFromObject(targetDataId)
}
function onImageChanged() {
updateNotificationFromObject(targetDataId)
}
function onActionsChanged() {
updateNotificationFromObject(targetDataId)
}
}
}
function updateNotificationServer() {
if (notificationServerLoader) {
notificationServerLoader.destroy()
notificationServerLoader = null
}
if (Settings.isLoaded && Settings.data.notifications.enabled !== false) {
notificationServerLoader = notificationServerComponent.createObject(root)
}
}
Component.onCompleted: {
if (Settings.isLoaded) {
updateNotificationServer()
}
}
Connections {
target: Settings
function onSettingsLoaded() {
updateNotificationServer()
}
function onSettingsSaved() {
updateNotificationServer()
}
}
// Helper function to generate content-based ID for deduplication
function getContentId(summary, body, appName) {
return Checksum.sha256(JSON.stringify({
"summary": summary || "",
"body": body || "",
"app": appName || ""
}))
}
// Main handler
function handleNotification(notification) {
const quickshellId = notification.id
const data = createData(notification)
addToHistory(data)
if (root.doNotDisturb || PowerProfileService.noctaliaPerformanceMode)
return
// Check if this is a replacement notification
const existingInternalId = quickshellIdToInternalId[quickshellId]
if (existingInternalId && activeNotifications[existingInternalId]) {
updateExistingNotification(existingInternalId, notification, data)
return
}
// Check for duplicate content
const duplicateId = findDuplicateNotification(data)
if (duplicateId) {
removeNotification(duplicateId)
}
// Add new notification
addNewNotification(quickshellId, notification, data)
}
function updateExistingNotification(internalId, notification, data) {
const index = findNotificationIndex(internalId)
if (index < 0)
return
const existing = activeList.get(index)
const oldTimestamp = existing.timestamp
const oldProgress = existing.progress
// Update properties (keeping original timestamp and progress)
activeList.setProperty(index, "summary", data.summary)
activeList.setProperty(index, "body", data.body)
activeList.setProperty(index, "appName", data.appName)
activeList.setProperty(index, "urgency", data.urgency)
activeList.setProperty(index, "expireTimeout", data.expireTimeout)
activeList.setProperty(index, "originalImage", data.originalImage)
activeList.setProperty(index, "cachedImage", data.cachedImage)
activeList.setProperty(index, "actionsJson", data.actionsJson)
activeList.setProperty(index, "timestamp", oldTimestamp)
activeList.setProperty(index, "progress", oldProgress)
// Update stored notification object
const notifData = activeNotifications[internalId]
notifData.notification = notification
notification.tracked = true
notification.closed.connect(() => removeNotification(internalId))
// Update metadata
notifData.metadata.urgency = data.urgency
notifData.metadata.duration = calculateDuration(data)
}
function addNewNotification(quickshellId, notification, data) {
// Map IDs
quickshellIdToInternalId[quickshellId] = data.id
// Create watcher
const watcher = notificationWatcherComponent.createObject(root, {
"targetNotification": notification,
"targetDataId": data.id
})
// Store notification data
activeNotifications[data.id] = {
"notification": notification,
"watcher": watcher,
"metadata": {
"timestamp": data.timestamp.getTime(),
"duration": calculateDuration(data),
"urgency": data.urgency,
"paused": false,
"pauseTime": 0
}
}
notification.tracked = true
notification.closed.connect(() => removeNotification(data.id))
// Add to list
activeList.insert(0, data)
// Remove overflow
while (activeList.count > maxVisible) {
const last = activeList.get(activeList.count - 1)
activeNotifications[last.id]?.notification?.dismiss()
activeList.remove(activeList.count - 1)
cleanupNotification(last.id)
}
}
function findDuplicateNotification(data) {
const contentId = getContentId(data.summary, data.body, data.appName)
for (var i = 0; i < activeList.count; i++) {
const existing = activeList.get(i)
const existingContentId = getContentId(existing.summary, existing.body, existing.appName)
if (existingContentId === contentId) {
return existing.id
}
}
return null
}
function calculateDuration(data) {
const durations = [Settings.data.notifications?.lowUrgencyDuration * 1000 || 3000, Settings.data.notifications?.normalUrgencyDuration * 1000 || 8000, Settings.data.notifications?.criticalUrgencyDuration * 1000 || 15000]
if (Settings.data.notifications?.respectExpireTimeout) {
if (data.expireTimeout === 0)
return -1 // Never expire
if (data.expireTimeout > 0)
return data.expireTimeout
}
return durations[data.urgency]
}
function createData(n) {
const time = new Date()
const id = Checksum.sha256(JSON.stringify({
"summary": n.summary,
"body": n.body,
"app": n.appName,
"time": time.getTime()
}))
const image = n.image || getIcon(n.appIcon)
const imageId = generateImageId(n, image)
queueImage(image, imageId)
return {
"id": id,
"summary": n.summary || "",
"body": stripTags(n.body || ""),
"appName": getAppName(n.appName || n.desktopEntry || ""),
"urgency": n.urgency < 0 || n.urgency > 2 ? 1 : n.urgency,
"expireTimeout": n.expireTimeout,
"timestamp": time,
"progress": 1.0,
"originalImage": image,
"cachedImage": imageId ? (Settings.cacheDirImagesNotifications + imageId + ".png") : image,
"actionsJson": JSON.stringify((n.actions || []).map(a => ({
"text": a.text || "Action",
"identifier": a.identifier || ""
})))
}
}
function findNotificationIndex(internalId) {
for (var i = 0; i < activeList.count; i++) {
if (activeList.get(i).id === internalId) {
return i
}
}
return -1
}
function updateNotificationFromObject(internalId) {
const notifData = activeNotifications[internalId]
if (!notifData)
return
const index = findNotificationIndex(internalId)
if (index < 0)
return
const data = createData(notifData.notification)
const existing = activeList.get(index)
// Update properties (keeping timestamp and progress)
activeList.setProperty(index, "summary", data.summary)
activeList.setProperty(index, "body", data.body)
activeList.setProperty(index, "appName", data.appName)
activeList.setProperty(index, "urgency", data.urgency)
activeList.setProperty(index, "expireTimeout", data.expireTimeout)
activeList.setProperty(index, "originalImage", data.originalImage)
activeList.setProperty(index, "cachedImage", data.cachedImage)
activeList.setProperty(index, "actionsJson", data.actionsJson)
// Update metadata
notifData.metadata.urgency = data.urgency
notifData.metadata.duration = calculateDuration(data)
}
function removeNotification(id) {
const index = findNotificationIndex(id)
if (index >= 0) {
activeList.remove(index)
}
cleanupNotification(id)
}
function cleanupNotification(id) {
const notifData = activeNotifications[id]
if (notifData) {
notifData.watcher?.destroy()
delete activeNotifications[id]
}
// Clean up quickshell ID mapping
for (const qsId in quickshellIdToInternalId) {
if (quickshellIdToInternalId[qsId] === id) {
delete quickshellIdToInternalId[qsId]
break
}
}
}
// Progress updates
Timer {
interval: 50
repeat: true
running: activeList.count > 0
onTriggered: updateAllProgress()
}
function updateAllProgress() {
const now = Date.now()
const toRemove = []
for (var i = 0; i < activeList.count; i++) {
const notif = activeList.get(i)
const notifData = activeNotifications[notif.id]
if (!notifData)
continue
const meta = notifData.metadata
if (meta.duration === -1 || meta.paused)
continue
const elapsed = now - meta.timestamp
const progress = Math.max(1.0 - (elapsed / meta.duration), 0.0)
if (progress <= 0) {
toRemove.push(notif.id)
} else if (Math.abs(notif.progress - progress) > 0.005) {
activeList.setProperty(i, "progress", progress)
}
}
if (toRemove.length > 0) {
animateAndRemove(toRemove[0])
}
}
// Image handling
function queueImage(path, imageId) {
if (!path || !path.startsWith("image://") || !imageId)
return
const dest = Settings.cacheDirImagesNotifications + imageId + ".png"
for (const req of imageQueue) {
if (req.imageId === imageId)
return
}
imageQueue.push({
"src": path,
"dest": dest,
"imageId": imageId
})
if (imageQueue.length === 1)
cacher.source = path
}
function updateImagePath(id, path) {
updateModel(activeList, id, "cachedImage", path)
updateModel(historyList, id, "cachedImage", path)
saveHistory()
}
function updateModel(model, id, prop, value) {
for (var i = 0; i < model.count; i++) {
if (model.get(i).id === id) {
model.setProperty(i, prop, value)
break
}
}
}
// History management
function addToHistory(data) {
historyList.insert(0, data)
while (historyList.count > maxHistory) {
const old = historyList.get(historyList.count - 1)
if (old.cachedImage && !old.cachedImage.startsWith("image://")) {
Quickshell.execDetached(["rm", "-f", old.cachedImage])
}
historyList.remove(historyList.count - 1)
}
saveHistory()
}
// Persistence - History
FileView {
id: historyFileView
path: historyFile
printErrors: false
onLoaded: loadHistory()
onLoadFailed: error => {
if (error === 2)
writeAdapter()
}
JsonAdapter {
id: adapter
property var notifications: []
}
}
// Persistence - State
FileView {
id: stateFileView
path: stateFile
printErrors: false
onLoaded: loadState()
onLoadFailed: error => {
if (error === 2)
writeAdapter()
}
JsonAdapter {
id: stateAdapter
property real lastSeenTs: 0
}
}
Timer {
id: saveTimer
interval: 200
onTriggered: performSaveHistory()
}
function saveHistory() {
saveTimer.restart()
}
function performSaveHistory() {
try {
const items = []
for (var i = 0; i < historyList.count; i++) {
const n = historyList.get(i)
const copy = Object.assign({}, n)
copy.timestamp = n.timestamp.getTime()
items.push(copy)
}
adapter.notifications = items
historyFileView.writeAdapter()
} catch (e) {
Logger.e("Notifications", "Save history failed:", e)
}
}
function loadHistory() {
try {
historyList.clear()
for (const item of adapter.notifications || []) {
const time = new Date(item.timestamp)
let cachedImage = item.cachedImage || ""
if (item.originalImage && item.originalImage.startsWith("image://") && !cachedImage) {
const imageId = generateImageId(item, item.originalImage)
if (imageId) {
cachedImage = Settings.cacheDirImagesNotifications + imageId + ".png"
}
}
historyList.append({
"id": item.id || "",
"summary": item.summary || "",
"body": item.body || "",
"appName": item.appName || "",
"urgency": item.urgency < 0 || item.urgency > 2 ? 1 : item.urgency,
"timestamp": time,
"originalImage": item.originalImage || "",
"cachedImage": cachedImage
})
}
} catch (e) {
Logger.e("Notifications", "Load failed:", e)
}
}
function loadState() {
try {
root.lastSeenTs = stateAdapter.lastSeenTs || 0
if (root.lastSeenTs === 0 && Settings.data.notifications && Settings.data.notifications.lastSeenTs) {
root.lastSeenTs = Settings.data.notifications.lastSeenTs
saveState()
Logger.i("Notifications", "Migrated lastSeenTs from settings to state file")
}
} catch (e) {
Logger.e("Notifications", "Load state failed:", e)
}
}
function saveState() {
try {
stateAdapter.lastSeenTs = root.lastSeenTs
stateFileView.writeAdapter()
} catch (e) {
Logger.e("Notifications", "Save state failed:", e)
}
}
function updateLastSeenTs() {
root.lastSeenTs = Time.timestamp * 1000
saveState()
}
// Utility functions
function getAppName(name) {
if (!name || name.trim() === "")
return "Unknown"
name = name.trim()
if (name.includes(".") && (name.startsWith("com.") || name.startsWith("org.") || name.startsWith("io.") || name.startsWith("net."))) {
const parts = name.split(".")
let appPart = parts[parts.length - 1]
if (!appPart || appPart === "app" || appPart === "desktop") {
appPart = parts[parts.length - 2] || parts[0]
}
if (appPart)
name = appPart
}
if (name.includes(".")) {
const parts = name.split(".")
let displayName = parts[parts.length - 1]
if (!displayName || /^\d+$/.test(displayName)) {
displayName = parts[parts.length - 2] || parts[0]
}
if (displayName) {
displayName = displayName.charAt(0).toUpperCase() + displayName.slice(1)
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
displayName = displayName.replace(/app$/i, '').trim()
displayName = displayName.replace(/desktop$/i, '').trim()
displayName = displayName.replace(/flatpak$/i, '').trim()
if (!displayName) {
displayName = parts[parts.length - 1].charAt(0).toUpperCase() + parts[parts.length - 1].slice(1)
}
}
return displayName || name
}
let displayName = name.charAt(0).toUpperCase() + name.slice(1)
displayName = displayName.replace(/([a-z])([A-Z])/g, '$1 $2')
displayName = displayName.replace(/app$/i, '').trim()
displayName = displayName.replace(/desktop$/i, '').trim()
return displayName || name
}
function getIcon(icon) {
if (!icon)
return ""
if (icon.startsWith("/") || icon.startsWith("file://"))
return icon
return ThemeIcons.iconFromName(icon)
}
function stripTags(text) {
return text.replace(/<[^>]*>?/gm, '')
}
function generateImageId(notification, image) {
if (image && image.startsWith("image://")) {
if (image.startsWith("image://qsimage/")) {
const key = (notification.appName || "") + "|" + (notification.summary || "")
return Checksum.sha256(key)
}
return Checksum.sha256(image)
}
return ""
}
function pauseTimeout(id) {
const notifData = activeNotifications[id]
if (notifData && !notifData.metadata.paused) {
notifData.metadata.paused = true
notifData.metadata.pauseTime = Date.now()
}
}
function resumeTimeout(id) {
const notifData = activeNotifications[id]
if (notifData && notifData.metadata.paused) {
notifData.metadata.timestamp += Date.now() - notifData.metadata.pauseTime
notifData.metadata.paused = false
}
}
// Public API
function dismissActiveNotification(id) {
activeNotifications[id]?.notification?.dismiss()
removeNotification(id)
}
function dismissOldestActive() {
if (activeList.count > 0) {
const lastNotif = activeList.get(activeList.count - 1)
dismissActiveNotification(lastNotif.id)
}
}
function dismissAllActive() {
for (const id in activeNotifications) {
activeNotifications[id].notification?.dismiss()
activeNotifications[id].watcher?.destroy()
}
activeList.clear()
activeNotifications = {}
quickshellIdToInternalId = {}
}
function invokeAction(id, actionId) {
const notifData = activeNotifications[id]
if (!notifData?.notification?.actions)
return false
for (const action of notifData.notification.actions) {
if (action.identifier === actionId && action.invoke) {
action.invoke()
return true
}
}
return false
}
function removeFromHistory(notificationId) {
for (var i = 0; i < historyList.count; i++) {
const notif = historyList.get(i)
if (notif.id === notificationId) {
if (notif.cachedImage && !notif.cachedImage.startsWith("image://")) {
Quickshell.execDetached(["rm", "-f", notif.cachedImage])
}
historyList.remove(i)
saveHistory()
return true
}
}
return false
}
function clearHistory() {
try {
Quickshell.execDetached(["sh", "-c", `rm -rf "${Settings.cacheDirImagesNotifications}"*`])
} catch (e) {
Logger.e("Notifications", "Failed to clear cache directory:", e)
}
historyList.clear()
saveHistory()
}
// Signals
signal animateAndRemove(string notificationId)
onDoNotDisturbChanged: {
ToastService.showNotice(doNotDisturb ? I18n.tr("toast.do-not-disturb.enabled") : I18n.tr("toast.do-not-disturb.disabled"), doNotDisturb ? I18n.tr("toast.do-not-disturb.enabled-desc") : I18n.tr("toast.do-not-disturb.disabled-desc"), doNotDisturb ? "bell-off" : "bell")
}
}