#!/bin/bash # ============================================================================= # junk.report - a read-only report of where your Mac's disk space went. # # Run it: curl -fsSL https://junk.report/scan | bash # Or read it first (recommended): # curl -fsSL https://junk.report/scan -o scan.sh && less scan.sh && bash scan.sh # # WHAT IT DOES # 1. Measures well-known space hogs in YOUR home folder using built-in # macOS tools only: du, find, stat, df, awk, sed, sort, dd, tail, md5, open, nc. # To spot duplicate copies it reads big files that have the exact same size # and compares their fingerprints (md5). Reading only; nothing is changed. # 2. Writes ONE report: ~/junk-report.html (only you can read it), opens it. # Also saves a small snapshot (sizes of the items in the report, plus free # space) in ~/.junk-report/, so next time it can show you what you freed. # 3. Keeps a tiny helper running on 127.0.0.1 (this Mac only, never the # network) so the report's "Show in Finder" buttons work. It only reveals # paths that are listed in the report, needs a random secret, and stops by # itself after 30 minutes or when you press Control-C. # # WHAT IT NEVER DOES # - delete, move, rename or change any of your files # - upload or send anything anywhere (no network calls at all) # - ask for your password / use sudo # - need Python, Homebrew or Xcode # # OPTIONS (environment variables, e.g. JR_NO_HELPER=1 bash scan.sh) # JR_NO_HELPER=1 don't start the helper; buttons copy the path instead # JR_MINUTES=30 how long the helper stays up (1-120) # JR_NO_OPEN=1 don't open the report automatically # JR_OUT=path write the report somewhere else # JR_NO_SNAPSHOT=1 don't save or compare snapshots (~/.junk-report) # JR_DUPE_MIN_MB=50 smallest file size checked for duplicates # JR_DUPE_SECONDS=120 time limit for the duplicate check # JR_FIND_SECONDS=150 time limit for the big-file search # JR_TEST=1 Linux test mode (with JR_HOME=fake home); see test/ # ============================================================================= # Everything lives in functions and runs from the last line, so a download that # gets cut off halfway can't run half a script. jr_say() { printf '%s\n' "$*" >&2; } jr_setup() { TAB=$(printf '\t') NL=' ' H="${JR_HOME:-$HOME}" H="${H%/}" TEST="${JR_TEST:-0}" OUT="${JR_OUT:-$H/junk-report.html}" MINUTES="${JR_MINUTES:-30}" case "$MINUTES" in ''|*[!0-9]*) MINUTES=30 ;; esac [ "$MINUTES" -lt 1 ] && MINUTES=1 [ "$MINUTES" -gt 120 ] && MINUTES=120 TOPN=25 DUPE_MIN_MB="${JR_DUPE_MIN_MB:-50}"; case "$DUPE_MIN_MB" in ''|*[!0-9]*) DUPE_MIN_MB=50 ;; esac [ "$DUPE_MIN_MB" -lt 1 ] && DUPE_MIN_MB=1 DUPE_SECONDS="${JR_DUPE_SECONDS:-120}"; case "$DUPE_SECONDS" in ''|*[!0-9]*) DUPE_SECONDS=120 ;; esac FIND_SECONDS="${JR_FIND_SECONDS:-150}"; case "$FIND_SECONDS" in ''|*[!0-9]*) FIND_SECONDS=150 ;; esac BIG_BLOCKS=204800 # 100 MB, in find's 512-byte -size units (same on BSD and GNU find) if [ "$TEST" = 1 ]; then APP_DIRS="${JR_APPS:-$H/_Applications}" else APP_DIRS="/Applications${NL}/Applications/Utilities${NL}$H/Applications${NL}/System/Applications${NL}/System/Applications/Utilities" fi umask 077 TMP=$(mktemp -d "${TMPDIR:-/tmp}/junkreport.XXXXXX") || { jr_say "Couldn't make a temp folder."; exit 1; } REC="$TMP/records.tsv"; ALLOW="$TMP/allow" : > "$REC"; : > "$ALLOW" NEXT_ID=0 HELPER_PID="" } jr_cleanup() { [ -n "${HELPER_PID:-}" ] && kill "$HELPER_PID" 2>/dev/null # shellcheck disable=SC2046 kill $(jobs -p) 2>/dev/null [ -n "${TMP:-}" ] && [ -d "$TMP" ] && rm -rf "$TMP" } # ---- portability shims ------------------------------------------------------ # du: macOS du reports space actually used on disk. In Linux test mode the # sample files are sparse, so we ask GNU du for their apparent size instead. # Test mode also enforces macOS du's rule that -a, -s and -d can't be combined # (Apple's du prints its usage message and measures nothing). v1 broke on exactly that. jr_du() { if [ "$TEST" = 1 ]; then _fa=0; _fs=0; _fd=0 for _arg in "$@"; do case "$_arg" in /*) continue ;; -*) ;; *) continue ;; esac case "$_arg" in -*a*) _fa=1 ;; esac case "$_arg" in -*s*) _fs=1 ;; esac case "$_arg" in -*d*) _fd=1 ;; esac done if [ $((_fa + _fs + _fd)) -gt 1 ]; then echo "du: macOS du refuses -a/-s/-d together" >&2; return 64; fi du --apparent-size "$@" else du "$@"; fi } # Size of every direct child of a folder (files and folders), one "KBpath" line each. jr_du_children() { if [ "$TEST" = 1 ]; then find "$1" -mindepth 1 -maxdepth 1 -print0 | xargs -0 -r du --apparent-size -sk else find "$1" -mindepth 1 -maxdepth 1 -print0 | xargs -0 du -sk; fi } # reads NUL-separated paths on stdin, prints "" for each. # macOS: blocks actually used on disk (512-byte units). Test mode: apparent size. jr_stat_kb() { if [ "$TEST" = 1 ]; then xargs -0 stat -c '%s %n' 2>/dev/null | awk '{s=$1; sub(/^[0-9]+ /,""); printf "%d\t%s\n", s/1024, $0}' else xargs -0 stat -f '%b %N' 2>/dev/null | awk '{s=$1; sub(/^[0-9]+ /,""); printf "%d\t%s\n", s/2, $0}' fi } # reads NUL-separated paths, prints "bytes512-byte-blocksdevice:inodepath" jr_stat_full() { if [ "$TEST" = 1 ]; then xargs -0 -r stat -c '%s %b %d:%i %n' 2>/dev/null else xargs -0 stat -f '%z %b %d:%i %N' 2>/dev/null fi | awk '{b=$1; k=$2; n=$3; sub(/^[0-9]+ [0-9]+ [0-9]+:[0-9]+ /,""); printf "%s\t%s\t%s\t%s\n", b, k, n, $0}' } # fingerprints: md5 of first + last 1 MB (quick), md5 of the whole file (sure) jr_qhash() { { dd if="$1" bs=1048576 count=1 2>/dev/null; tail -c 1048576 "$1" 2>/dev/null; } | if [ "$TEST" = 1 ]; then md5sum | awk '{print $1}'; else md5 | awk '{print $NF}'; fi } jr_fhash() { if [ "$TEST" = 1 ]; then md5sum < "$1" 2>/dev/null | awk '{print $1}'; else md5 -q "$1" 2>/dev/null; fi } jr_open() { if [ "$TEST" = 1 ]; then printf 'open %s\n' "$*" >> "${JR_TEST_LOG:-$TMP/open.log}"; return 0; fi open "$@" >/dev/null 2>&1 } # Run a command, but stop it after N seconds (macOS has no `timeout`). # Returns 124 if it had to be stopped. jr_killtree() { for _c in $(pgrep -P "$1" 2>/dev/null); do jr_killtree "$_c"; done kill "$1" 2>/dev/null } jr_timeout() { _secs=$1; shift "$@" & _pid=$! ( trap 'kill $_s 2>/dev/null; exit 0' TERM sleep "$_secs" & _s=$!; wait $_s : > "$TMP/timedout.$_pid"; jr_killtree "$_pid" ) >/dev/null 2>&1 & _dog=$! wait "$_pid" 2>/dev/null; _rc=$? kill "$_dog" 2>/dev/null; wait "$_dog" 2>/dev/null if [ -e "$TMP/timedout.$_pid" ]; then rm -f "$TMP/timedout.$_pid"; return 124; fi return $_rc } # ---- recording results -------------------------------------------------------- # Every path that appears in the report gets an id. The helper will only ever # reveal paths from this list (the allow-list). jr_add_path() { REPLY_ID=-1 case "$1" in *"$NL"*|*"$TAB"*|'') return 1 ;; esac printf '%s\n' "$1" >> "$ALLOW" printf 'P\t%s\t%s\n' "$NEXT_ID" "$1" >> "$REC" REPLY_ID=$NEXT_ID NEXT_ID=$((NEXT_ID + 1)) } # category: key verdict kb partial(0/1/2=timed out) [path] jr_cat() { _cid=-1 if [ -n "${5:-}" ]; then jr_add_path "$5"; _cid=$REPLY_ID; fi printf 'C\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "${3:-0}" "${4:-0}" "$_cid" >> "$REC" } # item: key kb path label [flag] jr_item() { jr_add_path "$3" || return 0 printf 'I\t%s\t%s\t%s\t%s\t%s\n' "$1" "$2" "$REPLY_ID" "${5:-}" "$4" >> "$REC" } jr_meta() { printf 'M\t%s\t%s\n' "$1" "$2" >> "$REC"; } # ---- measuring ---------------------------------------------------------------- # Measure a folder and its direct children in one pass. # Sets M_TOTAL (KB), M_PARTIAL (1 if macOS privacy blocked some of it, 2 if it # took too long) and leaves "KBpath" child lines in $TMP/kids, biggest first. jr_measure() { M_TOTAL=0; M_PARTIAL=0 : > "$TMP/kids" [ -d "$1" ] || return 1 jr_timeout "${2:-120}" jr_du_children "$1" > "$TMP/m.out" 2> "$TMP/m.err" < /dev/null [ $? -eq 124 ] && M_PARTIAL=2 [ "$M_PARTIAL" = 0 ] && [ -s "$TMP/m.err" ] && M_PARTIAL=1 awk -F'\t' '$1 ~ /^[0-9]+$/ && NF >= 2' "$TMP/m.out" | sort -t "$TAB" -k1,1nr > "$TMP/kids" M_TOTAL=$(awk -F'\t' '{s+=$1} END{print s+0}' "$TMP/kids") return 0 } jr_size() { S_KB=0; S_PARTIAL=0 [ -e "$1" ] || return 1 jr_timeout "${2:-120}" jr_du -sk "$1" > "$TMP/s.out" 2> "$TMP/s.err" < /dev/null [ $? -eq 124 ] && S_PARTIAL=2 [ "$S_PARTIAL" = 0 ] && [ -s "$TMP/s.err" ] && S_PARTIAL=1 S_KB=$(awk -F'\t' 'END{print $1+0}' "$TMP/s.out") return 0 } jr_kid_kb() { awk -F'\t' -v p="$1" '$2==p{print $1; exit}' "$TMP/kids"; } # A folder as one category, its biggest children as items. # usage: jr_dir_cat key verdict dir max_items [child-to-exclude ...] jr_dir_cat() { _key=$1; _ver=$2; _dir=$3; _max=$4; shift 4 jr_measure "$_dir" || return 0 _total=$M_TOTAL : > "$TMP/excl" for _ex in "$@"; do _ek=$(jr_kid_kb "$_ex"); _total=$((_total - ${_ek:-0})); printf '%s\n' "$_ex" >> "$TMP/excl" done [ "$_total" -gt 0 ] || return 0 jr_cat "$_key" "$_ver" "$_total" "$M_PARTIAL" "$_dir" _n=0 while IFS="$TAB" read -r _kb _p; do [ "$_n" -ge "$_max" ] && break [ "$_kb" -lt 10240 ] && break # skip anything under 10 MB grep -qxF -- "$_p" "$TMP/excl" && continue jr_item "$_key" "$_kb" "$_p" "${_p##*/}" _n=$((_n + 1)) done < "$TMP/kids" } # Several separate paths as one category. usage: jr_paths_cat key verdict label|path ... jr_paths_cat() { _key=$1; _ver=$2; shift 2 _total=0; _part=0; : > "$TMP/pc" for _lp in "$@"; do _label=${_lp%%|*}; _path=${_lp#*|} jr_size "$_path" || continue [ "$S_KB" -gt 0 ] || continue _total=$((_total + S_KB)); [ "$S_PARTIAL" -gt "$_part" ] && _part=$S_PARTIAL printf '%s\t%s\t%s\n' "$S_KB" "$_path" "$_label" >> "$TMP/pc" done [ "$_total" -gt 0 ] || return 0 jr_cat "$_key" "$_ver" "$_total" "$_part" "" sort -t "$TAB" -k1,1nr "$TMP/pc" > "$TMP/pc.s" while IFS="$TAB" read -r _kb _p _l; do jr_item "$_key" "$_kb" "$_p" "$_l"; done < "$TMP/pc.s" } # ---- "is the app for this folder still installed?" ----------------------------- jr_load_apps() { printf '%s\n' "$APP_DIRS" | while IFS= read -r _d; do [ -d "$_d" ] || continue for _a in "$_d"/*.app "$_d"/*/*.app; do [ -e "$_a" ] || continue _a=${_a##*/}; printf '%s\n' "${_a%.app}" done done | tr '[:upper:]' '[:lower:]' | sort -u > "$TMP/apps" } # returns 0 if we could NOT find an app that matches this support folder jr_looks_leftover() { _ln=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') case "$_ln" in com.apple.*|apple|addressbook|callhistory*|clouddocs|crashreporter|dock|icdd|knowledge|mobilesync|\ networkserviceproxy|fileprovider|icloud|syncservices|quicklook|siri|spotlight|ubiquity*|\ accessibility|animoji|homekit|cloudkit|familycircle|identityservices|com.crashlytics|\ google|microsoft|mozilla|jetbrains|dmd|tmp|cache|caches|logs|*.savedstate|.*) return 1 ;; esac _ltok=$_ln case "$_ln" in *.*.*) _ltok=${_ln##*.} ;; esac # com.tinyspeck.slackmacgap -> slackmacgap [ ${#_ltok} -lt 3 ] && return 1 awk -v n="$_ln" -v t="$_ltok" ' index($0, t) || index(t, $0) && length($0) >= 4 || index(n, $0) && length($0) >= 4 { found=1; exit } END { exit found ? 0 : 1 }' "$TMP/apps" && return 1 if [ "$TEST" != 1 ] && command -v mdfind >/dev/null 2>&1; then # shellcheck disable=SC1003 case "$1" in *"'"*|*'"'*|*'\'*) ;; *) [ -n "$(mdfind "kMDItemCFBundleIdentifier == '$1'c" 2>/dev/null < /dev/null | head -n 1)" ] && return 1 ;; esac fi return 0 } # ---- duplicates ------------------------------------------------------------------- # Same size -> same first+last MB -> same whole-file md5 = duplicate copies. # Hard links (same device:inode) are one file with two names, so they count once. jr_dupes() { jr_say " - checking same-size big files for duplicates (stops after $DUPE_SECONDS seconds)" _dend=$(( $(date +%s) + DUPE_SECONDS )); _dpart=0 _minbytes=$((DUPE_MIN_MB * 1048576)) # 1. candidates: big enough, not inside app-managed places (in ~/Library only iCloud Drive counts) # Files stored only in iCloud (or other cloud storage) use almost no disk; reading them would make # macOS download them, so anything using less than a quarter of its size on disk is skipped. _alloc=1; [ "$TEST" = 1 ] && _alloc=${JR_TEST_ALLOC:-0} # test sparse files look "not downloaded" awk -F'\t' -v min="$_minbytes" -v lib="$L/" -v icloud="$L/Mobile Documents/" -v alloc="$_alloc" ' $1 + 0 < min { next } alloc == 1 && $2 * 512 < $1 / 4 { next } index($4, lib) == 1 && index($4, icloud) != 1 { next } $4 ~ /\/node_modules\/|\/\.git\/|\.app\/|\.photoslibrary\/|\.fcpbundle\/|\.imovielibrary\/|\.logicx\/|\.musiclibrary\/|\/\.Trash\// { next } { print }' "$TMP/fstat" > "$TMP/d.cand" # 2. drop extra names of hard-linked files, keep sizes that occur 2+ times, biggest first awk -F'\t' '!seen[$3]++' "$TMP/d.cand" > "$TMP/d.uniq" jr_meta dupe_hardlinks "$(( $(wc -l < "$TMP/d.cand") - $(wc -l < "$TMP/d.uniq") ))" awk -F'\t' 'NR == FNR { n[$1]++; next } n[$1] > 1 { printf "%s\t%s\n", $1, $4 }' "$TMP/d.uniq" "$TMP/d.uniq" \ | sort -t "$TAB" -k1,1nr > "$TMP/d.size" # 3. quick fingerprint (first + last 1 MB) : > "$TMP/d.q" while IFS="$TAB" read -r _b _p; do if [ "$(date +%s)" -ge "$_dend" ]; then _dpart=1; break; fi _q=$(jr_qhash "$_p" < /dev/null) [ -n "$_q" ] && printf '%s\t%s\t%s\n' "$_b" "$_q" "$_p" >> "$TMP/d.q" done < "$TMP/d.size" awk -F'\t' 'NR == FNR { n[$1 FS $2]++; next } n[$1 FS $2] > 1' "$TMP/d.q" "$TMP/d.q" | sort -t "$TAB" -k1,1nr > "$TMP/d.q2" # 4. whole-file fingerprint, only for files whose quick fingerprints matched : > "$TMP/d.f" while IFS="$TAB" read -r _b _q _p; do _left=$(( _dend - $(date +%s) )) if [ "$_left" -le 0 ]; then _dpart=1; break; fi jr_timeout "$_left" jr_fhash "$_p" > "$TMP/d.h" 2>/dev/null < /dev/null if [ $? -eq 124 ]; then _dpart=1; break; fi _h=$(awk 'NR == 1 && /^[0-9a-f]+$/ {print; exit}' "$TMP/d.h") [ -n "$_h" ] && printf '%s\t%s\t%s\n' "$_b" "$_h" "$_p" >> "$TMP/d.f" done < "$TMP/d.q2" # 5. sets of 2+ identical files, biggest possible savings first awk -F'\t' 'NR == FNR { n[$1 FS $2]++; next } n[$1 FS $2] > 1 { printf "%d\t%s\t%s\t%s\n", $1 * (n[$1 FS $2] - 1), $1, $2, $3 }' \ "$TMP/d.f" "$TMP/d.f" | sort -t "$TAB" -k1,1nr -k3,3 -k4,4 > "$TMP/d.sets" _set=0; _last="" while IFS="$TAB" read -r _save _b _h _p; do if [ "$_h" != "$_last" ]; then _set=$((_set + 1)); _last=$_h; fi [ "$_set" -gt 40 ] && break jr_add_path "$_p" && printf 'D\t%s\t%s\t%s\n' "$_set" "$((_b / 1024))" "$REPLY_ID" >> "$REC" done < "$TMP/d.sets" jr_meta dupes_ran 1; jr_meta dupe_min_mb "$DUPE_MIN_MB"; jr_meta dupes_partial "$_dpart" } # ---- before/after snapshots -------------------------------------------------------- # Each run saves the sizes of the items and duplicate copies in the report, plus free # space, to ~/.junk-report/snapshot-DATE-TIME.tsv (only you can read it). If there's an # earlier snapshot of the same home folder, we compare: an item counts as "freed" only if # it got at least 10 MB smaller or is gone. jr_snapshot() { [ "${JR_NO_SNAPSHOT:-0}" = 1 ] && return 0 SNAPDIR="${JR_SNAPDIR:-$H/.junk-report}" LC_ALL=C awk -F'\t' -v home="$H" ' BEGIN { print "#junk-report-snapshot\t1"; print "H\t" home } $1 == "P" { p[$2] = $3; next } $1 == "M" && ($2 == "free_kb" || $2 == "disk_kb" || $2 == "when") { print "M\t" $2 "\t" $3; next } $1 == "C" { print "C\t" $2 "\t" $4 "\t" $5; next } $1 == "I" && ($4 in p) { print "I\t" $2 "\t" $3 "\t" p[$4]; next } $1 == "D" && ($4 in p) { print "D\t" $2 "\t" $3 "\t" p[$4]; next }' "$REC" > "$TMP/snap.new" _prev="" if [ -d "$SNAPDIR" ]; then for _f in "$SNAPDIR"/snapshot-*.tsv; do [ -f "$_f" ] && _prev=$_f; done # newest sorts last fi if [ -n "$_prev" ] && [ "$(awk -F'\t' '$1 == "H" { print $2; exit }' "$_prev")" = "$H" ]; then jr_say " - comparing with your last report" awk -F'\t' '$1 == "M" { print "M\tsince_" $2 "\t" $3 }' "$_prev" >> "$REC" # previous items and copies, with their size now ("?" = not in this report, look it up) awk -F'\t' ' NR == FNR { if ($1 == "I" || $1 == "D") cur[$4] = $3; if ($1 == "C" && $4 != 0) part[$2] = 1; next } $1 == "C" && $4 != 0 { part[$2] = 1; next } ($1 == "I" || $1 == "D") && !seen[$4]++ { now = ($4 in cur) ? cur[$4] : "?" if ($1 == "I" && ($2 in part) && now != "?") next # privacy settings changed what we could see print $1 "\t" $2 "\t" $3 "\t" now "\t" $4 }' "$TMP/snap.new" "$_prev" > "$TMP/snap.cmp" _n=0 while IFS="$TAB" read -r _kind _key _before _now _p; do _n=$((_n + 1)); [ "$_n" -gt 300 ] && break if [ "$_now" = "?" ]; then if [ -e "$_p" ]; then jr_size "$_p" 30; [ "$S_PARTIAL" = 0 ] || continue; _now=$S_KB else # Only call it gone if we can actually look inside the nearest folder that still # exists. (macOS privacy settings can make a file look missing when it isn't.) _d=${_p%/*} while [ -n "$_d" ] && [ ! -e "$_d" ]; do _d=${_d%/*}; done if [ -z "$_d" ] || ! ls "$_d" >/dev/null 2>&1; then continue; fi _now=0 fi fi if [ $((_before - _now)) -ge 10240 ]; then [ "$_kind" = D ] && _key=dupe jr_add_path "$_p" && printf 'G\t%s\t%s\t%s\t%s\n' "$_key" "$_before" "$_now" "$REPLY_ID" >> "$REC" fi done < "$TMP/snap.cmp" fi mkdir -p "$SNAPDIR" 2>/dev/null && chmod 700 "$SNAPDIR" 2>/dev/null || return 0 _snapf="$SNAPDIR/snapshot-$(date +%Y%m%d-%H%M%S).tsv" cp "$TMP/snap.new" "$_snapf" 2>/dev/null && chmod 600 "$_snapf" && jr_meta snapshot_saved 1 return 0 } # ---- the scan ----------------------------------------------------------------- jr_scan() { L="$H/Library" jr_say "junk.report: looking around (read-only). This usually takes a few minutes." jr_say "macOS may ask whether Terminal can access some folders. 'Don't Allow' is fine; that folder just gets skipped." jr_load_apps jr_say " - caches"; jr_dir_cat caches safe "$L/Caches" 8 "$L/Caches/Homebrew" jr_say " - Homebrew"; jr_paths_cat homebrew safe "Homebrew downloads|$L/Caches/Homebrew" jr_say " - Xcode"; jr_dir_cat derived safe "$L/Developer/Xcode/DerivedData" 8 jr_dir_cat devicesupport safe "$L/Developer/Xcode/iOS DeviceSupport" 8 jr_dir_cat archives review "$L/Developer/Xcode/Archives" 8 jr_dir_cat simulators review "$L/Developer/CoreSimulator" 6 jr_say " - device backups" jr_measure "$L/Application Support/MobileSync/Backup" if [ "$M_TOTAL" -gt 0 ]; then jr_cat ios_backups review "$M_TOTAL" "$M_PARTIAL" "$L/Application Support/MobileSync/Backup" while IFS="$TAB" read -r _kb _p; do _name=$(awk '/Device Name<\/key>/{getline; gsub(/.*|<\/string>.*/,""); print; exit}' "$_p/Info.plist" 2>/dev/null) jr_item ios_backups "$_kb" "$_p" "${_name:-Backup ${_p##*/}}" done < "$TMP/kids" fi jr_say " - Downloads, Movies, Photos" jr_dir_cat downloads review "$H/Downloads" 10 jr_dir_cat movies review "$H/Movies" 8 _ph=""; for _x in "$H/Pictures"/*.photoslibrary; do [ -d "$_x" ] && _ph="$_ph${_ph:+$NL}${_x##*/}|$_x"; done if [ -n "$_ph" ]; then _old=$IFS; IFS=$NL; set -f # shellcheck disable=SC2086 jr_paths_cat photos keep $_ph set +f; IFS=$_old fi jr_say " - iCloud Drive"; jr_dir_cat icloud keep "$L/Mobile Documents" 6 jr_say " - Docker / VMs"; jr_paths_cat docker review \ "Docker Desktop|$L/Containers/com.docker.docker" "OrbStack|$H/.orbstack" "Colima|$H/.colima" \ "Podman|$H/.local/share/containers" jr_say " - AI models"; jr_paths_cat ai_models review \ "Ollama|$H/.ollama/models" "Hugging Face|$H/.cache/huggingface" \ "LM Studio|$H/.lmstudio" "LM Studio (older)|$H/.cache/lm-studio" jr_say " - developer caches" jr_paths_cat dev_caches safe "npm|$H/.npm/_cacache" "Yarn|$H/.yarn/berry/cache" "Yarn (classic)|$L/Caches/Yarn" \ "pnpm|$L/pnpm/store" "Gradle|$H/.gradle/caches" "Cargo (Rust)|$H/.cargo/registry" "Go modules|$H/go/pkg/mod" \ "CocoaPods|$H/.cocoapods/repos" "pip|$L/Caches/pip" "Maven|$H/.m2/repository" "Bun|$H/.bun/install/cache" jr_say " - Trash"; jr_dir_cat trash safe "$H/.Trash" 8 jr_say " - Mail"; jr_paths_cat mail_downloads safe "Mail Downloads|$L/Containers/com.apple.mail/Data/Library/Mail Downloads" jr_paths_cat mail keep "Mail|$L/Mail" jr_say " - app support files" jr_measure "$L/Application Support" _total=$M_TOTAL _ek=$(jr_kid_kb "$L/Application Support/MobileSync"); _total=$((_total - ${_ek:-0})) if [ "$_total" -gt 0 ]; then jr_cat app_support keep "$_total" "$M_PARTIAL" "$L/Application Support" _n=0 while IFS="$TAB" read -r _kb _p; do [ "$_n" -ge 12 ] && break; [ "$_kb" -lt 51200 ] && break _b=${_p##*/} [ "$_b" = MobileSync ] && continue _flag=""; jr_looks_leftover "$_b" && _flag=leftover jr_item app_support "$_kb" "$_p" "$_b" "$_flag"; _n=$((_n + 1)) done < "$TMP/kids" fi jr_say " - sandboxed app data" jr_dir_cat containers keep "$L/Containers" 10 "$L/Containers/com.docker.docker" "$L/Containers/com.apple.mail" jr_say " - biggest files, duplicates and node_modules (search stops after $FIND_SECONDS seconds)" # One walk of the home folder. Skipped: app-managed areas with huge numbers of # files that the categories above already cover, and online-only cloud folders. _minb=$((DUPE_MIN_MB * 2048)); [ "$_minb" -gt "$BIG_BLOCKS" ] && _minb=$BIG_BLOCKS jr_timeout "$FIND_SECONDS" find "$H" -xdev \ \( -path "$L/CloudStorage" -o -path "$L/Caches" -o -path "$L/Containers" -o -path "$L/Group Containers" \ -o -path "$L/Mail" -o -path "$L/Developer" -o -path "$L/Application Support/MobileSync" -o -path "$L/pnpm" \ -o -path "$H/.npm" -o -path "$H/.Trash" -o -path "$H/go/pkg" -o -path "$H/.cargo/registry" \ -o -name '*.photoslibrary' -o -name .git \) -prune -o \ \( -type d -name node_modules -prune -print \) -o \ \( -type f -size +"$_minb" -print \) > "$TMP/found" 2>/dev/null < /dev/null [ $? -eq 124 ] && jr_meta find_timeout 1 # node_modules: skip ones inside Library or hidden folders (apps and editor extensions need those) grep '/node_modules$' "$TMP/found" | grep -v -e "^$L/" -e '/\.' | head -n 60 > "$TMP/nm" : > "$TMP/nmsizes" while IFS= read -r _p; do [ -d "$_p" ] || continue jr_size "$_p" 30 && [ "$S_KB" -gt 0 ] && printf '%s\t%s\n' "$S_KB" "$_p" >> "$TMP/nmsizes" done < "$TMP/nm" if [ -s "$TMP/nmsizes" ]; then jr_cat node_modules safe "$(awk -F'\t' '{s+=$1} END{print s+0}' "$TMP/nmsizes")" 0 "" sort -t "$TAB" -k1,1nr "$TMP/nmsizes" | head -n 12 > "$TMP/nm.s" while IFS="$TAB" read -r _kb _p; do _proj=${_p%/node_modules}; jr_item node_modules "$_kb" "$_p" "${_proj##*/}" done < "$TMP/nm.s" fi # -type f never matches symlinks, so links are never counted twice. grep -v '/node_modules$' "$TMP/found" | tr '\n' '\0' | jr_stat_full > "$TMP/fstat" # report size: on a Mac, space actually used on disk; in test mode, the sparse files' apparent size awk -F'\t' -v t="$TEST" '{ printf "%d\t%s\n", (t == 1 ? $1 / 1024 : $2 / 2), $4 }' "$TMP/fstat" | awk -F'\t' -v min=102400 '$1 >= min' | sort -t "$TAB" -k1,1nr | head -n "$TOPN" > "$TMP/top" while IFS="$TAB" read -r _kb _p; do jr_add_path "$_p" && printf 'F\t%s\t%s\n' "$_kb" "$REPLY_ID" >> "$REC" done < "$TMP/top" jr_dupes # disk totals. On modern macOS your files live on the "Data" volume. if [ "$TEST" = 1 ]; then _disk=${JR_DISK_KB:-482344960}; _free=${JR_FREE_KB:-40148992} _host="${JR_TEST_HOST:-Sample Mac (test data)}"; _os="test" else _vol=/; [ -d /System/Volumes/Data ] && _vol=/System/Volumes/Data _disk=$(df -kP "$_vol" 2>/dev/null | awk 'NR==2{print $2}'); _free=$(df -kP "$_vol" 2>/dev/null | awk 'NR==2{print $4}') _host=$(scutil --get ComputerName 2>/dev/null); _os=$(sw_vers -productVersion 2>/dev/null) fi jr_meta disk_kb "${_disk:-0}"; jr_meta free_kb "${_free:-0}" jr_meta host "${_host:-This Mac}"; jr_meta os "${_os:-}" jr_meta when "$(date +%s)"; jr_meta home "$H" } # ---- writing the report --------------------------------------------------------- # Turns the records into a JavaScript object. Every string is escaped so a file # named like JR_HTML } jr_main "$@"