#!/usr/bin/env bash # # Streams every notification sent on this desktop, one JSON object per line: # {"source": "popup" | "silenced", "key", "app", "summary", "body", # "appIcon", "urgency", "replacesId", "timestamp"} # # The source of truth is the session bus: every notification is a Notify # call on org.freedesktop.Notifications, watched with `busctl monitor`. That # sees everything, including what Omarchy's notification service drops on # the floor while Do Not Disturb is on (a bare notify-send, for one), so a # rule can still let it through. "silenced" means Do Not Disturb was on when # it arrived. # # Without busctl, the files Omarchy's notification service writes under # ~/.local/state/omarchy/notifications/ are watched instead: one per popup, # moved into history/ when it leaves the screen; a silenced one is written # straight into history/. set -uo pipefail command -v jq >/dev/null 2>&1 || { echo "jq is not installed" >&2; exit 1; } dnd_state() { local state state=$(omarchy-shell notifications isDnd 2>/dev/null || true) [[ $state == on ]] && echo silenced || echo popup } # ----------------------------------------------------------------- D-Bus if [[ ${AR_SOURCE:-bus} == bus ]] && command -v busctl >/dev/null 2>&1; then exec 3< <(busctl --user monitor --json=short \ --match "type='method_call',interface='org.freedesktop.Notifications',member='Notify'" 2>/dev/null) monitor=$! trap 'kill "$monitor" 2>/dev/null' EXIT INT TERM # Anything on the bus can send a notification, of any size. A message # over 256 KB is dropped unread, and what is passed on is cut to what a # rule could ever need: no field longer than a few thousand characters. while IFS= read -r line <&3; do [[ $line == *'"member":"Notify"'* ]] || continue (( ${#line} > 262144 )) && continue source=$(dnd_state) jq -c --arg source "$source" ' def clip($n): if type == "string" then .[0:$n] else "" end; select(.member == "Notify" and (.payload.data | type) == "array" and (.payload.data | length) >= 5) | .payload.data as $d | (if ($d[6] | type) == "object" then $d[6] else {} end) as $hints | (if ($d[1] | type) == "number" then $d[1] else 0 end) as $replaces | { source: $source, key: (if $replaces != 0 then "\($d[0] | clip(200)):replaces:\($replaces)" else "\(."timestamp-realtime" // 0)-\(.cookie // 0)" end), app: ($d[0] | clip(200)), summary: ($d[3] | clip(2000)), body: ($d[4] | clip(8000)), appIcon: (if ($d[2] | clip(1000)) != "" then ($d[2] | clip(1000)) else ($hints["image-path"].data // "" | clip(1000)) end), urgency: ($hints.urgency.data // 1), replacesId: $replaces, timestamp: ((."timestamp-realtime" // 0) / 1000 | floor) }' <<<"$line" 2>/dev/null done exit 0 fi # ----------------------------------------------------------------- files state_home=${XDG_STATE_HOME:-$HOME/.local/state} popup_dir=${AR_SRC_DIR:-$state_home/omarchy/notifications} history_dir=$popup_dir/history mkdir -p "$popup_dir" "$history_dir" || exit 1 emit() { local source=$1 path=$2 name key name=$(basename "$path") [[ $name == *.json ]] || return 0 key=${name%.json} [[ -s $path ]] || return 0 # The service writes small files; anything bigger is not one of them. (( $(stat -c %s "$path" 2>/dev/null || echo 0) > 262144 )) && return 0 jq -c --arg source "$source" --arg key "$key" ' def clip($n): if type == "string" then .[0:$n] else "" end; { source: $source, key: $key, app: (.app | clip(200)), summary: (.summary | clip(2000)), body: (.body | clip(8000)), appIcon: (.appIcon | clip(1000)), urgency: (if (.urgency | type) == "number" then .urgency else 1 end), timestamp: (.timestamp // 0) }' "$path" 2>/dev/null } if command -v inotifywait >/dev/null 2>&1; then echo "busctl not found; watching the notification files instead" >&2 inotifywait -m -q -e close_write -e moved_to --format '%e|%w|%f' "$popup_dir" "$history_dir" | while IFS='|' read -r events dir file; do dir=${dir%/} if [[ $dir == "$history_dir" ]]; then [[ $events == *MOVED_TO* ]] && continue emit silenced "$dir/$file" else emit popup "$dir/$file" fi done exit 0 fi echo "neither busctl nor inotifywait found; polling the notification files every half second" >&2 declare -A seen for f in "$popup_dir"/*.json "$history_dir"/*.json; do [[ -e $f ]] && seen[$f]=$(stat -c %Y "$f" 2>/dev/null || echo 0) done while sleep 0.5; do for f in "$popup_dir"/*.json; do [[ -e $f ]] || continue m=$(stat -c %Y "$f" 2>/dev/null || echo 0) [[ ${seen[$f]:-} == "$m" ]] && continue seen[$f]=$m emit popup "$f" done for f in "$history_dir"/*.json; do [[ -e $f ]] || continue [[ -n ${seen[$f]:-} ]] && continue seen[$f]=$(stat -c %Y "$f" 2>/dev/null || echo 0) [[ -n ${seen[$popup_dir/$(basename "$f")]:-} ]] && continue emit silenced "$f" done done