FreeDesktop-compliant notification daemon with rules and history
garnotify is a notification daemon for the gardesk ecosystem that implements the FreeDesktop Desktop Notifications Specification 1.2. It features configurable popup positioning, per-urgency timeouts and colors, a powerful rule engine for filtering and modifying notifications, persistent history, Do Not Disturb modes, and smooth animations. Integrates with gar's Lua configuration for unified desktop setup.
garnotify is available as an RPM package for Fedora and RHEL-compatible distributions.
gartk - gardesk UI toolkit for X11 renderingcairo - 2D graphics librarypango - Text rendering with markup supportdbus - D-Bus session bus for FreeDesktop interfaceStart the notification daemon and send a test notification.
Add garnotify to your gar configuration to start automatically.
-- ~/.config/gar/init.lua
gar.exec_once("garnotify daemon") Create a user service for automatic startup.
# ~/.config/systemd/user/garnotify.service
[Unit]
Description=garnotify notification daemon
PartOf=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/garnotify daemon --foreground
Restart=on-failure
[Install]
WantedBy=graphical-session.target Only one notification daemon can claim the D-Bus name. Stop existing daemons first.
garnotify loads configuration from these locations in priority order:
--config path (if provided)~/.config/gar/init.lua (gar.notification table)~/.config/garnotify/config.tomlA minimal TOML configuration to get started:
# ~/.config/garnotify/config.toml
[geometry]
position = "top-right"
width = 350
offset_x = 20
offset_y = 40
[timeouts]
normal = 5000
critical = 0 Configure garnotify within gar's init.lua for unified desktop setup:
-- ~/.config/gar/init.lua
gar.notification = {
position = "top-right",
width = 350,
offset_x = 20,
offset_y = 40,
max_visible = 5,
-- Timeouts
timeouts = {
low = 10000,
normal = 5000,
critical = 0,
},
-- Appearance
font = "Sans 11",
title_font = "Sans Bold 12",
padding = 12,
corner_radius = 8,
-- Colors (Catppuccin-inspired)
background = "#1e1e2e",
foreground = "#cdd6f4",
border = "#45475a",
-- Animation
animation = {
enabled = true,
fade_in = 150,
fade_out = 150,
slide = "down",
},
} # ~/.config/garnotify/config.toml
[general]
monitor = "primary"
follow_mouse = false
[geometry]
position = "top-right"
width = 350
max_height = 150
offset_x = 20
offset_y = 40
gap = 10
max_visible = 5
[timeouts]
default = 5000
low = 10000
normal = 5000
critical = 0
[appearance]
font = "Sans 11"
title_font = "Sans Bold 12"
icon_size = 48
padding = 12
corner_radius = 8
border_width = 2
markup_enabled = true
[appearance.colors]
background = "#1e1e2e"
foreground = "#cdd6f4"
border = "#45475a"
low_background = "#1e1e2e"
low_foreground = "#6c7086"
low_border = "#45475a"
critical_background = "#f38ba8"
critical_foreground = "#1e1e2e"
critical_border = "#f38ba8"
[animation]
enabled = true
fade_in = 150
fade_out = 150
slide = "down"
slide_distance = 20
[history]
max_length = 100
persist = true | Option | Type | Default | Description |
|---|---|---|---|
monitor | string | "primary" | Monitor to show notifications on: "primary", "mouse", or monitor name |
follow_mouse | bool | false | Follow mouse cursor to determine which monitor to use |
| Option | Type | Default | Description |
|---|---|---|---|
position | string | "top-right" | Position anchor: top-right, top-left, top-center, bottom-right, bottom-left, bottom-center |
width | u32 | 350 | Notification popup width in pixels |
max_height | u32 | 150 | Maximum height per notification (0 = unlimited) |
offset_x | i32 | 20 | Horizontal offset from screen edge in pixels |
offset_y | i32 | 40 | Vertical offset from screen edge in pixels |
gap | i32 | 10 | Gap between stacked notifications in pixels |
max_visible | u32 | 5 | Maximum number of visible notifications (0 = unlimited) |
| Option | Type | Default | Description |
|---|---|---|---|
default | i32 | 5000 | Default timeout in milliseconds (-1 = server default) |
low | i32 | 10000 | Timeout for low urgency notifications in milliseconds |
normal | i32 | 5000 | Timeout for normal urgency notifications in milliseconds |
critical | i32 | 0 | Timeout for critical notifications (0 = never expire) |
Timeouts are in milliseconds. A timeout of 0 means the notification never expires automatically. A value of -1 means use the server default (5000ms).
| Option | Type | Default | Description |
|---|---|---|---|
font | string | "Sans 11" | Body font specification in Pango format |
title_font | string | "Sans Bold 12" | Title/summary font specification in Pango format |
icon_size | u32 | 48 | Icon size in pixels |
padding | u32 | 12 | Padding inside notification popup |
corner_radius | u32 | 8 | Corner radius for rounded popup corners |
border_width | u32 | 2 | Border width around popup |
markup_enabled | bool | true | Enable Pango markup parsing in notification body |
| Option | Type | Default | Description |
|---|---|---|---|
background | color | "#1e1e2e" | Default background color |
foreground | color | "#cdd6f4" | Default foreground (text) color |
border | color | "#45475a" | Default border color |
low_background | color | "#1e1e2e" | Low urgency background |
low_foreground | color | "#6c7086" | Low urgency foreground (dimmed) |
low_border | color | "#45475a" | Low urgency border |
critical_background | color | "#f38ba8" | Critical urgency background (pink/red) |
critical_foreground | color | "#1e1e2e" | Critical urgency foreground (dark) |
critical_border | color | "#f38ba8" | Critical urgency border |
Colors can be specified as hex (#RRGGBB or #RRGGBBAA), rgb(), rgba(), or named colors. Default colors follow a Catppuccin-inspired dark theme.
| Option | Type | Default | Description |
|---|---|---|---|
enabled | bool | true | Enable slide and fade animations |
fade_in | u32 | 150 | Fade-in duration in milliseconds |
fade_out | u32 | 150 | Fade-out duration in milliseconds |
slide | string | "down" | Slide direction: up, down, left, right, none |
slide_distance | u32 | 20 | Slide distance in pixels |
| Option | Type | Default | Description |
|---|---|---|---|
max_length | usize | 100 | Maximum notifications to keep in history |
persist | bool | true | Persist history to disk between sessions |
History is stored in ~/.local/share/garnotify/history.json
when persistence is enabled.
garnotify is a FreeDesktop-compliant notification daemon that implements the Desktop Notifications Specification version 1.2. It serves as the standard notification handler for the gardesk desktop environment, receiving notifications from any application that uses the standard D-Bus notification interface.
| Option | Type | Default | Description |
|---|---|---|---|
actions | - | - | Supports notification action buttons |
body | - | - | Supports notification body text |
body-markup | - | - | Supports Pango markup in body (b, i, u, etc.) |
body-hyperlinks | - | - | Supports hyperlinks in body (rendered as underlined) |
icon-static | - | - | Supports static notification icons |
persistence | - | - | Supports notification history/persistence |
garnotify uses a multi-threaded architecture with async I/O. The main daemon thread handles D-Bus communication and IPC, while a dedicated UI thread manages X11 popup windows.
┌─────────────────────────────────────────────────────┐
│ garnotify daemon │
├─────────────────────────────────────────────────────┤
│ ┌──────────────┐ ┌───────────────┐ ┌──────────┐ │
│ │ D-Bus │ │ IPC Server │ │ Rule │ │
│ │ Service │ │ (Unix sock) │ │ Engine │ │
│ └──────┬───────┘ └───────┬───────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────────┐│
│ │ Notification Store ││
│ │ (timeout management, events) ││
│ └────────────────────────────────────────────────┘│
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌───────────┐ ┌───────────────┐ │
│ │ History │ │ Event │ │ UI Thread │ │
│ │ (JSON) │ │ Stream │ │ (X11/Cairo) │ │
│ └───────────┘ └───────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────┘
garnotify registers the org.freedesktop.Notifications
name on the session bus. It implements the standard methods:
GetCapabilities() - Returns supported featuresNotify(...) - Send a notification, returns IDCloseNotification(id) - Close notification by IDGetServerInformation() - Returns name, vendor, version, spec versionAnd signals:
NotificationClosed(id, reason) - Emitted when notification closesActionInvoked(id, action_key) - Emitted when user clicks action buttonThe notification store manages active notifications with automatic timeout scheduling. When a notification is added:
replaces_id > 0 and exists, the existing notification is replacedCreated or Updated event is emittedThe UI thread runs separately from the async runtime to handle X11 event polling without blocking. It uses gartk for window management and Cairo for rendering. The thread runs at approximately 60fps (16ms poll interval) to ensure smooth animations.
Communication happens via channels: the daemon sends PopupCommands
(Show, Update, Close, CloseAll) and receives PopupEvents
(Dismissed, ActionInvoked, Closed).
The main event loop uses tokio's async runtime and handles:
Each notification contains structured data following the FreeDesktop specification.
id - Unique notification ID (assigned by server)app_name - Name of the sending applicationreplaces_id - ID of notification to replace (0 for new)app_icon - Icon name or file pathsummary - Notification titlebody - Notification body text (may contain markup)actions - List of action buttons (key-label pairs)hints - Additional metadata (urgency, category, etc.)expire_timeout - Timeout in ms (-1 for default, 0 for never)Urgency affects timeout duration and visual appearance:
low (0) - Dimmed colors, longer timeout (10s default)normal (1) - Standard colors, normal timeout (5s default)critical (2) - Highlighted colors (pink/red), never expires by defaulturgency - Urgency level (0, 1, 2)category - Notification category (e.g., "email.arrived")desktop-entry - .desktop file name of senderimage-path - Path to image filesound-file - Path to sound filesound-name - Named sound from themesuppress-sound - Suppress sound playbacktransient - Skip history storageresident - Keep after action invokedx, y - Position hints (optional)action-icons - Interpret action keys as icon namesNotifications can include action buttons. Actions are specified as alternating key-label pairs in the D-Bus call:
When clicked, garnotify emits an ActionInvoked
signal with the action key. The sending application receives this and performs
the appropriate action.
garnotify supports Do Not Disturb (DND) mode to temporarily suppress notifications.
Three pause levels control which notifications are shown:
0 (ShowAll) - Show all notifications (DND disabled)1 (CriticalOnly) - Only show critical urgency notifications2 (ShowNone) - Show no notifications at allSuppressed notifications are still received and stored in history - only the popup display is suppressed.
garnotify maintains a circular buffer of dismissed notifications, allowing users to recall and re-display past notifications.
When history.persist = true, history is saved
to disk at ~/.local/share/garnotify/history.json
on daemon shutdown and loaded on startup.
Notifications with the transient hint set to
true are not saved to history. Use this for ephemeral notifications like volume
indicators or progress updates.
garnotify uses gartk for X11 window management and Cairo for 2D rendering.
Popups are created as override-redirect X11 windows with ARGB visuals for transparency support. gartk provides:
garnotify supports smooth animations for popup appearance and disappearance:
Animation states: Hidden → Appearing → Visible → Disappearing → Hidden
Each popup contains:
Click anywhere on the popup to dismiss it, or click action buttons to invoke specific actions.
The rule engine allows filtering and modifying notifications based on pattern matching. Rules are processed in order - the first matching rule's actions are applied.
Each rule has a name, match conditions, and actions to perform when matched.
# TOML rule example
[[rules]]
name = "mute-spotify"
enabled = true
match_app_name = "Spotify"
actions = [
{ type = "suppress" }
]
[[rules]]
name = "highlight-urgent-email"
match_app_name = "thunderbird"
match_summary = "*urgent*"
actions = [
{ type = "set_urgency", urgency = "critical" },
{ type = "prepend_summary", text = "[URGENT] " }
] | Option | Type | Default | Description |
|---|---|---|---|
name | string | - | Rule name for identification (required) |
enabled | bool | true | Whether this rule is active |
match_app_name | string? | - | Pattern to match against app_name field |
match_summary | string? | - | Pattern to match against summary/title field |
match_body | string? | - | Pattern to match against body text |
match_urgency | string? | - | Pattern to match against urgency (low, normal, critical) |
Match patterns support three formats:
* matches any characters, ? matches single character~ for full regex support# Simple: substring match (case-insensitive)
match_app_name = "firefox" # matches "Firefox", "Mozilla Firefox"
# Glob: wildcards
match_summary = "*error*" # matches "An error occurred"
match_app_name = "fire*" # matches "Firefox", "Firewall"
# Regex: prefix with ~
match_body = "~\\d+ new messages" # matches "5 new messages"
match_app_name = "~^(Firefox|Chrome)$" | Option | Type | Default | Description |
|---|---|---|---|
suppress | - | - | Suppress the notification (do not show) |
set_urgency | - | - | Set urgency level (requires "urgency" field: low, normal, critical) |
set_timeout | - | - | Override timeout in milliseconds (requires "timeout" field) |
set_summary | - | - | Replace summary text (requires "summary" field) |
set_body | - | - | Replace body text (requires "body" field) |
append_body | - | - | Append text to body (requires "text" field) |
prepend_summary | - | - | Prepend text to summary (requires "text" field) |
set_sound | - | - | Set notification sound (requires "sound" field) |
set_transient | - | - | Mark as transient - skip history (requires "transient" field) |
exec | - | - | Execute shell command with notification data as env vars (requires "command" field) |
[[rules]]
name = "mute-discord-typing"
match_app_name = "discord"
match_summary = "*typing*"
actions = [{ type = "suppress" }] [[rules]]
name = "long-email-timeout"
match_app_name = "thunderbird"
actions = [{ type = "set_timeout", timeout = 15000 }] [[rules]]
name = "tag-slack"
match_app_name = "Slack"
actions = [
{ type = "prepend_summary", text = "[Slack] " },
{ type = "set_urgency", urgency = "normal" }
] [[rules]]
name = "log-critical"
match_urgency = "critical"
actions = [
{ type = "exec", command = "logger -t garnotify 'Critical: $NOTIFICATION_SUMMARY'" }
]
The exec action provides environment variables:
NOTIFICATION_ID,
NOTIFICATION_APP,
NOTIFICATION_SUMMARY,
NOTIFICATION_BODY
Rules can be enabled or disabled at runtime without restarting the daemon.
garnotify provides an IPC interface via Unix domain socket for external control
and event subscriptions. The socket is located at
$XDG_RUNTIME_DIR/garnotify.sock.
| Option | Type | Default | Description |
|---|---|---|---|
--foreground, -f | - | false | Run daemon in foreground (useful for debugging) |
--config, -c | - | ~/.config/garnotify/config.toml | Path to configuration file |
| Option | Type | Default | Description |
|---|---|---|---|
close [ID] | - | - | Close notification by ID (omit ID to close most recent) |
close-all | - | - | Close all visible notifications |
history-pop | - | - | Pop and redisplay most recent notification from history |
history-clear | - | - | Clear notification history |
set-paused <true|false> [-l LEVEL] | - | - | Set Do Not Disturb mode (level: 0=show all, 1=critical only, 2=show none) |
is-paused | - | - | Check if DND is enabled (returns JSON with paused and level) |
count | - | - | Get count of active notifications |
list [--json] | - | - | List active notifications |
rule-enable <name> | - | - | Enable a rule by name |
rule-disable <name> | - | - | Disable a rule by name |
reload | - | - | Reload configuration file |
status | - | - | Get daemon status (running, counts, DND state) |
quit | - | - | Stop the daemon |
IPC uses newline-delimited JSON. Each command is a JSON object with a
"command" field.
| Option | Type | Default | Description |
|---|---|---|---|
close | - | - | Close notification by ID or most recent if id is null |
close_all | - | - | Close all visible notifications |
history_pop | - | - | Pop and redisplay from history |
history_clear | - | - | Clear notification history |
set_paused | - | - | Set DND state (paused: bool, level: u8) |
is_paused | - | - | Query DND state |
count | - | - | Get active notification count |
list | - | - | List active notifications |
rule_enable | - | - | Enable rule by name |
rule_disable | - | - | Disable rule by name |
reload | - | - | Reload configuration |
status | - | - | Get daemon status |
subscribe | - | - | Subscribe to notification events (keeps connection open) |
quit | - | - | Stop daemon |
// Close notification by ID
{"command": "close", "id": 42}
// Close most recent
{"command": "close"}
// Close all
{"command": "close_all"}
// Set DND (critical only)
{"command": "set_paused", "paused": true, "level": 1}
// Query status
{"command": "status"}
// Enable/disable rule
{"command": "rule_enable", "name": "mute-spotify"}
{"command": "rule_disable", "name": "mute-spotify"} All commands return a JSON response:
// Success (no data)
{"success": true, "message": "Closed notification 42"}
// Success with data
{"success": true, "data": {"count": 3}}
// Error
{"success": false, "message": "Notification not found"}
// Status response
{
"success": true,
"data": {
"running": true,
"active_count": 2,
"history_count": 15,
"paused": false,
"pause_level": 0
}
}
The subscribe command keeps the connection
open and streams events as they occur. Useful for status bar integration.
| Option | Type | Default | Description |
|---|---|---|---|
notification_new | - | - | New notification created (id, app_name, summary, body, urgency) |
notification_closed | - | - | Notification closed (id, reason) |
notification_updated | - | - | Notification updated/replaced (id, summary, body) |
paused_changed | - | - | DND state changed (paused: bool) |
count_changed | - | - | Active count changed (count: number) |
// New notification
{"event": "notification_new", "id": 42, "app_name": "Firefox",
"summary": "Download Complete", "body": "file.zip", "urgency": "Normal"}
// Notification closed
{"event": "notification_closed", "id": 42, "reason": "Dismissed"}
// DND changed
{"event": "paused_changed", "paused": true}
// Count changed
{"event": "count_changed", "count": 3} Example script for displaying notification count in a status bar:
#!/bin/bash
# garnotify-count.sh - for polybar, waybar, etc.
count=$(garnotifyctl count 2>/dev/null | jq -r '.count // 0')
paused=$(garnotifyctl is-paused 2>/dev/null | jq -r '.paused // false')
if [ "$paused" = "true" ]; then
echo " DND"
elif [ "$count" -gt 0 ]; then
echo " $count"
else
echo ""
fi #!/bin/bash
# Stream events and update status
echo '{"command":"subscribe"}' | nc -U "$XDG_RUNTIME_DIR/garnotify.sock" | while read -r line; do
event=$(echo "$line" | jq -r '.event // empty')
case "$event" in
count_changed)
count=$(echo "$line" | jq -r '.count')
echo "Notifications: $count"
;;
paused_changed)
paused=$(echo "$line" | jq -r '.paused')
[ "$paused" = "true" ] && echo "DND enabled" || echo "DND disabled"
;;
esac
done Only one notification daemon can claim the D-Bus name. Stop existing daemons:
A previous instance may have crashed. Clean up stale files:
Check if daemon is running and DND is disabled:
Check geometry configuration:
[geometry]
position = "top-right" # or top-left, top-center, bottom-*
offset_x = 20
offset_y = 40
monitor = "primary" # or specific monitor name Verify rule syntax and check if enabled:
Look for "Rule 'name' matched notification" or "Rule 'name' suppressed notification" messages.
garnotify looks for icons in standard locations. Ensure icon themes are installed:
Ensure persistence is enabled and the data directory exists:
[history]
persist = true
max_length = 100 --foregroundjournalctl --user -u garnotify~/.local/share/garnotify/history.jsonIf you encounter issues not covered here: