Attention Required: notification rules with effects for Omarchy
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.
This commit is contained in:
Executable
BIN
Binary file not shown.
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/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
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# attention-required: rules that turn a notification into an effect.
|
||||
#
|
||||
# attention-required list the rules, as a table
|
||||
# attention-required add NAME [options] add a rule
|
||||
# --words a,b,c words to look for (any of them; /regex/ allowed)
|
||||
# --apps x,y only notifications from these apps (substring of the app name; default: any app)
|
||||
# --effects e1,e2 nudge | flash | sound | command | your own (default: nudge)
|
||||
# --all every word must be present, not any
|
||||
# --cooldown SECONDS at most one firing per rule in this many seconds (default 3)
|
||||
# attention-required remove NAME
|
||||
# attention-required enable NAME | disable NAME
|
||||
# attention-required test EFFECT run one effect now, e.g. `test nudge`
|
||||
# or `test '{"type":"flash","color":"urgent"}'`
|
||||
# attention-required simulate APP SUMMARY [BODY] run a made-up notification past the rules
|
||||
# attention-required settings open the settings popup (also: click the bell in the bar)
|
||||
# attention-required set EFFECT OPTION VALUE change a default, e.g. `set nudge intensity 6`
|
||||
# attention-required toggle | on | off pause or resume every effect (also: right-click the bell)
|
||||
# attention-required status what the service has seen and fired
|
||||
# attention-required reload re-read rules.json (it is also watched)
|
||||
# attention-required edit open rules.json in $EDITOR
|
||||
# attention-required path print where rules.json is
|
||||
# attention-required export [FILE] copy the rules to FILE (or print them) to keep or share
|
||||
# attention-required import FILE replace the rules with FILE's (the old file is kept as a .bak)
|
||||
set -euo pipefail
|
||||
|
||||
config_dir=${XDG_CONFIG_HOME:-$HOME/.config}/attention-required
|
||||
rules=$config_dir/rules.json
|
||||
here=$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)
|
||||
|
||||
die() { echo "attention-required: $*" >&2; exit 1; }
|
||||
need() { command -v "$1" >/dev/null 2>&1 || die "$1 is not installed"; }
|
||||
need jq
|
||||
|
||||
ipc() {
|
||||
if command -v omarchy-shell >/dev/null 2>&1; then
|
||||
omarchy-shell attention-required "$@"
|
||||
else
|
||||
quickshell ipc -p "${OMARCHY_PATH:-/usr/share/omarchy}/shell" call attention-required "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ipc_panel() {
|
||||
if command -v omarchy-shell >/dev/null 2>&1; then
|
||||
omarchy-shell alanfortlink.attention-required "$@"
|
||||
else
|
||||
quickshell ipc -p "${OMARCHY_PATH:-/usr/share/omarchy}/shell" call alanfortlink.attention-required "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_rules() {
|
||||
mkdir -p "$config_dir/effects"
|
||||
[[ -e $rules ]] || cp "$here/rules.example.json" "$rules"
|
||||
}
|
||||
|
||||
# Written in place rather than moved into place, so the shell's file watch
|
||||
# sees the change without a restart.
|
||||
write_rules() {
|
||||
local tmp
|
||||
tmp=$(mktemp "$config_dir/.rules.XXXXXX")
|
||||
cat > "$tmp"
|
||||
jq . "$tmp" >/dev/null || { rm -f "$tmp"; die "refusing to write invalid JSON"; }
|
||||
cat "$tmp" > "$rules"
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
split_list() { jq -cn --arg s "$1" '$s | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))'; }
|
||||
|
||||
have_rule() { jq -e --arg n "$1" '.rules // [] | any(.name == $n)' "$rules" >/dev/null; }
|
||||
|
||||
cmd_list() {
|
||||
ensure_rules
|
||||
jq -r '
|
||||
(.rules // []) | if length == 0 then "no rules" else
|
||||
(["NAME", "ON", "WORDS", "APPS", "EFFECTS", "COOLDOWN"] | @tsv),
|
||||
(.[] | [
|
||||
.name,
|
||||
(if .enabled == false then "off" else "on" end),
|
||||
((.words // []) | join(", ")),
|
||||
((.apps // []) | if length == 0 then "any" else join(", ") end),
|
||||
((.effects // ["nudge"]) | map(if type == "string" then . else .type end) | join(", ")),
|
||||
(.cooldown // 3 | tostring)
|
||||
] | @tsv) end' "$rules" | column -t -s $'\t'
|
||||
}
|
||||
|
||||
cmd_add() {
|
||||
local name=${1:-}; shift || true
|
||||
[[ -n $name ]] || die "usage: add NAME --words a,b [--apps x,y] [--effects nudge,flash] [--all] [--cooldown N]"
|
||||
local words='[]' apps='[]' effects='["nudge"]' match=any cooldown=3
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--words) words=$(split_list "${2:-}"); shift 2 ;;
|
||||
--apps) apps=$(split_list "${2:-}"); shift 2 ;;
|
||||
--effects) effects=$(split_list "${2:-}"); shift 2 ;;
|
||||
--all) match=all; shift ;;
|
||||
--cooldown) cooldown=${2:-3}; shift 2 ;;
|
||||
*) die "unknown option $1" ;;
|
||||
esac
|
||||
done
|
||||
ensure_rules
|
||||
have_rule "$name" && die "a rule named '$name' exists; remove it first"
|
||||
jq --arg name "$name" --argjson words "$words" --argjson apps "$apps" --argjson effects "$effects" \
|
||||
--arg match "$match" --argjson cooldown "$cooldown" '
|
||||
.rules = ((.rules // []) + [{name: $name, words: $words, apps: $apps, effects: $effects, match: $match, cooldown: $cooldown}])
|
||||
| .version = (.version // 1)' "$rules" | write_rules
|
||||
echo "added '$name'"
|
||||
ipc reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cmd_remove() {
|
||||
local name=${1:-}
|
||||
[[ -n $name ]] || die "usage: remove NAME"
|
||||
ensure_rules
|
||||
have_rule "$name" || die "no rule named '$name'"
|
||||
jq --arg n "$name" '.rules = ((.rules // []) | map(select(.name != $n)))' "$rules" | write_rules
|
||||
echo "removed '$name'"
|
||||
ipc reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
cmd_toggle() {
|
||||
local enabled=$1 name=${2:-}
|
||||
[[ -n $name ]] || die "usage: enable|disable NAME"
|
||||
ensure_rules
|
||||
have_rule "$name" || die "no rule named '$name'"
|
||||
jq --arg n "$name" --argjson e "$enabled" '.rules = ((.rules // []) | map(if .name == $n then .enabled = $e else . end))' "$rules" | write_rules
|
||||
echo "'$name' is now $([[ $enabled == true ]] && echo on || echo off)"
|
||||
ipc reload >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
case ${1:-help} in
|
||||
list|ls) cmd_list ;;
|
||||
add) shift; cmd_add "$@" ;;
|
||||
remove|rm) shift; cmd_remove "$@" ;;
|
||||
enable) shift; cmd_toggle true "$@" ;;
|
||||
disable) shift; cmd_toggle false "$@" ;;
|
||||
test) [[ -n ${2:-} ]] || die "usage: test EFFECT"; ipc test "$2" ;;
|
||||
simulate|sim) [[ -n ${3:-} ]] || die "usage: simulate APP SUMMARY [BODY]"; ipc simulate "$2" "$3" "${4:-}" ;;
|
||||
settings) ipc_panel toggle ;;
|
||||
set) [[ -n ${4:-} ]] || die "usage: set <effect> <option> <value> e.g. set nudge intensity 6"; ipc setDefault "$2" "$3" "$4" ;;
|
||||
toggle) ipc toggle ;;
|
||||
on|resume) ipc setEnabled true ;;
|
||||
off|pause) ipc setEnabled false ;;
|
||||
status) ipc state | jq . ;;
|
||||
rules) ipc rules | jq . ;;
|
||||
reload) ipc reload ;;
|
||||
edit) ensure_rules; "${EDITOR:-nvim}" "$rules" ;;
|
||||
path) echo "$rules" ;;
|
||||
export)
|
||||
ensure_rules
|
||||
if [[ -n ${2:-} ]]; then cp "$rules" "$2" && echo "saved to $2"; else cat "$rules"; fi ;;
|
||||
import)
|
||||
[[ -n ${2:-} && -r $2 ]] || die "usage: import FILE"
|
||||
jq -e '(.rules | type) == "array"' "$2" >/dev/null 2>&1 || die "$2 is not a rules file (needs a \"rules\" list)"
|
||||
ensure_rules
|
||||
backup="$rules.bak.$(date +%s)"
|
||||
cp "$rules" "$backup"
|
||||
jq '.version = (.version // 1)' "$2" | write_rules
|
||||
echo "imported $2 ($(jq '.rules | length' "$rules") rules); the previous file is $backup"
|
||||
ipc reload >/dev/null 2>&1 || true ;;
|
||||
help|-h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' ;;
|
||||
*) die "unknown command '$1' (try: attention-required help)" ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user