#!/bin/bash # notify - display a macOS notification, speak it, or do both # # Usage: # notify [options] [message ...] # command | notify [options] # # Options: # -p, --say Display the notification and speak the message # -P, --say-only Speak without displaying a notification # --notify-only Disable speech, including NOTIFY_SAY # -t, --title title Notification title (default: "Notification") # -s, --subtitle subtitle Notification subtitle # -S, --sound sound Notification sound (default: "Glass") # -n, --no-sound Suppress the notification sound # -l, --list-sounds List notification sounds and exit # -v, --voice voice macOS speech voice # -r, --rate wpm Speech rate in words per minute # --say-text text Speak text instead of the displayed message # -b, --background Start speech asynchronously # -L, --list-voices List installed voices and exit # -h, --help Show help message # # Environment: # NOTIFY_TITLE Default title # NOTIFY_SOUND Default notification sound # NOTIFY_SAY Set to 1, true, yes, or on to enable --say # NOTIFY_VOICE Default speech voice # NOTIFY_RATE Default speech rate _notify() ( local SCRIPT_NAME SCRIPT_NAME="$(basename "${BASH_SOURCE[0]}")" case "${BASH_SOURCE[0]}" in /dev/*|/proc/*) SCRIPT_NAME="" ;; esac case "$SCRIPT_NAME" in ""|bash|sh|zsh|dash) SCRIPT_NAME="notify" ;; esac # The subshell keeps these settings from changing a shell that sources us set +f shopt -u failglob _error() { echo "[ERR][$SCRIPT_NAME] $*" >&2; } _show_help() { local s="" local r="" [ -t 1 ] && s=$'\033[4m' [ -t 1 ] && r=$'\033[24m' cat < Notifications > Script Editor. OUTPUT MODES -p, --say Display the notification and speak the message -P, --say-only Speak the message without displaying it --notify-only Disable speech, including NOTIFY_SAY The last explicit output-mode option wins. Without one, using a speech option implies --say. Otherwise, notification-only output is the default. NOTIFICATION OPTIONS -t, --title ${s}title${r} Title (default: "Notification") -s, --subtitle ${s}subtitle${r} Subtitle -S, --sound ${s}sound${r} Sound name (default: "Glass") -n, --no-sound Suppress the notification sound -l, --list-sounds List available notification sounds and exit SPEECH OPTIONS -v, --voice ${s}voice${r} Voice name (default: macOS system voice) -r, --rate ${s}wpm${r} Speaking rate in words per minute --say-text ${s}text${r} Speak ${s}text${r} instead of the displayed message -b, --background Start say asynchronously -L, --list-voices List installed voices and their locales, then exit INFORMATION -h, --help Show this help message ENVIRONMENT NOTIFY_TITLE Default title NOTIFY_SOUND Default notification sound NOTIFY_SAY Set to 1, true, yes, or on to enable --say NOTIFY_VOICE Default voice when speech is enabled NOTIFY_RATE Default speaking rate when speech is enabled EXAMPLES $SCRIPT_NAME "Backup complete" make && $SCRIPT_NAME -t "Build" --say --no-sound "Build finished" $SCRIPT_NAME --say-only -v Samantha -r 190 "Tea is ready" $SCRIPT_NAME --say --say-text "Deploy healthy" "Production deployment is healthy" $SCRIPT_NAME --list-voices EXIT STATUS 0 Success 1 No notification sounds were found 2 Invalid arguments or missing required message 3 A required macOS tool (osascript or say) is unavailable Otherwise, the status of a failed osascript or foreground say process is returned. Background mode can report only whether say was started. DEPENDENCIES osascript Notification output (macOS only) say Speech output and voice discovery (macOS only) EOF } _list_sounds() { local sounds="" local user_sounds="" local dir local file local name if [ -n "${HOME:-}" ]; then user_sounds="$HOME/Library/Sounds"; fi for dir in /System/Library/Sounds /Library/Sounds "$user_sounds"; do [ -n "$dir" ] || continue [ -d "$dir" ] || continue for file in "$dir"/*; do [ -f "$file" ] || continue name="${file##*/}" name="${name%.*}" sounds="${sounds:+$sounds$'\n'}$name" done done if [ -z "$sounds" ]; then _error "No sounds found in the system, local, or user Sounds directories" return 1 fi printf '%s\n' "$sounds" | sort -u } _expand_short_opts() { # $1 = string of short-opt letters that take a value (e.g. "nXHd"); "" for flag-only scripts # $2..$N = "$@" # Populates _EXPANDED; caller does: set -- "${_EXPANDED[@]}"; unset _EXPANDED local value_opts="$1"; shift _EXPANDED=() local passthru="" local arg local rest local c for arg in "$@"; do if [ -n "$passthru" ]; then _EXPANDED+=("$arg"); continue; fi case "$arg" in --) passthru=1; _EXPANDED+=("$arg") ;; --*|-|"") _EXPANDED+=("$arg") ;; -[a-zA-Z]?*) rest="${arg#-}" while [ -n "$rest" ]; do c="${rest%"${rest#?}"}"; rest="${rest#?}" _EXPANDED+=("-$c") case "$value_opts" in *"$c"*) [ -n "$rest" ] && _EXPANDED+=("$rest") rest="" ;; esac done ;; *) _EXPANDED+=("$arg") ;; esac done } _env_true() { case "${1:-}" in 1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) return 0 ;; *) return 1 ;; esac } _set_action() { local requested="$1" if [ -n "$action" ]; then _error "Only one list option may be used at a time" return 2 fi action="$requested" } _is_positive_integer() { case "$1" in ""|*[!0-9]*) return 1 ;; *[1-9]*) return 0 ;; *) return 1 ;; esac } local title="${NOTIFY_TITLE:-Notification}" local subtitle="" local sound="${NOTIFY_SOUND:-Glass}" local no_sound="" local explicit_mode="" local speech_requested="" local action="" local voice="${NOTIFY_VOICE:-}" local rate="${NOTIFY_RATE:-}" local say_text="" local say_text_set="" local background="" local -a args=() local -a _EXPANDED=() _expand_short_opts "tsSvr" "$@" set -- "${_EXPANDED[@]}"; unset _EXPANDED while [ $# -gt 0 ]; do case "$1" in -h|--help) _show_help return 0 ;; -p|--say) explicit_mode="both" shift ;; -P|--say-only) explicit_mode="say" shift ;; # Negation flag: --notify-only intentionally has no short form --notify-only) explicit_mode="notify" shift ;; -l|--list-sounds) _set_action "sounds" || return 2 shift ;; -L|--list-voices) _set_action "voices" || return 2 shift ;; -n|--no-sound) no_sound=1 shift ;; -t|--title) [ -n "${2-}" ] || { _error "-t|--title requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } title="$2" shift 2 ;; --title=*) title="${1#*=}" [ -n "$title" ] || { _error "-t|--title requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -s|--subtitle) [ -n "${2-}" ] || { _error "-s|--subtitle requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } subtitle="$2" shift 2 ;; --subtitle=*) subtitle="${1#*=}" [ -n "$subtitle" ] || { _error "-s|--subtitle requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -S|--sound) [ -n "${2-}" ] || { _error "-S|--sound requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } sound="$2" shift 2 ;; --sound=*) sound="${1#*=}" [ -n "$sound" ] || { _error "-S|--sound requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } shift ;; -v|--voice) [ -n "${2-}" ] || { _error "-v|--voice requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } voice="$2" speech_requested=1 shift 2 ;; --voice=*) voice="${1#*=}" [ -n "$voice" ] || { _error "-v|--voice requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } speech_requested=1 shift ;; -r|--rate) [ -n "${2-}" ] || { _error "-r|--rate requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } rate="$2" speech_requested=1 shift 2 ;; --rate=*) rate="${1#*=}" [ -n "$rate" ] || { _error "-r|--rate requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } speech_requested=1 shift ;; # Collision: --say-text stays long-only because -s and -t are notification options --say-text) [ -n "${2-}" ] || { _error "--say-text requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } say_text="$2" say_text_set=1 speech_requested=1 shift 2 ;; --say-text=*) say_text="${1#*=}" [ -n "$say_text" ] || { _error "--say-text requires a value. Run \`$SCRIPT_NAME -h\` for usage"; return 2; } say_text_set=1 speech_requested=1 shift ;; -b|--background) background=1 speech_requested=1 shift ;; --) shift while [ $# -gt 0 ]; do args+=("$1") shift done ;; -*) _error "Unknown argument '$1'. Run \`$SCRIPT_NAME -h\` for usage" return 2 ;; *) args+=("$1") shift ;; esac done if [ -n "$action" ]; then if [ "${#args[@]}" -gt 0 ]; then _error "List options do not accept a message" return 2 fi case "$action" in sounds) _list_sounds return $? ;; voices) if ! command -v say >/dev/null 2>&1; then _error "say is required" return 3 fi say -v '?' return $? ;; esac fi local message="" local index if [ "${#args[@]}" -gt 0 ]; then message="${args[0]}" index=1 while [ "$index" -lt "${#args[@]}" ]; do message="$message ${args[$index]}" index=$((index + 1)) done elif [ -p /dev/stdin ] || [ ! -t 0 ]; then message="$(cat)" fi if [ -z "$message" ]; then _error "MESSAGE is required (pass as arguments or pipe stdin)" _show_help >&2 return 2 fi local mode="notify" if [ -n "$explicit_mode" ]; then mode="$explicit_mode" elif _env_true "${NOTIFY_SAY:-}" || [ -n "$speech_requested" ]; then mode="both" fi if [ "$mode" = "notify" ] && [ -n "$explicit_mode" ] && [ -n "$speech_requested" ]; then _error "Speech options cannot be combined with --notify-only" return 2 fi local notify_enabled="" local say_enabled="" case "$mode" in notify) notify_enabled=1 ;; say) say_enabled=1 ;; both) notify_enabled=1; say_enabled=1 ;; esac if [ -n "$say_enabled" ] && [ -n "$rate" ] && ! _is_positive_integer "$rate"; then _error "-r|--rate must be a positive integer in words per minute" return 2 fi if [ -n "$notify_enabled" ] && ! command -v osascript >/dev/null 2>&1; then _error "osascript is required" return 3 fi if [ -n "$say_enabled" ] && ! command -v say >/dev/null 2>&1; then _error "say is required" return 3 fi local effective_sound="$sound" if [ -n "$no_sound" ]; then effective_sound=""; fi local speech_text="$message" local -a say_args=() if [ -n "$say_enabled" ]; then if [ -n "$say_text_set" ]; then speech_text="$say_text"; fi [ -n "$voice" ] && say_args+=(-v "$voice") [ -n "$rate" ] && say_args+=(-r "$rate") fi local notify_rc=0 local say_rc=0 if [ -n "$notify_enabled" ]; then osascript - "$message" "$title" "$subtitle" "$effective_sound" <<'APPLESCRIPT' on run argv set msg to item 1 of argv set ttl to item 2 of argv set sub to item 3 of argv set snd to item 4 of argv if sub is "" and snd is "" then display notification msg with title ttl else if sub is "" then display notification msg with title ttl sound name snd else if snd is "" then display notification msg with title ttl subtitle sub else display notification msg with title ttl subtitle sub sound name snd end if end run APPLESCRIPT notify_rc=$? fi if [ -n "$say_enabled" ]; then if [ -n "$background" ]; then say "${say_args[@]}" <<< "$speech_text" & say_rc=$? else say "${say_args[@]}" <<< "$speech_text" say_rc=$? fi fi if [ "$notify_rc" -ne 0 ]; then return "$notify_rc"; fi return "$say_rc" ) _notify "$@" __notify_rc=$? unset -f _notify if [ -n "${BASH_SOURCE[0]}" ] && [ "${BASH_SOURCE[0]}" != "$0" ]; then eval "unset __notify_rc; return $__notify_rc" fi eval "unset __notify_rc; exit $__notify_rc"