#!/bin/bash # omarchy:summary=Send an Omarchy desktop notification # omarchy:args=[--app-name ] [-g ] [-u ] [-i ] [-t ] [-r ] [-p] [--image ] [description] [--exec [args...]] # omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g 󰢌 set -euo pipefail headline="" description="" glyph= urgency="low" app_name="omarchy-action" app_icon="" image= expire_timeout=-1 replaces_id=0 print_id=0 exec_args=() exec_present=0 parsed_option_args=0 usage() { echo "Usage: omarchy-notification-send [--app-name ] [-g ] [-u ] [-i ] [-t ] [-r ] [-p] [--image ] [description] [--exec [args...]]" >&2 } # Recognize a known option, in both `--flag value` and `--flag=value` forms. # Returns 1 for anything unrecognized so the caller can decide (headline, or a # hard error in option position). parse_omarchy_option() { local opt val nargs if [[ $1 == --?*=* ]]; then opt=${1%%=*} val=${1#*=} nargs=1 else opt=$1 val=${2-} nargs=2 fi # -p/--print-id is a flag; it takes no value. if [[ $opt == -p || $opt == --print-id ]]; then print_id=1 parsed_option_args=1 return 0 fi case $opt in -g | --glyph | -u | --urgency | --app-name | -i | --icon | --image | -r | --replace-id | -t | --expire-time) ;; *) return 1 ;; esac if ((nargs == 2)) && (($# < 2)); then echo "Missing value for $opt" >&2 exit 1 fi case $opt in -g | --glyph) glyph=$val ;; -u | --urgency) urgency=$val ;; --app-name) app_name=$val ;; -i | --icon) app_icon=$val ;; --image) image=$val ;; -r | --replace-id) [[ $val =~ ^[0-9]+$ ]] || { echo "Invalid $opt value (numeric id expected): $val" >&2 exit 1 } replaces_id=$val ;; -t | --expire-time) [[ $val =~ ^-?[0-9]+$ ]] || { echo "Invalid $opt value (milliseconds expected): $val" >&2 exit 1 } expire_timeout=$val ;; esac parsed_option_args=$nargs return 0 } while (($# > 0)); do if parse_omarchy_option "$@"; then shift "$parsed_option_args" else break fi done if (($# < 1)); then usage exit 1 fi headline=$1 shift # The description is the next positional, taken as text even when it begins with # a dash — a body like "-50% off" or a negative number is content, not options. # Only a recognized option flag or --exec in that slot is not the description. known_flag() { case $1 in -g | --glyph | -u | --urgency | --app-name | -i | --icon | -t | --expire-time | --image | -r | --replace-id | -p | --print-id | --exec) return 0 ;; --glyph=* | --urgency=* | --app-name=* | --icon=* | --expire-time=* | --image=* | --replace-id=*) return 0 ;; esac return 1 } if (($# > 0)) && ! known_flag "$1"; then description=$1 shift fi while (($# > 0)); do if [[ $1 == "--exec" ]]; then # --exec consumes the rest of the line as the click command's argv. The # caller's shell already tokenized those words into discrete arguments, and # the shell runs them as-is (never re-parsed), so untrusted data in an # argument is only ever one argument and can never become a command. # Detected only here, after the headline/description positionals are # captured, so an untrusted headline that is literally "--exec" is taken as # text and can't be mistaken for the delimiter. --exec therefore comes last. shift exec_args=("$@") exec_present=1 break elif parse_omarchy_option "$@"; then shift "$parsed_option_args" else echo "Unknown option: $1" >&2 usage exit 1 fi done case $urgency in low) urgency_byte=0 ;; normal) urgency_byte=1 ;; critical) urgency_byte=2 ;; *) echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2 exit 1 ;; esac # a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte; # the rest are strings. The click command rides here as omarchy-exec-argv, built # only from --exec below. hints=(urgency y "$urgency_byte") if [[ -n $glyph ]]; then hints+=(omarchy-glyph s "$glyph") fi if [[ -n $image ]]; then hints+=(image-path s "$image") fi if ((exec_present)); then if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then echo "--exec needs a command: --exec [args...]" >&2 exit 1 fi # A single word with a space is almost always a whole command passed as one # quoted string — which would run a program literally named that. Splitting it # ourselves is exactly the injection we avoid, so reject it and point at the # unquoted form instead. if ((${#exec_args[@]} == 1)) && [[ ${exec_args[0]} == *[[:space:]]* ]]; then echo "--exec takes the command as separate words, not one quoted string." >&2 echo "Write: --exec ${exec_args[0]}" >&2 exit 1 fi # NUL-delimit into jq so every byte survives as data: jq's own --args would eat # a bare "--", and a newline in an arg must not split the vector. exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]') hints+=(omarchy-exec-argv s "$exec_argv_json") fi hint_count=$((${#hints[@]} / 3)) # Call org.freedesktop.Notifications.Notify directly — never notify-send. Its # argv parsing is the surface that reinterprets a relayed headline like # `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed # D-Bus parameter instead, and the leading `--` keeps a dash-leading value # (headline, description, a negative timeout) positional rather than a busctl # option. So the summary and body are strings that can never become a hint, and # omarchy-exec-argv is set only from --exec. # # Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body, # actions (empty), hints, expire_timeout. replaces_id (from -r) updates a toast # in place; -p prints the returned id so a caller can reuse it. notify_cmd=( busctl --user -- call org.freedesktop.Notifications /org/freedesktop/Notifications org.freedesktop.Notifications Notify susssasa{sv}i "$app_name" "$replaces_id" "$app_icon" "$headline" "$description" 0 "$hint_count" "${hints[@]}" "$expire_timeout" ) if ((print_id)); then # busctl prints the UINT32 return as "u "; emit just the id. out=$("${notify_cmd[@]}") printf '%s\n' "${out##* }" else "${notify_cmd[@]}" >/dev/null fi