A lightweight notification daemon and control center for Wayland, built from
scratch in Rust. Implements the org.freedesktop.Notifications D-Bus spec,
so it's a drop-in replacement for dunst/mako. Optimized for Hyprland, portable
to any wlr-layer-shell compositor.
Zero-bloat by design: no UI frameworks, no tokio/calloop — just smol-family async, tiny-skia for rendering, and cosmic-text for font shaping (emoji/CJK/RTL all work out of the box).
- Full
org.freedesktop.NotificationsD-Bus server — urgency levels, actions, body markup, images/icons,replaces_id, transient/resident notifications. - Popup toasts rendered directly onto Wayland layer-shell surfaces (no
compositor-side blur — set
layerrule = blur, notifin Hyprland instead). - Fractional scaling done correctly (crisp at 1.25x, 1.5x, etc.).
- Notification history ring, kept from the first notification.
- Do Not Disturb mode — normal notifications are silently filed to history; critical notifications always break through.
- A notification-center panel (second layer surface), anchored to its own corner with independent styling, showing currently-active notifications live followed by history, with per-entry dismiss and "clear all".
notifctl, a small CLI to control the daemon over a local socket: dismiss/close, toggle DND, toggle the center panel, query history/status.- Hot-reloading config file — edit
config.toml, save, and the running daemon picks it up immediately.
Single process, single-threaded async (smol's LocalExecutor + async-io
reactor). Everything is a message over async-channel — no shared mutable
state, no Mutex. A central core state machine owns all notification
state and is the only source of truth; the D-Bus and Wayland layers are pure
translators in and pure projections out.
┌────────────┐ DbusCmd ┌──────────────┐
D-Bus (zbus) ──▶│ notif-dbus │─────────────▶│ │
│ │◀─────────────│ notif-core │
└────────────┘ DbusSignal │ (state, IDs, │
inotify ───────▶ ConfigEvent ──────────────▶│ expiry, │
┌────────────┐ UiEvent │ history, │
Wayland ───────▶│ notif-wl │─────────────▶│ DND) │
(layer-shell, │ + render │◀─────────────│ │
seat, shm) └────────────┘ UiCommand └──────────────┘
▲
notifctl ── IPC socket ────────┘
| Crate | Responsibility |
|---|---|
notif-types |
Shared message/data vocabulary. No I/O, no policy. |
notif-config |
Config loading, validation, inotify hot-reload. |
notif-dbus |
The org.freedesktop.Notifications D-Bus server (zbus). All hint parsing lives here. |
notif-core |
The state machine — notification lifecycle, expiry timers, history, DND, IDs. Synchronous, channel-free, 100% unit-testable. |
notif-render |
Renderer trait + a tiny-skia/cosmic-text implementation (toasts + center panel). |
notif-wl |
Wayland: layer-shell surfaces, seat/pointer input, shm buffers (via smithay-client-toolkit). |
notif-ipc |
The notifctl control socket protocol + server. |
notifd |
The daemon binary — wires everything together. |
notifctl |
CLI client for the control socket. |
yay -S notif-gitOr manually with the PKGBUILD in this repo:
git clone https://github.com/adamrpostjr/notif.git
cd notif
makepkg -siThis installs notifd, notifctl, and a systemd user unit
(notifd.service).
Requires a recent stable Rust toolchain.
cargo build --release --workspace
# binaries at target/release/notifd and target/release/notifctlClaim the notification bus name directly:
notifdOr via systemd (recommended — auto-restarts, starts with your graphical session):
systemctl --user enable --now notifd.servicenotifd will exit if another notification daemon (dunst, mako, ...) already
owns org.freedesktop.Notifications on the session bus — disable/mask it
first.
Add to your Hyprland config so blur/rounding are handled compositor-side and the panel doesn't grab focus:
layerrule = blur, notif
layerrule = blur, notif-center
layerrule = ignorezero, notif
Usage: notifctl <subcommand> [options]
Subcommands:
dismiss-all Dismiss all active notifications
close <id> Close notification by ID
history [--json] Show notification history
clear-history Clear notification history
dnd Toggle do-not-disturb mode
center Toggle notification center panel
status [--json] Show daemon status
Bind whichever of these you want to a key, e.g. in Hyprland:
bind = $mainMod, N, exec, notifctl center
bind = $mainMod SHIFT, N, exec, notifctl dnd
notifd reads $XDG_CONFIG_HOME/notif/config.toml (usually
~/.config/notif/config.toml) on startup, and hot-reloads it on save —
no restart needed. A missing file just means defaults. Point it elsewhere
with notifd --config <path>.
Every field below is optional; unset fields fall back to the default shown.
# Corner to anchor the notification stack.
# Options: top_left, top_right, bottom_left, bottom_right
anchor = "top_right"
# Margins from the screen edge, and gap between stacked notifications (px).
margin_x = 12
margin_y = 12
gap = 8
# Toast size bounds (px), and how many can be visible at once (others queue).
max_width = 400
max_height = 200
max_visible = 5
# Font.
font_family = "sans-serif"
font_size = 13.0
# Icon size (px).
icon_size = 48
# Wayland output name to render on. Omit/null = compositor-chosen (usually
# the focused output).
# output = "DP-1"
# How many notifications the history ring keeps (oldest dropped past this).
history_limit = 100
# Parse the small <b>/<i>/<u>/<a> markup subset in notification bodies.
body_markup = true
# Notification-center panel width (logical px, 1-8192).
# Deprecated: use [center].width instead.
center_width = 400
# Notification-center panel overrides. Every field is optional and falls
# back to the corresponding top-level value above (colors/border/radius
# fall back to [normal]) when unset. The panel shows currently-active
# notifications live, followed by history, and re-anchors to its own
# corner independent of the toast stack's anchor.
[center]
# anchor = "top_right"
# margin_x = 12
# margin_y = 12
# width = 400
# max_entries = 100
# font_family = "sans-serif"
# font_size = 13.0
# background = "#1e1e2e"
# foreground = "#cdd6f4"
# border_color = "#89b4fa"
# border_width = 1
# corner_radius = 8
# Per-urgency appearance. Sections: [low], [normal], [critical].
[low]
background = "#1e1e2e"
foreground = "#cdd6f4"
border_color = "#313244"
border_width = 1
corner_radius = 8
default_timeout_ms = 5000 # 0 = never expire
ignore_timeout = false # if true, always use default_timeout_ms
[normal]
background = "#1e1e2e"
foreground = "#cdd6f4"
border_color = "#89b4fa"
border_width = 1
corner_radius = 8
default_timeout_ms = 8000
ignore_timeout = false
[critical]
background = "#1e1e2e"
foreground = "#f38ba8"
border_color = "#f38ba8"
border_width = 2
corner_radius = 8
default_timeout_ms = 0 # never auto-expire critical notifications
ignore_timeout = trueA fully-commented copy of this file (kept in sync with the actual defaults by
a test) lives at crates/notif-config/examples/config.toml.
See PLAN.md for the full architecture contract — module responsibilities, message types, crate choices, and the invariants a change must not break. CLAUDE.md has the condensed version plus the command cheat-sheet:
cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --checkMIT — see LICENSE.