Last active
January 6, 2026 00:30
-
-
Save UlisseMini/6f7100bb45c0f7b8becc6a44e6121cff to your computer and use it in GitHub Desktop.
notifdump (get and follow macos notifications via cli)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # IMPORTANT: Make sure your terminal has full-disk permissions or this won't work. | |
| # ABOUTME: Dumps macOS notification records from usernoted database | |
| # ABOUTME: Supports text, one-line, JSONL output, and follow mode | |
| set -euo pipefail | |
| usage() { | |
| cat <<'EOF' | |
| Usage: notifdump [-n COUNT] [-a APP_ID] [-o] [-j] [-f] [-v] [-h] | |
| Options: | |
| -n COUNT Records to read (default: 10) | |
| -a APP_ID Filter by app identifier | |
| -o One-line output | |
| -j JSONL output (one JSON object per line) | |
| -f Follow mode (watch for new notifications, requires entr) | |
| -v Verbose (full plist dump) | |
| -h Help | |
| EOF | |
| } | |
| COUNT=10 APP_FILTER="" ONELINE=0 JSONL=0 VERBOSE=0 FOLLOW=0 | |
| while getopts ":n:a:ojfvh" opt; do | |
| case "$opt" in | |
| n) COUNT="$OPTARG" ;; a) APP_FILTER="$OPTARG" ;; | |
| o) ONELINE=1 ;; j) JSONL=1 ;; f) FOLLOW=1 ;; v) VERBOSE=1 ;; | |
| h) usage; exit 0 ;; | |
| :) echo "Missing argument for -$OPTARG" >&2; exit 2 ;; | |
| \?) echo "Unknown option: -$OPTARG" >&2; exit 2 ;; | |
| esac | |
| done | |
| for cmd in sqlite3 plutil; do command -v "$cmd" >/dev/null || { echo "Missing: $cmd" >&2; exit 1; }; done | |
| (( JSONL )) && { command -v jq >/dev/null || { echo "Missing: jq" >&2; exit 1; }; } | |
| (( FOLLOW )) && { command -v entr >/dev/null || { echo "Missing: entr (brew install entr)" >&2; exit 1; }; } | |
| DB="$HOME/Library/Group Containers/group.com.apple.usernoted/db2/db" | |
| TMPDIR="$(mktemp -d)"; trap 'rm -rf "$TMPDIR"; exit 0' EXIT INT TERM | |
| # Extract field from plutil -p output | |
| pget() { awk -F' => ' "/$1"'[[:space:]]*=>/ {gsub(/^[[:space:]]*"|"$/, "", $2); print $2; exit}' "$2"; } | |
| # Output a single record | |
| output_record() { | |
| local rec_id="$1" identifier="$2" delivered="$3" | |
| [[ -z "$rec_id" ]] && return | |
| local bin="$TMPDIR/$rec_id.bin" json="$TMPDIR/$rec_id.json" ptxt="$TMPDIR/$rec_id.ptxt" use_json | |
| sqlite3 "$DB" "SELECT writefile('$bin', data) FROM record WHERE rec_id = $rec_id;" >/dev/null | |
| if plutil -convert json -o "$json" "$bin" 2>/dev/null && [[ -s "$json" ]]; then | |
| use_json=1 | |
| else | |
| use_json=0 | |
| plutil -p "$bin" > "$ptxt" 2>/dev/null || true | |
| fi | |
| # Skip records with no usable data | |
| if (( use_json )) && [[ ! -s "$json" ]]; then return; fi | |
| if (( ! use_json )) && [[ ! -s "$ptxt" ]]; then return; fi | |
| if (( JSONL )); then | |
| if (( use_json )); then | |
| jq -c --arg rid "$rec_id" --arg del "$delivered" --arg ident "$identifier" '{ | |
| rec_id: ($rid|tonumber), delivered: (if $del != "" then ($del|tonumber) else null end), identifier: (if $ident != "" then $ident else .app end), | |
| app: .app, date: .date, title: .req.titl, subtitle: .req.subt, body: .req.body, dest: .req.dest | |
| }' "$json" | |
| else | |
| local titl subt body app date | |
| titl=$(pget '"titl"' "$ptxt"); subt=$(pget '"subt"' "$ptxt") | |
| body=$(pget '"body"' "$ptxt"); app=$(pget '"app"' "$ptxt"); date=$(pget '"date"' "$ptxt") | |
| jq -nc --arg rid "$rec_id" --arg del "$delivered" --arg ident "$identifier" \ | |
| --arg app "$app" --arg date "$date" --arg titl "$titl" --arg subt "$subt" --arg body "$body" '{ | |
| rec_id: ($rid|tonumber), delivered: (if $del != "" then ($del|tonumber) else null end), identifier: (if $ident != "" then $ident else $app end), | |
| app: $app, date: (if $date != "" then ($date|tonumber? // $date) else null end), | |
| title: (if $titl != "" then $titl else null end), subtitle: (if $subt != "" then $subt else null end), | |
| body: (if $body != "" then $body else null end) | |
| }' | |
| fi | |
| elif (( ONELINE )); then | |
| if (( use_json )); then | |
| jq -r --arg rid "$rec_id" --arg del "$delivered" --arg ident "$identifier" ' | |
| def n: if . == null then "null" else tostring end; | |
| "rec_id=\($rid) delivered=\($del) app=\(if $ident != "" then $ident else (.app|n) end) titl=\(.req.titl|n) subt=\(.req.subt|n) body=\(.req.body|n)" | |
| ' "$json" | |
| else | |
| local titl subt body app | |
| titl=$(pget '"titl"' "$ptxt"); subt=$(pget '"subt"' "$ptxt") | |
| body=$(pget '"body"' "$ptxt"); app=$(pget '"app"' "$ptxt") | |
| printf 'rec_id=%s delivered=%s app=%s titl=%s subt=%s body=%s\n' \ | |
| "$rec_id" "$delivered" "${identifier:-${app:-null}}" "${titl:-null}" "${subt:-null}" "${body:-null}" | |
| fi | |
| else | |
| echo "==== rec_id=$rec_id app=$identifier delivered=$delivered ====" | |
| if (( VERBOSE )); then | |
| plutil -p "$bin" | |
| elif (( use_json )); then | |
| jq -r 'def n: if . == null then "null" else tostring end; | |
| "app: \(.app|n)\ndate: \(.date|n)\ntitl: \(.req.titl|n)\nsubt: \(.req.subt|n)\nbody: \(.req.body|n)"' "$json" | |
| else | |
| printf "app: %s\ndate: %s\ntitl: %s\nsubt: %s\nbody: %s\n" \ | |
| "$(pget '"app"' "$ptxt")" "$(pget '"date"' "$ptxt")" "$(pget '"titl"' "$ptxt")" "$(pget '"subt"' "$ptxt")" "$(pget '"body"' "$ptxt")" | |
| fi | |
| echo | |
| fi | |
| } | |
| # Build WHERE clause | |
| where_clause() { | |
| local min_id="${1:-0}" clauses=() | |
| (( min_id > 0 )) && clauses+=("r.rec_id > $min_id") | |
| [[ -n "$APP_FILTER" ]] && clauses+=("a.identifier = '${APP_FILTER//\'/\'\'}'") | |
| (( ${#clauses[@]} )) && { IFS=' AND '; echo "WHERE ${clauses[*]}"; } || echo "" | |
| } | |
| # Query and output records | |
| query_records() { | |
| local min_id="${1:-0}" where limit | |
| where=$(where_clause "$min_id") | |
| limit=$([[ "$min_id" -gt 0 ]] && echo "" || echo "LIMIT $COUNT") | |
| sqlite3 "$DB" "SELECT r.rec_id, a.identifier, r.delivered_date FROM record r LEFT JOIN app a ON r.app_id = a.app_id $where ORDER BY r.rec_id ASC $limit;" | \ | |
| while IFS='|' read -r rec_id identifier delivered; do | |
| output_record "$rec_id" "$identifier" "$delivered" | |
| done | |
| } | |
| if (( FOLLOW )); then | |
| # Initial dump (ordered by rec_id desc, then we reverse for display) | |
| last_id=$(sqlite3 "$DB" "SELECT COALESCE(MAX(rec_id), 0) FROM record;") | |
| start_id=$((last_id - COUNT)) | |
| (( start_id < 0 )) && start_id=0 | |
| query_records "$start_id" | |
| # Watch for changes | |
| while true; do | |
| printf '%s\n' "$DB" "$DB-wal" "$DB-shm" 2>/dev/null | entr -pnzd true 2>/dev/null || true | |
| new_max=$(sqlite3 "$DB" "SELECT COALESCE(MAX(rec_id), 0) FROM record;") | |
| if (( new_max > last_id )); then | |
| query_records "$last_id" | |
| last_id=$new_max | |
| fi | |
| done | |
| else | |
| # Normal mode - just dump recent records (newest first) | |
| where=$(where_clause 0) | |
| sqlite3 "$DB" "SELECT r.rec_id, a.identifier, r.delivered_date FROM record r LEFT JOIN app a ON r.app_id = a.app_id $where ORDER BY r.delivered_date DESC LIMIT $COUNT;" | \ | |
| while IFS='|' read -r rec_id identifier delivered; do | |
| output_record "$rec_id" "$identifier" "$delivered" | |
| done | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment