Promote shell to its own top-level directory
This commit is contained in:
@@ -0,0 +1,891 @@
|
||||
// Notification service. Adapted from noctalia-shell (MIT) and
|
||||
// DankMaterialShell (MIT). Original implementations:
|
||||
// https://github.com/noctalia-dev/noctalia-shell
|
||||
// https://github.com/AvengeMedia/DankMaterialShell
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Services.Notifications
|
||||
import qs.Commons
|
||||
|
||||
import "components"
|
||||
|
||||
Item {
|
||||
id: service
|
||||
|
||||
// Injected by omarchy-shell (the first-party service loader).
|
||||
property string omarchyPath: ""
|
||||
property var shell: null
|
||||
property var manifest: null
|
||||
|
||||
readonly property string home: Quickshell.env("HOME")
|
||||
// History + DND live under XDG_STATE_HOME: they're persistent user state
|
||||
// (history of received notifications, last-set DND preference), not
|
||||
// regeneratable cache that a `rm -rf ~/.cache` should wipe.
|
||||
readonly property string stateDir: home + "/.local/state/omarchy/"
|
||||
readonly property string historyPath: stateDir + "notifications.json"
|
||||
// Thumbnails copied from /tmp screenshots are genuinely disposable — if
|
||||
// they vanish the row just renders without an image — so they stay in
|
||||
// ~/.cache where regeneratable artifacts belong.
|
||||
readonly property string cacheDir: home + "/.cache/omarchy/"
|
||||
readonly property string imageCacheDir: cacheDir + "notification-images/"
|
||||
// Corner radius is shared with omarchy-shell menu and bar settings panel —
|
||||
// `omarchy style corners <sharp|round>` writes this file once and every
|
||||
// surface reads it. Default 0 for sharp corners.
|
||||
readonly property int cornerRadius: Style.cornerRadius
|
||||
// Surfaces anchor relative to the omarchy bar so popups and history land
|
||||
// alongside the other shell panels rather than on top of the bar itself.
|
||||
// Falls back to the bar's default size (26 horizontal / 28 vertical) when
|
||||
// shell.bar isn't reachable so the popup never lands on top of the bar.
|
||||
readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top"
|
||||
readonly property bool barVertical: barPosition === "left" || barPosition === "right"
|
||||
readonly property int defaultBarSize: barVertical ? 28 : 26
|
||||
readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize
|
||||
readonly property int barClearance: liveBarSize + 12
|
||||
|
||||
// Fired by IPC (`omarchy-shell notifications showHistory`) so the
|
||||
// bar widget can drop its PopupCard from the same anchor a click would.
|
||||
signal historyOpenRequested()
|
||||
|
||||
// PersistentProperties handles in-process QML reloads. The on-disk
|
||||
// notifications.json file is the cross-restart backstop — its `dnd` key
|
||||
// is hydrated into persisted.doNotDisturb on startup and written back via
|
||||
// the same debounced save timer used for history entries.
|
||||
PersistentProperties {
|
||||
id: persisted
|
||||
reloadableId: "omarchy-notifications"
|
||||
property bool doNotDisturb: false
|
||||
onDoNotDisturbChanged: {
|
||||
// Suppress the write that load-time hydration would otherwise trigger.
|
||||
if (service._hydrating) return
|
||||
service.scheduleHistorySave()
|
||||
}
|
||||
}
|
||||
|
||||
// Guards onDoNotDisturbChanged while we're hydrating from disk so the
|
||||
// hydration assignment doesn't immediately schedule a write-back.
|
||||
property bool _hydrating: false
|
||||
|
||||
readonly property alias doNotDisturb: persisted.doNotDisturb
|
||||
|
||||
function setDoNotDisturb(value) {
|
||||
persisted.doNotDisturb = !!value
|
||||
}
|
||||
|
||||
// popupModel feeds the on-screen toast stack.
|
||||
// pendingModel = notifications received but not yet "seen" by the user.
|
||||
// Anything DND-suppressed lands here and stays there until
|
||||
// the user reviews it; anything that pops up also lives
|
||||
// here until the popup dismisses, then moves to pastModel.
|
||||
// pastModel = notifications the user has already seen on-screen.
|
||||
// Surfaced under the Past tab in the history panel.
|
||||
//
|
||||
// Aliased as properties so the bar widget and HistoryPanel (outside this
|
||||
// Item's id scope) can bind to them. QML ids aren't visible to external
|
||||
// consumers without the alias.
|
||||
property alias popupModel: popupModel
|
||||
property alias pendingModel: pendingModel
|
||||
property alias pastModel: pastModel
|
||||
ListModel { id: popupModel }
|
||||
ListModel { id: pendingModel }
|
||||
ListModel { id: pastModel }
|
||||
|
||||
readonly property int historyCap: 100
|
||||
|
||||
function durationFor(urgency) {
|
||||
switch (urgency) {
|
||||
case NotificationUrgency.Critical:
|
||||
return 0
|
||||
case NotificationUrgency.Low:
|
||||
return 3000
|
||||
default:
|
||||
return 5000
|
||||
}
|
||||
}
|
||||
|
||||
// DND bypass: only let through notifications we trust to be intentional
|
||||
// and rare.
|
||||
// - omarchy-action: a user-action confirmation toast ("Theme changed",
|
||||
// "Screenshot saved"). The user JUST did something — their feedback
|
||||
// should show.
|
||||
// - urgency=critical AND app_name=notify-send: bare-CLI emergency alerts
|
||||
// (omarchy-battery-monitor uses this for low-battery; a few scripts
|
||||
// use it for emergency failures). Trusted because it's almost always
|
||||
// omarchy or system shell scripts — chat apps set app_name to
|
||||
// their brand (Discord/Slack/Vesktop) which falls outside this rule.
|
||||
function shouldBypassDnd(notification) {
|
||||
var appName = String(notification.appName || "")
|
||||
if (appName === "omarchy-action") return true
|
||||
if (appName === "notify-send" && notification.urgency === NotificationUrgency.Critical) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function snapshotOf(notification) {
|
||||
var glyph = ""
|
||||
try {
|
||||
if (notification.hints) {
|
||||
var hintGlyph = notification.hints["omarchy-glyph"]
|
||||
if (hintGlyph !== undefined && hintGlyph !== null)
|
||||
glyph = String(hintGlyph)
|
||||
}
|
||||
} catch (e) { glyph = "" }
|
||||
var summary = String(notification.summary || "")
|
||||
|
||||
return {
|
||||
id: notification.id,
|
||||
originalId: notification.id,
|
||||
app: notification.appName || "",
|
||||
appIcon: notification.appIcon || "",
|
||||
summary: summary,
|
||||
body: notification.body || "",
|
||||
image: notification.image || "",
|
||||
glyph: glyph,
|
||||
urgency: notification.urgency,
|
||||
timestamp: Date.now(),
|
||||
ref: notification
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotification(notification) {
|
||||
// Without `tracked = true` the Notification object is destroyed as soon
|
||||
// as this signal handler returns, which would null out the `ref` we just
|
||||
// captured for the popup card.
|
||||
notification.tracked = true
|
||||
var snapshot = snapshotOf(notification)
|
||||
// History is for notifications from real apps (Slack, Discord, mailer,
|
||||
// etc.) — things the user might want to look back at. Skip the pending
|
||||
// / past bookkeeping when:
|
||||
// - the freedesktop `transient` hint is set ("popup only, don't store")
|
||||
// - app_name is "notify-send" (the CLI default — means the sender
|
||||
// didn't bother declaring an identity, so it's almost certainly
|
||||
// ephemeral test/feedback noise)
|
||||
// - app_name is "omarchy-action" (omarchy's own user-action
|
||||
// confirmation toasts — the user just triggered them, they don't
|
||||
// need to be archived)
|
||||
var transient = false
|
||||
try {
|
||||
transient = !!(notification.hints && notification.hints["transient"])
|
||||
} catch (e) { transient = false }
|
||||
var appName = String(notification.appName || "")
|
||||
var ephemeralApp = appName === "notify-send" || appName === "omarchy-action"
|
||||
if (transient || ephemeralApp) {
|
||||
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
|
||||
notification.tracked = false
|
||||
return
|
||||
}
|
||||
Qt.callLater(function() {
|
||||
removeByOriginalId(popupModel, snapshot.originalId)
|
||||
popupModel.insert(0, snapshot)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Pending first, unconditionally. DND only suppresses the toast — the
|
||||
// record still has to land somewhere the user can review later.
|
||||
addToPending(snapshot)
|
||||
|
||||
// Kick off a copy of any /tmp screenshot into the persistent image cache.
|
||||
// The cp races the popup; the popup keeps the original path so it always
|
||||
// renders, and the history row gets rewritten to the cached path once
|
||||
// cp.exits.
|
||||
maybeCacheImage(snapshot)
|
||||
|
||||
// DND bypass rules — see ~/Work/omarchy/dnd-fix-plan.md. The pending
|
||||
// entry already captured this notification above; we just decide here
|
||||
// whether to also pop a toast. Chat apps abuse urgency=critical to
|
||||
// force visibility, so critical alone isn't enough — we also require
|
||||
// the sender to be CLI-style. See shouldBypassDnd().
|
||||
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
|
||||
notification.tracked = false
|
||||
return
|
||||
}
|
||||
|
||||
// Qt.callLater avoids "QV4::Object::insertMember" crashes when a
|
||||
// Repeater is mid-incubation while we mutate its model — see noctalia
|
||||
// NotificationService.qml ~L307.
|
||||
Qt.callLater(function() {
|
||||
removeByOriginalId(popupModel, snapshot.originalId)
|
||||
popupModel.insert(0, snapshot)
|
||||
})
|
||||
}
|
||||
|
||||
// Remove every row in `model` whose originalId matches. Chat apps reuse
|
||||
// `replaces_id` per the freedesktop spec to update a single notification
|
||||
// in place — without this, every Discord/Slack ping leaves a fresh row
|
||||
// behind and pending fills with hundreds of duplicates.
|
||||
function removeByOriginalId(model, originalId) {
|
||||
for (var i = model.count - 1; i >= 0; i--) {
|
||||
var row = model.get(i)
|
||||
if (row && row.originalId === originalId) model.remove(i)
|
||||
}
|
||||
}
|
||||
|
||||
function addToPending(snapshot) {
|
||||
Qt.callLater(function() {
|
||||
removeByOriginalId(pendingModel, snapshot.originalId)
|
||||
pendingModel.insert(0, snapshot)
|
||||
while (pendingModel.count > service.historyCap) {
|
||||
pendingModel.remove(pendingModel.count - 1)
|
||||
}
|
||||
scheduleHistorySave()
|
||||
})
|
||||
}
|
||||
|
||||
// Find a pending entry by its libnotify id and move it to pastModel. Called
|
||||
// when a popup naturally dismisses (timer expired or user clicked X / the
|
||||
// default action) — the user is assumed to have seen it.
|
||||
function markSeenByOriginalId(originalId) {
|
||||
Qt.callLater(function() {
|
||||
for (var i = 0; i < pendingModel.count; i++) {
|
||||
var entry = pendingModel.get(i)
|
||||
if (!entry || entry.originalId !== originalId) continue
|
||||
var snapshot = service.snapshotFromRow(entry)
|
||||
pendingModel.remove(i)
|
||||
pastModel.insert(0, snapshot)
|
||||
while (pastModel.count > service.historyCap) {
|
||||
pastModel.remove(pastModel.count - 1)
|
||||
}
|
||||
scheduleHistorySave()
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Copy a ListModel row into a plain JS object so we can re-insert it into
|
||||
// a different model without sharing references.
|
||||
function snapshotFromRow(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
originalId: row.originalId,
|
||||
app: row.app,
|
||||
appIcon: row.appIcon,
|
||||
summary: row.summary,
|
||||
body: row.body,
|
||||
image: row.image,
|
||||
glyph: row.glyph || "",
|
||||
urgency: row.urgency,
|
||||
timestamp: row.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
function markAllSeen() {
|
||||
Qt.callLater(function() {
|
||||
while (pendingModel.count > 0) {
|
||||
var entry = pendingModel.get(0)
|
||||
var snapshot = service.snapshotFromRow(entry)
|
||||
pendingModel.remove(0)
|
||||
pastModel.insert(0, snapshot)
|
||||
}
|
||||
while (pastModel.count > service.historyCap) {
|
||||
pastModel.remove(pastModel.count - 1)
|
||||
}
|
||||
scheduleHistorySave()
|
||||
})
|
||||
}
|
||||
|
||||
function dismissPopup(index) {
|
||||
if (index < 0 || index >= popupModel.count) return
|
||||
var entry = popupModel.get(index)
|
||||
var ref = entry ? entry.ref : null
|
||||
var originalId = entry ? entry.originalId : -1
|
||||
popupModel.remove(index)
|
||||
if (ref) {
|
||||
try {
|
||||
if (ref.tracked) ref.dismiss()
|
||||
} catch (e) {
|
||||
// Object already torn down by the server — nothing to dismiss.
|
||||
}
|
||||
}
|
||||
// User (or the lifetime timer) saw the popup — archive it.
|
||||
if (originalId >= 0) markSeenByOriginalId(originalId)
|
||||
}
|
||||
|
||||
function clearPopups() {
|
||||
while (popupModel.count > 0) dismissPopup(0)
|
||||
}
|
||||
|
||||
function dismissPending(index) {
|
||||
if (index < 0 || index >= pendingModel.count) return
|
||||
var entry = pendingModel.get(index)
|
||||
if (entry) maybeDeleteCachedImage(entry.image)
|
||||
pendingModel.remove(index)
|
||||
scheduleHistorySave()
|
||||
}
|
||||
|
||||
function dismissPast(index) {
|
||||
if (index < 0 || index >= pastModel.count) return
|
||||
var entry = pastModel.get(index)
|
||||
if (entry) maybeDeleteCachedImage(entry.image)
|
||||
pastModel.remove(index)
|
||||
scheduleHistorySave()
|
||||
}
|
||||
|
||||
function clearPending() {
|
||||
for (var i = 0; i < pendingModel.count; i++) {
|
||||
var entry = pendingModel.get(i)
|
||||
if (entry) maybeDeleteCachedImage(entry.image)
|
||||
}
|
||||
pendingModel.clear()
|
||||
scheduleHistorySave()
|
||||
}
|
||||
|
||||
function clearPast() {
|
||||
for (var i = 0; i < pastModel.count; i++) {
|
||||
var entry = pastModel.get(i)
|
||||
if (entry) maybeDeleteCachedImage(entry.image)
|
||||
}
|
||||
pastModel.clear()
|
||||
scheduleHistorySave()
|
||||
}
|
||||
|
||||
// Invoke the libnotify "default" action on the popup's underlying
|
||||
// notification, if it has one, then dismiss. Clients register the default
|
||||
// action with the canonical identifier "default"; e.g. screenshot toasts
|
||||
// use `notify-send -A default=Edit ...` so click-the-card opens the editor.
|
||||
function invokePopupDefault(index) {
|
||||
if (index < 0 || index >= popupModel.count) return
|
||||
var entry = popupModel.get(index)
|
||||
var ref = entry ? entry.ref : null
|
||||
var invoked = false
|
||||
if (ref && ref.actions) {
|
||||
for (var i = 0; i < ref.actions.length; i++) {
|
||||
var action = ref.actions[i]
|
||||
if (action && action.identifier === "default") {
|
||||
try { action.invoke(); invoked = true } catch (e) { console.warn("invoke default failed:", e) }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Chat apps (Slack, Discord, Vesktop, etc.) rarely register a "default"
|
||||
// libnotify action — they just expect clicking the notification to
|
||||
// focus their window. Fall back to focusing the sending app by class so
|
||||
// that click-to-jump actually works.
|
||||
if (!invoked) focusApp(entry)
|
||||
dismissPopup(index)
|
||||
}
|
||||
|
||||
// Try to focus an existing Hyprland window matching the notification's
|
||||
// sender. We shell out to a small bash one-liner because Hyprland's class
|
||||
// matcher is regex-based but its case-sensitivity is implementation-
|
||||
// defined (std::regex doesn't reliably honor `(?i)`). Easier to query the
|
||||
// client list ourselves and pick the first match.
|
||||
function focusApp(entry) {
|
||||
if (!entry || !entry.app) return
|
||||
var lower = String(entry.app).toLowerCase()
|
||||
focusAppProc.command = ["bash", "-lc",
|
||||
"hyprctl clients -j 2>/dev/null | " +
|
||||
"jq -r --arg name \"" + lower + "\" " +
|
||||
"'[.[] | select((.class // \"\") | ascii_downcase | startswith($name))] | first.address // empty' | " +
|
||||
"xargs -r -I{} hyprctl dispatch focuswindow address:{}"]
|
||||
focusAppProc.running = true
|
||||
}
|
||||
|
||||
Process { id: focusAppProc; running: false }
|
||||
|
||||
// Open the popup's large image in the user's default image viewer.
|
||||
// image:// URIs come from the upstream raw-bytes provider and have no
|
||||
// on-disk path to hand to xdg-open, so we just dismiss in that case.
|
||||
function openPopupImage(index) {
|
||||
if (index < 0 || index >= popupModel.count) return
|
||||
var entry = popupModel.get(index)
|
||||
if (!entry) return
|
||||
var image = String(entry.image || "")
|
||||
if (image.indexOf("file://") === 0) {
|
||||
var lower = image.toLowerCase()
|
||||
if (lower.endsWith(".png") || lower.endsWith(".jpg") ||
|
||||
lower.endsWith(".jpeg") || lower.endsWith(".webp")) {
|
||||
var path = decodeURIComponent(image.substring(7))
|
||||
Quickshell.execDetached(["xdg-open", path])
|
||||
}
|
||||
}
|
||||
dismissPopup(index)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- image cache
|
||||
//
|
||||
// Notifications coming from screenshot helpers ship an `image-path` hint
|
||||
// pointing at /tmp/<file>. We want the history thumbnail to outlive that
|
||||
// file, so we copy it into a long-lived cache dir on ingress and rewrite
|
||||
// the history row's `image` to point at the cache once cp finishes.
|
||||
// image:// (raw-bytes) URIs aren't trivially copyable from QML; document
|
||||
// and skip them for v1.
|
||||
|
||||
function imageExtension(srcPath) {
|
||||
var lower = srcPath.toLowerCase()
|
||||
var dot = lower.lastIndexOf(".")
|
||||
if (dot < 0) return "png"
|
||||
var ext = lower.substring(dot + 1)
|
||||
if (ext.length === 0 || ext.length > 5) return "png"
|
||||
return ext
|
||||
}
|
||||
|
||||
function maybeCacheImage(snapshot) {
|
||||
var image = String(snapshot.image || "")
|
||||
if (!image) return
|
||||
// image:// URIs are decoded from raw bytes by Quickshell's image provider.
|
||||
// We can't copy them out from QML, so let history reference them by URI
|
||||
// and accept that they disappear with the source notification.
|
||||
if (image.indexOf("image://") === 0) return
|
||||
if (image.indexOf("file:///tmp/") !== 0) return
|
||||
|
||||
var srcPath = decodeURIComponent(image.substring(7))
|
||||
var ext = imageExtension(srcPath)
|
||||
var destPath = imageCacheDir + snapshot.timestamp + "-" + snapshot.originalId + "." + ext
|
||||
var destUri = "file://" + destPath
|
||||
|
||||
imageCacheProc.targetUri = destUri
|
||||
imageCacheProc.matchOriginalId = snapshot.originalId
|
||||
imageCacheProc.matchTimestamp = snapshot.timestamp
|
||||
imageCacheProc.command = ["cp", "-f", srcPath, destPath]
|
||||
imageCacheProc.running = true
|
||||
}
|
||||
|
||||
function maybeDeleteCachedImage(image) {
|
||||
var path = String(image || "")
|
||||
if (!path) return
|
||||
if (path.indexOf("file://") !== 0) return
|
||||
var local = decodeURIComponent(path.substring(7))
|
||||
if (local.indexOf(imageCacheDir) !== 0) return
|
||||
deleteImageProc.command = ["rm", "-f", local]
|
||||
deleteImageProc.running = true
|
||||
}
|
||||
|
||||
Process {
|
||||
id: ensureDirsProc
|
||||
command: ["mkdir", "-p", service.stateDir, service.imageCacheDir]
|
||||
running: false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: imageCacheProc
|
||||
property string targetUri: ""
|
||||
property int matchOriginalId: -1
|
||||
property double matchTimestamp: 0
|
||||
onExited: function(exitCode) {
|
||||
if (exitCode !== 0 || !targetUri) return
|
||||
// Search both models since a notification may have moved to past
|
||||
// between when we kicked off the copy and when it finished.
|
||||
function rewrite(model) {
|
||||
for (var i = 0; i < model.count; i++) {
|
||||
var row = model.get(i)
|
||||
if (row && row.originalId === matchOriginalId && row.timestamp === matchTimestamp) {
|
||||
model.setProperty(i, "image", targetUri)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (rewrite(pendingModel) || rewrite(pastModel)) scheduleHistorySave()
|
||||
}
|
||||
}
|
||||
|
||||
Process { id: deleteImageProc; running: false }
|
||||
|
||||
// ---------------------------------------------------- history persistence
|
||||
|
||||
FileView {
|
||||
id: historyFile
|
||||
path: service.historyPath
|
||||
watchChanges: false
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
onLoaded: service.loadHistory(text())
|
||||
// First-run: the file doesn't exist yet. Without this branch,
|
||||
// `historyLoaded` stays false forever and `scheduleHistorySave` becomes
|
||||
// a no-op — so the file is never created and history vanishes on
|
||||
// shell restart.
|
||||
onLoadFailed: service.loadHistory("")
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: historySaveTimer
|
||||
interval: 200
|
||||
repeat: false
|
||||
onTriggered: service.flushHistory()
|
||||
}
|
||||
|
||||
// Past is a rolling "recently" window. Sweep every minute and drop
|
||||
// anything older than 15 minutes so the tab doesn't accumulate forever.
|
||||
readonly property int pastTtlMs: 15 * 60 * 1000
|
||||
|
||||
Timer {
|
||||
id: pastPruneTimer
|
||||
interval: 60 * 1000
|
||||
repeat: true
|
||||
running: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: service.prunePast()
|
||||
}
|
||||
|
||||
function prunePast() {
|
||||
if (pastModel.count === 0) return
|
||||
var cutoff = Date.now() - service.pastTtlMs
|
||||
var removed = false
|
||||
for (var i = pastModel.count - 1; i >= 0; i--) {
|
||||
var entry = pastModel.get(i)
|
||||
if (entry && entry.timestamp && entry.timestamp < cutoff) {
|
||||
if (entry.image) maybeDeleteCachedImage(entry.image)
|
||||
pastModel.remove(i)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
if (removed) scheduleHistorySave()
|
||||
}
|
||||
|
||||
function scheduleHistorySave() {
|
||||
if (!service.historyLoaded) return
|
||||
historySaveTimer.restart()
|
||||
}
|
||||
|
||||
property bool historyLoaded: false
|
||||
|
||||
function loadHistory(raw) {
|
||||
// FileView can fire onLoaded more than once during startup — the implicit
|
||||
// preload when `path` resolves, plus the explicit `historyFile.reload()`
|
||||
// in Component.onCompleted can both end up calling here. Without this
|
||||
// guard, the second fire appends a second copy of every persisted row
|
||||
// to the in-memory model.
|
||||
if (service.historyLoaded) return
|
||||
var text = String(raw || "").trim()
|
||||
if (!text) { service.historyLoaded = true; return }
|
||||
try {
|
||||
var parsed = JSON.parse(text)
|
||||
if (parsed && typeof parsed.dnd === "boolean") {
|
||||
service._hydrating = true
|
||||
persisted.doNotDisturb = parsed.dnd
|
||||
service._hydrating = false
|
||||
}
|
||||
var pending = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
|
||||
var past = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
|
||||
// v1 backwards compat: the old schema had a single `entries` array.
|
||||
// Treat all of those as past since the user already presumably saw
|
||||
// them (and DND-suppressed notifications from before the split are
|
||||
// a rare edge case).
|
||||
if (parsed && Array.isArray(parsed.entries)) past = past.concat(parsed.entries)
|
||||
|
||||
function entryFor(e) {
|
||||
return {
|
||||
id: e.id || 0,
|
||||
originalId: e.originalId || e.id || 0,
|
||||
app: e.app || "",
|
||||
appIcon: e.appIcon || "",
|
||||
summary: e.summary || "",
|
||||
body: e.body || "",
|
||||
image: e.image || "",
|
||||
glyph: e.glyph || "",
|
||||
urgency: typeof e.urgency === "number" ? e.urgency : NotificationUrgency.Normal,
|
||||
timestamp: e.timestamp || 0,
|
||||
ref: null
|
||||
}
|
||||
}
|
||||
// Older builds didn't dedupe chat-app replacements, so hydrated files
|
||||
// can hold hundreds of identical rows (same originalId). Collapse on
|
||||
// load — keep the newest occurrence (highest timestamp) and drop the
|
||||
// rest. Save is rescheduled below so the disk file rewrites cleanly.
|
||||
function dedupeByOriginalId(rows) {
|
||||
var keep = {}
|
||||
for (var k = 0; k < rows.length; k++) {
|
||||
var r = rows[k]
|
||||
if (!r) continue
|
||||
var key = r.originalId
|
||||
if (key === undefined || key === null) { keep["_" + k] = r; continue }
|
||||
var prior = keep[key]
|
||||
if (!prior || (r.timestamp || 0) >= (prior.timestamp || 0)) keep[key] = r
|
||||
}
|
||||
var out = []
|
||||
for (var id in keep) out.push(keep[id])
|
||||
out.sort(function(a, b) { return (b.timestamp || 0) - (a.timestamp || 0) })
|
||||
return out
|
||||
}
|
||||
var pendingDeduped = dedupeByOriginalId(pending)
|
||||
var pastDeduped = dedupeByOriginalId(past)
|
||||
var hadDuplicates = pendingDeduped.length !== pending.length
|
||||
|| pastDeduped.length !== past.length
|
||||
// Newest-first on disk; insert in order so models match.
|
||||
Qt.callLater(function() {
|
||||
for (var i = 0; i < pendingDeduped.length; i++) {
|
||||
pendingModel.append(entryFor(pendingDeduped[i]))
|
||||
if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1)
|
||||
}
|
||||
for (var j = 0; j < pastDeduped.length; j++) {
|
||||
pastModel.append(entryFor(pastDeduped[j]))
|
||||
if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1)
|
||||
}
|
||||
service.historyLoaded = true
|
||||
if (hadDuplicates) service.scheduleHistorySave()
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn("notifications: history parse failed:", e)
|
||||
service.historyLoaded = true
|
||||
}
|
||||
}
|
||||
|
||||
function flushHistory() {
|
||||
function dump(model) {
|
||||
var out = []
|
||||
for (var i = 0; i < model.count; i++) {
|
||||
var r = model.get(i)
|
||||
if (!r) continue
|
||||
out.push({
|
||||
id: r.id,
|
||||
originalId: r.originalId,
|
||||
app: r.app,
|
||||
appIcon: r.appIcon,
|
||||
summary: r.summary,
|
||||
body: r.body,
|
||||
image: r.image,
|
||||
glyph: r.glyph || "",
|
||||
urgency: r.urgency,
|
||||
timestamp: r.timestamp
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
var payload = {
|
||||
version: 2,
|
||||
dnd: persisted.doNotDisturb,
|
||||
pending: dump(pendingModel),
|
||||
past: dump(pastModel)
|
||||
}
|
||||
historyFile.setText(JSON.stringify(payload, null, 2) + "\n")
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
ensureDirsProc.running = true
|
||||
// Once mkdir has had a tick, load the existing history file. FileView
|
||||
// surfaces an empty string when the file doesn't exist; loadHistory
|
||||
// handles that path.
|
||||
Qt.callLater(function() { historyFile.reload() })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- IPC
|
||||
|
||||
IpcHandler {
|
||||
target: "notifications"
|
||||
|
||||
function dndState(): string {
|
||||
return service.doNotDisturb ? "on" : "off"
|
||||
}
|
||||
|
||||
function toggleDnd(): string {
|
||||
service.setDoNotDisturb(!service.doNotDisturb)
|
||||
return dndState()
|
||||
}
|
||||
|
||||
function setDnd(value: string): string {
|
||||
var v = String(value || "").toLowerCase()
|
||||
var on = v === "true" || v === "1" || v === "on" || v === "yes"
|
||||
service.setDoNotDisturb(on)
|
||||
return dndState()
|
||||
}
|
||||
|
||||
function isDnd(): string {
|
||||
return dndState()
|
||||
}
|
||||
|
||||
function showHistory(): string {
|
||||
service.historyOpenRequested()
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// `clear` empties the past tab (the "I already saw these" bucket).
|
||||
function clear(): string {
|
||||
service.clearPast()
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function clearPending(): string {
|
||||
service.clearPending()
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function markAllSeen(): string {
|
||||
service.markAllSeen()
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function dismissAll(): string {
|
||||
service.clearPopups()
|
||||
service.clearPending()
|
||||
service.clearPast()
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// dismiss the most recent popup; fall back to the most recent pending
|
||||
// entry, then past, if no popup is currently showing.
|
||||
function dismissOne(): string {
|
||||
if (popupModel.count > 0) {
|
||||
service.dismissPopup(0)
|
||||
return "ok"
|
||||
}
|
||||
if (pendingModel.count > 0) {
|
||||
service.dismissPending(0)
|
||||
return "ok"
|
||||
}
|
||||
if (pastModel.count > 0) {
|
||||
service.dismissPast(0)
|
||||
return "ok"
|
||||
}
|
||||
return "none"
|
||||
}
|
||||
|
||||
// Fire the default action on the most recent popup, then dismiss it.
|
||||
function invokeLast(): string {
|
||||
if (popupModel.count === 0) return "none"
|
||||
service.invokePopupDefault(0)
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function dismiss(summary: string): string {
|
||||
var needle = String(summary || "")
|
||||
if (!needle) return "none"
|
||||
var hit = false
|
||||
function sweep(model, dismissFn) {
|
||||
for (var i = model.count - 1; i >= 0; i--) {
|
||||
var row = model.get(i)
|
||||
if (row && String(row.summary || "").indexOf(needle) !== -1) {
|
||||
dismissFn(i)
|
||||
hit = true
|
||||
}
|
||||
}
|
||||
}
|
||||
sweep(pendingModel, service.dismissPending)
|
||||
sweep(pastModel, service.dismissPast)
|
||||
sweep(popupModel, service.dismissPopup)
|
||||
return hit ? "ok" : "none"
|
||||
}
|
||||
|
||||
function ping(): string { return "ok" }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- server
|
||||
|
||||
NotificationServer {
|
||||
id: server
|
||||
keepOnReload: false
|
||||
imageSupported: true
|
||||
actionsSupported: true
|
||||
bodyMarkupSupported: true
|
||||
bodyHyperlinksSupported: true
|
||||
persistenceSupported: true
|
||||
|
||||
onNotification: function(notification) {
|
||||
service.handleNotification(notification)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- popup UI
|
||||
//
|
||||
// One PanelWindow per output (Variants on Quickshell.screens) holding the
|
||||
// stacked toast cards. Layer is Overlay, exclusionMode Ignore, no
|
||||
// keyboard focus — popups are passive surfaces and must never steal input
|
||||
// from the focused application.
|
||||
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
|
||||
PanelWindow {
|
||||
id: popupWindow
|
||||
required property var modelData
|
||||
screen: modelData
|
||||
visible: popupModel.count > 0
|
||||
|
||||
WlrLayershell.namespace: "omarchy-notifications"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
color: "transparent"
|
||||
|
||||
anchors {
|
||||
top: service.barPosition !== "bottom"
|
||||
bottom: service.barPosition === "bottom"
|
||||
left: service.barPosition === "left"
|
||||
right: service.barPosition !== "left"
|
||||
}
|
||||
margins {
|
||||
top: service.barPosition === "top" ? service.barClearance + 12 : 20
|
||||
bottom: service.barPosition === "bottom" ? service.barClearance + 12 : 20
|
||||
left: service.barPosition === "left" ? service.barClearance + 12 : 20
|
||||
right: service.barPosition === "right" ? service.barClearance + 12 : 20
|
||||
}
|
||||
|
||||
implicitWidth: popupColumn.implicitWidth
|
||||
implicitHeight: popupColumn.implicitHeight
|
||||
|
||||
ColumnLayout {
|
||||
id: popupColumn
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
spacing: 8
|
||||
|
||||
Repeater {
|
||||
model: popupModel
|
||||
|
||||
// The delegate is a slot Item that owns lifetime state (timer,
|
||||
// progress, paused). The actual visuals live in NotificationCard,
|
||||
// which the history panel also reuses.
|
||||
delegate: Item {
|
||||
id: cardSlot
|
||||
required property int index
|
||||
required property string app
|
||||
required property string appIcon
|
||||
required property string summary
|
||||
required property string body
|
||||
required property string image
|
||||
required property string glyph
|
||||
required property int urgency
|
||||
required property double timestamp
|
||||
|
||||
// Each card sizes itself based on mode (text vs media); the slot
|
||||
// tracks the card so the column auto-fits to whichever is widest.
|
||||
Layout.preferredWidth: card.implicitWidth
|
||||
Layout.alignment: Qt.AlignRight
|
||||
implicitHeight: card.implicitHeight
|
||||
|
||||
readonly property real lifetime: service.durationFor(cardSlot.urgency)
|
||||
property real progress: 1.0
|
||||
readonly property bool ticking: cardSlot.lifetime > 0 && !card.hovered
|
||||
|
||||
Timer {
|
||||
interval: 50
|
||||
repeat: true
|
||||
running: cardSlot.ticking
|
||||
onTriggered: {
|
||||
if (cardSlot.lifetime <= 0) return
|
||||
cardSlot.progress -= 50.0 / cardSlot.lifetime
|
||||
if (cardSlot.progress <= 0) {
|
||||
cardSlot.progress = 0
|
||||
service.dismissPopup(cardSlot.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCard {
|
||||
id: card
|
||||
anchors.right: parent.right
|
||||
app: cardSlot.app
|
||||
appIcon: cardSlot.appIcon
|
||||
summary: cardSlot.summary
|
||||
body: cardSlot.body
|
||||
image: cardSlot.image
|
||||
urgency: cardSlot.urgency
|
||||
timestamp: cardSlot.timestamp
|
||||
cornerRadius: service.cornerRadius
|
||||
fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : ""
|
||||
glyph: cardSlot.glyph
|
||||
progress: cardSlot.progress
|
||||
showProgress: cardSlot.lifetime > 0
|
||||
|
||||
onCloseRequested: service.dismissPopup(cardSlot.index)
|
||||
onCardClicked: service.invokePopupDefault(cardSlot.index)
|
||||
onImageClicked: service.openPopupImage(cardSlot.index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Notification card. Pure presentational — no service, Notification, or
|
||||
// ListModel references. The popup container drives lifetime; the history
|
||||
// panel drives static rendering. Both use the same component.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import qs.Commons
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string app: ""
|
||||
property string appIcon: ""
|
||||
property string summary: ""
|
||||
property string body: ""
|
||||
property string image: ""
|
||||
// Nerd Font glyph rendered in the icon slot when no real icon is set.
|
||||
// Used by omarchy-notification-send so user-action toasts (`Silenced
|
||||
// notifications` etc.) show their bell/lock/etc. glyph without leaking
|
||||
// into the summary text.
|
||||
property string glyph: ""
|
||||
// NotificationUrgency: Low=0, Normal=1, Critical=2 (upstream).
|
||||
property int urgency: 1
|
||||
property double timestamp: 0
|
||||
property int cornerRadius: 0
|
||||
|
||||
property real progress: 1.0
|
||||
property bool showProgress: false
|
||||
|
||||
// System font from shell.json bar.fontFamily, injected by the container.
|
||||
property string fontFamily: ""
|
||||
|
||||
readonly property bool hovered: hoverTracker.hovered
|
||||
|
||||
signal closeRequested()
|
||||
signal cardClicked()
|
||||
signal imageClicked()
|
||||
|
||||
// Media mode = the notification carries a real screenshot or screen
|
||||
// recording preview. Quickshell normalizes file paths from `-i` and the
|
||||
// `image-path` hint into `image://icon//<absolute path>` (double slash
|
||||
// marks an absolute filesystem path vs a themed icon name like
|
||||
// `image://icon/firefox`).
|
||||
function _imageFilePath(s) {
|
||||
if (!s) return ""
|
||||
if (s.indexOf("image://icon//") === 0) return s.substring("image://icon/".length)
|
||||
if (s.indexOf("file://") === 0) return decodeURIComponent(s.substring(7))
|
||||
return ""
|
||||
}
|
||||
function _isMediaFile(path) {
|
||||
if (!path) return false
|
||||
var lower = path.toLowerCase()
|
||||
return lower.endsWith(".png") || lower.endsWith(".jpg") ||
|
||||
lower.endsWith(".jpeg") || lower.endsWith(".webp") ||
|
||||
lower.endsWith(".gif")
|
||||
}
|
||||
readonly property string mediaImageSource: ""
|
||||
readonly property bool mediaMode: false
|
||||
// Use only what the notification explicitly carries — no themed-icon
|
||||
// theme-lookup fallback because Quickshell's icon image provider returns
|
||||
// a placeholder for missing names (rather than erroring), which means
|
||||
// we'd render Qt's pink "broken image" pattern for any unknown app.
|
||||
// Apps that send their own icon via `image` (image-data hint) or
|
||||
// `appIcon` (-i flag) still get one.
|
||||
readonly property string smallIconSource: image.length > 0 ? image : appIcon
|
||||
readonly property bool hasGlyph: glyph.length > 0
|
||||
readonly property bool hasSmallIcon: !mediaMode && (smallIconSource.length > 0 || hasGlyph)
|
||||
readonly property bool chromiumDerived: {
|
||||
var source = (app + "\n" + appIcon).toLowerCase()
|
||||
return source.indexOf("chrom") >= 0 || source.indexOf("brave") >= 0 ||
|
||||
source.indexOf("vivaldi") >= 0 || source.indexOf("microsoft-edge") >= 0 ||
|
||||
source.indexOf("opera") >= 0
|
||||
}
|
||||
readonly property string sanitizedBody: sanitizeBody(body)
|
||||
|
||||
readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4)
|
||||
readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15)
|
||||
readonly property color hoverColor: Qt.rgba(Color.notifications.text.r, Color.notifications.text.g, Color.notifications.text.b, 0.14)
|
||||
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
|
||||
|
||||
function sanitizeBody(s) {
|
||||
var text = String(s).replace(/<img[^>]*>/gi, "")
|
||||
if (!chromiumDerived) return text
|
||||
|
||||
// Chromium web notifications often prefix the body with the sending
|
||||
// origin, sometimes as a hyperlink. The browser icon already identifies
|
||||
// the source, so drop only that leading URL/domain.
|
||||
return text
|
||||
.replace(/^\s*<a\b[^>]*>\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/[^<\s]*)?\s*<\/a>\s*/i, "")
|
||||
.replace(/^\s*(?:https?:\/\/|www\.)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:\/\S*)?\s+/i, "")
|
||||
}
|
||||
|
||||
implicitWidth: 380
|
||||
// Add 2 * border.width so mainColumn (inset by border.width on top/left/right)
|
||||
// doesn't push content under the bottom edge. The bottom edge is also inset
|
||||
// for symmetry except when the progress bar replaces it.
|
||||
implicitHeight: mainColumn.implicitHeight + border.width * 2
|
||||
radius: cornerRadius
|
||||
color: Color.notifications.background
|
||||
border.color: urgency === 2 ? Color.urgent : Color.notifications.border
|
||||
border.width: 2
|
||||
clip: true
|
||||
|
||||
HoverHandler { id: hoverTracker }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.cardClicked()
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: mainColumn
|
||||
// Inset by the card border so the hero image (and the text row) don't
|
||||
// paint over the card's outer border. Without this the left/right/top
|
||||
// border is invisible under the image.
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.topMargin: root.border.width
|
||||
anchors.leftMargin: root.border.width
|
||||
anchors.rightMargin: root.border.width
|
||||
spacing: 0
|
||||
|
||||
// Hero image strip (media notifications only). PreserveAspectCrop so
|
||||
// the preview looks like a clean banner without dark letterboxing.
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 140
|
||||
visible: root.mediaMode
|
||||
clip: true
|
||||
|
||||
Image {
|
||||
anchors.fill: parent
|
||||
source: root.mediaImageSource
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
sourceSize.width: width > 0 ? width * Screen.devicePixelRatio : 0
|
||||
sourceSize.height: height > 0 ? height * Screen.devicePixelRatio : 0
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
cache: false
|
||||
}
|
||||
|
||||
// Bottom divider matching the card border so the screenshot is
|
||||
// visually framed on every side (card border wraps top/left/right;
|
||||
// this line completes the bottom).
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: root.border.width
|
||||
color: root.urgency === 2 ? Color.urgent : Color.notifications.border
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: root.imageClicked()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Text content. Always rendered — for media notifications this carries
|
||||
// the summary/body ("Screenshot saved" etc) under the hero image.
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 12
|
||||
Layout.rightMargin: 12
|
||||
Layout.topMargin: 10
|
||||
Layout.bottomMargin: 10
|
||||
spacing: 12
|
||||
|
||||
Item {
|
||||
id: smallIconSlot
|
||||
Layout.preferredWidth: 40
|
||||
Layout.preferredHeight: 40
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
// Hide the slot when the icon failed to resolve (themed-icon name
|
||||
// not in the user's icon theme) AND we don't have a glyph fallback
|
||||
// — prevents rendering Qt's pink broken-image placeholder.
|
||||
visible: root.hasSmallIcon && (root.hasGlyph || smallIconImage.status !== Image.Error)
|
||||
|
||||
Image {
|
||||
id: smallIconImage
|
||||
anchors.fill: parent
|
||||
source: root.smallIconSource
|
||||
sourceSize.width: 40 * Screen.devicePixelRatio
|
||||
sourceSize.height: 40 * Screen.devicePixelRatio
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: true
|
||||
smooth: true
|
||||
visible: !root.hasGlyph || smallIconImage.status === Image.Ready
|
||||
}
|
||||
|
||||
// Glyph fallback (Nerd Font character) when no image icon is
|
||||
// available. Used by omarchy-notification-send's `-g` flag.
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
|
||||
text: root.glyph
|
||||
color: Color.notifications.text
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: 18
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.summary.length > 0
|
||||
text: root.summary
|
||||
font.family: "Liberation Sans"
|
||||
color: Color.notifications.text
|
||||
font.pixelSize: 14
|
||||
font.bold: true
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 2
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 2
|
||||
visible: root.sanitizedBody.length > 0
|
||||
text: root.sanitizedBody
|
||||
textFormat: Text.StyledText
|
||||
font.family: "Liberation Sans"
|
||||
color: root.bodyColor
|
||||
font.pixelSize: 14
|
||||
wrapMode: Text.WordWrap
|
||||
elide: Text.ElideRight
|
||||
maximumLineCount: 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar at the bottom edge. Stays visible while the container has
|
||||
// a finite lifetime; freezes (doesn't decrement) when hover pauses the
|
||||
// tick from the container side.
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
height: 3
|
||||
color: Color.notifications.border
|
||||
visible: false
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.top: parent.top
|
||||
anchors.bottom: parent.bottom
|
||||
width: parent.width * Math.max(0, Math.min(1, root.progress))
|
||||
color: root.accentColor
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "omarchy.notifications",
|
||||
"name": "Notifications",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Notification daemon, popups, and history",
|
||||
"kinds": ["service"],
|
||||
"activation": "persistent",
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {
|
||||
"service": "Service.qml"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user