This file explains how the project works today and should be updated as implementation changes.
notify-send and most Linux apps send desktop notifications over D-Bus to:
- bus name:
org.freedesktop.Notifications - object path:
/org/freedesktop/Notifications - interface:
org.freedesktop.Notifications
Only one process can own that bus name at a time.
- If
mako/dunstalready owns it,wisp-debug/wispdcannot start as server. - The notification daemon is the server (name owner).
- Apps are clients (they call methods like
Notify). wispd-monitoris passive and does not ownorg.freedesktop.Notifications.
crates/
wisp-types # shared Rust types/events
wisp-source # D-Bus server + notification store + event stream
wisp-monitor # shared passive D-Bus monitoring/parsing helpers
bins/
wispd # iced + layer-shell frontend with queue policy + popup rendering
wisp-debug # debug daemon entrypoint that logs incoming notifications
wispd-monitor # passive D-Bus monitor for notifications traffic
wispd-forward # host->VM notification forwarder via SSH
wisp-debugorwispdstarts the daemon process (manually, systemd autostart, or D-Bus activation viaorg.freedesktop.Notifications.service).wispdcallsWispSource::start_dbus(config).wisp-sourceconnects to session bus, requests nameorg.freedesktop.Notifications, and serves interface at/org/freedesktop/Notifications.- Client apps call
Notify. wisp-sourceconverts D-Bus args intowisp_types::Notification.- Notification is inserted/replaced in in-memory store.
wisp-sourceschedules timeout expiry (if applicable).wisp-sourceemitsNotificationEventthroughtokio::mpsc.wispdrunswisp-sourceon a dedicated Tokio runtime thread and forwards events to the UI via a std channel.wispdapplies queue policy (max visible, newest on top, replacement in-place).wispdopens one layer-shell window per visible notification and reflows their margins for stacking.- For timed notifications,
wispdrenders a progress edge bar (top/bottom) using elapsed time vs effective timeout.
Implemented now:
- Owns notification state (
HashMap<u32, StoredNotification>) with generation counters - Allocates IDs
- Replacement semantics:
replaces_id == 0: new ID- existing
replaces_id: replace in place, keep same ID, increment generation - missing
replaces_id: create new ID
- Timeout/expiry scheduler
expire_timeout > 0: uses requested timeoutexpire_timeout < 0: usesdefault_timeout_msexpire_timeout == 0: no automatic expiry
- Exposes snapshot API (
snapshot()) - Exposes action API (
invoke_action(id, action_key)) - D-Bus methods:
NotifyCloseNotificationGetCapabilitiesGetServerInformation
- Declares D-Bus signals:
NotificationClosedActionInvoked
- Parses core hints (
urgency,category,desktop-entry,transient) and preserves unknown hints as debug strings - Emits
NotificationClosedsignal for close paths handled by source (CloseNotification, timeout expiry, action dismiss) - Emits
ActionInvokedsignal when an action is invoked
Not implemented yet:
- click-to-dismiss behavior in
wispdUI - richer hint coverage (images/sound/etc beyond current parsed subset)
- polished visual styling/layout behavior expected from mature daemons
Known bug (tracked):
- Multi-display focused-output behavior is still unreliable in some compositor setups.
- Symptom: notifications can appear on a non-focused monitor even with
ui.output = "focused". - Current behavior is intentionally mako-like: when no popup stack exists, the first popup is compositor-chosen (or uses
focused_output_commandif configured); while a stack is visible, later popups stay on that same output. wispdclears sticky stack-output state when the popup stack becomes empty or windows are compositor-closed (for example after an output disappears), which avoids getting stuck on a disconnected monitor.wispdalso subscribes to Wayland output registry changes and rebuilds visible popup windows only when the removed output could invalidate the current stack binding.- This improves mako-style stack stickiness/recovery, but does not fully solve compositor-agnostic focused-output tracking.
- Symptom: notifications can appear on a non-focused monitor even with
Main shared types in wisp-types:
Notification(includesapp_icon,actions,hints)NotificationHints(category,desktop_entry,transient,extra)NotificationActionUrgencyCloseReasonNotificationEvent(Received,Replaced,Closed,ActionInvoked)
Event transport is currently tokio::mpsc (single consumer stream per source instance).
wispd currently applies queue behavior:
- max visible: 5
- newest notifications at top
- replacement updates existing item in-place (keeps slot)
- close removes item
Config file is loaded from:
$XDG_CONFIG_HOME/wispd/config.toml- fallback:
~/.config/wispd/config.toml
source config currently supports:
capabilitieslist (reported byGetCapabilities)default_timeout_ms(used when incoming timeout is negative)- if unset, negative incoming timeouts are treated as persistent
ui config currently supports:
formatstring with placeholders ({id},{app_name},{summary},{body},{urgency})max_visiblewidthheight(minimum popup height; windows grow based on formatted content)gappaddingfont_sizefont_family(alias:font)anchoroutput(focused,last-outputsticky,none/default, or exact output name likeDP-1)focused_output_command(optional shell command override; first stdout line used as focused output name whenoutput = "focused"; if unset,focuseduses compositor-picked output for first popup and stickylast-outputwhile stack is visible)margin(top,right,bottom,left)- urgency colors (
low,normal,critical) plus basebackground,text, andtimeout_progress - timeout progress indicator controls:
show_timeout_progresstimeout_progress_heighttimeout_progress_position(top/bottom)
- click action controls:
left_click_action(dismiss/invoke-default-action)right_click_action(dismiss/invoke-default-action)
Runtime reload:
wispdlistens forSIGHUP.- On
SIGHUP, it reloadsconfig.tomland applies updated UI settings in place. - Reload is applied only when config loading passes TOML parsing and basic sanity validation (for example valid anchors, timeout-progress position, colors, and non-zero popup size).
- If reload validation fails,
wispdkeeps the current configuration and emits a local critical notification describing the reload failure. - Source runtime settings (
capabilities,default_timeout_ms) are updated without restarting D-Bus ownership.
Implemented tests in wisp-source:
- replacement keeps same ID
- missing
replaces_idallocates a fresh ID - replacement resets timeout generation (old expiry does not win)
- timeout expiry emits
Closed(Expired)event - negative timeout without configured default remains persistent
- zero timeout remains persistent (no expiry scheduled)
- action invoke emits
ActionInvoked+Closed(Dismissed) - unknown action returns false and emits no extra events
- invoking actions after replacement targets the current notification generation/actions
- duplicate action keys and empty/odd action lists are handled safely
- snapshot reflects replacement and close state
- closing unknown IDs is a safe no-op
- hint parsing unit coverage for known fields (
urgency,category,desktop-entry,transient) - D-Bus integration tests (skip when session bus unavailable):
Notifyemits received event (including parsed icon/hints)- rapid
Notifybursts preserve ordering and allocated IDs - replace storms over D-Bus converge to one final live notification state
CloseNotificationduring active timeout races results in one final close event/signalCloseNotificationemits closed event withClosedByCallNotificationClosedsignal is emitted with expected reason codeActionInvokedsignal is emitted for action invocationGetCapabilitiesreturns configured capabilitiesGetServerInformationreturns configured values- runtime config updates are reflected in
GetCapabilitieswhile server info remains stable
Implemented tests in wispd UI logic:
- newest notification goes to front
- replacement keeps its visible slot
- replacing a hidden-but-still-visible notification does not corrupt visible ordering
- closing/removal compacts visible UI state correctly
- stack output policy resets when the last notification/window goes away
- later notifications retain the current stack output binding while the stack is visible
- output removal rebuilds visible windows only when the active stack binding is affected
- config application updates UI settings and source runtime settings
- applying config while notifications are visible preserves sane popup ordering
- applying config does not strand windows on stale output bindings
- placeholder formatting, icon-path helpers, timeout normalization, and click action routing have unit coverage
cargo run -p wisp-debugIn another terminal:
notify-send "hello" "from notify-send"wisp-debug also accepts stdin commands:
listclose <id>action <id> <action-key>helpquit
If startup fails with "name already taken on the bus", stop the currently running notification daemon first.
wispd-forward uses BecomeMonitor to observe host Notify method calls and replays them in the VM via SSH (notify-send on guest). This allows host mako to stay active while testing wispd in a VM.
wispd requires a Wayland session and Wayland runtime libraries. If you see NoWaylandLib, run inside nix develop, verify WAYLAND_DISPLAY is set, and ensure Wayland runtime libs are available in the runtime environment.