401b46c886
Rules watch every notification for words and apps and answer with an effect: nudge, flash, banner, airplane, confetti, blink, sound, focus, or a command. Silenced notifications that match a rule are let through. Settings popup behind a bar bell, rules kept in one JSON file.
121 lines
4.9 KiB
Bash
Executable File
121 lines
4.9 KiB
Bash
Executable File
#!/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
|