mirror of
https://github.com/tw93/Mole.git
synced 2026-03-22 19:40:07 +00:00
Add hint_is_system_binary() to short-circuit detection for plists that use /bin/*, /usr/bin/*, /usr/libexec/* etc. as their program path. These are custom scripts, not app-backed launch agents, so the stale detection logic does not apply. Previously the function relied on the absence of AssociatedBundleIdentifiers as an implicit skip, which could fail on certain macOS plutil edge cases. Also add teardown() in clean_hints.bats to explicitly remove the LaunchAgents directory after each test, and add run_with_timeout mock in the "skips custom shell wrappers" test to prevent mdfind from influencing results. This eliminates the intermittent failure where test 70 showed "Review:" in output without "Potential stale login item:".
484 lines
15 KiB
Bash
484 lines
15 KiB
Bash
#!/bin/bash
|
|
# Hint notices used by `mo clean` (non-destructive guidance only).
|
|
|
|
set -euo pipefail
|
|
|
|
mole_hints_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
# shellcheck disable=SC1090
|
|
source "$mole_hints_dir/purge_shared.sh"
|
|
|
|
# Quick reminder probe for project build artifacts handled by `mo purge`.
|
|
# Designed to be very fast: shallow directory checks only, no deep find scans.
|
|
# shellcheck disable=SC2329
|
|
load_quick_purge_hint_paths() {
|
|
local config_file="$HOME/.config/mole/purge_paths"
|
|
local -a paths=()
|
|
|
|
while IFS= read -r line; do
|
|
[[ -n "$line" ]] && paths+=("$line")
|
|
done < <(mole_purge_read_paths_config "$config_file")
|
|
|
|
if [[ ${#paths[@]} -eq 0 ]]; then
|
|
paths=("${MOLE_PURGE_DEFAULT_SEARCH_PATHS[@]}")
|
|
fi
|
|
|
|
if [[ ${#paths[@]} -gt 0 ]]; then
|
|
printf '%s\n' "${paths[@]}"
|
|
fi
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
hint_get_path_size_kb_with_timeout() {
|
|
local path="$1"
|
|
local timeout_seconds="${2:-0.8}"
|
|
local du_tmp
|
|
du_tmp=$(mktemp)
|
|
|
|
local du_status=0
|
|
if run_with_timeout "$timeout_seconds" du -skP "$path" > "$du_tmp" 2> /dev/null; then
|
|
du_status=0
|
|
else
|
|
du_status=$?
|
|
fi
|
|
|
|
if [[ $du_status -ne 0 ]]; then
|
|
rm -f "$du_tmp"
|
|
return 1
|
|
fi
|
|
|
|
local size_kb
|
|
size_kb=$(awk 'NR==1 {print $1; exit}' "$du_tmp")
|
|
rm -f "$du_tmp"
|
|
|
|
[[ "$size_kb" =~ ^[0-9]+$ ]] || return 1
|
|
printf '%s\n' "$size_kb"
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
hint_extract_launch_agent_program_path() {
|
|
local plist="$1"
|
|
local program=""
|
|
|
|
program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null || echo "")
|
|
if [[ -z "$program" ]]; then
|
|
program=$(plutil -extract Program raw "$plist" 2> /dev/null || echo "")
|
|
fi
|
|
|
|
printf '%s\n' "$program"
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
hint_extract_launch_agent_associated_bundle() {
|
|
local plist="$1"
|
|
local associated=""
|
|
|
|
associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null || echo "")
|
|
if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then
|
|
associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null || echo "")
|
|
if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then
|
|
associated=""
|
|
fi
|
|
fi
|
|
|
|
printf '%s\n' "$associated"
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
hint_is_app_scoped_launch_target() {
|
|
local program="$1"
|
|
|
|
case "$program" in
|
|
/Applications/Setapp/*.app/* | \
|
|
/Applications/*.app/* | \
|
|
"$HOME"/Applications/*.app/* | \
|
|
/Library/Input\ Methods/*.app/* | \
|
|
/Library/PrivilegedHelperTools/*)
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
return 1
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
hint_is_system_binary() {
|
|
local program="$1"
|
|
|
|
case "$program" in
|
|
/bin/* | /sbin/* | /usr/bin/* | /usr/sbin/* | /usr/libexec/*)
|
|
return 0
|
|
;;
|
|
esac
|
|
|
|
return 1
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
hint_launch_agent_bundle_exists() {
|
|
local bundle_id="$1"
|
|
|
|
[[ -z "$bundle_id" ]] && return 1
|
|
|
|
if run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$bundle_id'" 2> /dev/null | head -1 | grep -q .; then
|
|
return 0
|
|
fi
|
|
|
|
return 1
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
record_project_artifact_hint() {
|
|
local path="$1"
|
|
|
|
PROJECT_ARTIFACT_HINT_COUNT=$((PROJECT_ARTIFACT_HINT_COUNT + 1))
|
|
|
|
if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -lt 2 ]]; then
|
|
PROJECT_ARTIFACT_HINT_EXAMPLES+=("${path/#$HOME/~}")
|
|
fi
|
|
|
|
local sample_max=3
|
|
if [[ $PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES -ge $sample_max ]]; then
|
|
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=true
|
|
return 0
|
|
fi
|
|
|
|
local timeout_seconds="0.8"
|
|
local size_kb=""
|
|
if size_kb=$(hint_get_path_size_kb_with_timeout "$path" "$timeout_seconds"); then
|
|
if [[ "$size_kb" =~ ^[0-9]+$ ]]; then
|
|
PROJECT_ARTIFACT_HINT_ESTIMATED_KB=$((PROJECT_ARTIFACT_HINT_ESTIMATED_KB + size_kb))
|
|
PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=$((PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES + 1))
|
|
else
|
|
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=true
|
|
fi
|
|
else
|
|
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=true
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
is_quick_purge_project_root() {
|
|
mole_purge_is_project_root "$1"
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
probe_project_artifact_hints() {
|
|
PROJECT_ARTIFACT_HINT_DETECTED=false
|
|
PROJECT_ARTIFACT_HINT_COUNT=0
|
|
PROJECT_ARTIFACT_HINT_TRUNCATED=false
|
|
PROJECT_ARTIFACT_HINT_EXAMPLES=()
|
|
PROJECT_ARTIFACT_HINT_ESTIMATED_KB=0
|
|
PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES=0
|
|
PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL=false
|
|
|
|
local max_projects=200
|
|
local max_projects_per_root=0
|
|
local max_nested_per_project=120
|
|
local max_matches=12
|
|
|
|
local -a target_names=()
|
|
while IFS= read -r target_name; do
|
|
[[ -n "$target_name" ]] && target_names+=("$target_name")
|
|
done < <(mole_purge_quick_hint_target_names)
|
|
|
|
local -a scan_roots=()
|
|
while IFS= read -r path; do
|
|
[[ -n "$path" ]] && scan_roots+=("$path")
|
|
done < <(load_quick_purge_hint_paths)
|
|
|
|
[[ ${#scan_roots[@]} -eq 0 ]] && return 0
|
|
|
|
# Fairness: avoid one very large root exhausting the entire scan budget.
|
|
if [[ $max_projects_per_root -le 0 ]]; then
|
|
max_projects_per_root=$(((max_projects + ${#scan_roots[@]} - 1) / ${#scan_roots[@]}))
|
|
[[ $max_projects_per_root -lt 25 ]] && max_projects_per_root=25
|
|
fi
|
|
[[ $max_projects_per_root -gt $max_projects ]] && max_projects_per_root=$max_projects
|
|
|
|
local nullglob_was_set=0
|
|
if shopt -q nullglob; then
|
|
nullglob_was_set=1
|
|
fi
|
|
shopt -s nullglob
|
|
|
|
local scanned_projects=0
|
|
local stop_scan=false
|
|
local root project_dir nested_dir target_name candidate
|
|
|
|
for root in "${scan_roots[@]}"; do
|
|
[[ -d "$root" ]] || continue
|
|
local root_projects_scanned=0
|
|
|
|
if is_quick_purge_project_root "$root"; then
|
|
scanned_projects=$((scanned_projects + 1))
|
|
root_projects_scanned=$((root_projects_scanned + 1))
|
|
if [[ $scanned_projects -gt $max_projects ]]; then
|
|
PROJECT_ARTIFACT_HINT_TRUNCATED=true
|
|
stop_scan=true
|
|
break
|
|
fi
|
|
|
|
for target_name in "${target_names[@]}"; do
|
|
candidate="$root/$target_name"
|
|
if [[ -d "$candidate" ]]; then
|
|
record_project_artifact_hint "$candidate"
|
|
fi
|
|
done
|
|
fi
|
|
[[ "$stop_scan" == "true" ]] && break
|
|
|
|
if [[ $root_projects_scanned -ge $max_projects_per_root ]]; then
|
|
PROJECT_ARTIFACT_HINT_TRUNCATED=true
|
|
continue
|
|
fi
|
|
|
|
for project_dir in "$root"/*/; do
|
|
[[ -d "$project_dir" ]] || continue
|
|
project_dir="${project_dir%/}"
|
|
|
|
local project_name
|
|
project_name=$(basename "$project_dir")
|
|
[[ "$project_name" == .* ]] && continue
|
|
|
|
if [[ $root_projects_scanned -ge $max_projects_per_root ]]; then
|
|
PROJECT_ARTIFACT_HINT_TRUNCATED=true
|
|
break
|
|
fi
|
|
|
|
scanned_projects=$((scanned_projects + 1))
|
|
root_projects_scanned=$((root_projects_scanned + 1))
|
|
if [[ $scanned_projects -gt $max_projects ]]; then
|
|
PROJECT_ARTIFACT_HINT_TRUNCATED=true
|
|
stop_scan=true
|
|
break
|
|
fi
|
|
|
|
for target_name in "${target_names[@]}"; do
|
|
candidate="$project_dir/$target_name"
|
|
if [[ -d "$candidate" ]]; then
|
|
record_project_artifact_hint "$candidate"
|
|
fi
|
|
done
|
|
[[ "$stop_scan" == "true" ]] && break
|
|
|
|
local nested_count=0
|
|
for nested_dir in "$project_dir"/*/; do
|
|
[[ -d "$nested_dir" ]] || continue
|
|
nested_dir="${nested_dir%/}"
|
|
|
|
local nested_name
|
|
nested_name=$(basename "$nested_dir")
|
|
[[ "$nested_name" == .* ]] && continue
|
|
|
|
case "$nested_name" in
|
|
node_modules | target | build | dist | DerivedData | Pods)
|
|
continue
|
|
;;
|
|
esac
|
|
|
|
nested_count=$((nested_count + 1))
|
|
if [[ $nested_count -gt $max_nested_per_project ]]; then
|
|
break
|
|
fi
|
|
|
|
for target_name in "${target_names[@]}"; do
|
|
candidate="$nested_dir/$target_name"
|
|
if [[ -d "$candidate" ]]; then
|
|
record_project_artifact_hint "$candidate"
|
|
fi
|
|
done
|
|
|
|
[[ "$stop_scan" == "true" ]] && break
|
|
done
|
|
|
|
[[ "$stop_scan" == "true" ]] && break
|
|
done
|
|
|
|
[[ "$stop_scan" == "true" ]] && break
|
|
done
|
|
|
|
if [[ $nullglob_was_set -eq 0 ]]; then
|
|
shopt -u nullglob
|
|
fi
|
|
|
|
if [[ $PROJECT_ARTIFACT_HINT_COUNT -gt 0 ]]; then
|
|
PROJECT_ARTIFACT_HINT_DETECTED=true
|
|
fi
|
|
|
|
# Preserve a compact display hint if candidate count is large, but do not
|
|
# stop scanning early solely because we exceeded this threshold.
|
|
if [[ $PROJECT_ARTIFACT_HINT_COUNT -gt $max_matches ]]; then
|
|
PROJECT_ARTIFACT_HINT_TRUNCATED=true
|
|
fi
|
|
|
|
return 0
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
show_system_data_hint_notice() {
|
|
local min_gb=2
|
|
local timeout_seconds="0.8"
|
|
local max_hits=3
|
|
|
|
local threshold_kb=$((min_gb * 1024 * 1024))
|
|
local -a clue_labels=()
|
|
local -a clue_sizes=()
|
|
local -a clue_paths=()
|
|
|
|
local -a labels=(
|
|
"Xcode DerivedData"
|
|
"Xcode Archives"
|
|
"iPhone backups"
|
|
"Simulator data"
|
|
"Docker Desktop data"
|
|
"Mail data"
|
|
)
|
|
local -a paths=(
|
|
"$HOME/Library/Developer/Xcode/DerivedData"
|
|
"$HOME/Library/Developer/Xcode/Archives"
|
|
"$HOME/Library/Application Support/MobileSync/Backup"
|
|
"$HOME/Library/Developer/CoreSimulator/Devices"
|
|
"$HOME/Library/Containers/com.docker.docker/Data"
|
|
"$HOME/Library/Mail"
|
|
)
|
|
|
|
local i
|
|
for i in "${!paths[@]}"; do
|
|
local path="${paths[$i]}"
|
|
[[ -d "$path" ]] || continue
|
|
|
|
local size_kb=""
|
|
if size_kb=$(hint_get_path_size_kb_with_timeout "$path" "$timeout_seconds"); then
|
|
if [[ "$size_kb" -ge "$threshold_kb" ]]; then
|
|
clue_labels+=("${labels[$i]}")
|
|
clue_sizes+=("$size_kb")
|
|
clue_paths+=("${path/#$HOME/~}")
|
|
if [[ ${#clue_labels[@]} -ge $max_hits ]]; then
|
|
break
|
|
fi
|
|
fi
|
|
fi
|
|
done
|
|
|
|
if [[ ${#clue_labels[@]} -eq 0 ]]; then
|
|
note_activity
|
|
echo -e " ${GREEN}${ICON_SUCCESS}${NC} No common System Data clues detected"
|
|
return 0
|
|
fi
|
|
|
|
note_activity
|
|
|
|
for i in "${!clue_labels[@]}"; do
|
|
local human_size
|
|
human_size=$(bytes_to_human "$((clue_sizes[i] * 1024))")
|
|
echo -e " ${GREEN}${ICON_LIST}${NC} ${clue_labels[$i]}: ${human_size}"
|
|
echo -e " ${GRAY}${ICON_SUBLIST}${NC} Path: ${GRAY}${clue_paths[$i]}${NC}"
|
|
done
|
|
echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo analyze, Device backups, docker system df"
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
show_project_artifact_hint_notice() {
|
|
probe_project_artifact_hints
|
|
|
|
if [[ "$PROJECT_ARTIFACT_HINT_DETECTED" != "true" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
note_activity
|
|
|
|
local hint_count_label="$PROJECT_ARTIFACT_HINT_COUNT"
|
|
[[ "$PROJECT_ARTIFACT_HINT_TRUNCATED" == "true" ]] && hint_count_label="${hint_count_label}+"
|
|
|
|
local example_text=""
|
|
if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -gt 0 ]]; then
|
|
example_text="${PROJECT_ARTIFACT_HINT_EXAMPLES[0]}"
|
|
if [[ ${#PROJECT_ARTIFACT_HINT_EXAMPLES[@]} -gt 1 ]]; then
|
|
example_text+=", ${PROJECT_ARTIFACT_HINT_EXAMPLES[1]}"
|
|
fi
|
|
fi
|
|
|
|
if [[ $PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES -gt 0 ]]; then
|
|
local estimate_human
|
|
estimate_human=$(bytes_to_human "$((PROJECT_ARTIFACT_HINT_ESTIMATED_KB * 1024))")
|
|
|
|
local estimate_is_partial="$PROJECT_ARTIFACT_HINT_ESTIMATE_PARTIAL"
|
|
if [[ "$PROJECT_ARTIFACT_HINT_TRUNCATED" == "true" ]] || [[ $PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES -lt $PROJECT_ARTIFACT_HINT_COUNT ]]; then
|
|
estimate_is_partial=true
|
|
fi
|
|
|
|
if [[ "$estimate_is_partial" == "true" ]]; then
|
|
echo -e " ${GREEN}${ICON_LIST}${NC} ${GREEN}${hint_count_label}${NC} candidates, at least ${estimate_human} sampled from ${PROJECT_ARTIFACT_HINT_ESTIMATE_SAMPLES} items"
|
|
else
|
|
echo -e " ${GREEN}${ICON_LIST}${NC} ${GREEN}${hint_count_label}${NC} candidates, sampled ${estimate_human}"
|
|
fi
|
|
else
|
|
echo -e " ${GREEN}${ICON_LIST}${NC} ${GREEN}${hint_count_label}${NC} candidates"
|
|
fi
|
|
|
|
if [[ -n "$example_text" ]]; then
|
|
echo -e " ${GRAY}${ICON_SUBLIST}${NC} Examples: ${GRAY}${example_text}${NC}"
|
|
fi
|
|
echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: mo purge"
|
|
}
|
|
|
|
# shellcheck disable=SC2329
|
|
show_user_launch_agent_hint_notice() {
|
|
local launch_agents_dir="$HOME/Library/LaunchAgents"
|
|
[[ -d "$launch_agents_dir" ]] || return 0
|
|
|
|
local max_hits=3
|
|
local -a labels=()
|
|
local -a reasons=()
|
|
local -a targets=()
|
|
local plist
|
|
|
|
while IFS= read -r -d '' plist; do
|
|
local filename
|
|
filename=$(basename "$plist")
|
|
[[ "$filename" == com.apple.* ]] && continue
|
|
|
|
local reason=""
|
|
local target=""
|
|
local program=""
|
|
local associated=""
|
|
|
|
program=$(hint_extract_launch_agent_program_path "$plist")
|
|
if [[ -n "$program" ]] && hint_is_system_binary "$program"; then
|
|
continue
|
|
fi
|
|
if [[ -n "$program" ]] && hint_is_app_scoped_launch_target "$program" && [[ ! -e "$program" ]]; then
|
|
reason="Missing app/helper target"
|
|
target="${program/#$HOME/~}"
|
|
else
|
|
associated=$(hint_extract_launch_agent_associated_bundle "$plist")
|
|
if [[ -n "$associated" ]] && ! hint_launch_agent_bundle_exists "$associated"; then
|
|
reason="Associated app not found"
|
|
target="$associated"
|
|
fi
|
|
fi
|
|
|
|
if [[ -n "$reason" ]]; then
|
|
labels+=("$filename")
|
|
reasons+=("$reason")
|
|
targets+=("$target")
|
|
if [[ ${#labels[@]} -ge $max_hits ]]; then
|
|
break
|
|
fi
|
|
fi
|
|
done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null)
|
|
|
|
[[ ${#labels[@]} -eq 0 ]] && return 0
|
|
|
|
note_activity
|
|
|
|
local i
|
|
for i in "${!labels[@]}"; do
|
|
echo -e " ${GREEN}${ICON_LIST}${NC} Potential stale login item: ${labels[$i]}"
|
|
echo -e " ${GRAY}${ICON_SUBLIST}${NC} ${reasons[$i]}: ${GRAY}${targets[$i]}${NC}"
|
|
done
|
|
echo -e " ${GRAY}${ICON_REVIEW}${NC} Review: open ~/Library/LaunchAgents and remove only items you recognize"
|
|
}
|