Ctrl K
ComunidadGuías…

bin/uninstall.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <app_path>|<app_name>|<app_mtime>.
# 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 <UNINSTALL NAME>\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 "$@"