From 70c5db8c9ae2c6308aa4703c319c4cdf34fe58fc Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 9 Oct 2025 14:24:00 +0800 Subject: [PATCH] Neat and uniform output --- bin/analyze.sh | 50 +++++++++------ bin/clean.sh | 31 +++++++--- bin/uninstall.sh | 33 +++++----- install.sh | 62 ++++++++++++++----- lib/batch_uninstall.sh | 94 ++++++++++++++-------------- lib/common.sh | 129 ++++++++++++++++++++++++++++++++++----- lib/whitelist_manager.sh | 12 +++- mo | 2 + mole | 107 +++++++++++++++++++++----------- 9 files changed, 361 insertions(+), 159 deletions(-) diff --git a/bin/analyze.sh b/bin/analyze.sh index 9439c61..5aac442 100755 --- a/bin/analyze.sh +++ b/bin/analyze.sh @@ -1220,17 +1220,38 @@ display_file_types() { return fi - # Analyze common file types - local -A type_map=( - ["Videos"]="kMDItemContentType == 'public.movie' || kMDItemContentType == 'public.video'" - ["Images"]="kMDItemContentType == 'public.image'" - ["Archives"]="kMDItemContentType == 'public.archive' || kMDItemContentType == 'public.zip-archive'" - ["Documents"]="kMDItemContentType == 'com.adobe.pdf' || kMDItemContentType == 'public.text'" - ["Audio"]="kMDItemContentType == 'public.audio'" - ) - - for type_name in "${!type_map[@]}"; do - local query="${type_map[$type_name]}" + # Analyze common file types (bash 3.2 compatible - no associative arrays) + local -a type_names=("Videos" "Images" "Archives" "Documents" "Audio") + + local type_name + for type_name in "${type_names[@]}"; do + local query="" + local badge="$BADGE_FILE" + + # Map type name to query and badge + case "$type_name" in + "Videos") + query="kMDItemContentType == 'public.movie' || kMDItemContentType == 'public.video'" + badge="$BADGE_MEDIA" + ;; + "Images") + query="kMDItemContentType == 'public.image'" + badge="$BADGE_MEDIA" + ;; + "Archives") + query="kMDItemContentType == 'public.archive' || kMDItemContentType == 'public.zip-archive'" + badge="$BADGE_BUNDLE" + ;; + "Documents") + query="kMDItemContentType == 'com.adobe.pdf' || kMDItemContentType == 'public.text'" + badge="$BADGE_FILE" + ;; + "Audio") + query="kMDItemContentType == 'public.audio'" + badge="🎵" + ;; + esac + local files=$(mdfind -onlyin "$CURRENT_PATH" "$query" 2>/dev/null) local count=$(echo "$files" | grep -c . || echo "0") local total_size=0 @@ -1245,13 +1266,6 @@ display_file_types() { if [[ $total_size -gt 0 ]]; then local human_size=$(bytes_to_human "$total_size") - local badge="$BADGE_FILE" - case "$type_name" in - "Videos"|"Images") badge="$BADGE_MEDIA" ;; - "Archives") badge="$BADGE_BUNDLE" ;; - "Documents") badge="$BADGE_FILE" ;; - "Audio") badge="🎵" ;; - esac printf " %s %-12s %8s (%d files)\n" "$badge" "$type_name:" "$human_size" "$count" fi fi diff --git a/bin/clean.sh b/bin/clean.sh index 74cfd6f..2c39a6b 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -1325,11 +1325,30 @@ perform_cleanup() { local freed_gb=$(echo "$total_size_cleaned" | awk '{printf "%.2f", $1/1024/1024}') if [[ "$DRY_RUN" == "true" ]]; then echo "Potential reclaimable space: ${GREEN}${freed_gb}GB${NC} (no changes made) | Free space now: $(get_free_space)" + + # Show file/category stats for dry run + if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then + printf "Files to clean: %s | Categories: %s\n" "$files_cleaned" "$total_items" + elif [[ $files_cleaned -gt 0 ]]; then + printf "Files to clean: %s\n" "$files_cleaned" + elif [[ $total_items -gt 0 ]]; then + printf "Categories: %s\n" "$total_items" + fi + + echo "" + echo "To protect specific cache files from deletion, run: mole clean --whitelist" else echo "Space freed: ${GREEN}${freed_gb}GB${NC} | Free space now: $(get_free_space)" - fi - if [[ "$DRY_RUN" != "true" ]]; then + # Show file/category stats for actual cleanup + if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then + printf "Files cleaned: %s | Categories: %s\n" "$files_cleaned" "$total_items" + elif [[ $files_cleaned -gt 0 ]]; then + printf "Files cleaned: %s\n" "$files_cleaned" + elif [[ $total_items -gt 0 ]]; then + printf "Categories: %s\n" "$total_items" + fi + if [[ $(echo "$freed_gb" | awk '{print ($1 >= 1) ? 1 : 0}') -eq 1 ]]; then local movies=$(echo "$freed_gb" | awk '{printf "%.0f", $1/4.5}') if [[ $movies -gt 0 ]]; then @@ -1344,14 +1363,6 @@ perform_cleanup() { echo "No significant space was freed (system was already clean) | Free space: $(get_free_space)" fi fi - - if [[ $files_cleaned -gt 0 && $total_items -gt 0 ]]; then - printf "Files cleaned: %s | Categories processed: %s\n" "$files_cleaned" "$total_items" - elif [[ $files_cleaned -gt 0 ]]; then - printf "Files cleaned: %s\n" "$files_cleaned" - elif [[ $total_items -gt 0 ]]; then - printf "Categories processed: %s\n" "$total_items" - fi printf "====================================================================\n" } diff --git a/bin/uninstall.sh b/bin/uninstall.sh index d3691f7..572eb69 100755 --- a/bin/uninstall.sh +++ b/bin/uninstall.sh @@ -122,7 +122,7 @@ scan_applications() { fi fi - local temp_file=$(mktemp_file) + local temp_file=$(create_temp_file) echo "" >&2 # Add space before scanning output without breaking stdout return # Pre-cache current epoch to avoid repeated calls @@ -375,7 +375,8 @@ load_applications() { uninstall_applications() { local total_size_freed=0 - log_header "Uninstalling selected applications" + echo "" + echo -e "${PURPLE}▶ Uninstalling selected applications${NC}" if [[ ${#selected_apps[@]} -eq 0 ]]; then log_warning "No applications selected for uninstallation" @@ -389,14 +390,14 @@ uninstall_applications() { # Check if app is running if pgrep -f "$app_name" >/dev/null 2>&1; then - log_warning "$app_name is currently running" + echo -e "${YELLOW}⚠ $app_name is currently running${NC}" read -p " Force quit $app_name? (y/N): " -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]]; then pkill -f "$app_name" 2>/dev/null || true sleep 2 else - log_warning "Skipping $app_name (still running)" + echo -e " ${BLUE}○${NC} Skipped $app_name" continue fi fi @@ -414,7 +415,7 @@ uninstall_applications() { local total_kb=$((app_size_kb + related_size_kb + system_size_kb)) # Show what will be removed - echo -e " ${YELLOW}Files to be removed:${NC}" + echo -e "${BLUE}◎${NC} $app_name - Files to be removed:" echo -e " ${GREEN}✓${NC} Application: $(echo "$app_path" | sed "s|$HOME|~|")" # Show user-level files @@ -425,7 +426,7 @@ uninstall_applications() { # Show system-level files if [[ -n "$system_files" ]]; then while IFS= read -r file; do - [[ -n "$file" && -e "$file" ]] && echo -e " ${YELLOW}✓${NC} [System] $file" + [[ -n "$file" && -e "$file" ]] && echo -e " ${BLUE}●${NC} System: $file" done <<< "$system_files" fi @@ -448,7 +449,7 @@ uninstall_applications() { if rm -rf "$app_path" 2>/dev/null; then echo -e " ${GREEN}✓${NC} Removed application" else - log_error "Failed to remove $app_path" + echo -e " ${RED}✗${NC} Failed to remove $app_path" continue fi @@ -463,13 +464,13 @@ uninstall_applications() { # Remove system-level files (requires sudo) if [[ -n "$system_files" ]]; then - echo -e " ${YELLOW}System-level files require administrator privileges${NC}" + echo -e " ${BLUE}●${NC} Admin access required for system files" while IFS= read -r file; do if [[ -n "$file" && -e "$file" ]]; then if sudo rm -rf "$file" 2>/dev/null; then - echo -e " ${GREEN}✓${NC} Removed [System] $(basename "$file")" + echo -e " ${GREEN}✓${NC} Removed $(basename "$file")" else - log_warning "Failed to remove system file: $file" + echo -e " ${YELLOW}⚠${NC} Failed to remove: $file" fi fi done <<< "$system_files" @@ -479,15 +480,15 @@ uninstall_applications() { ((files_cleaned++)) ((total_items++)) - log_success "$app_name uninstalled successfully" + echo -e " ${GREEN}✓${NC} $app_name uninstalled successfully" else - echo -e " ${BLUE}❂${NC} Skipped $app_name" + echo -e " ${BLUE}○${NC} Skipped $app_name" fi done # Show final summary echo "" - log_header "Uninstallation Summary" + echo -e "${PURPLE}▶ Uninstallation Summary${NC}" if [[ $total_size_freed -gt 0 ]]; then if [[ $total_size_freed -gt 1048576 ]]; then # > 1GB @@ -498,10 +499,10 @@ uninstall_applications() { local freed_display="${total_size_freed}KB" fi - log_success "Freed $freed_display of disk space" + echo -e " ${GREEN}✓${NC} Freed $freed_display of disk space" fi - echo "Applications uninstalled: $files_cleaned" + echo -e " ${GREEN}✓${NC} Applications uninstalled: $files_cleaned" ((total_size_cleaned += total_size_freed)) } @@ -561,7 +562,7 @@ main() { local extra=$((selection_count-3)) local list="${names[*]}" [[ $extra -gt 0 ]] && list+=" +${extra}" - echo "◎ ${selection_count} apps: ${list}" + echo -e "${BLUE}◎${NC} ${selection_count} apps: ${list}" # Execute batch uninstallation (handles confirmation) batch_uninstall_applications diff --git a/install.sh b/install.sh index 2f99af9..0644288 100755 --- a/install.sh +++ b/install.sh @@ -19,17 +19,25 @@ start_line_spinner() { ( while true; do c="${chars:$((i % ${#chars})):1}"; printf "\r ${BLUE}%s${NC} %s" "$c" "$msg"; ((i++)); sleep 0.12; done ) & _SPINNER_PID=$! } -stop_line_spinner() { if [[ -n "$_SPINNER_PID" ]]; then kill "$_SPINNER_PID" 2>/dev/null || true; wait "$_SPINNER_PID" 2>/dev/null || true; _SPINNER_PID=""; printf "\r"; fi; } +stop_line_spinner() { if [[ -n "$_SPINNER_PID" ]]; then kill "$_SPINNER_PID" 2>/dev/null || true; wait "$_SPINNER_PID" 2>/dev/null || true; _SPINNER_PID=""; printf "\r\033[K"; fi; } # Verbosity (0 = quiet, 1 = verbose) VERBOSE=1 +# Icons (duplicated from lib/common.sh - necessary as install.sh runs standalone) +readonly ICON_SUCCESS="✓" +readonly ICON_ADMIN="●" +readonly ICON_CONFIRM="◎" +readonly ICON_ERROR="✗" + # Logging functions log_info() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}$1${NC}"; } -log_success() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${GREEN}$1${NC}"; } +log_success() { [[ ${VERBOSE} -eq 1 ]] && echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"; } log_warning() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${YELLOW}$1${NC}"; } -log_error() { echo -e "${RED}$1${NC}"; } +log_error() { echo -e "${RED}${ICON_ERROR}${NC} $1"; } +log_admin() { [[ ${VERBOSE} -eq 1 ]] && echo -e " ${BLUE}${ICON_ADMIN}${NC} $1"; } +log_confirm() { [[ ${VERBOSE} -eq 1 ]] && echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"; } # Default installation directory INSTALL_DIR="/usr/local/bin" @@ -239,12 +247,14 @@ install_files() { if [[ -f "$SOURCE_DIR/mole" ]]; then if [[ "$source_dir_abs" != "$install_dir_abs" ]]; then if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then + log_admin "Admin access required for /usr/local/bin" sudo cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole" sudo chmod +x "$INSTALL_DIR/mole" else cp "$SOURCE_DIR/mole" "$INSTALL_DIR/mole" chmod +x "$INSTALL_DIR/mole" fi + log_success "Installed mole to $INSTALL_DIR" fi else log_error "mole executable not found in ${SOURCE_DIR:-unknown}" @@ -254,7 +264,7 @@ install_files() { # Install mo alias for Mole if available if [[ -f "$SOURCE_DIR/mo" ]]; then if [[ "$source_dir_abs" == "$install_dir_abs" ]]; then - log_info "mo alias already present in $INSTALL_DIR" + log_success "mo alias already present" else if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then sudo cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo" @@ -263,6 +273,7 @@ install_files() { cp "$SOURCE_DIR/mo" "$INSTALL_DIR/mo" chmod +x "$INSTALL_DIR/mo" fi + log_success "Installed mo alias" fi fi @@ -271,10 +282,11 @@ install_files() { local source_bin_abs="$(cd "$SOURCE_DIR/bin" && pwd)" local config_bin_abs="$(cd "$CONFIG_DIR/bin" && pwd)" if [[ "$source_bin_abs" == "$config_bin_abs" ]]; then - log_info "Configuration bin directory already synced" + log_success "Modules already synced" else cp -r "$SOURCE_DIR/bin"/* "$CONFIG_DIR/bin/" chmod +x "$CONFIG_DIR/bin"/* + log_success "Installed modules" fi fi @@ -282,9 +294,10 @@ install_files() { local source_lib_abs="$(cd "$SOURCE_DIR/lib" && pwd)" local config_lib_abs="$(cd "$CONFIG_DIR/lib" && pwd)" if [[ "$source_lib_abs" == "$config_lib_abs" ]]; then - log_info "Configuration lib directory already synced" + log_success "Libraries already synced" else cp -r "$SOURCE_DIR/lib"/* "$CONFIG_DIR/lib/" + log_success "Installed libraries" fi fi @@ -355,6 +368,8 @@ print_usage_summary() { return fi + echo "" + local message="Mole ${action} successfully" if [[ "$action" == "updated" && -n "$previous_version" && -n "$new_version" && "$previous_version" != "$new_version" ]]; then @@ -363,7 +378,7 @@ print_usage_summary() { message+=" (version ${new_version})" fi - log_success "$message!" + log_confirm "$message" echo "" echo "Usage:" @@ -389,16 +404,18 @@ print_usage_summary() { # Uninstall function uninstall_mole() { - log_info "Uninstalling mole..." + log_confirm "Uninstalling Mole" + echo "" # Remove executable if [[ -f "$INSTALL_DIR/mole" ]]; then if [[ "$INSTALL_DIR" == "/usr/local/bin" ]] && [[ ! -w "$INSTALL_DIR" ]]; then + log_admin "Admin access required" sudo rm -f "$INSTALL_DIR/mole" else rm -f "$INSTALL_DIR/mole" fi - log_success "Removed executable from $INSTALL_DIR" + log_success "Removed mole executable" fi if [[ -f "$INSTALL_DIR/mo" ]]; then @@ -407,7 +424,7 @@ uninstall_mole() { else rm -f "$INSTALL_DIR/mo" fi - log_success "Removed mo alias from $INSTALL_DIR" + log_success "Removed mo alias" fi # SAFETY CHECK: Verify config directory is safe to remove @@ -442,14 +459,15 @@ uninstall_mole() { echo "" read -p "Remove configuration directory $CONFIG_DIR? (y/N): " -n 1 -r; echo ""; if [[ $REPLY =~ ^[Yy]$ ]]; then rm -rf "$CONFIG_DIR" - log_success "Removed configuration directory" + log_success "Removed configuration" else - log_info "Configuration directory preserved" + log_success "Configuration preserved" fi fi fi - log_success "Mole uninstalled successfully" + echo "" + log_confirm "Mole uninstalled successfully" } # Main installation function @@ -486,12 +504,26 @@ perform_update() { update_via_homebrew "$VERSION" else # Fallback: inline implementation - echo -e "${BLUE}|${NC} Updating Homebrew..." + if [[ -t 1 ]]; then + start_line_spinner "Updating Homebrew..." + else + echo "Updating Homebrew..." + fi brew update 2>&1 | grep -Ev "^(==>|Already up-to-date)" || true + if [[ -t 1 ]]; then + stop_line_spinner + fi - echo -e "${BLUE}|${NC} Upgrading Mole..." + if [[ -t 1 ]]; then + start_line_spinner "Upgrading Mole..." + else + echo "Upgrading Mole..." + fi local upgrade_output upgrade_output=$(brew upgrade mole 2>&1) || true + if [[ -t 1 ]]; then + stop_line_spinner + fi if echo "$upgrade_output" | grep -q "already installed"; then local current_version diff --git a/lib/batch_uninstall.sh b/lib/batch_uninstall.sh index 5e63898..6b75501 100755 --- a/lib/batch_uninstall.sh +++ b/lib/batch_uninstall.sh @@ -57,23 +57,32 @@ batch_uninstall_applications() { # Format size display (convert KB to bytes for bytes_to_human()) local size_display=$(bytes_to_human "$((total_estimated_size * 1024))") - # Request sudo access if needed (do this before confirmation) + # Show summary and get batch confirmation first (before asking for password) + local app_total=${#selected_apps[@]} + local app_text="app" + [[ $app_total -gt 1 ]] && app_text="apps" + if [[ ${#running_apps[@]} -gt 0 ]]; then + echo -n "${BLUE}${ICON_CONFIRM}${NC} Remove ${app_total} ${app_text} | ${size_display} | Force quit: ${running_apps[*]} | Enter=go / ESC=q: " + else + echo -n "${BLUE}${ICON_CONFIRM}${NC} Remove ${app_total} ${app_text} | ${size_display} | Enter=go / ESC=q: " + fi + IFS= read -r -s -n1 key || key="" + case "$key" in + $'\e'|q|Q) echo ""; return 0 ;; + ""|$'\n'|$'\r'|y|Y) echo "" ;; + *) echo ""; return 0 ;; + esac + + # User confirmed, now request sudo access if needed if [[ ${#sudo_apps[@]} -gt 0 ]]; then # Check if sudo is already cached - if sudo -n true 2>/dev/null; then - echo "◎ Admin access confirmed for: ${sudo_apps[*]}" - else - echo "◎ Admin required for: ${sudo_apps[*]}" - echo "" - if ! request_sudo_access "Uninstalling system apps requires admin access"; then + if ! sudo -n true 2>/dev/null; then + if ! request_sudo_access "Admin required for system apps: ${sudo_apps[*]}"; then echo "" log_error "Admin access denied" return 1 fi - echo "" - echo "✓ Admin access granted" fi - echo "◎ Gathering targets..." (while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null) & local sudo_keepalive_pid=$! local _trap_cleanup_cmd="kill $sudo_keepalive_pid 2>/dev/null || true; wait $sudo_keepalive_pid 2>/dev/null || true" @@ -87,22 +96,7 @@ batch_uninstall_applications() { done fi - # Show summary and get batch confirmation - local app_total=${#selected_apps[@]} - if [[ ${#running_apps[@]} -gt 0 ]]; then - echo -n "${BLUE}◎ Remove ${app_total} app(s) (${size_display}) | Quit: ${running_apps[*]} | Enter=go / ESC=q:${NC} " - else - echo -n "${BLUE}◎ Remove ${app_total} app(s) (${size_display}) | Enter=go / ESC=q:${NC} " - fi - IFS= read -r -s -n1 key || key="" - case "$key" in - $'\e'|q|Q) echo ""; return 0 ;; - ""|$'\n'|$'\r'|y|Y) echo "" ;; - *) echo ""; return 0 ;; - esac - - echo -n "◎ Starting in 3s... 3"; sleep 1; echo -ne "\r◎ Starting in 3s... 2"; sleep 1; echo -ne "\r◎ Starting in 3s... 1"; sleep 1 - echo -ne "\r\033[K" + echo "" if [[ -t 1 ]]; then start_inline_spinner "Uninstalling apps..."; fi # Force quit running apps first (batch) @@ -113,11 +107,11 @@ batch_uninstall_applications() { if pgrep -f "${running_apps[0]}" >/dev/null 2>&1; then sleep 1; fi fi - # Perform uninstallations (compact output) + # Perform uninstallations (silent mode, show results at end) if [[ -t 1 ]]; then stop_inline_spinner; fi - echo "" local success_count=0 failed_count=0 local -a failed_items=() + local -a success_items=() for detail in "${app_details[@]}"; do IFS='|' read -r app_name app_path bundle_id total_kb encoded_files <<< "$detail" local related_files=$(echo "$encoded_files" | base64 -d) @@ -144,7 +138,7 @@ batch_uninstall_applications() { ((success_count++)) ((files_cleaned++)) ((total_items++)) - printf " ${GREEN}OK${NC} %-20s%s\n" "$app_name" $([[ $files_removed -gt 0 ]] && echo "+$files_removed" ) + success_items+=("$app_name") else ((failed_count++)) failed_items+=("$app_name:$reason") @@ -152,32 +146,36 @@ batch_uninstall_applications() { done # Summary - local freed_display="0B" - if [[ $total_size_freed -gt 0 ]]; then - local freed_kb=$total_size_freed - if [[ $freed_kb -ge 1048576 ]]; then - freed_display=$(echo "$freed_kb" | awk '{printf "%.2fGB", $1/1024/1024}') - elif [[ $freed_kb -ge 1024 ]]; then - freed_display=$(echo "$freed_kb" | awk '{printf "%.1fMB", $1/1024}') - else - freed_display="${freed_kb}KB" - fi - fi + local freed_display=$(bytes_to_human "$((total_size_freed * 1024))") local bar="================================================================================" echo "" echo "$bar" + if [[ $success_count -gt 0 ]]; then + local success_list="${success_items[*]}" + echo -e "Removed: ${GREEN}${success_list}${NC} | Freed: ${GREEN}${freed_display}${NC}" + fi if [[ $failed_count -gt 0 ]]; then - echo -e "Removed: ${GREEN}$success_count${NC} | Failed: ${RED}$failed_count${NC} | Freed: ${GREEN}$freed_display${NC}" + local failed_names=() + local reason_summary="" + for item in "${failed_items[@]}"; do + local name=${item%%:*} + failed_names+=("$name") + done + local failed_list="${failed_names[*]}" + + # Determine primary reason if [[ $failed_count -eq 1 ]]; then - local first="${failed_items[0]}" - local name=${first%%:*} - local reason=${first#*:} - echo "${name} $(map_uninstall_reason "$reason")" + local first_reason=${failed_items[0]#*:} + case "$first_reason" in + still*running*) reason_summary="still running" ;; + remove*failed*) reason_summary="could not be removed" ;; + permission*) reason_summary="permission denied" ;; + *) reason_summary="$first_reason" ;; + esac + echo -e "Failed: ${RED}${failed_list}${NC} ${reason_summary}" else - local joined="${failed_items[*]}"; echo "Failures: $joined" + echo -e "Failed: ${RED}${failed_list}${NC} could not be removed" fi - else - echo -e "Removed: ${GREEN}$success_count${NC} | Failed: ${RED}$failed_count${NC} | Freed: ${GREEN}$freed_display${NC}" fi echo "$bar" diff --git a/lib/common.sh b/lib/common.sh index 412352d..f2ceb75 100755 --- a/lib/common.sh +++ b/lib/common.sh @@ -20,6 +20,15 @@ readonly RED="${ESC}[0;31m" readonly GRAY="${ESC}[0;90m" readonly NC="${ESC}[0m" +# Icon definitions +readonly ICON_CONFIRM="◎" # Confirm operation +readonly ICON_ADMIN="●" # Admin permission +readonly ICON_SUCCESS="✓" # Success +readonly ICON_ERROR="✗" # Error +readonly ICON_EMPTY="○" # Empty state +readonly ICON_LIST="-" # List item +readonly ICON_MENU="▸" # Menu item + # Spinner character helpers (ASCII by default, overridable via env) mo_spinner_chars() { local chars="${MO_SPINNER_CHARS:-|/-\\}" @@ -52,7 +61,7 @@ log_info() { log_success() { rotate_log - echo -e " ${GREEN}✓${NC} $1" + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$LOG_FILE" 2>/dev/null || true } @@ -64,16 +73,47 @@ log_warning() { log_error() { rotate_log - echo -e "${RED}$1${NC}" >&2 + echo -e "${RED}${ICON_ERROR}${NC} $1" >&2 echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$LOG_FILE" 2>/dev/null || true } log_header() { rotate_log - echo -e "\n${PURPLE}▶ $1${NC}" + echo -e "\n${PURPLE}${ICON_MENU} $1${NC}" echo "[$(date '+%Y-%m-%d %H:%M:%S')] SECTION: $1" >> "$LOG_FILE" 2>/dev/null || true } +# Icon output helpers +icon_confirm() { + echo -e "${BLUE}${ICON_CONFIRM}${NC} $1" +} + +icon_admin() { + echo -e "${BLUE}${ICON_ADMIN}${NC} $1" +} + +icon_success() { + echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1" +} + +icon_error() { + echo -e " ${RED}${ICON_ERROR}${NC} $1" +} + +icon_empty() { + echo -e " ${BLUE}${ICON_EMPTY}${NC} $1" +} + +icon_list() { + echo -e " ${ICON_LIST} $1" +} + +icon_menu() { + local num="$1" + local text="$2" + echo -e "${BLUE}${ICON_MENU} ${num}. ${text}${NC}" +} + # System detection detect_architecture() { if [[ "$(uname -m)" == "arm64" ]]; then @@ -276,7 +316,7 @@ request_sudo_access() { # If Touch ID is supported and not forced to use password if [[ "$force_password" != "true" ]] && check_touchid_support; then - echo -e "${BLUE}${prompt_msg}${NC} ${GRAY}(Touch ID or password)${NC}" + echo -e "${BLUE}${ICON_ADMIN}${NC} ${prompt_msg} ${GRAY}(Touch ID or password)${NC}" if sudo -v 2>/dev/null; then return 0 else @@ -284,8 +324,8 @@ request_sudo_access() { fi else # Traditional password method - echo -e "${BLUE}${prompt_msg}${NC}" - echo -ne "${BLUE} Password> ${NC}" + echo -e "${BLUE}${ICON_ADMIN}${NC} ${prompt_msg}" + echo -ne "${BLUE}${ICON_MENU}${NC} Password: " read -s password echo "" if [[ -n "$password" ]] && echo "$password" | sudo -S true 2>/dev/null; then @@ -313,13 +353,27 @@ request_sudo() { update_via_homebrew() { local version="${1:-unknown}" - echo -e "${BLUE}|${NC} Updating Homebrew..." + if [[ -t 1 ]]; then + start_inline_spinner "Updating Homebrew..." + else + echo "Updating Homebrew..." + fi # Filter out common noise but show important info brew update 2>&1 | grep -Ev "^(==>|Already up-to-date)" || true + if [[ -t 1 ]]; then + stop_inline_spinner + fi - echo -e "${BLUE}|${NC} Upgrading Mole..." + if [[ -t 1 ]]; then + start_inline_spinner "Upgrading Mole..." + else + echo "Upgrading Mole..." + fi local upgrade_output upgrade_output=$(brew upgrade mole 2>&1) || true + if [[ -t 1 ]]; then + stop_inline_spinner + fi if echo "$upgrade_output" | grep -q "already installed"; then # Get current version @@ -397,17 +451,21 @@ start_inline_spinner() { if [[ -t 1 ]]; then ( + trap 'exit 0' TERM INT EXIT local chars chars="$(mo_spinner_chars)" + [[ -z "$chars" ]] && chars='|/-\' local i=0 while true; do local c="${chars:$((i % ${#chars})):1}" - printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" + printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" 2>/dev/null || exit 0 ((i++)) - sleep 0.12 + # macOS supports decimal sleep, this is the primary target + sleep 0.1 2>/dev/null || sleep 1 2>/dev/null || exit 0 done ) & INLINE_SPINNER_PID=$! + disown 2>/dev/null || true else echo -n " ${BLUE}|${NC} $message" fi @@ -419,7 +477,7 @@ stop_inline_spinner() { kill "$INLINE_SPINNER_PID" 2>/dev/null || true wait "$INLINE_SPINNER_PID" 2>/dev/null || true INLINE_SPINNER_PID="" - [[ -t 1 ]] && printf "\r" + [[ -t 1 ]] && printf "\r\033[K" fi } @@ -556,10 +614,45 @@ parallel_execute() { # Set MOLE_SPINNER_PREFIX=" " for indented spinner (e.g., in clean context) with_spinner() { local msg="$1"; shift || true + local timeout="${MOLE_CMD_TIMEOUT:-180}" # Default 3min timeout + if [[ -t 1 ]]; then start_inline_spinner "$msg" fi - "$@" >/dev/null 2>&1 || return $? + + # Run command with timeout protection + if command -v timeout >/dev/null 2>&1; then + # GNU timeout available + timeout "$timeout" "$@" >/dev/null 2>&1 || { + local exit_code=$? + if [[ -t 1 ]]; then stop_inline_spinner; fi + # Exit code 124 means timeout + [[ $exit_code -eq 124 ]] && echo -e " ${YELLOW}⚠${NC} $msg timed out (skipped)" >&2 + return $exit_code + } + else + # Fallback: run in background with manual timeout + "$@" >/dev/null 2>&1 & + local cmd_pid=$! + local elapsed=0 + while kill -0 $cmd_pid 2>/dev/null; do + if [[ $elapsed -ge $timeout ]]; then + kill -TERM $cmd_pid 2>/dev/null || true + wait $cmd_pid 2>/dev/null || true + if [[ -t 1 ]]; then stop_inline_spinner; fi + echo -e " ${YELLOW}⚠${NC} $msg timed out (skipped)" >&2 + return 124 + fi + sleep 1 + ((elapsed++)) + done + wait $cmd_pid 2>/dev/null || { + local exit_code=$? + if [[ -t 1 ]]; then stop_inline_spinner; fi + return $exit_code + } + fi + if [[ -t 1 ]]; then stop_inline_spinner fi @@ -575,8 +668,16 @@ clean_tool_cache() { echo -e " ${YELLOW}→${NC} $label (would clean)" return 0 fi - MOLE_SPINNER_PREFIX=" " with_spinner "$label" "$@" - echo -e " ${GREEN}✓${NC} $label" + if MOLE_SPINNER_PREFIX=" " with_spinner "$label" "$@"; then + echo -e " ${GREEN}✓${NC} $label" + else + local exit_code=$? + # Timeout returns 124, don't show error message (already shown by with_spinner) + if [[ $exit_code -ne 124 ]]; then + echo -e " ${YELLOW}⚠${NC} $label failed (skipped)" >&2 + fi + fi + return 0 # Always return success to continue cleanup } # ============================================================================ diff --git a/lib/whitelist_manager.sh b/lib/whitelist_manager.sh index d020b71..d804619 100755 --- a/lib/whitelist_manager.sh +++ b/lib/whitelist_manager.sh @@ -42,13 +42,21 @@ collect_files_to_be_cleaned() { local clean_sh="$SCRIPT_DIR/../bin/clean.sh" local -a items=() - echo -e "${BLUE}|${NC} Scanning cache files..." - echo "" + if [[ -t 1 ]]; then + start_inline_spinner "Scanning cache files..." + else + echo "Scanning cache files..." + fi # Run clean.sh in dry-run mode local temp_output=$(create_temp_file) echo "" | bash "$clean_sh" --dry-run 2>&1 > "$temp_output" || true + if [[ -t 1 ]]; then + stop_inline_spinner + fi + echo "" + # Strip ANSI color codes for parsing local temp_plain=$(create_temp_file) sed $'s/\033\[[0-9;]*m//g' "$temp_output" > "$temp_plain" diff --git a/mo b/mo index f0a9572..890dab7 100755 --- a/mo +++ b/mo @@ -1,5 +1,7 @@ #!/bin/bash # Lightweight alias to run Mole via `mo` +set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" exec "$SCRIPT_DIR/mole" "$@" diff --git a/mole b/mole index f0f2ae4..eee7a53 100755 --- a/mole +++ b/mole @@ -22,7 +22,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib/common.sh" # Version info -VERSION="1.7.0" +VERSION="1.7.1" MOLE_TAGLINE="can dig deep to clean your Mac." # Check for updates (non-blocking, cached) @@ -148,7 +148,11 @@ update_mole() { fi # Download and run installer with progress - echo -e "${BLUE}|${NC} Downloading latest version..." + if [[ -t 1 ]]; then + start_inline_spinner "Downloading latest version..." + else + echo "Downloading latest version..." + fi local installer_url="https://raw.githubusercontent.com/tw93/mole/main/install.sh" local tmp_installer @@ -157,22 +161,26 @@ update_mole() { # Download installer with progress if command -v curl >/dev/null 2>&1; then if ! curl -fsSL --connect-timeout 10 --max-time 60 "$installer_url" -o "$tmp_installer" 2>&1; then + if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" log_error "Update failed. Check network connection." exit 1 fi elif command -v wget >/dev/null 2>&1; then if ! wget --timeout=10 --tries=3 -qO "$tmp_installer" "$installer_url" 2>&1; then + if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" log_error "Update failed. Check network connection." exit 1 fi else + if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" log_error "curl or wget required" exit 1 fi + if [[ -t 1 ]]; then stop_inline_spinner; fi chmod +x "$tmp_installer" # Determine install directory @@ -181,23 +189,35 @@ update_mole() { local install_dir install_dir="$(cd "$(dirname "$mole_path")" && pwd)" - echo -e "${BLUE}|${NC} Installing update..." + if [[ -t 1 ]]; then + start_inline_spinner "Installing update..." + else + echo "Installing update..." + fi # Run installer with visible output (but capture for error handling) local install_output if install_output=$("$tmp_installer" --prefix "$install_dir" --config "$HOME/.config/mole" --update 2>&1); then + if [[ -t 1 ]]; then stop_inline_spinner; fi echo "$install_output" | grep -Ev "^$" || true - local new_version - new_version=$("$mole_path" --version 2>/dev/null | awk 'NF {print $NF}' || echo "") - echo -e "${GREEN}✓${NC} Updated to latest version (${new_version:-unknown})" - else - # Retry without --update flag - if install_output=$("$tmp_installer" --prefix "$install_dir" --config "$HOME/.config/mole" 2>&1); then - echo "$install_output" | grep -Ev "^$" || true + # Only show success message if not already shown by installer + if ! echo "$install_output" | grep -q "Already on latest version"; then local new_version new_version=$("$mole_path" --version 2>/dev/null | awk 'NF {print $NF}' || echo "") echo -e "${GREEN}✓${NC} Updated to latest version (${new_version:-unknown})" + fi + else + # Retry without --update flag + if install_output=$("$tmp_installer" --prefix "$install_dir" --config "$HOME/.config/mole" 2>&1); then + if [[ -t 1 ]]; then stop_inline_spinner; fi + echo "$install_output" | grep -Ev "^$" || true + if ! echo "$install_output" | grep -q "Already on latest version"; then + local new_version + new_version=$("$mole_path" --version 2>/dev/null | awk 'NF {print $NF}' || echo "") + echo -e "${GREEN}✓${NC} Updated to latest version (${new_version:-unknown})" + fi else + if [[ -t 1 ]]; then stop_inline_spinner; fi rm -f "$tmp_installer" log_error "Update failed" echo "$install_output" | tail -10 >&2 # Show last 10 lines of error @@ -216,7 +236,13 @@ remove_mole() { echo -e "${YELLOW}Remove Mole${NC}" echo "" - # Detect all installations + # Detect all installations with loading + if [[ -t 1 ]]; then + start_inline_spinner "Detecting Mole installations..." + else + echo "Detecting installations..." + fi + local is_homebrew=false local -a manual_installs=() local -a alias_installs=() @@ -254,43 +280,52 @@ remove_mole() { fi done - # Show what will be removed - echo "This will remove:" - echo "" - - if [[ "$is_homebrew" == "true" ]]; then - echo -e " ${GREEN}✓${NC} Mole (via Homebrew)" + if [[ -t 1 ]]; then + stop_inline_spinner fi - if [[ ${#manual_installs[@]} -gt 0 ]]; then - for install in "${manual_installs[@]}"; do - echo -e " ${GREEN}✓${NC} $install" - done - fi - - if [[ ${#alias_installs[@]} -gt 0 ]]; then - for alias in "${alias_installs[@]}"; do - echo -e " ${GREEN}✓${NC} $alias" - done - fi - - echo -e " ${GREEN}✓${NC} ~/.config/mole/ (configuration)" - echo -e " ${GREEN}✓${NC} ~/.cache/mole/ (cache)" - + # Check if anything to remove if [[ "$is_homebrew" == "false" && ${#manual_installs[@]} -eq 0 && ${#alias_installs[@]} -eq 0 ]]; then echo "" echo -e "${YELLOW}No Mole installation detected${NC}" exit 0 fi + # Show what will be removed + echo "Will remove:" echo "" - # Confirm removal - read -p "Are you sure you want to remove Mole? (y/N): " -n 1 -r; echo ""; if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Cancelled." - exit 0 + if [[ "$is_homebrew" == "true" ]]; then + echo " - Mole via Homebrew" fi + for install in "${manual_installs[@]}" "${alias_installs[@]}"; do + echo " - $install" + done + + echo " - ~/.config/mole" + echo " - ~/.cache/mole" + + echo "" + echo -n "Press Enter to confirm, ESC or q to cancel: " + + # Read single key + IFS= read -r -s -n1 key || key="" + echo "" + case "$key" in + $'\e'|q|Q) + echo "Cancelled" + exit 0 + ;; + ""|$'\n'|$'\r') + # Continue with removal + ;; + *) + echo "Cancelled" + exit 0 + ;; + esac + echo "" # Remove Homebrew installation