#!/bin/bash # Mole - Uninstall command. # Interactive app uninstaller. # Removes app files and leftovers. set -euo pipefail # User state and installed tools must never run with inherited root privileges. # Individual maintenance operations request administrator access themselves. if [[ "$EUID" -eq 0 ]]; then printf '%s\n' 'Run Mole without sudo; it requests administrator access when needed.' >&2 exit 1 fi # Preserve user's locale for app display name lookup. readonly MOLE_UNINSTALL_USER_LC_ALL="${LC_ALL:-}" readonly MOLE_UNINSTALL_USER_LANG="${LANG:-}" # Fix locale issues on non-English systems. export LC_ALL=C export LANG=C # Load shared helpers. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/../lib/core/common.sh" # Clean temp files on exit. trap cleanup_temp_files EXIT INT TERM source "$SCRIPT_DIR/../lib/ui/menu_paginated.sh" source "$SCRIPT_DIR/../lib/ui/app_selector.sh" source "$SCRIPT_DIR/../lib/uninstall/steam.sh" source "$SCRIPT_DIR/../lib/uninstall/batch.sh" # State selected_apps=() declare -a apps_data=() declare -a selection_state=() total_items=0 files_cleaned=0 total_size_cleaned=0 readonly MOLE_UNINSTALL_META_CACHE_DIR="$HOME/.cache/mole" readonly MOLE_UNINSTALL_META_CACHE_FILE="$MOLE_UNINSTALL_META_CACHE_DIR/uninstall_app_metadata_v5" readonly MOLE_UNINSTALL_META_CACHE_LOCK="${MOLE_UNINSTALL_META_CACHE_FILE}.lock" readonly MOLE_UNINSTALL_META_REFRESH_TTL=604800 # 7 days readonly MOLE_UNINSTALL_EPOCH_FLOOR=978307200 # Display-name mdls lookup budget during scan; overridable for slow disks or # cold Spotlight. readonly MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC="${MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC:-0.04}" readonly MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC="${MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC:-0.04}" # Bounded inline du fallback for cold rows whose quick mdls probe missed # (new apps are often not yet Spotlight-indexed). Only enabled when the # cold-row count is small so a fully cold first scan keeps the fast path. readonly MOLE_UNINSTALL_INLINE_DU_SIZE_TIMEOUT_SEC="${MOLE_UNINSTALL_INLINE_DU_SIZE_TIMEOUT_SEC:-2}" readonly MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS="${MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS:-20}" uninstall_normalize_size_display() { local size="${1:-}" local app_path="${2:-}" if [[ -n "$app_path" ]] && uninstall_app_is_steam_launcher "$app_path"; then echo "N/A (Steam-managed)" return 0 fi if [[ -z "$size" || "$size" == "0" || "$size" == "Unknown" ]]; then echo "N/A" return 0 fi echo "$size" } uninstall_normalize_last_used_display() { local last_used="${1:-}" local display display=$(format_last_used_summary "$last_used") if [[ -z "$display" || "$display" == "Never" ]]; then echo "Unknown" return 0 fi echo "$display" } uninstall_quick_app_size_kb() { local app_path="$1" [[ -n "$app_path" && -d "$app_path" ]] || { echo "0" return 0 } local physical_size physical_size=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_SIZE_TIMEOUT_SEC" mdls -name kMDItemPhysicalSize -raw "$app_path" 2> /dev/null || echo "") if [[ "$physical_size" =~ ^[0-9]+$ && "$physical_size" -gt 0 ]]; then echo $(((physical_size + 1023) / 1024)) return 0 fi echo "0" } # This bounded physical-size fallback stands in until the deferred refresh # can query Spotlight metadata. uninstall_inline_du_size_kb() { local app_path="$1" [[ -n "$app_path" && -d "$app_path" ]] || { echo "0" return 0 } # A PlayCover app lives inside PlayCover's container. if mole_sizing_would_ask_for_app_data "$app_path"; then echo "0" return 0 fi local du_size_kb du_size_kb=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_DU_SIZE_TIMEOUT_SEC" du -skP "$app_path" 2> /dev/null | awk '{print $1; exit}') || du_size_kb="" if [[ "$du_size_kb" =~ ^[0-9]+$ && "$du_size_kb" -gt 0 ]]; then echo "$du_size_kb" return 0 fi echo "0" } # The user's UI language preference, most preferred first. Resolved once per # run: `defaults` costs a fork, and the answer cannot change mid-scan. An empty # result (no `defaults`, or a fresh account with no array) simply skips the # localized lookup and leaves the bundle's unlocalized names in charge. mole_uninstall_preferred_languages() { defaults read -g AppleLanguages 2> /dev/null | sed -e 's/[()"]//g' -e 's/,//g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | grep -v '^$' } # Directory names to try inside Contents/Resources for one BCP-47 tag, most # specific first. macOS bundles spell the same locale several ways, so # "zh-Hans-CN" has to reach a zh-Hans.lproj, a zh_CN.lproj or a bare zh.lproj. _uninstall_lproj_candidates() { local tag="$1" [[ -n "$tag" ]] || return 0 local base script region rest base="${tag%%-*}" rest="${tag#"$base"}" rest="${rest#-}" script="" region="" if [[ -n "$rest" ]]; then local second="${rest%%-*}" if [[ "$second" =~ ^[A-Z][a-z]{3}$ ]]; then script="$second" region="${rest#"$second"}" region="${region#-}" else region="$second" fi fi printf '%s\n' "$tag" "${tag//-/_}" [[ -n "$script" ]] && printf '%s\n' "${base}-${script}" "${base}_${script}" [[ -n "$region" ]] && printf '%s\n' "${base}_${region}" "${base}-${region}" printf '%s\n' "$base" } readonly MOLE_UNINSTALL_PREFERRED_LANGS="$(mole_uninstall_preferred_languages)" # Display names depend on the ordered language list. Keep the fingerprint in # each cache row so a language change invalidates only the derived name while # size and last-used metadata remain reusable. readonly MOLE_UNINSTALL_LANGUAGE_SIGNATURE="$(printf '%s' "$MOLE_UNINSTALL_PREFERRED_LANGS" | cksum | awk '{print $1 ":" $2}')" # The bundle's own name for a locale, read from the same InfoPlist.strings that # Finder consults. Prints nothing when the bundle does not localize the name, # which leaves the caller on the unlocalized Info.plist values. # # The search stops at the first preferred language the bundle localizes at all, # and never continues into a language the user did not ask for. MiaoYan.app is # the case that rule exists for: it ships zh-Hans.lproj but no en.lproj, and an # English-preferring Mac shows "MiaoYan", not the Chinese name that happens to # be the only override present. _uninstall_localized_bundle_name() { local app_path="$1" local resources="$app_path/Contents/Resources" [[ -d "$resources" ]] || return 0 local dev_region="" dev_region=$(plutil -extract CFBundleDevelopmentRegion raw "$app_path/Contents/Info.plist" 2> /dev/null || echo "") case "$dev_region" in English) dev_region="en" ;; Japanese) dev_region="ja" ;; French) dev_region="fr" ;; German) dev_region="de" ;; esac local dev_base="${dev_region%%-*}" local lang candidate lproj="" while IFS= read -r lang; do [[ -n "$lang" ]] || continue while IFS= read -r candidate; do if [[ -d "$resources/$candidate.lproj" ]]; then lproj="$resources/$candidate.lproj" break fi done < <(_uninstall_lproj_candidates "$lang") [[ -n "$lproj" ]] && break # Base.lproj carries the development region, so a bundle with only # Base.lproj still counts as localized for that language. if [[ -n "$dev_base" && "${lang%%-*}" == "$dev_base" && -d "$resources/Base.lproj" ]]; then lproj="$resources/Base.lproj" break fi done <<< "$MOLE_UNINSTALL_PREFERRED_LANGS" [[ -n "$lproj" && -f "$lproj/InfoPlist.strings" ]] || return 0 local localized localized=$(plutil -extract CFBundleDisplayName raw -- "$lproj/InfoPlist.strings" 2> /dev/null || echo "") if [[ -z "$localized" || "$localized" == "(null)" ]]; then localized=$(plutil -extract CFBundleName raw -- "$lproj/InfoPlist.strings" 2> /dev/null || echo "") fi [[ -n "$localized" && "$localized" != "(null)" ]] || return 0 printf '%s' "$localized" } uninstall_resolve_display_name() { local app_path="$1" local app_name="$2" local display_name="$app_name" if mole_is_playcover_app "$app_path"; then # Flat iOS bundle named after its bundle id; the plist holds the name. local playcover_name="" playcover_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Info.plist" 2> /dev/null || echo "") if [[ -z "$playcover_name" || "$playcover_name" == "(null)" ]]; then playcover_name=$(plutil -extract CFBundleName raw "$app_path/Info.plist" 2> /dev/null || echo "") fi [[ -n "$playcover_name" && "$playcover_name" != "(null)" ]] && display_name="$playcover_name" elif [[ -f "$app_path/Contents/Info.plist" ]]; then # The bundle's own localized name is what Finder shows, so it wins and # spares the mdls fork. mdls only ever reports the on-disk file name for # an app bundle, which is exactly the unrecognizable string this avoids. local localized_name localized_name=$(_uninstall_localized_bundle_name "$app_path") local md_display_name="" if [[ -n "$localized_name" ]]; then : elif [[ -n "$MOLE_UNINSTALL_USER_LC_ALL" ]]; then md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" env LC_ALL="$MOLE_UNINSTALL_USER_LC_ALL" LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") elif [[ -n "$MOLE_UNINSTALL_USER_LANG" ]]; then md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" env LANG="$MOLE_UNINSTALL_USER_LANG" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") else md_display_name=$(run_with_timeout "$MOLE_UNINSTALL_INLINE_MDLS_DISPLAY_TIMEOUT_SEC" mdls -name kMDItemDisplayName -raw "$app_path" 2> /dev/null || echo "") fi local bundle_display_name bundle_display_name=$(plutil -extract CFBundleDisplayName raw "$app_path/Contents/Info.plist" 2> /dev/null || echo "") local bundle_name bundle_name=$(plutil -extract CFBundleName raw "$app_path/Contents/Info.plist" 2> /dev/null || echo "") # A path, or a hidden bundle's own file name (.Aegis-backup-1.app), # is never a display name; the bundle's plist names it instead. With # Spotlight off mdls answers (null) and the plist already wins, so # this keeps the name the same on both kinds of Mac (#1791). if [[ "$md_display_name" == /* || "$md_display_name" == .* ]]; then md_display_name="" fi md_display_name="${md_display_name//|/-}" md_display_name="${md_display_name//[$'\t\r\n']/}" bundle_display_name="${bundle_display_name//|/-}" bundle_display_name="${bundle_display_name//[$'\t\r\n']/}" bundle_name="${bundle_name//|/-}" bundle_name="${bundle_name//[$'\t\r\n']/}" if [[ -n "$localized_name" ]]; then display_name="$localized_name" elif [[ -n "$md_display_name" && "$md_display_name" != "(null)" && "$md_display_name" != "$app_name" ]]; then display_name="$md_display_name" elif [[ -n "$bundle_display_name" && "$bundle_display_name" != "(null)" ]]; then display_name="$bundle_display_name" elif [[ -n "$bundle_name" && "$bundle_name" != "(null)" ]]; then display_name="$bundle_name" fi fi if [[ "$display_name" == /* ]]; then display_name="$app_name" fi # Keep versioned bundle names when metadata collapses distinct installs. if [[ -n "$display_name" && "$app_name" == "$display_name"* && "$app_name" != "$display_name" ]]; then local suffix suffix="${app_name#"$display_name"}" if [[ "$suffix" == *[0-9]* ]]; then display_name="$app_name" fi fi display_name="${display_name%.[aA][pP][pP]}" display_name="${display_name//|/-}" display_name="${display_name//[$'\t\r\n']/}" echo "$display_name" } uninstall_acquire_metadata_lock() { local lock_dir="$1" local attempts=0 while ! mkdir "$lock_dir" 2> /dev/null; do ((attempts++)) if [[ $attempts -ge 40 ]]; then return 1 fi # Clean stale lock if older than 5 minutes. if [[ -d "$lock_dir" ]]; then local lock_mtime lock_mtime=$(get_file_mtime "$lock_dir") # Skip stale detection if mtime lookup failed (returns 0). if [[ "$lock_mtime" =~ ^[0-9]+$ && $lock_mtime -gt 0 ]]; then local lock_age lock_age=$(($(get_epoch_seconds) - lock_mtime)) if [[ "$lock_age" =~ ^-?[0-9]+$ && $lock_age -gt 300 ]]; then rmdir "$lock_dir" 2> /dev/null || true fi fi fi sleep 0.1 2> /dev/null || sleep 1 done return 0 } uninstall_release_metadata_lock() { local lock_dir="$1" [[ -d "$lock_dir" ]] && rmdir "$lock_dir" 2> /dev/null || true } # Atomically replace the metadata cache file, healing stale root-owned copies. # stdin is closed so BSD mv/cp never blocks prompting on a non-writable target. uninstall_persist_cache_file() { local src="$1" local dst="$2" [[ -s "$src" ]] || { rm -f "$src" 2> /dev/null || true return 0 } # Heal stale file the user cannot write to (e.g. root-owned from a prior # sudo run). The parent dir is user-owned, so rm succeeds regardless. if [[ -e "$dst" && ! -w "$dst" ]]; then rm -f "$dst" 2> /dev/null || true fi # shellcheck disable=SC2217 # BSD mv/cp read stdin when prompting; close it to avoid hang. mv -f "$src" "$dst" < /dev/null 2> /dev/null || { # shellcheck disable=SC2217 cp -f "$src" "$dst" < /dev/null 2> /dev/null || true rm -f "$src" 2> /dev/null || true } } start_uninstall_metadata_refresh() { local refresh_file="$1" [[ ! -s "$refresh_file" ]] && { rm -f "$refresh_file" 2> /dev/null || true return 0 } ( _refresh_debug() { if [[ "${MO_DEBUG:-}" == "1" ]]; then local ts ts=$(date "+%Y-%m-%d %H:%M:%S" 2> /dev/null || echo "?") echo "[$ts] DEBUG: [metadata-refresh] $*" >> "$DEBUG_LOG_FILE" 2> /dev/null || true fi } ensure_user_dir "$MOLE_UNINSTALL_META_CACHE_DIR" ensure_user_file "$MOLE_UNINSTALL_META_CACHE_FILE" if [[ ! -r "$MOLE_UNINSTALL_META_CACHE_FILE" ]]; then if ! : > "$MOLE_UNINSTALL_META_CACHE_FILE" 2> /dev/null; then _refresh_debug "Cannot create cache file, aborting" exit 0 fi fi if [[ ! -w "$MOLE_UNINSTALL_META_CACHE_FILE" ]]; then _refresh_debug "Cache file not writable, aborting" exit 0 fi local updates_file updates_file=$(mktemp 2> /dev/null) || { _refresh_debug "mktemp failed, aborting" exit 0 } local now_epoch now_epoch=$(get_epoch_seconds) local max_parallel max_parallel=$(get_optimal_parallel_jobs "io") if [[ ! "$max_parallel" =~ ^[0-9]+$ || $max_parallel -lt 1 ]]; then max_parallel=1 elif [[ $max_parallel -gt 4 ]]; then max_parallel=4 fi local -a worker_pids=() local worker_idx=0 wait_rc=0 while IFS='|' read -r app_path app_mtime bundle_id display_name language_signature; do [[ -n "$app_path" && -d "$app_path" ]] || continue ((worker_idx++)) local worker_output="${updates_file}.${worker_idx}" # stdin from /dev/null: these workers never read the terminal, and a # background job that keeps the tty on stdin lets its timeout helpers # take the terminal away from the foreground prompt (#1222). ( local last_used_epoch=0 local metadata_date metadata_date=$(run_with_timeout 0.2 mdls -name kMDItemLastUsedDate -raw "$app_path" 2> /dev/null || echo "") # 0.2s: per-app probe in tight scan loop, see lib/core/timeouts.sh if [[ "$metadata_date" != "(null)" && -n "$metadata_date" ]]; then last_used_epoch=$("$_DATE_CMD" -j -f "%Y-%m-%d %H:%M:%S %z" "$metadata_date" "+%s" 2> /dev/null || echo "0") fi if [[ ! "$last_used_epoch" =~ ^[0-9]+$ || $last_used_epoch -le 0 || $last_used_epoch -lt $MOLE_UNINSTALL_EPOCH_FLOOR ]]; then last_used_epoch=0 fi local size_kb=0 if ! mole_sizing_would_ask_for_app_data "$app_path"; then size_kb=$(get_path_size_kb "$app_path") fi [[ "$size_kb" =~ ^[0-9]+$ ]] || size_kb=0 printf "%s|%s|%s|%s|%s|%s|%s|%s\n" "$app_path" "${app_mtime:-0}" "$size_kb" "${last_used_epoch:-0}" "$now_epoch" "$bundle_id" "$display_name" "$language_signature" > "$worker_output" ) < /dev/null & worker_pids+=($!) if ((${#worker_pids[@]} >= max_parallel)); then local completed_pid="" worker_slot wait_rc=0 mole_wait_for_any_worker completed_pid "${worker_pids[@]}" || wait_rc=$? if [[ $wait_rc -ge 128 ]]; then for worker_pid in "${worker_pids[@]}"; do kill "$worker_pid" 2> /dev/null || true done for worker_pid in "${worker_pids[@]}"; do wait "$worker_pid" 2> /dev/null || true done break fi for worker_slot in "${!worker_pids[@]}"; do if [[ "${worker_pids[$worker_slot]}" == "$completed_pid" ]]; then unset 'worker_pids[worker_slot]' break fi done fi done < "$refresh_file" if [[ $wait_rc -ge 128 ]]; then local output_idx for ((output_idx = 1; output_idx <= worker_idx; output_idx++)); do rm -f "${updates_file}.${output_idx}" # SAFE: exact scratch output of this refresh worker done rm -f "$updates_file" "$refresh_file" # SAFE: exact scratch files owned by this detached refresh exit "$wait_rc" fi local worker_pid for worker_pid in "${worker_pids[@]}"; do wait "$worker_pid" 2> /dev/null || true done local worker_output for worker_output in "${updates_file}".*; do [[ -f "$worker_output" ]] || continue cat "$worker_output" >> "$updates_file" rm -f "$worker_output" done if [[ ! -s "$updates_file" ]]; then rm -f "$updates_file" exit 0 fi if ! uninstall_acquire_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"; then _refresh_debug "Failed to acquire lock, aborting merge" rm -f "$updates_file" exit 0 fi local refresh_merged_file refresh_merged_file=$(mktemp 2> /dev/null) || { _refresh_debug "mktemp for merge failed, aborting" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" rm -f "$updates_file" exit 0 } awk -F'|' ' NR == FNR { updates[$1] = $0; next } !($1 in updates) { print } END { for (path in updates) { print updates[path] } } ' "$updates_file" "$MOLE_UNINSTALL_META_CACHE_FILE" > "$refresh_merged_file" uninstall_persist_cache_file "$refresh_merged_file" "$MOLE_UNINSTALL_META_CACHE_FILE" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" rm -f "$updates_file" "$refresh_merged_file" rm -f "$refresh_file" 2> /dev/null || true # Redirect stdin from /dev/null so the perl timeout fallback does not see # a tty on stdin and hand the controlling terminal to its timed child. # This background refresh (and its nested workers, which inherit this # stdin) never needs the terminal; leaving stdin as the tty lets a worker # steal the foreground process group and stop the foreground prompt with # SIGTTIN (issue #1222). The interactive sudo handoff (#1201) is on # non-background call sites and is unaffected. ) > /dev/null 2>&1 < /dev/null & disown "$!" 2> /dev/null || true } uninstall_print_app_search_dirs() { local -a app_dirs=( "/Applications" "$HOME/Applications" "/Library/Input Methods" "$HOME/Library/Input Methods" ) local playcover_apps_dir playcover_apps_dir=$(mole_playcover_apps_dir) [[ -d "$playcover_apps_dir" ]] && app_dirs+=("$playcover_apps_dir") local vol_app_dir local nullglob_was_set=0 shopt -q nullglob && nullglob_was_set=1 shopt -s nullglob for vol_app_dir in /Volumes/*/Applications; do [[ -d "$vol_app_dir" && -r "$vol_app_dir" ]] || continue if [[ -d "/Applications" && "$vol_app_dir" -ef "/Applications" ]]; then continue fi if [[ -d "$HOME/Applications" && "$vol_app_dir" -ef "$HOME/Applications" ]]; then continue fi app_dirs+=("$vol_app_dir") done if [[ $nullglob_was_set -eq 0 ]]; then shopt -u nullglob fi printf '%s\n' "${app_dirs[@]}" } uninstall_should_skip_app_path() { local app_path="$1" [[ -e "$app_path" ]] || return 0 # Skip nested apps inside another case-variant .app bundle. local parent_dir="${app_path%/*}" if [[ "$parent_dir" == *.[aA][pP][pP] || "$parent_dir" == *.[aA][pP][pP]/* ]]; then return 0 fi # A PlayCover launcher alias is listed through the bundle it points at. if [[ "$parent_dir" == "$HOME/Applications/PlayCover" ]] && mole_playcover_alias_target "$app_path" > /dev/null 2>&1; then return 0 fi if [[ -L "$app_path" ]]; then local link_target link_target=$(readlink "$app_path" 2> /dev/null) if [[ -n "$link_target" ]]; then local resolved_target="$link_target" if [[ "$link_target" != /* ]]; then local link_dir="${app_path%/*}" local _link_parent="${link_target%/*}" [[ "$_link_parent" == "$link_target" ]] && _link_parent="." resolved_target=$(cd "$link_dir" 2> /dev/null && cd "$_link_parent" 2> /dev/null && pwd)/"${link_target##*/}" 2> /dev/null || echo "" fi case "$resolved_target" in /System/* | /usr/bin/* | /usr/lib/* | /bin/* | /sbin/* | /private/etc/*) return 0 ;; esac fi fi return 1 } uninstall_resolve_bundle_id() { local app_path="$1" local fallback_bundle_id="${2:-}" local bundle_id="" local plist="$app_path/Contents/Info.plist" mole_is_playcover_app "$app_path" && plist="$app_path/Info.plist" fallback_bundle_id="${fallback_bundle_id//|/-}" fallback_bundle_id="${fallback_bundle_id//[$'\t\r\n']/}" if [[ -f "$plist" ]]; then bundle_id=$(plutil -extract CFBundleIdentifier raw "$plist" 2> /dev/null || echo "") bundle_id="${bundle_id//|/-}" bundle_id="${bundle_id//[$'\t\r\n']/}" fi if [[ -n "$bundle_id" && "$bundle_id" != "(null)" ]]; then printf '%s\n' "$bundle_id" return 0 fi if [[ -n "$fallback_bundle_id" && "$fallback_bundle_id" != "(null)" ]]; then printf '%s\n' "$fallback_bundle_id" return 0 fi printf '%s\n' "unknown" } uninstall_app_is_background_only() { local app_path="$1" local plist="$app_path/Contents/Info.plist" [[ -f "$plist" ]] || return 1 local bg_only bg_only=$(plutil -extract LSBackgroundOnly raw "$plist" 2> /dev/null || echo "") case "$bg_only" in 1 | YES | yes | TRUE | true) return 0 ;; esac return 1 } uninstall_app_is_directly_in_search_root() { local app_path="$1" local app_parent="${app_path%/*}" local app_dir while IFS= read -r app_dir; do [[ -n "$app_dir" ]] || continue if [[ "$app_parent" == "$app_dir" ]]; then return 0 fi done < <(uninstall_print_app_search_dirs) return 1 } uninstall_app_is_currently_eligible() { local app_path="$1" local bundle_id="${2:-}" [[ -n "$app_path" && -e "$app_path" ]] || return 1 if [[ -n "$bundle_id" && "$bundle_id" != "unknown" ]] && should_protect_from_uninstall "$bundle_id"; then return 1 fi if uninstall_app_is_background_only "$app_path" && ! uninstall_app_is_directly_in_search_root "$app_path"; then return 1 fi return 0 } uninstall_resolve_eligible_bundle_id() { local app_path="$1" local fallback_bundle_id="${2:-}" local bundle_id bundle_id=$(uninstall_resolve_bundle_id "$app_path" "$fallback_bundle_id") uninstall_app_is_currently_eligible "$app_path" "$bundle_id" || return 1 printf '%s\n' "$bundle_id" } uninstall_print_app_paths_with_mtime() { local app_dir="$1" local include_info_mtime="${2:-false}" [[ -d "$app_dir" ]] || return 0 # shellcheck disable=SC2016 # The child shell expands the batch arguments. # A hidden folder holds updater staging copies (/Applications/.x-update/app/X.app), # not installs; a hidden .app itself is still listed. command find "$app_dir" -mindepth 1 -maxdepth 3 \( -type d -name '.*' ! -iname '*.app' -prune \) -o -iname "*.app" -exec /bin/bash -c ' stat_cmd="$1" include_info="$2" shift 2 stat_paths=("$@") if [[ "$include_info" == true ]]; then for app_path in "$@"; do if [[ -e "$app_path/Contents/Info.plist" || -L "$app_path/Contents/Info.plist" ]]; then stat_paths+=("$app_path/Contents/Info.plist") elif [[ -e "$app_path/Info.plist" || -L "$app_path/Info.plist" ]]; then stat_paths+=("$app_path/Info.plist") fi done fi if rows=$("$stat_cmd" -f "%m%t%N" "${stat_paths[@]}" 2>/dev/null); then if [[ "$include_info" == true ]]; then printf "%s\n" "$rows" | awk -F "\t" '\'' { path = substr($0, index($0, "\t") + 1) mtimes[path] = $1 ~ /^[0-9]+$/ ? $1 : 0 if (tolower(path) ~ /[.]app$/) paths[++count] = path } END { for (i = 1; i <= count; i++) { path = paths[i] info = mtimes[path "/Contents/Info.plist"] if (info == "") info = mtimes[path "/Info.plist"] printf "%s\t%s\t%s\n", mtimes[path], (info == "" ? 0 : info), path } } '\'' else printf "%s\n" "$rows" | awk -F "\t" '\'' { printf "%s\t%s\n", ($1 ~ /^[0-9]+$/ ? $1 : 0), substr($0, index($0, "\t") + 1) } '\'' fi else # Discard a partial batch and preserve the old zero-mtime fallback. for app_path in "$@"; do app_mtime=$("$stat_cmd" -f%m "$app_path" 2>/dev/null) || app_mtime=0 [[ "$app_mtime" =~ ^[0-9]+$ ]] || app_mtime=0 if [[ "$include_info" == true ]]; then info_path="$app_path/Contents/Info.plist" if [[ ! -e "$info_path" && ! -L "$info_path" ]]; then info_path="$app_path/Info.plist" fi info_mtime=$("$stat_cmd" -f%m "$info_path" 2>/dev/null) || info_mtime=0 [[ "$info_mtime" =~ ^[0-9]+$ ]] || info_mtime=0 printf "%s\t%s\t%s\n" "$app_mtime" "$info_mtime" "$app_path" else printf "%s\t%s\n" "$app_mtime" "$app_path" fi done fi ' _ "$STAT_BSD" "$include_info_mtime" {} + 2> /dev/null } uninstall_app_inventory_fingerprint() { if [[ -n "${1:-}" && -s "$1" ]]; then LC_ALL=C sort -u "$1" return $? fi local app_dir app_path app_mtime info_mtime pkg_app_path { while IFS= read -r pkg_app_path; do [[ -n "$pkg_app_path" && -d "$pkg_app_path" ]] || continue app_mtime=$(get_file_mtime "$pkg_app_path") info_mtime=$(get_file_mtime "$pkg_app_path/Contents/Info.plist") printf '%s|%s|%s\n' "$pkg_app_path" "${app_mtime:-0}" "${info_mtime:-0}" done < <(pkg_receipt_nonstandard_app_paths) while IFS= read -r app_dir; do [[ -d "$app_dir" ]] || continue while IFS=$'\t' read -r app_mtime info_mtime app_path; do if [[ ! "$info_mtime" =~ ^[0-9]+$ ]]; then app_path="${info_mtime}${app_path:+$'\t'$app_path}" info_mtime="" fi [[ -n "$app_path" ]] || continue uninstall_should_skip_app_path "$app_path" && continue [[ "$info_mtime" =~ ^[0-9]+$ ]] || info_mtime=$(get_file_mtime "$app_path/Contents/Info.plist") printf '%s|%s|%s\n' "$app_path" "${app_mtime:-0}" "${info_mtime:-0}" done < <(uninstall_print_app_paths_with_mtime "$app_dir" true) done < <(uninstall_print_app_search_dirs) } | LC_ALL=C sort -u } # The in-session app index remains valid when the live inventory only loses # rows. load_applications rechecks path existence before displaying each row. # New rows and changed mtimes must rebuild the index so protection and bundle # metadata are evaluated again. uninstall_inventory_can_reuse_cached_apps() { local cached_inventory="$1" local current_inventory="$2" local additions="" local removals="" [[ -n "$cached_inventory" && -n "$current_inventory" ]] || return 1 additions=$(LC_ALL=C comm -13 \ <(printf '%s\n' "$cached_inventory") \ <(printf '%s\n' "$current_inventory")) || return 1 [[ -z "$additions" ]] || return 1 removals=$(LC_ALL=C comm -23 \ <(printf '%s\n' "$cached_inventory") \ <(printf '%s\n' "$current_inventory")) || return 1 local removed_row removed_path while IFS= read -r removed_row; do [[ -n "$removed_row" ]] || continue removed_path="${removed_row%|*}" removed_path="${removed_path%|*}" [[ ! -e "$removed_path" ]] || return 1 done <<< "$removals" return 0 } # Internal helpers for scan_applications. They read and write locals # declared in the orchestrator's scope via bash dynamic scoping; do not # call them outside scan_applications. # Both metadata phases publish their live workers to scan_applications so # interruption can stop and reap them before removing the scan scratch files. _scan_stop_metadata_workers() { if [[ ${#metadata_worker_pids[@]} -gt 0 ]]; then local metadata_pid for metadata_pid in "${metadata_worker_pids[@]}"; do kill "$metadata_pid" 2> /dev/null || true done for metadata_pid in "${metadata_worker_pids[@]}"; do wait "$metadata_pid" 2> /dev/null || true done fi metadata_worker_pids=() if [[ ${#metadata_worker_outputs[@]} -gt 0 ]]; then local metadata_output for metadata_output in "${metadata_worker_outputs[@]}"; do rm -f "$metadata_output" # SAFE: exact per-worker scan scratch path done fi metadata_worker_outputs=() if [[ -n "${metadata_worker_output_dir:-}" ]]; then rmdir "$metadata_worker_output_dir" 2> /dev/null || true metadata_worker_output_dir="" fi } _scan_record_inventory_app() { local app_path="$1" app_mtime="$2" info_mtime="${3:-}" [[ "$info_mtime" =~ ^[0-9]+$ ]] || info_mtime=$(get_file_mtime "$app_path/Contents/Info.plist") printf '%s|%s|%s\n' "$app_path" "${app_mtime:-0}" "${info_mtime:-0}" >> "$scan_inventory_file" } # Phase 2 (Pass 1): discover candidate .app paths by combining the # configured app search directories with pkg-receipt non-standard install # locations, skipping bundles flagged by uninstall_should_skip_app_path. # Each row in discovered_file is encoded as ||. # Writes: discovered_file and scan_inventory_file _scan_discover_apps() { local -a app_dirs=() local app_dir while IFS= read -r app_dir; do [[ -n "$app_dir" ]] && app_dirs+=("$app_dir") done < <(uninstall_print_app_search_dirs) # Scan for pkg-installed apps in non-standard locations. local pkg_app_path while IFS= read -r pkg_app_path; do [[ -n "$pkg_app_path" ]] || continue local app_mtime app_mtime=$(get_file_mtime "$pkg_app_path") if [[ -d "$pkg_app_path" ]]; then _scan_record_inventory_app "$pkg_app_path" "$app_mtime" fi local already_scanned=false for app_dir in "${app_dirs[@]}"; do if [[ "$pkg_app_path" == "$app_dir"/*.[aA][pP][pP] ]]; then already_scanned=true break fi done [[ "$already_scanned" == true ]] && continue local app_name="${pkg_app_path##*/}" app_name="${app_name%.[aA][pP][pP]}" printf "%s|%s|%s\n" "$pkg_app_path" "$app_name" "${app_mtime:-0}" >> "$discovered_file" done < <(pkg_receipt_nonstandard_app_paths) for app_dir in "${app_dirs[@]}"; do if [[ ! -d "$app_dir" ]]; then continue; fi local info_mtime while IFS=$'\t' read -r app_mtime info_mtime app_path; do if [[ ! "$info_mtime" =~ ^[0-9]+$ ]]; then app_path="${info_mtime}${app_path:+$'\t'$app_path}" info_mtime="" fi if [[ ! -e "$app_path" ]]; then continue; fi local app_name="${app_path##*/}" app_name="${app_name%.[aA][pP][pP]}" uninstall_should_skip_app_path "$app_path" && continue _scan_record_inventory_app "$app_path" "$app_mtime" "$info_mtime" printf "%s|%s|%s\n" "$app_path" "$app_name" "${app_mtime:-0}" >> "$discovered_file" done < <(uninstall_print_app_paths_with_mtime "$app_dir" true) done } # Phase 3: partition discovered apps into warm-cache rows (written # directly to scan_raw_file) and cold rows (queued in app_data_tuples # for parallel metadata resolution in _scan_resolve_uncached). # Reads: cache_source, discovered_file # Writes: cached_rows_file, uncached_rows_file, scan_raw_file (via the # nested use_cached_scan_metadata helper), app_data_tuples _scan_partition_cache() { use_cached_scan_metadata() { local cached_app_path="$1" local cached_app_mtime="$2" local cached_bundle_id="$3" local cached_display_name="$4" local cached_size_kb="$5" local cached_output_file="$6" [[ -n "$cached_bundle_id" && -n "$cached_display_name" ]] || return 1 [[ "$cached_size_kb" =~ ^[0-9]+$ && "$cached_size_kb" -gt 0 ]] || return 1 cached_bundle_id=$(uninstall_resolve_eligible_bundle_id "$cached_app_path" "$cached_bundle_id") || return 1 printf "%s|%s|%s|%s|%s\n" "$cached_app_path" "$cached_display_name" "$cached_bundle_id" "$cached_app_mtime" "$cached_size_kb" >> "$cached_output_file" return 0 } if [[ -s "$discovered_file" ]]; then awk -F'|' -v cached_out="$cached_rows_file" -v uncached_out="$uncached_rows_file" -v language_signature="$MOLE_UNINSTALL_LANGUAGE_SIGNATURE" ' FILENAME == ARGV[1] { cache_mtime[$1] = $2 cache_size[$1] = $3 cache_bundle[$1] = $6 cache_display[$1] = $7 cache_language[$1] = $8 next } { path = $1 app_mtime = $3 if (cache_mtime[path] == app_mtime && cache_display[path] != "" && cache_language[path] == language_signature && cache_size[path] ~ /^[0-9]+$/ && cache_size[path] > 0) { cached_bundle = cache_bundle[path] == "" ? "unknown" : cache_bundle[path] print path "|" app_mtime "|" cached_bundle "|" cache_display[path] "|" cache_size[path] >> cached_out } else { cached_display = cache_language[path] == language_signature ? cache_display[path] : "" print path "|" $2 "|" app_mtime "|" cache_bundle[path] "|" cached_display >> uncached_out } } ' "$cache_source" "$discovered_file" # Live eligibility checks still run for warm rows, but share a small # worker budget instead of serializing every plist and protection probe. _ensure_uninstall_regex local max_parallel max_parallel=$(get_optimal_parallel_jobs io) [[ "$max_parallel" =~ ^[0-9]+$ && $max_parallel -gt 0 ]] || max_parallel=1 [[ $max_parallel -le 4 ]] || max_parallel=4 metadata_worker_outputs=() metadata_worker_pids=() if [[ -s "$cached_rows_file" ]]; then metadata_worker_output_dir=$(create_temp_dir) || return 1 fi local cached_app_path cached_app_mtime cached_bundle_id cached_display_name cached_size_kb local completed_pid="" worker_slot warm_rc=0 while IFS='|' read -r cached_app_path cached_app_mtime cached_bundle_id cached_display_name cached_size_kb; do local warm_output="${metadata_worker_output_dir}/warm.${#metadata_worker_outputs[@]}" metadata_worker_outputs+=("$warm_output") ( umask 077 use_cached_scan_metadata "$cached_app_path" "$cached_app_mtime" "$cached_bundle_id" "$cached_display_name" "$cached_size_kb" "$warm_output" || true ) < /dev/null & metadata_worker_pids+=("$!") if [[ ${#metadata_worker_pids[@]} -ge $max_parallel ]]; then warm_rc=0 mole_wait_for_any_worker completed_pid "${metadata_worker_pids[@]}" || warm_rc=$? if [[ $warm_rc -ge 128 ]]; then break fi for worker_slot in "${!metadata_worker_pids[@]}"; do if [[ "${metadata_worker_pids[$worker_slot]}" == "$completed_pid" ]]; then unset 'metadata_worker_pids[worker_slot]' break fi done fi done < "$cached_rows_file" if [[ $warm_rc -ge 128 ]]; then _scan_stop_metadata_workers fi if [[ ${#metadata_worker_pids[@]} -gt 0 ]]; then for pid in "${metadata_worker_pids[@]}"; do [[ $warm_rc -lt 128 ]] || kill "$pid" 2> /dev/null || true local worker_rc=0 wait "$pid" 2> /dev/null || worker_rc=$? [[ $worker_rc -lt 128 ]] || warm_rc=$worker_rc done fi metadata_worker_pids=() if [[ ${#metadata_worker_outputs[@]} -gt 0 ]]; then for warm_output in "${metadata_worker_outputs[@]}"; do if [[ $warm_rc -lt 128 && -f "$warm_output" ]]; then local cached_scan_row while IFS= read -r cached_scan_row; do printf '%s\n' "$cached_scan_row" done < "$warm_output" >> "$scan_raw_file" fi rm -f "$warm_output" # SAFE: exact per-worker scan scratch path above done fi metadata_worker_outputs=() if [[ -n "${metadata_worker_output_dir:-}" ]]; then rmdir "$metadata_worker_output_dir" 2> /dev/null || true metadata_worker_output_dir="" fi [[ $warm_rc -lt 128 ]] || return "$warm_rc" local uncached_app_path uncached_app_name uncached_app_mtime uncached_bundle_id uncached_display_name while IFS='|' read -r uncached_app_path uncached_app_name uncached_app_mtime uncached_bundle_id uncached_display_name; do app_data_tuples+=("${uncached_app_path}|${uncached_app_name}|${uncached_app_mtime}|${uncached_bundle_id}|${uncached_display_name}") done < "$uncached_rows_file" fi } # Phase 5 (Pass 2): resolve display names and bundle IDs in parallel for # the cold rows queued by _scan_partition_cache. Spawns the progress # spinner subprocess (assigns spinner_pid), fans out workers up to # max_parallel, and waits for completion. # Reads: app_data_tuples # Writes: scan_raw_file (appended by worker subshells) _scan_resolve_uncached() { local app_count=0 local total_apps=${#app_data_tuples[@]} # Cold rows are usually the handful of newly installed or updated apps; # give those a bounded du when the quick mdls probe misses so the size # shows on first paint. A fully cold cache (first run) exceeds the cap # and keeps the fast path; the deferred refresh still fills the cache. local inline_du_fallback=0 if [[ "$MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS" =~ ^[0-9]+$ ]] && ((total_apps > 0 && total_apps <= MOLE_UNINSTALL_INLINE_DU_MAX_COLD_ROWS)); then inline_du_fallback=1 fi local max_parallel max_parallel=$(get_optimal_parallel_jobs "io") if [[ $max_parallel -lt 8 ]]; then max_parallel=8 # At least 8 for good performance elif [[ $max_parallel -gt 32 ]]; then max_parallel=32 # Cap at 32 to avoid too many processes fi metadata_worker_pids=() process_app_metadata() { local app_data_tuple="$1" local output_file="$2" IFS='|' read -r app_path app_name app_mtime cached_bundle_id cached_display_name <<< "$app_data_tuple" local bundle_id bundle_id=$(uninstall_resolve_eligible_bundle_id "$app_path" "${cached_bundle_id:-}") || return 0 local display_name="${cached_display_name:-}" if [[ -z "$display_name" ]]; then display_name=$(uninstall_resolve_display_name "$app_path" "$app_name") fi display_name="${display_name%.[aA][pP][pP]}" display_name="${display_name//|/-}" display_name="${display_name//[$'\t\r\n']/}" local quick_size_kb quick_size_kb=$(uninstall_quick_app_size_kb "$app_path") [[ "$quick_size_kb" =~ ^[0-9]+$ ]] || quick_size_kb=0 if [[ "$quick_size_kb" -eq 0 && "${inline_du_fallback:-0}" == "1" ]]; then quick_size_kb=$(uninstall_inline_du_size_kb "$app_path") [[ "$quick_size_kb" =~ ^[0-9]+$ ]] || quick_size_kb=0 fi echo "${app_path}|${display_name}|${bundle_id}|${app_mtime}|${quick_size_kb}" >> "$output_file" } update_scan_status "Scanning applications..." "0" "$total_apps" # Skip Pass 2 when the warm cache already wrote every row to $scan_raw_file. # Also avoids expanding an empty array; macOS bash 3.2 (the /bin/bash that # this script targets) treats `"${empty[@]}"` as unbound under `set -u`. if ((total_apps > 0)); then for app_data_tuple in "${app_data_tuples[@]}"; do ((app_count++)) # Redirect stdin from /dev/null so the perl timeout fallback used by # process_app_metadata does not hand the controlling terminal to its # timed mdls/du child from this background worker (issue #1222). process_app_metadata "$app_data_tuple" "$scan_raw_file" < /dev/null & metadata_worker_pids+=($!) update_scan_status "Scanning applications..." "$app_count" "$total_apps" if ((${#metadata_worker_pids[@]} >= max_parallel)); then local completed_pid="" worker_slot wait_rc=0 mole_wait_for_any_worker completed_pid "${metadata_worker_pids[@]}" || wait_rc=$? if [[ $wait_rc -ge 128 ]]; then return "$wait_rc" fi for worker_slot in "${!metadata_worker_pids[@]}"; do if [[ "${metadata_worker_pids[$worker_slot]}" == "$completed_pid" ]]; then unset 'metadata_worker_pids[worker_slot]' break fi done fi done for pid in "${metadata_worker_pids[@]+"${metadata_worker_pids[@]}"}"; do local wait_rc=0 wait "$pid" 2> /dev/null || wait_rc=$? if [[ $wait_rc -ge 128 ]]; then return "$wait_rc" fi done metadata_worker_pids=() fi } # Phase 6: collapse duplicate bundle IDs discovered from backup volumes or # mirrored Applications folders. Keep the live app locations first. # The dedupe key includes the .app basename so distinct installs that share a # bundle ID (e.g. Xcode.app and Xcode-beta.app, both com.apple.dt.Xcode) are # kept, while true clones of the same bundle name in mirrored roots collapse. # A hidden bundle (.Aegis-backup-1.app, left by a manual reinstall) folds into # a visible app with the same bundle ID, so name matching cannot pick the # backup over the live install (#1791); a hidden app with no visible twin stays. _scan_dedupe_bundle_ids() { [[ -s "$scan_raw_file" ]] || return 0 local deduped_file="${scan_raw_file}.deduped" if ! awk -F'|' -v home_apps="$HOME/Applications/" ' function starts_with(value, prefix) { return prefix != "" && substr(value, 1, length(prefix)) == prefix } function direct_app_under(path, prefix, rest) { if (!starts_with(path, prefix)) { return 0 } rest = substr(path, length(prefix) + 1) return index(rest, "/") == 0 && tolower(rest) ~ /[.]app$/ } function path_rank(path) { if (direct_app_under(path, "/Applications/")) { return 1 } if (direct_app_under(path, home_apps)) { return 2 } if (starts_with(path, "/Volumes/")) { return 4 } return 3 } function app_basename(path, n, parts) { n = split(path, parts, "/") return parts[n] } NR == FNR { if ($3 != "" && $3 != "unknown" && substr(app_basename($1), 1, 1) != "." && !($3 in visible_key)) { visible_key[$3] = $3 "|" app_basename($1) } next } { bundle_id = $3 if (bundle_id == "" || bundle_id == "unknown") { key = "__path__" NR rows[key] = $0 order[++count] = key next } key = bundle_id "|" app_basename($1) rank = path_rank($1) if (substr(app_basename($1), 1, 1) == "." && (bundle_id in visible_key)) { key = visible_key[bundle_id] rank = 9 } if (!(key in rows)) { rows[key] = $0 ranks[key] = rank order[++count] = key next } if (rank < ranks[key]) { rows[key] = $0 ranks[key] = rank } } END { for (i = 1; i <= count; i++) { key = order[i] if (key in rows) { print rows[key] } } } ' "$scan_raw_file" "$scan_raw_file" > "$deduped_file"; then rm -f "$deduped_file" 2> /dev/null || true return 0 fi if ! mv "$deduped_file" "$scan_raw_file" 2> /dev/null; then rm -f "$deduped_file" 2> /dev/null || true fi } # Phase 7+8: merge scan_raw_file with the persistent metadata cache, # compute display size / last-used / refresh-needed flags via the embedded awk # pipeline, persist the cache snapshot under a lock, sort the result by epoch, # kick off the deferred background refresh, and echo the sorted index path for # the caller to capture. # Reads: scan_raw_file, cache_source # Writes: merged_file, refresh_file, cache_snapshot_file, temp_file, # ${temp_file}.sorted, MOLE_UNINSTALL_META_CACHE_FILE # Returns: 0 on success (sorted path is echoed on stdout), 1 if sort # fails or the sorted file did not materialize. _scan_finalize_index() { update_scan_status "Merging cache data..." "0" "0" awk -F'|' ' NR == FNR { cache_mtime[$1] = $2 cache_size[$1] = $3 cache_epoch[$1] = $4 cache_updated[$1] = $5 cache_bundle[$1] = $6 cache_display[$1] = $7 cache_language[$1] = $8 next } { print $0 "|" cache_mtime[$1] "|" cache_size[$1] "|" cache_epoch[$1] "|" cache_updated[$1] "|" cache_bundle[$1] "|" cache_display[$1] "|" cache_language[$1] } ' "$cache_source" "$scan_raw_file" > "$merged_file" if [[ ! -s "$merged_file" && -s "$scan_raw_file" ]]; then awk '{print $0 "|||||||"}' "$scan_raw_file" > "$merged_file" fi local current_epoch current_epoch=$(get_epoch_seconds) local metadata_total=0 metadata_total=$(wc -l < "$merged_file" 2> /dev/null || echo "0") [[ "$metadata_total" =~ ^[0-9]+$ ]] || metadata_total=0 update_scan_status "Collecting metadata..." "0" "$metadata_total" awk -F'|' \ -v now="$current_epoch" \ -v floor="$MOLE_UNINSTALL_EPOCH_FLOOR" \ -v ttl="$MOLE_UNINSTALL_META_REFRESH_TTL" \ -v language_signature="$MOLE_UNINSTALL_LANGUAGE_SIGNATURE" \ -v refresh_out="$refresh_file" \ -v snapshot_out="$cache_snapshot_file" \ -v apps_out="$temp_file" ' function isnum(value) { return value ~ /^[0-9]+$/ } function human_size(kb, bytes, scaled) { if (!isnum(kb) || kb <= 0) { return "--" } bytes = kb * 1024 if (bytes >= 1000000000) { scaled = int((bytes * 100 + 500000000) / 1000000000) return sprintf("%d.%02dGB", int(scaled / 100), scaled % 100) } if (bytes >= 1000000) { scaled = int((bytes * 10 + 500000) / 1000000) return sprintf("%d.%01dMB", int(scaled / 10), scaled % 10) } if (bytes >= 1000) { return sprintf("%dKB", int((bytes + 500) / 1000)) } return sprintf("%dB", bytes) } function relative_time(epoch, now_epoch, days_ago, weeks_ago, months_ago, years_ago) { if (!isnum(epoch) || epoch <= 0 || epoch < floor) { return "Unknown" } days_ago = int((now_epoch - epoch) / 86400) if (days_ago < 0) { days_ago = 0 } if (days_ago == 0) { return "Today" } if (days_ago == 1) { return "Yesterday" } if (days_ago < 7) { return days_ago " days ago" } if (days_ago < 30) { weeks_ago = int(days_ago / 7) return weeks_ago == 1 ? "1 week ago" : weeks_ago " weeks ago" } if (days_ago < 365) { months_ago = int(days_ago / 30) return months_ago == 1 ? "1 month ago" : months_ago " months ago" } years_ago = int(days_ago / 365) return years_ago == 1 ? "1 year ago" : years_ago " years ago" } { app_path = $1 display_name = $2 bundle_id = $3 app_mtime = $4 # A merged row is the 5-field scan row plus the 7 cache fields # appended above, so 12 is the only width the current writers # produce. The threshold has to track that sum: while the cache # block was 6 fields wide, 11 named the same shape, and leaving # it at 11 after the language signature landed would have let a # hypothetical 11-field row take this branch and read every # cached_* value shifted by one, so the signature itself would # render as the display name. if (NF >= 12) { inline_size_kb = $5 cached_mtime = $6 cached_size_kb = $7 cached_epoch = $8 cached_updated_epoch = $9 cached_bundle_id = $10 cached_display_name = $11 cached_language_signature = $12 } else { inline_size_kb = 0 cached_mtime = $5 cached_size_kb = $6 cached_epoch = $7 cached_updated_epoch = $8 cached_bundle_id = $9 cached_display_name = $10 cached_language_signature = "" } cache_match = (cached_mtime != "" && app_mtime != "" && cached_mtime == app_mtime) final_epoch = (isnum(cached_epoch) && cached_epoch > 0) ? cached_epoch : 0 if (isnum(final_epoch) && final_epoch < floor) { final_epoch = 0 } if ((!isnum(final_epoch) || final_epoch <= 0) && isnum(app_mtime) && app_mtime > floor) { final_epoch = app_mtime } final_size_kb = (isnum(cached_size_kb) && cached_size_kb > 0) ? cached_size_kb : 0 if ((!isnum(final_size_kb) || final_size_kb <= 0) && isnum(inline_size_kb) && inline_size_kb > 0) { final_size_kb = inline_size_kb } final_size = human_size(final_size_kb) final_last_used = relative_time(final_epoch, now) needs_refresh = 0 if (!cache_match) { needs_refresh = 1 } else if (!isnum(cached_size_kb) || cached_size_kb <= 0) { needs_refresh = 1 } else if (!isnum(cached_epoch) || cached_epoch <= 0) { needs_refresh = 1 } else if (!isnum(cached_updated_epoch)) { needs_refresh = 1 } else if (cached_bundle_id == "" || cached_display_name == "") { needs_refresh = 1 } else if (cached_language_signature != language_signature) { needs_refresh = 1 } else if ((now - cached_updated_epoch) > ttl || cached_updated_epoch > now) { needs_refresh = 1 } if (needs_refresh) { print app_path "|" app_mtime "|" bundle_id "|" display_name "|" language_signature >> refresh_out } persist_updated_epoch = (isnum(cached_updated_epoch) && cached_updated_epoch > 0) ? cached_updated_epoch : 0 print app_path "|" app_mtime "|" final_size_kb "|" final_epoch "|" persist_updated_epoch "|" bundle_id "|" display_name "|" language_signature >> snapshot_out print final_epoch "|" app_path "|" display_name "|" bundle_id "|" final_size "|" final_last_used "|" final_size_kb >> apps_out } ' "$merged_file" update_scan_status "Updating cache..." "0" "0" if [[ -s "$cache_snapshot_file" ]]; then if uninstall_acquire_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK"; then uninstall_persist_cache_file "$cache_snapshot_file" "$MOLE_UNINSTALL_META_CACHE_FILE" uninstall_release_metadata_lock "$MOLE_UNINSTALL_META_CACHE_LOCK" fi fi update_scan_status "Sorting application list..." "0" "0" sort -t'|' -k1,1n "$temp_file" > "${temp_file}.sorted" || { stop_scan_spinner rm -f "$scan_inventory_file" "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true restore_scan_int_trap return 1 } rm -f "$temp_file" "$scan_raw_file" "$merged_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true update_scan_status "Finalizing list..." "0" "0" debug_log "Uninstall finalization: metadata refresh begin (elapsed ${SECONDS}s, parent $$)" start_uninstall_metadata_refresh "$refresh_file" debug_log "Uninstall finalization: metadata refresh launched (elapsed ${SECONDS}s, parent $$)" stop_scan_spinner "Preparing app list..." debug_log "Uninstall finalization: spinner stopped (elapsed ${SECONDS}s, parent $$)" if [[ -f "${temp_file}.sorted" ]]; then # shellcheck disable=SC2217 # BSD mv may read stdin when overriding permissions. mv -f "$scan_inventory_file" "${temp_file}.sorted.inventory" < /dev/null || { rm -f "$scan_inventory_file" "${temp_file}.sorted" # SAFE: exact scan output and inventory scratch files restore_scan_int_trap return 1 } register_temp_file "${temp_file}.sorted.inventory" register_temp_file "${temp_file}.sorted" restore_scan_int_trap echo "${temp_file}.sorted" return 0 else restore_scan_int_trap return 1 fi } # Scan applications and collect information. Orchestrates the four # phases (discover, partition, resolve, finalize) and owns the shared # temp files, spinner subprocess, INT trap, and metadata cache lock. scan_applications() { local temp_file scan_raw_file merged_file refresh_file cache_snapshot_file discovered_file cached_rows_file uncached_rows_file temp_file=$(create_temp_file) || return 1 scan_raw_file="${temp_file}.scan" merged_file="${temp_file}.merged" refresh_file="${temp_file}.refresh" cache_snapshot_file="${temp_file}.cache" discovered_file="${temp_file}.discovered" cached_rows_file="${temp_file}.cached_rows" uncached_rows_file="${temp_file}.uncached_rows" local scan_inventory_file scan_inventory_file=$(create_temp_file) || { rm -f "$temp_file" # SAFE: exact scan scratch anchor, before any worker starts return 1 } local scan_status_file="${temp_file}.scan_status" : > "$scan_raw_file" : > "$refresh_file" : > "$cache_snapshot_file" : > "$discovered_file" : > "$cached_rows_file" : > "$uncached_rows_file" : > "$scan_status_file" ensure_user_dir "$MOLE_UNINSTALL_META_CACHE_DIR" ensure_user_file "$MOLE_UNINSTALL_META_CACHE_FILE" local cache_source="$MOLE_UNINSTALL_META_CACHE_FILE" local cache_source_is_temp=false if [[ ! -r "$cache_source" ]]; then cache_source=$(create_temp_file) : > "$cache_source" cache_source_is_temp=true fi local -a metadata_worker_pids=() metadata_worker_outputs=() local metadata_worker_output_dir="" # Local spinner_pid for cleanup local spinner_pid="" local spinner_shown_file="${temp_file}.spinner_shown" local previous_int_trap="" local previous_term_trap="" previous_int_trap=$(trap -p INT || true) previous_term_trap=$(trap -p TERM || true) restore_scan_int_trap() { if [[ -n "$previous_int_trap" ]]; then # eval: restore previous trap captured by $(trap -p INT) eval "$previous_int_trap" else trap - INT fi if [[ -n "$previous_term_trap" ]]; then # eval: restore previous trap captured by $(trap -p TERM) eval "$previous_term_trap" else trap - TERM fi } # Trap to handle Ctrl+C during scan # shellcheck disable=SC2329 # Function invoked indirectly via trap trap_scan_cleanup() { _scan_stop_metadata_workers if [[ -n "$spinner_pid" ]]; then kill -TERM "$spinner_pid" 2> /dev/null || true wait "$spinner_pid" 2> /dev/null || true fi if [[ -f "$spinner_shown_file" ]]; then printf "\r\033[K" >&2 fi rm -f "$scan_inventory_file" "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true exit "${1:-130}" } trap trap_scan_cleanup INT trap 'trap_scan_cleanup 143' TERM update_scan_status() { local message="$1" local completed="${2:-0}" local total="${3:-0}" printf "%s|%s|%s\n" "$message" "$completed" "$total" > "$scan_status_file" } start_scan_spinner() { [[ -n "$spinner_pid" ]] && return 0 [[ -t 2 || "${MOLE_TEST_FORCE_SCAN_SPINNER:-0}" == "1" ]] || return 0 ( # shellcheck disable=SC2329 # Function invoked indirectly via trap cleanup_spinner() { exit 0; } trap cleanup_spinner TERM INT EXIT [[ -f "$scan_status_file" ]] || exit 0 mo_load_spinner_frames local i=0 : > "$spinner_shown_file" while true; do local status_line status_message status_completed status_total status_line=$(cat "$scan_status_file" 2> /dev/null || echo "") IFS='|' read -r status_message status_completed status_total <<< "$status_line" [[ -z "$status_message" ]] && status_message="Scanning applications..." local c="${MO_SPINNER_FRAMES[$((i % ${#MO_SPINNER_FRAMES[@]}))]}" if [[ "$status_completed" =~ ^[0-9]+$ && "$status_total" =~ ^[0-9]+$ && $status_total -gt 0 ]]; then printf "\r\033[K%s %s %d/%d" "$c" "$status_message" "$status_completed" "$status_total" >&2 else printf "\r\033[K%s %s" "$c" "$status_message" >&2 fi ((i++)) sleep 0.1 2> /dev/null || sleep 1 done ) & spinner_pid=$! } # With a final message, a shown spinner leaves that line in place on the # alternate screen instead of erasing it, so the screen is not blank while # the selector builds its rows. The caller animates it from there. stop_scan_spinner() { local final_message="${1:-}" if [[ -n "$spinner_pid" ]]; then debug_log "Uninstall spinner stop begin (pid $spinner_pid, elapsed ${SECONDS}s)" kill -TERM "$spinner_pid" 2> /dev/null || true wait "$spinner_pid" 2> /dev/null || true debug_log "Uninstall spinner stop complete (pid $spinner_pid, elapsed ${SECONDS}s)" spinner_pid="" fi if [[ -f "$spinner_shown_file" ]]; then if [[ -n "$final_message" && "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then mo_load_spinner_frames printf "\r\033[K%s %s" "${MO_SPINNER_FRAMES[0]}" "$final_message" >&2 else printf "\r\033[K" >&2 fi fi rm -f "$spinner_shown_file" "$scan_status_file" 2> /dev/null || true } update_scan_status "Scanning applications..." "0" "0" start_scan_spinner # Phase 2: discover candidate apps. _scan_discover_apps # Phase 3: partition into warm-cache and cold rows. local -a app_data_tuples=() local metadata_rc=0 _scan_partition_cache || metadata_rc=$? if [[ $metadata_rc -ne 0 ]]; then _scan_stop_metadata_workers stop_scan_spinner rm -f "$scan_inventory_file" "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" # SAFE: exact scan scratch paths above [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true restore_scan_int_trap return "$metadata_rc" fi # Phase 4: bail out if discovery yielded nothing. if [[ ${#app_data_tuples[@]} -eq 0 && ! -s "$scan_raw_file" ]]; then stop_scan_spinner rm -f "$scan_inventory_file" "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "$scan_status_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true restore_scan_int_trap printf "\r\033[K" >&2 echo "No applications found to uninstall." >&2 return 1 fi # Phase 5: parallel metadata resolution for cold rows. _scan_resolve_uncached || metadata_rc=$? if [[ $metadata_rc -ne 0 ]]; then _scan_stop_metadata_workers stop_scan_spinner rm -f "$scan_inventory_file" "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" # SAFE: exact scan scratch paths above [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true restore_scan_int_trap return "$metadata_rc" fi # Phase 6: bail out if Pass 2 produced nothing. update_scan_status "Building uninstall index..." "0" "0" if [[ ! -s "$scan_raw_file" ]]; then stop_scan_spinner echo "No applications found to uninstall" >&2 rm -f "$scan_inventory_file" "$temp_file" "$scan_raw_file" "$merged_file" "$refresh_file" "$cache_snapshot_file" "$discovered_file" "$cached_rows_file" "$uncached_rows_file" "${temp_file}.sorted" "$spinner_shown_file" 2> /dev/null || true [[ $cache_source_is_temp == true ]] && rm -f "$cache_source" 2> /dev/null || true restore_scan_int_trap return 1 fi _scan_dedupe_bundle_ids # Phase 7+8: merge cache, persist, sort, return path. _scan_finalize_index } load_applications() { local apps_file="$1" if [[ ! -f "$apps_file" || ! -s "$apps_file" ]]; then log_warning "No applications found for uninstallation" return 1 fi apps_data=() selection_state=() while IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb; do [[ ! -e "$app_path" ]] && continue apps_data+=("$epoch|$app_path|$app_name|$bundle_id|$size|$last_used|${size_kb:-0}") selection_state+=(false) done < "$apps_file" if [[ ${#apps_data[@]} -eq 0 ]]; then log_warning "No applications available for uninstallation" return 1 fi return 0 } # Keep the scan and selector on one alternate screen so restoring the terminal # also restores the primary-screen cursor to the command's original row. start_uninstall_interactive_screen() { if [[ -t 1 && -t 2 && "${MOLE_ALT_SCREEN_ACTIVE:-}" != "1" ]]; then enter_alt_screen export MOLE_ALT_SCREEN_ACTIVE=1 export MOLE_MANAGED_ALT_SCREEN=1 printf '\033[2J\033[H' >&2 fi } stop_uninstall_interactive_screen() { # Every exit from the scan and selector passes here, so a "Preparing app # list..." spinner never outlives the alternate screen. stop_inline_spinner 2> /dev/null || true if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then leave_alt_screen fi unset MOLE_ALT_SCREEN_ACTIVE MOLE_MANAGED_ALT_SCREEN } # Surface an abort during scan/load/selection instead of returning to the # prompt as if the run had succeeded. Interactive mode renders on an alternate # screen, so the reason has to be printed after the screen is restored (#1339). uninstall_abort() { local reason="$1" stop_uninstall_interactive_screen show_cursor log_error "Uninstall aborted: $reason" } # Cleanup: restore cursor and kill keepalive. cleanup() { local exit_code="${1:-$?}" trap - EXIT INT TERM stop_uninstall_interactive_screen if [[ -n "${sudo_keepalive_pid:-}" ]]; then kill "$sudo_keepalive_pid" 2> /dev/null || true wait "$sudo_keepalive_pid" 2> /dev/null || true sudo_keepalive_pid="" fi # Log session end log_operation_session_end "uninstall" "${files_cleaned:-0}" "${total_size_cleaned:-0}" cleanup_temp_files show_cursor exit "$exit_code" } trap cleanup EXIT trap 'cleanup 130' INT trap 'cleanup 143' TERM # Match app names from scan data against user-provided search terms. # Performs case-insensitive substring matching on app display names. # Returns matched entries from apps_data in selected_apps. match_apps_by_name() { local -a search_terms=("$@") selected_apps=() local -a matched_indices=() # Lowercase every display name and bundle directory name in one tr pass, # indexed like apps_data. Forking echo|tr and basename per app, per term, # per round cost seconds with a few hundred apps. tr keeps the locale's # case mapping (É to é under UTF-8), which mole_ascii_lowercase would not. # Fields come from one `read` line each, so they hold no newline and the # 2N output lines stay aligned with the input. local -a app_names_lower=() app_dirs_lower=() if [[ ${#apps_data[@]} -gt 0 ]]; then local lower_app lower_dir lower_line lower_i=0 local -a lower_input=() lower_output=() for lower_app in "${apps_data[@]}"; do IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$lower_app" lower_dir="${app_path##*/}" lower_input+=("$app_name" "${lower_dir%.[aA][pP][pP]}") done while IFS= read -r lower_line; do lower_output+=("$lower_line") done < <(printf '%s\n' "${lower_input[@]}" | /usr/bin/tr '[:upper:]' '[:lower:]' 2> /dev/null) # tr stops at the first byte that is not valid in the locale, which # would leave every later app unmatched. Redo the names one by one # then, folding only ASCII for a name tr cannot read. if [[ ${#lower_output[@]} -ne ${#lower_input[@]} ]]; then local lower_folded lower_output=() for lower_line in "${lower_input[@]}"; do lower_folded=$(printf '%s\n' "$lower_line" | /usr/bin/tr '[:upper:]' '[:lower:]' 2> /dev/null) || lower_folded=$(printf '%s\n' "$lower_line" | LC_ALL=C /usr/bin/tr '[:upper:]' '[:lower:]') lower_output+=("$lower_folded") done fi while [[ $lower_i -lt ${#apps_data[@]} ]]; do app_names_lower+=("${lower_output[$((lower_i * 2))]-}") app_dirs_lower+=("${lower_output[$((lower_i * 2 + 1))]-}") lower_i=$((lower_i + 1)) done fi # `mo uninstall Tor Browser` arrives as two words. Matching each word # alone sent "Tor" into a substring hit on WebSTORm while the app the # user actually named sat in the list (#1365). When the words joined # with spaces exactly match an installed app's display or directory # name, that is the query, UNLESS every word already exactly names its # own installed app: with Foo.app, Bar.app, and "Foo Bar.app" all # present, `mo uninstall Foo Bar` keeps its original two-app meaning # rather than silently collapsing into the third. if [[ ${#search_terms[@]} -gt 1 ]]; then local every_word_exact=true local word word_lower word_hit word_idx for word in "${search_terms[@]}"; do word_lower=$(echo "$word" | /usr/bin/tr '[:upper:]' '[:lower:]') word_hit=false word_idx=0 while [[ $word_idx -lt ${#apps_data[@]} ]]; do if [[ "${app_names_lower[$word_idx]}" == "$word_lower" || "${app_dirs_lower[$word_idx]}" == "$word_lower" ]]; then word_hit=true break fi word_idx=$((word_idx + 1)) done if [[ "$word_hit" == "false" ]]; then every_word_exact=false break fi done if [[ "$every_word_exact" == "false" ]]; then local joined_lower joined_app joined_idx=0 joined_lower=$(echo "$*" | /usr/bin/tr '[:upper:]' '[:lower:]') for joined_app in "${apps_data[@]}"; do if [[ "${app_names_lower[$joined_idx]}" == "$joined_lower" || "${app_dirs_lower[$joined_idx]}" == "$joined_lower" ]]; then selected_apps=("$joined_app") return 0 fi joined_idx=$((joined_idx + 1)) done fi fi for search_term in "${search_terms[@]}"; do local search_lower search_lower=$(echo "$search_term" | /usr/bin/tr '[:upper:]' '[:lower:]') # Escape glob characters to prevent pattern injection search_lower=${search_lower//\\/\\\\} search_lower=${search_lower//\*/\\*} search_lower=${search_lower//\?/\\?} search_lower=${search_lower//\[/\\[} local found=false local idx=0 for app_data in "${apps_data[@]}"; do local name_lower="${app_names_lower[$idx]}" # Also try matching against the .app directory base name local dir_lower="${app_dirs_lower[$idx]}" if [[ "$name_lower" == "$search_lower" || "$dir_lower" == "$search_lower" ]]; then # Exact match - prefer this local already=false local mi for mi in "${matched_indices[@]+"${matched_indices[@]}"}"; do [[ -z "$mi" ]] && continue [[ "$mi" == "$idx" ]] && already=true && break done if [[ "$already" == "false" ]]; then selected_apps+=("$app_data") matched_indices+=("$idx") fi found=true break fi idx=$((idx + 1)) done # If no exact match, try substring match if [[ "$found" == "false" ]]; then idx=0 for app_data in "${apps_data[@]}"; do local name_lower="${app_names_lower[$idx]}" local dir_lower="${app_dirs_lower[$idx]}" if [[ "$name_lower" == *"$search_lower"* || "$dir_lower" == *"$search_lower"* ]]; then local already=false local mi for mi in "${matched_indices[@]+"${matched_indices[@]}"}"; do [[ -z "$mi" ]] && continue [[ "$mi" == "$idx" ]] && already=true && break done if [[ "$already" == "false" ]]; then selected_apps+=("$app_data") matched_indices+=("$idx") fi found=true fi idx=$((idx + 1)) done fi if [[ "$found" == "false" ]]; then echo -e "${YELLOW}Warning:${NC} No application found matching '$search_term'" fi done } # Escape a value for embedding in a single-line JSON string. Only handles # the chars that would break a one-line value: backslash, quote, and C0 # whitespace. Bundle IDs / display names never contain control bytes worth # preserving in this output. uninstall_list_json_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\"/\\\"}" s="${s//$'\t'/ }" s="${s//$'\r'/ }" s="${s//$'\n'/ }" printf '%s' "$s" } # Read-only listing: surface each installed app's display name, bundle id, # the exact name `mo uninstall` accepts, and human-readable size. Reuses the # existing scanner so the output stays in lockstep with what the destructive # path sees. uninstall_list_apps() { local apps_file="" if ! apps_file=$(scan_applications); then uninstall_abort "could not complete the application scan" return 1 fi if [[ ! -f "$apps_file" ]]; then uninstall_abort "application scan produced no list" return 1 fi if ! load_applications "$apps_file"; then rm -f "$apps_file" "${apps_file}.inventory" # SAFE: exact scan output and inventory sidecar uninstall_abort "no applications available for uninstallation" return 1 fi rm -f "$apps_file" "${apps_file}.inventory" # SAFE: exact scan output and inventory sidecar # One read-only Caskroom and `brew list --cask` snapshot serves every row # instead of one probe per app. It is scoped to this listing pass only. local _MOLE_BREW_BATCH_LIST_READY=0 _MOLE_BREW_BATCH_LIST="" _MOLE_BREW_BATCH_LIST_RC=0 local _MOLE_BREW_BATCH_ROOM_READY=0 _MOLE_BREW_BATCH_ROOM_FILE="" _MOLE_BREW_BATCH_ROOM_RC=0 local _MOLE_BREW_BATCH_INFO_READY=0 _MOLE_BREW_BATCH_INFO_DIR="" local brew_inventory_dir="" brew_prep_rc=0 if is_homebrew_available && brew_inventory_dir=$(create_temp_dir); then _MOLE_BREW_BATCH_ROOM_FILE="$brew_inventory_dir/caskroom" _mole_brew_prepare_batch_inventory || brew_prep_rc=$? if [[ $brew_prep_rc -lt 128 ]]; then local -a brew_app_paths=() local brew_app_row brew_app_path for brew_app_row in "${apps_data[@]+"${apps_data[@]}"}"; do IFS='|' read -r _ brew_app_path _ <<< "$brew_app_row" [[ -n "$brew_app_path" ]] && brew_app_paths+=("$brew_app_path") done _MOLE_BREW_BATCH_INFO_DIR="$brew_inventory_dir/info" mkdir -p "$_MOLE_BREW_BATCH_INFO_DIR" || _MOLE_BREW_BATCH_INFO_DIR="" if [[ -n "$_MOLE_BREW_BATCH_INFO_DIR" && ${#brew_app_paths[@]} -gt 0 ]]; then _mole_brew_prefetch_cask_info "${brew_app_paths[@]}" || brew_prep_rc=$? fi fi if [[ $brew_prep_rc -ge 128 ]]; then rm -rf -- "$brew_inventory_dir" # SAFE: private read-only Homebrew inventory directory created above return "$brew_prep_rc" fi fi # Auto-switch to JSON when stdout is piped, matching `mo status`. local format="text" if [[ ! -t 1 ]]; then format="json" fi if [[ "$format" == "json" ]]; then printf '[' local first=1 local app_data for app_data in "${apps_data[@]+"${apps_data[@]}"}"; do IFS='|' read -r _ app_path app_name bundle_id size _ _ <<< "$app_data" local cask="" if is_homebrew_available; then cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) fi local uninstall_name="${cask:-$app_name}" local source_label="App" [[ -n "$cask" ]] && source_label="Homebrew" local size_display size_display=$(uninstall_normalize_size_display "$size" "$app_path") if [[ $first -eq 1 ]]; then first=0 printf '\n' else printf ',\n' fi printf ' {"name": "%s", "bundle_id": "%s", "source": "%s", "uninstall_name": "%s", "path": "%s", "size": "%s"}' \ "$(uninstall_list_json_escape "$app_name")" \ "$(uninstall_list_json_escape "$bundle_id")" \ "$source_label" \ "$(uninstall_list_json_escape "$uninstall_name")" \ "$(uninstall_list_json_escape "$app_path")" \ "$(uninstall_list_json_escape "$size_display")" done if [[ $first -eq 0 ]]; then printf '\n' fi printf ']\n' [[ -z "$brew_inventory_dir" ]] || rm -rf -- "$brew_inventory_dir" # SAFE: private read-only Homebrew inventory directory created above return 0 fi local total=${#apps_data[@]} if [[ $total -eq 0 ]]; then echo "No applications found." [[ -z "$brew_inventory_dir" ]] || rm -rf -- "$brew_inventory_dir" # SAFE: private read-only Homebrew inventory directory created above return 0 fi printf '\n' printf '%-36s %-30s %-30s %8s\n' 'NAME' 'BUNDLE ID' 'UNINSTALL NAME' 'SIZE' printf -- '-%.0s' $(seq 1 108) printf '\n' local app_data for app_data in "${apps_data[@]+"${apps_data[@]}"}"; do IFS='|' read -r _ app_path app_name bundle_id size _ _ <<< "$app_data" local cask="" if is_homebrew_available; then cask=$(get_brew_cask_name "$app_path" 2> /dev/null || true) fi local uninstall_name="${cask:-$app_name}" local size_display size_display=$(uninstall_normalize_size_display "$size" "$app_path") # Truncate by display columns, then adjust printf width for CJK. # printf counts bytes (LC_ALL=C), but CJK chars are 3 bytes yet only # 2 display columns wide, so we pad with the extra bytes to land on # the correct visual column. # The uninstall name is the app name when no cask owns the app, so it # gets the same treatment: a byte slice would split a CJK character. local name_trunc name_display_w name_byte_count name_printf_w local un_trunc un_display_w un_byte_count un_printf_w name_trunc=$(truncate_by_display_width "$app_name" 34) name_display_w=$(get_display_width "$name_trunc") un_trunc=$(truncate_by_display_width "$uninstall_name" 28) un_display_w=$(get_display_width "$un_trunc") # Get byte count in C locale for printf local old_lc="${LC_ALL:-}" export LC_ALL=C name_byte_count=${#name_trunc} un_byte_count=${#un_trunc} if [[ -n "$old_lc" ]]; then export LC_ALL="$old_lc" else unset LC_ALL fi name_printf_w=$((36 + name_byte_count - name_display_w)) un_printf_w=$((30 + un_byte_count - un_display_w)) printf "%-*s %-30s %-*s %8s\n" \ "$name_printf_w" "$name_trunc" \ "${bundle_id:0:28}" \ "$un_printf_w" "$un_trunc" \ "$size_display" done printf '\n%d application(s) | Remove with: mo uninstall \n\n' "$total" [[ -z "$brew_inventory_dir" ]] || rm -rf -- "$brew_inventory_dir" # SAFE: private read-only Homebrew inventory directory created above return 0 } main() { # Set current command for operation logging export MOLE_CURRENT_COMMAND="uninstall" # Apps that failed across this run's batches; nonzero exits 1. MOLE_UNINSTALL_FAILED_APPS=0 # Default to Trash routing so an accidental uninstall is recoverable. # The caller can opt back into rm -rf with --permanent. See #723. export MOLE_DELETE_MODE="${MOLE_DELETE_MODE:-trash}" # Only the --with-data flag preselects personal data, never the environment. MOLE_UNINSTALL_WITH_DATA=0 # Parse flags and collect app name arguments local -a app_name_args=() local list_mode=0 for arg in "$@"; do case "$arg" in "--help" | "-h") show_uninstall_help exit 0 ;; "--debug") export MO_DEBUG=1 ;; "--dry-run" | "-n") export MOLE_DRY_RUN=1 ;; "--permanent") export MOLE_DELETE_MODE="permanent" ;; "--with-data") MOLE_UNINSTALL_WITH_DATA=1 ;; "--list") list_mode=1 ;; "--whitelist") echo "Unknown uninstall option: $arg" >&2 echo "Whitelist management is currently supported by: mo clean --whitelist / mo optimize --whitelist" >&2 echo "Use 'mo uninstall --help' for supported options." >&2 exit 1 ;; -*) echo "Unknown uninstall option: $arg" >&2 echo "Use 'mo uninstall --help' for supported options." >&2 exit 1 ;; *) app_name_args+=("$arg") ;; esac done # --list short-circuits before any destructive code. Read-only path: # scan, resolve uninstall names, print table or JSON, exit 0. if [[ $list_mode -eq 1 ]]; then uninstall_list_apps return $? fi # Opened only after flags are parsed: --dry-run must reach the marker, and # --help, --list and a rejected flag change nothing worth a session. log_operation_session_start "uninstall" hide_cursor if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then echo -e "${YELLOW}${ICON_DRY_RUN} DRY RUN MODE${NC}, No app files or settings will be modified" printf '\n' fi # Direct uninstall by app name if [[ ${#app_name_args[@]} -gt 0 ]]; then local apps_file="" if ! apps_file=$(scan_applications); then uninstall_abort "could not complete the application scan" return 1 fi if [[ ! -f "$apps_file" ]]; then uninstall_abort "application scan produced no list" return 1 fi if ! load_applications "$apps_file"; then rm -f "$apps_file" "${apps_file}.inventory" # SAFE: exact scan output and inventory sidecar uninstall_abort "no applications available for uninstallation" return 1 fi match_apps_by_name "${app_name_args[@]}" rm -f "$apps_file" "${apps_file}.inventory" # SAFE: exact scan output and inventory sidecar if [[ ${#selected_apps[@]} -eq 0 ]]; then show_cursor echo "No matching applications found." return 1 fi show_cursor clear_screen local selection_count=${#selected_apps[@]} echo -e "${BLUE}${ICON_CONFIRM}${NC} Matched ${selection_count} app(s):" local index=1 for selected_app in "${selected_apps[@]}"; do IFS='|' read -r _ app_path app_name _ size last_used _ <<< "$selected_app" local safe_name safe_name=$(mole_terminal_safe_text "$app_name") local size_display size_display=$(uninstall_normalize_size_display "$size" "$app_path") local last_display last_display=$(uninstall_normalize_last_used_display "$last_used") printf "%d. %s %s | Last: %s\n" "$index" "$safe_name" "$size_display" "$last_display" ((index++)) done printf '\n' # One confirmation, not two: batch_uninstall_applications renders the # full preview and asks there, after sizing and leftover discovery. A # y/N prompt here runs before that scan, so it cannot serve as review. batch_uninstall_applications [[ ${MOLE_UNINSTALL_FAILED_APPS:-0} -eq 0 ]] || return 1 return 0 fi local first_scan=true local cached_apps_file="" local cached_inventory_fingerprint="" unset MOLE_INLINE_LOADING MOLE_MANAGED_ALT_SCREEN MOLE_ALT_SCREEN_ACTIVE while true; do unset MOLE_INLINE_LOADING # Keep scanning and selection on one alternate screen. Entering the # selector only after the scan leaves the primary-screen cursor below # the scan progress; restoring it on cancel then creates a large blank # gap before the next shell prompt (#1194). start_uninstall_interactive_screen if [[ $first_scan == false ]]; then echo -e "${GRAY}Checking application list...${NC}" >&2 fi first_scan=false local apps_file="" local reused_app_cache=false if [[ -n "$cached_apps_file" && -f "$cached_apps_file" && -n "$cached_inventory_fingerprint" ]]; then local current_inventory_fingerprint current_inventory_fingerprint=$(uninstall_app_inventory_fingerprint 2> /dev/null || echo "") if uninstall_inventory_can_reuse_cached_apps "$cached_inventory_fingerprint" "$current_inventory_fingerprint"; then apps_file="$cached_apps_file" reused_app_cache=true cached_inventory_fingerprint="$current_inventory_fingerprint" fi fi if [[ "$reused_app_cache" != "true" ]]; then if [[ -n "$cached_apps_file" && -f "$cached_apps_file" ]]; then rm -f "$cached_apps_file" 2> /dev/null || true fi local scan_abort_reason="" debug_log "Uninstall interactive scan begin (elapsed ${SECONDS}s, parent $$)" if ! apps_file=$(scan_applications); then scan_abort_reason="could not complete the application scan" elif [[ ! -f "$apps_file" ]]; then scan_abort_reason="application scan produced no list" fi if [[ -n "$scan_abort_reason" ]]; then uninstall_abort "$scan_abort_reason" rm -f "$apps_file" [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" return 1 fi debug_log "Uninstall interactive scan returned (elapsed ${SECONDS}s, parent $$)" # The scan runs in a command substitution, so it can only leave a # static "Preparing app list..." line. Animate that line here until # the selector stops the spinner right before its first draw. if [[ "${MOLE_ALT_SCREEN_ACTIVE:-}" == "1" ]]; then MOLE_SPINNER_PREFIX="" start_inline_spinner "Preparing app list..." fi cached_apps_file="$apps_file" debug_log "Uninstall inventory fingerprint begin (elapsed ${SECONDS}s, parent $$)" cached_inventory_fingerprint=$(uninstall_app_inventory_fingerprint "${apps_file}.inventory" 2> /dev/null || echo "") rm -f "${apps_file}.inventory" # SAFE: exact sidecar produced by scan_applications debug_log "Uninstall inventory fingerprint complete (elapsed ${SECONDS}s, parent $$)" fi debug_log "Uninstall list load begin (elapsed ${SECONDS}s, parent $$)" if ! load_applications "$apps_file"; then rm -f "$apps_file" [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" uninstall_abort "no applications available for uninstallation" return 1 fi debug_log "Uninstall list load complete (elapsed ${SECONDS}s, parent $$)" # Keystrokes typed during the scan/load phase must not leak into the # selector. A queued Enter would confirm whichever app is highlighted # first and drop the user straight into the destructive path. See #726. # The drain flushes everything already queued, so a longer idle wait # only adds blank screen before the list. debug_log "Uninstall input drain begin (elapsed ${SECONDS}s, parent $$)" drain_pending_input debug_log "Uninstall selector begin (elapsed ${SECONDS}s, parent $$)" set +e select_apps_for_uninstall local exit_code=$? debug_log "Uninstall selector returned (exit $exit_code, elapsed ${SECONDS}s, parent $$)" set -e if [[ $exit_code -ne 0 ]]; then rm -f "$apps_file" [[ "$apps_file" == "$cached_apps_file" ]] && cached_apps_file="" if [[ "${_MOLE_MENU_USER_QUIT:-0}" == "1" ]]; then # A deliberate q is a cancel, not a failure: leave quietly # with success, matching mole's other cancel flows. Only a # selector that broke gets the visible abort below. stop_uninstall_interactive_screen show_cursor # An app that failed earlier in this run still fails the run. [[ ${MOLE_UNINSTALL_FAILED_APPS:-0} -eq 0 ]] || return 1 return 0 fi uninstall_abort "application selection did not complete" return 1 fi stop_uninstall_interactive_screen show_cursor clear_screen printf '\033[2J\033[H' >&2 local selection_count=${#selected_apps[@]} if [[ $selection_count -eq 0 ]]; then echo "No apps selected" continue fi echo -e "${BLUE}${ICON_CONFIRM}${NC} Selected ${selection_count} apps:" local -a summary_rows=() local max_name_display_width=0 local max_size_width=0 local max_last_width=0 for selected_app in "${selected_apps[@]}"; do IFS='|' read -r _ app_path app_name _ size last_used _ <<< "$selected_app" local name_width=$(get_display_width "$app_name") [[ $name_width -gt $max_name_display_width ]] && max_name_display_width=$name_width local size_display size_display=$(uninstall_normalize_size_display "$size" "$app_path") [[ ${#size_display} -gt $max_size_width ]] && max_size_width=${#size_display} local last_display last_display=$(uninstall_normalize_last_used_display "$last_used") [[ ${#last_display} -gt $max_last_width ]] && max_last_width=${#last_display} done ((max_size_width < 5)) && max_size_width=5 ((max_last_width < 5)) && max_last_width=5 ((max_name_display_width < 16)) && max_name_display_width=16 local term_width=$(tput cols 2> /dev/null || echo 100) local available_for_name=$((term_width - 17 - max_size_width - max_last_width)) local min_name_width=24 if [[ $term_width -ge 120 ]]; then min_name_width=50 elif [[ $term_width -ge 100 ]]; then min_name_width=42 elif [[ $term_width -ge 80 ]]; then min_name_width=30 fi local name_trunc_limit=$max_name_display_width [[ $name_trunc_limit -lt $min_name_width ]] && name_trunc_limit=$min_name_width [[ $name_trunc_limit -gt $available_for_name ]] && name_trunc_limit=$available_for_name [[ $name_trunc_limit -gt 60 ]] && name_trunc_limit=60 max_name_display_width=0 for selected_app in "${selected_apps[@]}"; do IFS='|' read -r epoch app_path app_name bundle_id size last_used size_kb <<< "$selected_app" local display_name display_name=$(truncate_by_display_width "$app_name" "$name_trunc_limit") local current_width current_width=$(get_display_width "$display_name") [[ $current_width -gt $max_name_display_width ]] && max_name_display_width=$current_width local size_display size_display=$(uninstall_normalize_size_display "$size" "$app_path") local last_display last_display=$(uninstall_normalize_last_used_display "$last_used") summary_rows+=("$display_name|$size_display|$last_display") done ((max_name_display_width < 16)) && max_name_display_width=16 local index=1 for row in "${summary_rows[@]}"; do IFS='|' read -r name_cell size_cell last_cell <<< "$row" local name_display_width name_display_width=$(get_display_width "$name_cell") # Get byte count for printf width calculation local old_lc="${LC_ALL:-}" export LC_ALL=C local name_byte_count=${#name_cell} if [[ -n "$old_lc" ]]; then export LC_ALL="$old_lc" else unset LC_ALL fi local padding_needed=$((max_name_display_width - name_display_width)) local printf_name_width=$((name_byte_count + padding_needed)) printf "%d. %-*s %*s | Last: %s\n" "$index" "$printf_name_width" "$name_cell" "$max_size_width" "$size_cell" "$last_cell" ((index++)) done batch_uninstall_applications # A nested command may have returned the controlling terminal to the # parent shell. Reading while Mole is no longer the foreground process # group would suspend the completed uninstall with SIGTTIN. The removal # is already finished, so exit cleanly instead of touching terminal input. if ! mole_tty_is_foreground; then show_cursor [[ ${MOLE_UNINSTALL_FAILED_APPS:-0} -eq 0 ]] || return 1 return 0 fi local _countdown=5 local _key="" local _pressed=false while [[ $_countdown -gt 0 ]]; do printf "\r${GRAY}Press Enter to return to the app list, press q to exit (%d)${NC} " "$_countdown" if IFS= read -r -s -n1 -t 1 _key 2> /dev/null; then _pressed=true break fi ((_countdown--)) done printf "\n" drain_pending_input if [[ "$_pressed" == "true" && -z "$_key" ]]; then : else show_cursor [[ ${MOLE_UNINSTALL_FAILED_APPS:-0} -eq 0 ]] || return 1 return 0 fi done } # Run only when executed; sourcing loads definitions for tests. Kept on one # line because test harnesses slice this file with sed/awk anchored on the # `main "$@"` sentinel, and a multi-line guard leaves them an unclosed `if`. [[ "${BASH_SOURCE[0]}" != "$0" ]] || main "$@"