mac_cleaner 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/common.sh ADDED
@@ -0,0 +1,1751 @@
1
+ #!/bin/bash
2
+ # Mole - Common Functions Library
3
+ # Shared utilities and functions for all modules
4
+
5
+ set -euo pipefail
6
+
7
+ # Prevent multiple sourcing
8
+ if [[ -n "${MOLE_COMMON_LOADED:-}" ]]; then
9
+ return 0
10
+ fi
11
+ readonly MOLE_COMMON_LOADED=1
12
+
13
+ # Color definitions (readonly for safety)
14
+ readonly ESC=$'\033'
15
+ readonly GREEN="${ESC}[0;32m"
16
+ readonly BLUE="${ESC}[0;34m"
17
+ readonly YELLOW="${ESC}[1;33m"
18
+ readonly PURPLE="${ESC}[0;35m"
19
+ readonly RED="${ESC}[0;31m"
20
+ readonly GRAY="${ESC}[0;90m"
21
+ readonly NC="${ESC}[0m"
22
+
23
+ # Icon definitions (shared across modules)
24
+ readonly ICON_CONFIRM="◎" # Confirm operation / spinner text
25
+ readonly ICON_ADMIN="⚙" # Gear indicator for admin/settings/system info
26
+ readonly ICON_SUCCESS="✓" # Success mark
27
+ readonly ICON_ERROR="☻" # Error / warning mark
28
+ readonly ICON_EMPTY="○" # Hollow circle (empty state / unchecked)
29
+ readonly ICON_SOLID="●" # Solid circle (selected / system marker)
30
+ readonly ICON_LIST="•" # Basic list bullet
31
+ readonly ICON_ARROW="➤" # Pointer / prompt indicator
32
+ readonly ICON_WARNING="☻" # Warning marker (shares glyph with error)
33
+ readonly ICON_NAV_UP="↑" # Navigation up
34
+ readonly ICON_NAV_DOWN="↓" # Navigation down
35
+ readonly ICON_NAV_LEFT="←" # Navigation left
36
+ readonly ICON_NAV_RIGHT="→" # Navigation right
37
+
38
+ # Spinner character helpers (ASCII by default, overridable via env)
39
+ mo_spinner_chars() {
40
+ local chars="${MO_SPINNER_CHARS:-|/-\\}"
41
+ [[ -z "$chars" ]] && chars='|/-\\'
42
+ printf "%s" "$chars"
43
+ }
44
+
45
+ # Logging configuration
46
+ readonly LOG_FILE="${HOME}/.config/mole/mole.log"
47
+ readonly LOG_MAX_SIZE_DEFAULT=1048576 # 1MB
48
+
49
+ # Ensure log directory exists
50
+ mkdir -p "$(dirname "$LOG_FILE")" 2> /dev/null || true
51
+
52
+ # Log file maintenance (must be defined before logging functions)
53
+ rotate_log() {
54
+ local max_size="${MOLE_MAX_LOG_SIZE:-$LOG_MAX_SIZE_DEFAULT}"
55
+ if [[ -f "$LOG_FILE" ]] && [[ $(stat -f%z "$LOG_FILE" 2> /dev/null || echo 0) -gt "$max_size" ]]; then
56
+ mv "$LOG_FILE" "${LOG_FILE}.old" 2> /dev/null || true
57
+ touch "$LOG_FILE" 2> /dev/null || true
58
+ fi
59
+ }
60
+
61
+ # Enhanced logging functions with file logging support
62
+ log_info() {
63
+ rotate_log
64
+ echo -e "${BLUE}$1${NC}"
65
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $1" >> "$LOG_FILE" 2> /dev/null || true
66
+ }
67
+
68
+ log_success() {
69
+ rotate_log
70
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
71
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] SUCCESS: $1" >> "$LOG_FILE" 2> /dev/null || true
72
+ }
73
+
74
+ log_warning() {
75
+ rotate_log
76
+ echo -e "${YELLOW}$1${NC}"
77
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: $1" >> "$LOG_FILE" 2> /dev/null || true
78
+ }
79
+
80
+ log_error() {
81
+ rotate_log
82
+ echo -e "${RED}${ICON_ERROR}${NC} $1" >&2
83
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >> "$LOG_FILE" 2> /dev/null || true
84
+ }
85
+
86
+ log_header() {
87
+ rotate_log
88
+ echo -e "\n${PURPLE}${ICON_ARROW} $1${NC}"
89
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] SECTION: $1" >> "$LOG_FILE" 2> /dev/null || true
90
+ }
91
+
92
+ # Icon output helpers
93
+ icon_confirm() {
94
+ echo -e "${BLUE}${ICON_CONFIRM}${NC} $1"
95
+ }
96
+
97
+ icon_admin() {
98
+ echo -e "${BLUE}${ICON_ADMIN}${NC} $1"
99
+ }
100
+
101
+ icon_success() {
102
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} $1"
103
+ }
104
+
105
+ icon_error() {
106
+ echo -e " ${RED}${ICON_ERROR}${NC} $1"
107
+ }
108
+
109
+ icon_empty() {
110
+ echo -e " ${BLUE}${ICON_EMPTY}${NC} $1"
111
+ }
112
+
113
+ icon_list() {
114
+ echo -e " ${ICON_LIST} $1"
115
+ }
116
+
117
+ icon_menu() {
118
+ local num="$1"
119
+ local text="$2"
120
+ echo -e "${BLUE}${ICON_ARROW} ${num}. ${text}${NC}"
121
+ }
122
+
123
+ # Consistent summary blocks for command results
124
+ print_summary_block() {
125
+ local heading=""
126
+
127
+ if [[ $# -gt 0 ]]; then
128
+ shift
129
+ fi
130
+
131
+ if [[ $# -gt 0 ]]; then
132
+ heading="$1"
133
+ shift
134
+ fi
135
+
136
+ local -a details=("$@")
137
+ local divider="======================================================================"
138
+
139
+ echo "$divider"
140
+ if [[ -n "$heading" ]]; then
141
+ echo -e "${BLUE}${heading}${NC}"
142
+ fi
143
+ for detail in "${details[@]}"; do
144
+ [[ -z "$detail" ]] && continue
145
+ echo -e "${detail}"
146
+ done
147
+ echo "$divider"
148
+ }
149
+
150
+ # System detection
151
+ detect_architecture() {
152
+ if [[ "$(uname -m)" == "arm64" ]]; then
153
+ echo "Apple Silicon"
154
+ else
155
+ echo "Intel"
156
+ fi
157
+ }
158
+
159
+ get_free_space() {
160
+ df -h / | awk 'NR==2 {print $4}'
161
+ }
162
+
163
+ # Common UI functions
164
+ clear_screen() {
165
+ printf '\033[2J\033[H'
166
+ }
167
+
168
+ hide_cursor() {
169
+ printf '\033[?25l'
170
+ }
171
+
172
+ show_cursor() {
173
+ printf '\033[?25h'
174
+ }
175
+
176
+ # Keyboard input handling (simple and robust)
177
+ read_key() {
178
+ local key rest read_status
179
+
180
+ # Read with explicit status check
181
+ IFS= read -r -s -n 1 key
182
+ read_status=$?
183
+
184
+ # Handle read failure (Ctrl+D, EOF, etc.) - treat as quit
185
+ if [[ $read_status -ne 0 ]]; then
186
+ echo "QUIT"
187
+ return 0
188
+ fi
189
+
190
+ # Raw typing mode (filter): map most keys to CHAR:<key>
191
+ if [[ "${MOLE_READ_KEY_FORCE_CHAR:-}" == "1" ]]; then
192
+ # Some terminals return empty on Enter with -n1
193
+ if [[ -z "$key" ]]; then
194
+ echo "ENTER"
195
+ return 0
196
+ fi
197
+ case "$key" in
198
+ $'\n' | $'\r') echo "ENTER" ;;
199
+ $'\x7f' | $'\x08') echo "DELETE" ;;
200
+ $'\x1b') echo "QUIT" ;; # ESC cancels filter
201
+ *)
202
+ case "$key" in
203
+ [[:print:]]) echo "CHAR:$key" ;;
204
+ *) echo "OTHER" ;;
205
+ esac
206
+ ;;
207
+ esac
208
+ return 0
209
+ fi
210
+
211
+ # Some terminals can yield empty on Enter with -n1; treat as ENTER
212
+ if [[ -z "$key" ]]; then
213
+ echo "ENTER"
214
+ return 0
215
+ fi
216
+
217
+ case "$key" in
218
+ $'\n' | $'\r') echo "ENTER" ;;
219
+ ' ') echo "SPACE" ;;
220
+ 'Q') echo "QUIT" ;;
221
+ 'R') echo "RETRY" ;;
222
+ 'o' | 'O') echo "OPEN" ;;
223
+ '/') echo "FILTER" ;; # Trigger filter mode
224
+ $'\x03') echo "QUIT" ;; # Ctrl+C
225
+ $'\x7f' | $'\x08') echo "DELETE" ;; # Backspace/Delete key
226
+ $'\x1b')
227
+ # ESC sequence - could be arrow key, delete key, or ESC alone
228
+ # Read the next two bytes within 1s
229
+ if IFS= read -r -s -n 1 -t 1 rest 2> /dev/null; then
230
+ if [[ "$rest" == "[" ]]; then
231
+ # Got ESC [, read next character
232
+ if IFS= read -r -s -n 1 -t 1 rest2 2> /dev/null; then
233
+ case "$rest2" in
234
+ "A") echo "UP" ;;
235
+ "B") echo "DOWN" ;;
236
+ "C") echo "RIGHT" ;;
237
+ "D") echo "LEFT" ;;
238
+ "3")
239
+ # Delete key (Fn+Delete): ESC [ 3 ~
240
+ IFS= read -r -s -n 1 -t 1 rest3 2> /dev/null
241
+ if [[ "$rest3" == "~" ]]; then
242
+ echo "DELETE"
243
+ else
244
+ echo "OTHER"
245
+ fi
246
+ ;;
247
+ "5")
248
+ # Page Up key: ESC [ 5 ~
249
+ IFS= read -r -s -n 1 -t 1 rest3 2> /dev/null
250
+ [[ "$rest3" == "~" ]] && echo "OTHER" || echo "OTHER"
251
+ ;;
252
+ "6")
253
+ # Page Down key: ESC [ 6 ~
254
+ IFS= read -r -s -n 1 -t 1 rest3 2> /dev/null
255
+ [[ "$rest3" == "~" ]] && echo "OTHER" || echo "OTHER"
256
+ ;;
257
+ *) echo "OTHER" ;;
258
+ esac
259
+ else
260
+ echo "QUIT" # ESC [ timeout
261
+ fi
262
+ else
263
+ echo "QUIT" # ESC + something else
264
+ fi
265
+ else
266
+ # ESC pressed alone - treat as quit
267
+ echo "QUIT"
268
+ fi
269
+ ;;
270
+ *)
271
+ # Printable ASCII -> expose as CHAR:<key> (for live filtering)
272
+ case "$key" in
273
+ [[:print:]]) echo "CHAR:$key" ;;
274
+ *) echo "OTHER" ;;
275
+ esac
276
+ ;;
277
+ esac
278
+ }
279
+
280
+ # Drain pending input (useful for scrolling prevention)
281
+ drain_pending_input() {
282
+ local drained=0
283
+ # Single pass with reasonable timeout
284
+ # Touchpad scrolling can generate bursts of arrow keys
285
+ while IFS= read -r -s -n 1 -t 0.001 _ 2> /dev/null; do
286
+ ((drained++))
287
+ # Safety limit to prevent infinite loop
288
+ [[ $drained -gt 500 ]] && break
289
+ done
290
+ }
291
+
292
+ # Menu display helper
293
+ show_menu_option() {
294
+ local number="$1"
295
+ local text="$2"
296
+ local selected="$3"
297
+
298
+ if [[ "$selected" == "true" ]]; then
299
+ echo -e "${BLUE}${ICON_ARROW} $number. $text${NC}"
300
+ else
301
+ echo " $number. $text"
302
+ fi
303
+ }
304
+
305
+ # Error handling
306
+ handle_error() {
307
+ local message="$1"
308
+ local exit_code="${2:-1}"
309
+
310
+ log_error "$message"
311
+ exit "$exit_code"
312
+ }
313
+
314
+ # File size utilities
315
+ get_human_size() {
316
+ local path="$1"
317
+ if [[ ! -e "$path" ]]; then
318
+ echo "N/A"
319
+ return 1
320
+ fi
321
+ du -sh "$path" 2> /dev/null | cut -f1 || echo "N/A"
322
+ }
323
+
324
+ # Convert bytes to human readable format
325
+ bytes_to_human() {
326
+ local bytes="$1"
327
+ if [[ ! "$bytes" =~ ^[0-9]+$ ]]; then
328
+ echo "0B"
329
+ return 1
330
+ fi
331
+
332
+ if ((bytes >= 1073741824)); then # >= 1GB
333
+ local divisor=1073741824
334
+ local whole=$((bytes / divisor))
335
+ local remainder=$((bytes % divisor))
336
+ local frac=$(((remainder * 100 + divisor / 2) / divisor)) # Two decimals, rounded
337
+ if ((frac >= 100)); then
338
+ frac=0
339
+ ((whole++))
340
+ fi
341
+ printf "%d.%02dGB\n" "$whole" "$frac"
342
+ return 0
343
+ fi
344
+
345
+ if ((bytes >= 1048576)); then # >= 1MB
346
+ local divisor=1048576
347
+ local whole=$((bytes / divisor))
348
+ local remainder=$((bytes % divisor))
349
+ local frac=$(((remainder * 10 + divisor / 2) / divisor)) # One decimal, rounded
350
+ if ((frac >= 10)); then
351
+ frac=0
352
+ ((whole++))
353
+ fi
354
+ printf "%d.%01dMB\n" "$whole" "$frac"
355
+ return 0
356
+ fi
357
+
358
+ if ((bytes >= 1024)); then # >= 1KB
359
+ local rounded_kb=$(((bytes + 512) / 1024)) # Nearest integer KB
360
+ printf "%dKB\n" "$rounded_kb"
361
+ return 0
362
+ fi
363
+
364
+ printf "%dB\n" "$bytes"
365
+ }
366
+
367
+ # Calculate directory size in bytes
368
+ get_directory_size_bytes() {
369
+ local path="$1"
370
+ if [[ ! -d "$path" ]]; then
371
+ echo "0"
372
+ return 1
373
+ fi
374
+ du -sk "$path" 2> /dev/null | cut -f1 | awk '{print $1 * 1024}' || echo "0"
375
+ }
376
+
377
+ # Permission checks
378
+ check_sudo() {
379
+ if ! sudo -n true 2> /dev/null; then
380
+ return 1
381
+ fi
382
+ return 0
383
+ }
384
+
385
+ # Check if Touch ID is configured for sudo
386
+ check_touchid_support() {
387
+ if [[ -f /etc/pam.d/sudo ]]; then
388
+ grep -q "pam_tid.so" /etc/pam.d/sudo 2> /dev/null
389
+ return $?
390
+ fi
391
+ return 1
392
+ }
393
+
394
+ # Request sudo access with Touch ID support
395
+ # Usage: request_sudo_access "prompt message" [optional: force_password]
396
+ request_sudo_access() {
397
+ local prompt_msg="${1:-Admin access required}"
398
+ local force_password="${2:-false}"
399
+
400
+ # Check if already has sudo access
401
+ if sudo -n true 2> /dev/null; then
402
+ return 0
403
+ fi
404
+
405
+ # If Touch ID is supported and not forced to use password
406
+ if [[ "$force_password" != "true" ]] && check_touchid_support; then
407
+ echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg} ${GRAY}(Touch ID or password)${NC}"
408
+ if sudo -v 2> /dev/null; then
409
+ return 0
410
+ else
411
+ return 1
412
+ fi
413
+ else
414
+ # Traditional password method
415
+ echo -e "${PURPLE}${ICON_ARROW}${NC} ${prompt_msg}"
416
+ echo -ne "${PURPLE}${ICON_ARROW}${NC} Password: "
417
+ read -s password
418
+ echo ""
419
+ if [[ -n "$password" ]] && echo "$password" | sudo -S true 2> /dev/null; then
420
+ return 0
421
+ else
422
+ return 1
423
+ fi
424
+ fi
425
+ }
426
+
427
+ request_sudo() {
428
+ echo "This operation requires administrator privileges."
429
+ echo -n "Please enter your password: "
430
+ read -s password
431
+ echo
432
+ if echo "$password" | sudo -S true 2> /dev/null; then
433
+ return 0
434
+ else
435
+ log_error "Invalid password or cancelled"
436
+ return 1
437
+ fi
438
+ }
439
+
440
+ # Homebrew update utilities
441
+ update_via_homebrew() {
442
+ local version="${1:-unknown}"
443
+
444
+ if [[ -t 1 ]]; then
445
+ start_inline_spinner "Updating Homebrew..."
446
+ else
447
+ echo "Updating Homebrew..."
448
+ fi
449
+ # Filter out common noise but show important info
450
+ brew update 2>&1 | grep -Ev "^(==>|Already up-to-date)" || true
451
+ if [[ -t 1 ]]; then
452
+ stop_inline_spinner
453
+ fi
454
+
455
+ if [[ -t 1 ]]; then
456
+ start_inline_spinner "Upgrading Mole..."
457
+ else
458
+ echo "Upgrading Mole..."
459
+ fi
460
+ local upgrade_output
461
+ upgrade_output=$(brew upgrade mole 2>&1) || true
462
+ if [[ -t 1 ]]; then
463
+ stop_inline_spinner
464
+ fi
465
+
466
+ if echo "$upgrade_output" | grep -q "already installed"; then
467
+ # Get current version
468
+ local current_version
469
+ current_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}')
470
+ echo -e "${GREEN}${ICON_SUCCESS}${NC} Already on latest version (${current_version:-$version})"
471
+ elif echo "$upgrade_output" | grep -q "Error:"; then
472
+ log_error "Homebrew upgrade failed"
473
+ echo "$upgrade_output" | grep "Error:" >&2
474
+ return 1
475
+ else
476
+ # Show relevant output, filter noise
477
+ echo "$upgrade_output" | grep -Ev "^(==>|Updating Homebrew|Warning:)" || true
478
+ # Get new version
479
+ local new_version
480
+ new_version=$(brew list --versions mole 2> /dev/null | awk '{print $2}')
481
+ echo -e "${GREEN}${ICON_SUCCESS}${NC} Updated to latest version (${new_version:-$version})"
482
+ fi
483
+
484
+ # Clear version check cache
485
+ rm -f "$HOME/.cache/mole/version_check" "$HOME/.cache/mole/update_message"
486
+ return 0
487
+ }
488
+
489
+ # Load basic configuration
490
+ load_config() {
491
+ MOLE_MAX_LOG_SIZE="${MOLE_MAX_LOG_SIZE:-1048576}"
492
+ }
493
+
494
+ # Initialize configuration on sourcing
495
+ load_config
496
+
497
+ # ============================================================================
498
+ # Spinner and Progress Indicators
499
+ # ============================================================================
500
+
501
+ # Global spinner process IDs
502
+ SPINNER_PID=""
503
+ INLINE_SPINNER_PID=""
504
+
505
+ # Start a full-line spinner with message
506
+ start_spinner() {
507
+ local message="$1"
508
+
509
+ if [[ ! -t 1 ]]; then
510
+ echo -n " ${BLUE}|${NC} $message"
511
+ return
512
+ fi
513
+
514
+ echo -n " ${BLUE}|${NC} $message"
515
+ (
516
+ local delay=0.5
517
+ while true; do
518
+ printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}|${NC} $message. "
519
+ sleep $delay
520
+ printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}|${NC} $message.. "
521
+ sleep $delay
522
+ printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}|${NC} $message..."
523
+ sleep $delay
524
+ printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}|${NC} $message "
525
+ sleep $delay
526
+ done
527
+ ) &
528
+ SPINNER_PID=$!
529
+ }
530
+
531
+ # Start an inline spinner (rotating character)
532
+ start_inline_spinner() {
533
+ stop_inline_spinner 2> /dev/null || true
534
+ local message="$1"
535
+
536
+ if [[ -t 1 ]]; then
537
+ (
538
+ trap 'exit 0' TERM INT EXIT
539
+ local chars
540
+ chars="$(mo_spinner_chars)"
541
+ [[ -z "$chars" ]] && chars='|/-\'
542
+ local i=0
543
+ while true; do
544
+ local c="${chars:$((i % ${#chars})):1}"
545
+ printf "\r${MOLE_SPINNER_PREFIX:-}${BLUE}%s${NC} %s" "$c" "$message" 2> /dev/null || exit 0
546
+ ((i++))
547
+ # macOS supports decimal sleep, this is the primary target
548
+ sleep 0.1 2> /dev/null || sleep 1 2> /dev/null || exit 0
549
+ done
550
+ ) &
551
+ INLINE_SPINNER_PID=$!
552
+ disown 2> /dev/null || true
553
+ else
554
+ echo -n " ${BLUE}|${NC} $message"
555
+ fi
556
+ }
557
+
558
+ # Stop inline spinner
559
+ stop_inline_spinner() {
560
+ if [[ -n "$INLINE_SPINNER_PID" ]]; then
561
+ kill "$INLINE_SPINNER_PID" 2> /dev/null || true
562
+ wait "$INLINE_SPINNER_PID" 2> /dev/null || true
563
+ INLINE_SPINNER_PID=""
564
+ [[ -t 1 ]] && printf "\r\033[K"
565
+ fi
566
+ }
567
+
568
+ # Stop spinner with optional result message
569
+ stop_spinner() {
570
+ local result_message="${1:-Done}"
571
+
572
+ stop_inline_spinner
573
+
574
+ if [[ -n "$SPINNER_PID" ]]; then
575
+ kill "$SPINNER_PID" 2> /dev/null || true
576
+ wait "$SPINNER_PID" 2> /dev/null || true
577
+ SPINNER_PID=""
578
+ fi
579
+
580
+ if [[ -n "$result_message" ]]; then
581
+ if [[ -t 1 ]]; then
582
+ printf "\r${MOLE_SPINNER_PREFIX:-}${GREEN}${ICON_SUCCESS}${NC} %s\n" "$result_message"
583
+ else
584
+ echo " ${ICON_SUCCESS} $result_message"
585
+ fi
586
+ fi
587
+ }
588
+
589
+ # ============================================================================
590
+ # User Interaction - Confirmation Dialogs
591
+ # ============================================================================
592
+
593
+ # ============================================================================
594
+ # Temporary File Management
595
+ # ============================================================================
596
+
597
+ # Global temp file tracking
598
+ declare -a MOLE_TEMP_FILES=()
599
+ declare -a MOLE_TEMP_DIRS=()
600
+
601
+ # Create tracked temporary file
602
+ # Returns: temp file path
603
+ create_temp_file() {
604
+ local temp
605
+ temp=$(mktemp) || return 1
606
+ MOLE_TEMP_FILES+=("$temp")
607
+ echo "$temp"
608
+ }
609
+
610
+ # Create tracked temporary directory
611
+ # Returns: temp directory path
612
+ create_temp_dir() {
613
+ local temp
614
+ temp=$(mktemp -d) || return 1
615
+ MOLE_TEMP_DIRS+=("$temp")
616
+ echo "$temp"
617
+ }
618
+
619
+ # Create temp file with prefix (for analyze.sh compatibility)
620
+ # Args: $1 - prefix/suffix string
621
+ # Returns: temp file path
622
+ create_temp_file_named() {
623
+ local suffix="${1:-}"
624
+ local temp
625
+ temp=$(mktemp "/tmp/mole_${suffix}_XXXXXX") || return 1
626
+ MOLE_TEMP_FILES+=("$temp")
627
+ echo "$temp"
628
+ }
629
+
630
+ # Cleanup all tracked temp files
631
+ cleanup_temp_files() {
632
+ local file
633
+ if [[ ${#MOLE_TEMP_FILES[@]} -gt 0 ]]; then
634
+ for file in "${MOLE_TEMP_FILES[@]}"; do
635
+ [[ -f "$file" ]] && rm -f "$file" 2> /dev/null || true
636
+ done
637
+ fi
638
+
639
+ if [[ ${#MOLE_TEMP_DIRS[@]} -gt 0 ]]; then
640
+ for file in "${MOLE_TEMP_DIRS[@]}"; do
641
+ [[ -d "$file" ]] && rm -rf "$file" 2> /dev/null || true
642
+ done
643
+ fi
644
+
645
+ MOLE_TEMP_FILES=()
646
+ MOLE_TEMP_DIRS=()
647
+ }
648
+
649
+ # Auto-cleanup on script exit (call this in main scripts)
650
+ register_temp_cleanup() {
651
+ trap cleanup_temp_files EXIT INT TERM
652
+ }
653
+
654
+ # ============================================================================
655
+ # Parallel Processing Framework
656
+ # ============================================================================
657
+
658
+ # Execute commands in parallel with job control
659
+ # Args: $1 - max parallel jobs
660
+ # $2 - worker function name
661
+ # $3+ - items to process
662
+ parallel_execute() {
663
+ local max_jobs="${1:-12}"
664
+ local worker_func="$2"
665
+ shift 2
666
+ local -a items=("$@")
667
+
668
+ if [[ ${#items[@]} -eq 0 ]]; then
669
+ return 0
670
+ fi
671
+
672
+ local -a pids=()
673
+ for item in "${items[@]}"; do
674
+ # Execute worker function in background
675
+ "$worker_func" "$item" &
676
+ pids+=($!)
677
+
678
+ # Wait for a slot if we've hit max parallel jobs
679
+ if ((${#pids[@]} >= max_jobs)); then
680
+ wait "${pids[0]}" 2> /dev/null || true
681
+ pids=("${pids[@]:1}")
682
+ fi
683
+ done
684
+
685
+ # Wait for remaining background jobs
686
+ if ((${#pids[@]} > 0)); then
687
+ for pid in "${pids[@]}"; do
688
+ wait "$pid" 2> /dev/null || true
689
+ done
690
+ fi
691
+ }
692
+
693
+ # ============================================================================
694
+ # Lightweight spinner helper wrappers
695
+ # ============================================================================
696
+ # Usage: with_spinner "Message" cmd arg...
697
+ # Set MOLE_SPINNER_PREFIX=" " for indented spinner (e.g., in clean context)
698
+ with_spinner() {
699
+ local msg="$1"
700
+ shift || true
701
+ local timeout="${MOLE_CMD_TIMEOUT:-180}" # Default 3min timeout
702
+
703
+ if [[ -t 1 ]]; then
704
+ start_inline_spinner "$msg"
705
+ fi
706
+
707
+ # Run command with timeout protection
708
+ if command -v timeout > /dev/null 2>&1; then
709
+ # GNU timeout available
710
+ timeout "$timeout" "$@" > /dev/null 2>&1 || {
711
+ local exit_code=$?
712
+ if [[ -t 1 ]]; then stop_inline_spinner; fi
713
+ # Exit code 124 means timeout
714
+ [[ $exit_code -eq 124 ]] && echo -e " ${YELLOW}${ICON_WARNING}${NC} $msg timed out (skipped)" >&2
715
+ return $exit_code
716
+ }
717
+ else
718
+ # Fallback: run in background with manual timeout
719
+ "$@" > /dev/null 2>&1 &
720
+ local cmd_pid=$!
721
+ local elapsed=0
722
+ while kill -0 $cmd_pid 2> /dev/null; do
723
+ if [[ $elapsed -ge $timeout ]]; then
724
+ kill -TERM $cmd_pid 2> /dev/null || true
725
+ wait $cmd_pid 2> /dev/null || true
726
+ if [[ -t 1 ]]; then stop_inline_spinner; fi
727
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} $msg timed out (skipped)" >&2
728
+ return 124
729
+ fi
730
+ sleep 1
731
+ ((elapsed++))
732
+ done
733
+ wait $cmd_pid 2> /dev/null || {
734
+ local exit_code=$?
735
+ if [[ -t 1 ]]; then stop_inline_spinner; fi
736
+ return $exit_code
737
+ }
738
+ fi
739
+
740
+ if [[ -t 1 ]]; then
741
+ stop_inline_spinner
742
+ fi
743
+ }
744
+
745
+ # ============================================================================
746
+ # Cache/tool cleanup abstraction
747
+ # ============================================================================
748
+ # clean_tool_cache "Label" command...
749
+ clean_tool_cache() {
750
+ local label="$1"
751
+ shift || true
752
+ if [[ "$DRY_RUN" == "true" ]]; then
753
+ echo -e " ${YELLOW}→${NC} $label (would clean)"
754
+ return 0
755
+ fi
756
+ if MOLE_SPINNER_PREFIX=" " with_spinner "$label" "$@"; then
757
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} $label"
758
+ else
759
+ local exit_code=$?
760
+ # Timeout returns 124, don't show error message (already shown by with_spinner)
761
+ if [[ $exit_code -ne 124 ]]; then
762
+ echo -e " ${YELLOW}${ICON_WARNING}${NC} $label failed (skipped)" >&2
763
+ fi
764
+ fi
765
+ return 0 # Always return success to continue cleanup
766
+ }
767
+
768
+ # ============================================================================
769
+ # Unified confirmation prompt with consistent style
770
+ # ============================================================================
771
+
772
+ # Unified action prompt
773
+ # Usage: prompt_action "action" "cancel_text" -> returns 0 for yes, 1 for no
774
+ # Example: prompt_action "enable" "quit" -> "☛ Press Enter to enable, ESC to quit: "
775
+ prompt_action() {
776
+ local action="$1"
777
+ local cancel="${2:-cancel}"
778
+
779
+ echo ""
780
+ echo -ne "${PURPLE}${ICON_ARROW}${NC} Press ${GREEN}Enter${NC} to ${action}, ${GRAY}ESC${NC} to ${cancel}: "
781
+ IFS= read -r -s -n1 key || key=""
782
+
783
+ case "$key" in
784
+ $'\e') # ESC
785
+ echo ""
786
+ return 1
787
+ ;;
788
+ "" | $'\n' | $'\r') # Enter
789
+ printf "\r\033[K" # Clear the prompt line
790
+ return 0
791
+ ;;
792
+ *)
793
+ echo ""
794
+ return 1
795
+ ;;
796
+ esac
797
+ }
798
+
799
+ # Legacy confirmation prompt (kept for compatibility)
800
+ # confirm_prompt "Message" -> 0 yes, 1 no
801
+ confirm_prompt() {
802
+ local message="$1"
803
+ echo -n "$message (Enter=OK / ESC q=Cancel): "
804
+ IFS= read -r -s -n1 _key || _key=""
805
+ case "$_key" in
806
+ $'\e' | q | Q)
807
+ echo ""
808
+ return 1
809
+ ;;
810
+ "" | $'\n' | $'\r' | y | Y)
811
+ echo ""
812
+ return 0
813
+ ;;
814
+ *)
815
+ echo ""
816
+ return 1
817
+ ;;
818
+ esac
819
+ }
820
+
821
+ # Get optimal parallel job count based on CPU cores
822
+
823
+ # =========================================================================
824
+ # Size helpers
825
+ # =========================================================================
826
+ bytes_to_human_kb() { bytes_to_human "$((${1:-0} * 1024))"; }
827
+ print_space_stat() {
828
+ local freed_kb="$1"
829
+ shift || true
830
+ local current_free
831
+ current_free=$(get_free_space)
832
+ local human
833
+ human=$(bytes_to_human_kb "$freed_kb")
834
+ echo "Space freed: ${GREEN}${human}${NC} | Free space now: $current_free"
835
+ }
836
+
837
+ # =========================================================================
838
+ # mktemp unification wrappers (register access)
839
+ # =========================================================================
840
+ register_temp_file() { MOLE_TEMP_FILES+=("$1"); }
841
+ register_temp_dir() { MOLE_TEMP_DIRS+=("$1"); }
842
+
843
+ mktemp_file() {
844
+ local f
845
+ f=$(mktemp) || return 1
846
+ register_temp_file "$f"
847
+ echo "$f"
848
+ }
849
+ mktemp_dir() {
850
+ local d
851
+ d=$(mktemp -d) || return 1
852
+ register_temp_dir "$d"
853
+ echo "$d"
854
+ }
855
+
856
+ # =========================================================================
857
+ # Uninstall helper abstractions
858
+ # =========================================================================
859
+ force_kill_app() {
860
+ # Args: app_name [app_path]; tries graceful then force kill; returns 0 if stopped, 1 otherwise
861
+ local app_name="$1"
862
+ local app_path="${2:-}"
863
+
864
+ # Use app path for precise matching if provided
865
+ local match_pattern="$app_name"
866
+ if [[ -n "$app_path" && -e "$app_path" ]]; then
867
+ # Use the app bundle path for more precise matching
868
+ match_pattern="$app_path"
869
+ fi
870
+
871
+ if pgrep -f "$match_pattern" > /dev/null 2>&1; then
872
+ pkill -f "$match_pattern" 2> /dev/null || true
873
+ sleep 1
874
+ fi
875
+ if pgrep -f "$match_pattern" > /dev/null 2>&1; then
876
+ pkill -9 -f "$match_pattern" 2> /dev/null || true
877
+ sleep 1
878
+ fi
879
+ pgrep -f "$match_pattern" > /dev/null 2>&1 && return 1 || return 0
880
+ }
881
+
882
+ # Remove application icons from the Dock (best effort)
883
+ remove_apps_from_dock() {
884
+ if [[ $# -eq 0 ]]; then
885
+ return 0
886
+ fi
887
+
888
+ local plist="$HOME/Library/Preferences/com.apple.dock.plist"
889
+ [[ -f "$plist" ]] || return 0
890
+
891
+ if ! command -v python3 > /dev/null 2>&1; then
892
+ return 0
893
+ fi
894
+
895
+ # Execute Python helper to prune dock entries for the given app paths.
896
+ # Exit status 2 means entries were removed.
897
+ local target_count=$#
898
+
899
+ python3 - "$@" << 'PY'
900
+ import os
901
+ import plistlib
902
+ import subprocess
903
+ import sys
904
+ import urllib.parse
905
+
906
+ plist_path = os.path.expanduser('~/Library/Preferences/com.apple.dock.plist')
907
+ if not os.path.exists(plist_path):
908
+ sys.exit(0)
909
+
910
+ def normalise(path):
911
+ if not path:
912
+ return ''
913
+ return os.path.normpath(os.path.realpath(path.rstrip('/')))
914
+
915
+ targets = {normalise(arg) for arg in sys.argv[1:] if arg}
916
+ targets = {t for t in targets if t}
917
+ if not targets:
918
+ sys.exit(0)
919
+
920
+ with open(plist_path, 'rb') as fh:
921
+ try:
922
+ data = plistlib.load(fh)
923
+ except Exception:
924
+ sys.exit(0)
925
+
926
+ apps = data.get('persistent-apps')
927
+ if not isinstance(apps, list):
928
+ sys.exit(0)
929
+
930
+ changed = False
931
+ filtered = []
932
+ for item in apps:
933
+ try:
934
+ url = item['tile-data']['file-data']['_CFURLString']
935
+ except (KeyError, TypeError):
936
+ filtered.append(item)
937
+ continue
938
+
939
+ if not isinstance(url, str):
940
+ filtered.append(item)
941
+ continue
942
+
943
+ parsed = urllib.parse.urlparse(url)
944
+ path = urllib.parse.unquote(parsed.path or '')
945
+ if not path:
946
+ filtered.append(item)
947
+ continue
948
+
949
+ candidate = normalise(path)
950
+ if any(candidate == t or candidate.startswith(t + os.sep) for t in targets):
951
+ changed = True
952
+ continue
953
+
954
+ filtered.append(item)
955
+
956
+ if not changed:
957
+ sys.exit(0)
958
+
959
+ data['persistent-apps'] = filtered
960
+ with open(plist_path, 'wb') as fh:
961
+ try:
962
+ plistlib.dump(data, fh, fmt=plistlib.FMT_BINARY)
963
+ except Exception:
964
+ plistlib.dump(data, fh)
965
+
966
+ # Restart Dock to apply changes (ignore errors)
967
+ try:
968
+ subprocess.run(['killall', 'Dock'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
969
+ except Exception:
970
+ pass
971
+
972
+ sys.exit(2)
973
+ PY
974
+ local python_status=$?
975
+ if [[ $python_status -eq 2 ]]; then
976
+ if [[ $target_count -gt 1 ]]; then
977
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed app icons from Dock"
978
+ else
979
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Removed app icon from Dock"
980
+ fi
981
+ return 0
982
+ fi
983
+ return $python_status
984
+ }
985
+
986
+ map_uninstall_reason() {
987
+ # Args: reason_token
988
+ case "$1" in
989
+ still*running*) echo "was not removed; it remains running and resisted termination." ;;
990
+ remove*failed*) echo "was not removed due to a removal failure (permissions or protection)." ;;
991
+ permission*) echo "was not removed due to insufficient permissions." ;;
992
+ *) echo "was not removed; $1." ;;
993
+ esac
994
+ }
995
+
996
+ batch_safe_clean() {
997
+ # Usage: batch_safe_clean "Label" path1 path2 ...
998
+ local label="$1"
999
+ shift || true
1000
+ local -a paths=("$@")
1001
+ if [[ ${#paths[@]} -eq 0 ]]; then return 0; fi
1002
+ safe_clean "${paths[@]}" "$label"
1003
+ }
1004
+
1005
+ # Get optimal parallel job count based on CPU cores
1006
+ get_optimal_parallel_jobs() {
1007
+ local operation_type="${1:-default}"
1008
+ local cpu_cores
1009
+ cpu_cores=$(sysctl -n hw.ncpu 2> /dev/null || echo 4)
1010
+ case "$operation_type" in
1011
+ scan | io)
1012
+ echo $((cpu_cores * 2))
1013
+ ;;
1014
+ compute)
1015
+ echo "$cpu_cores"
1016
+ ;;
1017
+ *)
1018
+ echo $((cpu_cores + 2))
1019
+ ;;
1020
+ esac
1021
+ }
1022
+
1023
+ # ============================================================================
1024
+ # Sudo Keepalive Management
1025
+ # ============================================================================
1026
+
1027
+ # Start sudo keepalive process
1028
+ # Returns: PID of the keepalive process
1029
+ start_sudo_keepalive() {
1030
+ (
1031
+ local retry_count=0
1032
+ while true; do
1033
+ if ! sudo -n true 2> /dev/null; then
1034
+ ((retry_count++))
1035
+ if [[ $retry_count -ge 3 ]]; then
1036
+ exit 1
1037
+ fi
1038
+ sleep 5
1039
+ continue
1040
+ fi
1041
+ retry_count=0
1042
+ sleep 30
1043
+ kill -0 "$$" 2> /dev/null || exit
1044
+ done
1045
+ ) 2> /dev/null &
1046
+ echo $!
1047
+ }
1048
+
1049
+ # Stop sudo keepalive process
1050
+ # Args: $1 - PID of the keepalive process
1051
+ stop_sudo_keepalive() {
1052
+ local pid="${1:-}"
1053
+ if [[ -n "$pid" ]]; then
1054
+ kill "$pid" 2> /dev/null || true
1055
+ wait "$pid" 2> /dev/null || true
1056
+ fi
1057
+ }
1058
+
1059
+ # ============================================================================
1060
+ # Section Management
1061
+ # ============================================================================
1062
+
1063
+ # Section tracking variables
1064
+ TRACK_SECTION=0
1065
+ SECTION_ACTIVITY=0
1066
+
1067
+ # Start a new section
1068
+ start_section() {
1069
+ TRACK_SECTION=1
1070
+ SECTION_ACTIVITY=0
1071
+ echo ""
1072
+ echo -e "${PURPLE}${ICON_ARROW} $1${NC}"
1073
+ }
1074
+
1075
+ # End a section (show "Nothing to tidy" if no activity)
1076
+ end_section() {
1077
+ if [[ $TRACK_SECTION -eq 1 && $SECTION_ACTIVITY -eq 0 ]]; then
1078
+ echo -e " ${GREEN}${ICON_SUCCESS}${NC} Nothing to tidy"
1079
+ fi
1080
+ TRACK_SECTION=0
1081
+ }
1082
+
1083
+ # Mark activity in current section
1084
+ note_activity() {
1085
+ if [[ $TRACK_SECTION -eq 1 ]]; then
1086
+ SECTION_ACTIVITY=1
1087
+ fi
1088
+ }
1089
+
1090
+ # ============================================================================
1091
+ # App Management Functions
1092
+ # ============================================================================
1093
+
1094
+ # System critical components that should NEVER be uninstalled
1095
+ readonly SYSTEM_CRITICAL_BUNDLES=(
1096
+ "com.apple.*" # System essentials
1097
+ "loginwindow"
1098
+ "dock"
1099
+ "systempreferences"
1100
+ "finder"
1101
+ "safari"
1102
+ "keychain*"
1103
+ "security*"
1104
+ "bluetooth*"
1105
+ "wifi*"
1106
+ "network*"
1107
+ "tcc"
1108
+ "notification*"
1109
+ "accessibility*"
1110
+ "universalaccess*"
1111
+ "HIToolbox*"
1112
+ "textinput*"
1113
+ "TextInput*"
1114
+ "keyboard*"
1115
+ "Keyboard*"
1116
+ "inputsource*"
1117
+ "InputSource*"
1118
+ "keylayout*"
1119
+ "KeyLayout*"
1120
+ "GlobalPreferences"
1121
+ ".GlobalPreferences"
1122
+ # Input methods (critical for international users)
1123
+ "com.tencent.inputmethod.QQInput"
1124
+ "com.sogou.inputmethod.*"
1125
+ "com.baidu.inputmethod.*"
1126
+ "com.apple.inputmethod.*"
1127
+ "com.googlecode.rimeime.*"
1128
+ "im.rime.*"
1129
+ "org.pqrs.Karabiner*"
1130
+ "*.inputmethod"
1131
+ "*.InputMethod"
1132
+ "*IME"
1133
+ "com.apple.inputsource*"
1134
+ "com.apple.TextInputMenuAgent"
1135
+ "com.apple.TextInputSwitcher"
1136
+ )
1137
+
1138
+ # Apps with important data/licenses - protect during cleanup but allow uninstall
1139
+ readonly DATA_PROTECTED_BUNDLES=(
1140
+ # ============================================================================
1141
+ # System Utilities & Cleanup Tools
1142
+ # ============================================================================
1143
+ "com.nektony.*" # App Cleaner & Uninstaller
1144
+ "com.macpaw.*" # CleanMyMac, CleanMaster
1145
+ "com.freemacsoft.AppCleaner" # AppCleaner
1146
+ "com.omnigroup.omnidisksweeper" # OmniDiskSweeper
1147
+ "com.daisydiskapp.*" # DaisyDisk
1148
+ "com.tunabellysoftware.*" # Disk Utility apps
1149
+ "com.grandperspectiv.*" # GrandPerspective
1150
+ "com.binaryfruit.*" # FusionCast
1151
+
1152
+ # ============================================================================
1153
+ # Password Managers & Security
1154
+ # ============================================================================
1155
+ "com.1password.*" # 1Password
1156
+ "com.agilebits.*" # 1Password legacy
1157
+ "com.lastpass.*" # LastPass
1158
+ "com.dashlane.*" # Dashlane
1159
+ "com.bitwarden.*" # Bitwarden
1160
+ "com.keepassx.*" # KeePassXC
1161
+ "org.keepassx.*" # KeePassX
1162
+ "com.authy.*" # Authy
1163
+ "com.yubico.*" # YubiKey Manager
1164
+
1165
+ # ============================================================================
1166
+ # Development Tools - IDEs & Editors
1167
+ # ============================================================================
1168
+ "com.jetbrains.*" # JetBrains IDEs (IntelliJ, DataGrip, etc.)
1169
+ "JetBrains*" # JetBrains Application Support folders
1170
+ "com.microsoft.VSCode" # Visual Studio Code
1171
+ "com.visualstudio.code.*" # VS Code variants
1172
+ "com.sublimetext.*" # Sublime Text
1173
+ "com.sublimehq.*" # Sublime Merge
1174
+ "com.microsoft.VSCodeInsiders" # VS Code Insiders
1175
+ "com.apple.dt.Xcode" # Xcode (keep settings)
1176
+ "com.coteditor.CotEditor" # CotEditor
1177
+ "com.macromates.TextMate" # TextMate
1178
+ "com.panic.Nova" # Nova
1179
+ "abnerworks.Typora" # Typora (Markdown editor)
1180
+ "com.uranusjr.macdown" # MacDown
1181
+
1182
+ # ============================================================================
1183
+ # Development Tools - Database Clients
1184
+ # ============================================================================
1185
+ "com.sequelpro.*" # Sequel Pro
1186
+ "com.sequel-ace.*" # Sequel Ace
1187
+ "com.tinyapp.*" # TablePlus
1188
+ "com.dbeaver.*" # DBeaver
1189
+ "com.navicat.*" # Navicat
1190
+ "com.mongodb.compass" # MongoDB Compass
1191
+ "com.redis.RedisInsight" # Redis Insight
1192
+ "com.pgadmin.pgadmin4" # pgAdmin
1193
+ "com.eggerapps.Sequel-Pro" # Sequel Pro legacy
1194
+ "com.valentina-db.Valentina-Studio" # Valentina Studio
1195
+ "com.dbvis.DbVisualizer" # DbVisualizer
1196
+
1197
+ # ============================================================================
1198
+ # Development Tools - API & Network
1199
+ # ============================================================================
1200
+ "com.postmanlabs.mac" # Postman
1201
+ "com.konghq.insomnia" # Insomnia
1202
+ "com.CharlesProxy.*" # Charles Proxy
1203
+ "com.proxyman.*" # Proxyman
1204
+ "com.getpaw.*" # Paw
1205
+ "com.luckymarmot.Paw" # Paw legacy
1206
+ "com.charlesproxy.charles" # Charles
1207
+ "com.telerik.Fiddler" # Fiddler
1208
+ "com.usebruno.app" # Bruno (API client)
1209
+
1210
+ # ============================================================================
1211
+ # Development Tools - Git & Version Control
1212
+ # ============================================================================
1213
+ "com.github.GitHubDesktop" # GitHub Desktop
1214
+ "com.sublimemerge" # Sublime Merge
1215
+ "com.torusknot.SourceTreeNotMAS" # SourceTree
1216
+ "com.git-tower.Tower*" # Tower
1217
+ "com.gitfox.GitFox" # GitFox
1218
+ "com.github.Gitify" # Gitify
1219
+ "com.fork.Fork" # Fork
1220
+ "com.axosoft.gitkraken" # GitKraken
1221
+
1222
+ # ============================================================================
1223
+ # Development Tools - Terminal & Shell
1224
+ # ============================================================================
1225
+ "com.googlecode.iterm2" # iTerm2
1226
+ "net.kovidgoyal.kitty" # Kitty
1227
+ "io.alacritty" # Alacritty
1228
+ "com.github.wez.wezterm" # WezTerm
1229
+ "com.hyper.Hyper" # Hyper
1230
+ "com.mizage.divvy" # Divvy
1231
+ "com.fig.Fig" # Fig (terminal assistant)
1232
+ "dev.warp.Warp-Stable" # Warp
1233
+ "com.termius-dmg" # Termius (SSH client)
1234
+
1235
+ # ============================================================================
1236
+ # Development Tools - Docker & Virtualization
1237
+ # ============================================================================
1238
+ "com.docker.docker" # Docker Desktop
1239
+ "com.getutm.UTM" # UTM
1240
+ "com.vmware.fusion" # VMware Fusion
1241
+ "com.parallels.desktop.*" # Parallels Desktop
1242
+ "org.virtualbox.app.VirtualBox" # VirtualBox
1243
+ "com.vagrant.*" # Vagrant
1244
+ "com.orbstack.OrbStack" # OrbStack
1245
+
1246
+ # ============================================================================
1247
+ # System Monitoring & Performance
1248
+ # ============================================================================
1249
+ "com.bjango.istatmenus*" # iStat Menus
1250
+ "eu.exelban.Stats" # Stats
1251
+ "com.monitorcontrol.*" # MonitorControl
1252
+ "com.bresink.system-toolkit.*" # TinkerTool System
1253
+ "com.mediaatelier.MenuMeters" # MenuMeters
1254
+ "com.activity-indicator.app" # Activity Indicator
1255
+ "net.cindori.sensei" # Sensei
1256
+
1257
+ # ============================================================================
1258
+ # Window Management & Productivity
1259
+ # ============================================================================
1260
+ "com.macitbetter.*" # BetterTouchTool, BetterSnapTool
1261
+ "com.hegenberg.*" # BetterTouchTool legacy
1262
+ "com.manytricks.*" # Moom, Witch, Name Mangler, Resolutionator
1263
+ "com.divisiblebyzero.*" # Spectacle
1264
+ "com.koingdev.*" # Koingg apps
1265
+ "com.if.Amphetamine" # Amphetamine
1266
+ "com.lwouis.alt-tab-macos" # AltTab
1267
+ "net.matthewpalmer.Vanilla" # Vanilla
1268
+ "com.lightheadsw.Caffeine" # Caffeine
1269
+ "com.contextual.Contexts" # Contexts
1270
+ "com.amethyst.Amethyst" # Amethyst
1271
+ "com.knollsoft.Rectangle" # Rectangle
1272
+ "com.knollsoft.Hookshot" # Hookshot
1273
+ "com.surteesstudios.Bartender" # Bartender
1274
+ "com.gaosun.eul" # eul (system monitor)
1275
+ "com.pointum.hazeover" # HazeOver
1276
+
1277
+ # ============================================================================
1278
+ # Launcher & Automation
1279
+ # ============================================================================
1280
+ "com.runningwithcrayons.Alfred" # Alfred
1281
+ "com.raycast.macos" # Raycast
1282
+ "com.blacktree.Quicksilver" # Quicksilver
1283
+ "com.stairways.keyboardmaestro.*" # Keyboard Maestro
1284
+ "com.manytricks.Butler" # Butler
1285
+ "com.happenapps.Quitter" # Quitter
1286
+ "com.pilotmoon.scroll-reverser" # Scroll Reverser
1287
+ "org.pqrs.Karabiner-Elements" # Karabiner-Elements
1288
+ "com.apple.Automator" # Automator (system, but keep user workflows)
1289
+
1290
+ # ============================================================================
1291
+ # Note-Taking & Documentation
1292
+ # ============================================================================
1293
+ "com.bear-writer.*" # Bear
1294
+ "com.typora.*" # Typora
1295
+ "com.ulyssesapp.*" # Ulysses
1296
+ "com.literatureandlatte.*" # Scrivener
1297
+ "com.dayoneapp.*" # Day One
1298
+ "notion.id" # Notion
1299
+ "md.obsidian" # Obsidian
1300
+ "com.logseq.logseq" # Logseq
1301
+ "com.evernote.Evernote" # Evernote
1302
+ "com.onenote.mac" # OneNote
1303
+ "com.omnigroup.OmniOutliner*" # OmniOutliner
1304
+ "net.shinyfrog.bear" # Bear legacy
1305
+ "com.goodnotes.GoodNotes" # GoodNotes
1306
+ "com.marginnote.MarginNote*" # MarginNote
1307
+ "com.roamresearch.*" # Roam Research
1308
+ "com.reflect.ReflectApp" # Reflect
1309
+ "com.inkdrop.*" # Inkdrop
1310
+
1311
+ # ============================================================================
1312
+ # Design & Creative Tools
1313
+ # ============================================================================
1314
+ "com.adobe.*" # Adobe Creative Suite
1315
+ "com.bohemiancoding.*" # Sketch
1316
+ "com.figma.*" # Figma
1317
+ "com.framerx.*" # Framer
1318
+ "com.zeplin.*" # Zeplin
1319
+ "com.invisionapp.*" # InVision
1320
+ "com.principle.*" # Principle
1321
+ "com.pixelmatorteam.*" # Pixelmator
1322
+ "com.affinitydesigner.*" # Affinity Designer
1323
+ "com.affinityphoto.*" # Affinity Photo
1324
+ "com.affinitypublisher.*" # Affinity Publisher
1325
+ "com.linearity.curve" # Linearity Curve
1326
+ "com.canva.CanvaDesktop" # Canva
1327
+ "com.maxon.cinema4d" # Cinema 4D
1328
+ "com.autodesk.*" # Autodesk products
1329
+ "com.sketchup.*" # SketchUp
1330
+
1331
+ # ============================================================================
1332
+ # Communication & Collaboration
1333
+ # ============================================================================
1334
+ "com.tencent.xinWeChat" # WeChat (Chinese users)
1335
+ "com.tencent.qq" # QQ
1336
+ "com.alibaba.DingTalkMac" # DingTalk
1337
+ "com.alibaba.AliLang.osx" # AliLang (retain login/config data)
1338
+ "com.alibaba.alilang3.osx.ShipIt" # AliLang updater component
1339
+ "com.alibaba.AlilangMgr.QueryNetworkInfo" # AliLang network helper
1340
+ "us.zoom.xos" # Zoom
1341
+ "com.microsoft.teams*" # Microsoft Teams
1342
+ "com.slack.Slack" # Slack
1343
+ "com.hnc.Discord" # Discord
1344
+ "org.telegram.desktop" # Telegram
1345
+ "ru.keepcoder.Telegram" # Telegram legacy
1346
+ "net.whatsapp.WhatsApp" # WhatsApp
1347
+ "com.skype.skype" # Skype
1348
+ "com.cisco.webexmeetings" # Webex
1349
+ "com.ringcentral.RingCentral" # RingCentral
1350
+ "com.readdle.smartemail-Mac" # Spark Email
1351
+ "com.airmail.*" # Airmail
1352
+ "com.postbox-inc.postbox" # Postbox
1353
+ "com.tinyspeck.slackmacgap" # Slack legacy
1354
+
1355
+ # ============================================================================
1356
+ # Task Management & Productivity
1357
+ # ============================================================================
1358
+ "com.omnigroup.OmniFocus*" # OmniFocus
1359
+ "com.culturedcode.*" # Things
1360
+ "com.todoist.*" # Todoist
1361
+ "com.any.do.*" # Any.do
1362
+ "com.ticktick.*" # TickTick
1363
+ "com.microsoft.to-do" # Microsoft To Do
1364
+ "com.trello.trello" # Trello
1365
+ "com.asana.nativeapp" # Asana
1366
+ "com.clickup.*" # ClickUp
1367
+ "com.monday.desktop" # Monday.com
1368
+ "com.airtable.airtable" # Airtable
1369
+ "com.notion.id" # Notion (also note-taking)
1370
+ "com.linear.linear" # Linear
1371
+
1372
+ # ============================================================================
1373
+ # File Transfer & Sync
1374
+ # ============================================================================
1375
+ "com.panic.transmit*" # Transmit (FTP/SFTP)
1376
+ "com.binarynights.ForkLift*" # ForkLift
1377
+ "com.noodlesoft.Hazel" # Hazel
1378
+ "com.cyberduck.Cyberduck" # Cyberduck
1379
+ "io.filezilla.FileZilla" # FileZilla
1380
+ "com.apple.Xcode.CloudDocuments" # Xcode Cloud Documents
1381
+ "com.synology.*" # Synology apps
1382
+
1383
+ # ============================================================================
1384
+ # Screenshot & Recording
1385
+ # ============================================================================
1386
+ "com.cleanshot.*" # CleanShot X
1387
+ "com.xnipapp.xnip" # Xnip
1388
+ "com.reincubate.camo" # Camo
1389
+ "com.tunabellysoftware.ScreenFloat" # ScreenFloat
1390
+ "net.telestream.screenflow*" # ScreenFlow
1391
+ "com.techsmith.snagit*" # Snagit
1392
+ "com.techsmith.camtasia*" # Camtasia
1393
+ "com.obsidianapp.screenrecorder" # Screen Recorder
1394
+ "com.kap.Kap" # Kap
1395
+ "com.getkap.*" # Kap legacy
1396
+ "com.linebreak.CloudApp" # CloudApp
1397
+ "com.droplr.droplr-mac" # Droplr
1398
+
1399
+ # ============================================================================
1400
+ # Media & Entertainment
1401
+ # ============================================================================
1402
+ "com.spotify.client" # Spotify
1403
+ "com.apple.Music" # Apple Music
1404
+ "com.apple.podcasts" # Apple Podcasts
1405
+ "com.apple.FinalCutPro" # Final Cut Pro
1406
+ "com.apple.Motion" # Motion
1407
+ "com.apple.Compressor" # Compressor
1408
+ "com.blackmagic-design.*" # DaVinci Resolve
1409
+ "com.colliderli.iina" # IINA
1410
+ "org.videolan.vlc" # VLC
1411
+ "io.mpv" # MPV
1412
+ "com.noodlesoft.Hazel" # Hazel (automation)
1413
+ "tv.plex.player.desktop" # Plex
1414
+ "com.netease.163music" # NetEase Music
1415
+
1416
+ # ============================================================================
1417
+ # License Management & App Stores
1418
+ # ============================================================================
1419
+ "com.paddle.Paddle*" # Paddle (license management)
1420
+ "com.setapp.DesktopClient" # Setapp
1421
+ "com.devmate.*" # DevMate (license framework)
1422
+ "org.sparkle-project.Sparkle" # Sparkle (update framework)
1423
+ )
1424
+
1425
+ # Legacy function - preserved for backward compatibility
1426
+ # Use should_protect_from_uninstall() or should_protect_data() instead
1427
+ readonly PRESERVED_BUNDLE_PATTERNS=("${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}")
1428
+ should_preserve_bundle() {
1429
+ local bundle_id="$1"
1430
+ for pattern in "${PRESERVED_BUNDLE_PATTERNS[@]}"; do
1431
+ # Use case for safer glob matching
1432
+ case "$bundle_id" in
1433
+ "$pattern") return 0 ;;
1434
+ esac
1435
+ done
1436
+ return 1
1437
+ }
1438
+
1439
+ # Check if app is a system component that should never be uninstalled
1440
+ should_protect_from_uninstall() {
1441
+ local bundle_id="$1"
1442
+ for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}"; do
1443
+ # Use case for safer glob matching
1444
+ case "$bundle_id" in
1445
+ "$pattern") return 0 ;;
1446
+ esac
1447
+ done
1448
+ return 1
1449
+ }
1450
+
1451
+ # Check if app data should be protected during cleanup (but app can be uninstalled)
1452
+ should_protect_data() {
1453
+ local bundle_id="$1"
1454
+ # Protect both system critical and data protected bundles during cleanup
1455
+ for pattern in "${SYSTEM_CRITICAL_BUNDLES[@]}" "${DATA_PROTECTED_BUNDLES[@]}"; do
1456
+ # Use case for safer glob matching
1457
+ case "$bundle_id" in
1458
+ "$pattern") return 0 ;;
1459
+ esac
1460
+ done
1461
+ return 1
1462
+ }
1463
+
1464
+ # Find and list app-related files (consolidated from duplicates)
1465
+ find_app_files() {
1466
+ local bundle_id="$1"
1467
+ local app_name="$2"
1468
+ local -a files_to_clean=()
1469
+
1470
+ # ============================================================================
1471
+ # User-level files (no sudo required)
1472
+ # ============================================================================
1473
+
1474
+ # Application Support
1475
+ [[ -d ~/Library/Application\ Support/"$app_name" ]] && files_to_clean+=("$HOME/Library/Application Support/$app_name")
1476
+ [[ -d ~/Library/Application\ Support/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Support/$bundle_id")
1477
+
1478
+ # Caches
1479
+ [[ -d ~/Library/Caches/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Caches/$bundle_id")
1480
+ [[ -d ~/Library/Caches/"$app_name" ]] && files_to_clean+=("$HOME/Library/Caches/$app_name")
1481
+
1482
+ # Preferences
1483
+ [[ -f ~/Library/Preferences/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/Preferences/$bundle_id.plist")
1484
+ while IFS= read -r -d '' pref; do
1485
+ files_to_clean+=("$pref")
1486
+ done < <(find ~/Library/Preferences/ByHost \( -name "$bundle_id*.plist" \) -print0 2> /dev/null)
1487
+
1488
+ # Logs
1489
+ [[ -d ~/Library/Logs/"$app_name" ]] && files_to_clean+=("$HOME/Library/Logs/$app_name")
1490
+ [[ -d ~/Library/Logs/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Logs/$bundle_id")
1491
+
1492
+ # Saved Application State
1493
+ [[ -d ~/Library/Saved\ Application\ State/"$bundle_id".savedState ]] && files_to_clean+=("$HOME/Library/Saved Application State/$bundle_id.savedState")
1494
+
1495
+ # Containers (sandboxed apps)
1496
+ [[ -d ~/Library/Containers/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Containers/$bundle_id")
1497
+
1498
+ # Group Containers
1499
+ while IFS= read -r -d '' container; do
1500
+ files_to_clean+=("$container")
1501
+ done < <(find ~/Library/Group\ Containers -type d \( -name "*$bundle_id*" \) -print0 2> /dev/null)
1502
+
1503
+ # WebKit data
1504
+ [[ -d ~/Library/WebKit/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/$bundle_id")
1505
+ [[ -d ~/Library/WebKit/com.apple.WebKit.WebContent/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/WebKit/com.apple.WebKit.WebContent/$bundle_id")
1506
+
1507
+ # HTTP Storage
1508
+ [[ -d ~/Library/HTTPStorages/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/HTTPStorages/$bundle_id")
1509
+
1510
+ # Cookies
1511
+ [[ -f ~/Library/Cookies/"$bundle_id".binarycookies ]] && files_to_clean+=("$HOME/Library/Cookies/$bundle_id.binarycookies")
1512
+
1513
+ # Launch Agents (user-level)
1514
+ [[ -f ~/Library/LaunchAgents/"$bundle_id".plist ]] && files_to_clean+=("$HOME/Library/LaunchAgents/$bundle_id.plist")
1515
+
1516
+ # Application Scripts
1517
+ [[ -d ~/Library/Application\ Scripts/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Application Scripts/$bundle_id")
1518
+
1519
+ # Services
1520
+ [[ -d ~/Library/Services/"$app_name".workflow ]] && files_to_clean+=("$HOME/Library/Services/$app_name.workflow")
1521
+
1522
+ # Internet Plug-Ins
1523
+ while IFS= read -r -d '' plugin; do
1524
+ files_to_clean+=("$plugin")
1525
+ done < <(find ~/Library/Internet\ Plug-Ins \( -name "$bundle_id*" -o -name "$app_name*" \) -print0 2> /dev/null)
1526
+
1527
+ # QuickLook Plugins
1528
+ [[ -d ~/Library/QuickLook/"$app_name".qlgenerator ]] && files_to_clean+=("$HOME/Library/QuickLook/$app_name.qlgenerator")
1529
+
1530
+ # Preference Panes
1531
+ [[ -d ~/Library/PreferencePanes/"$app_name".prefPane ]] && files_to_clean+=("$HOME/Library/PreferencePanes/$app_name.prefPane")
1532
+
1533
+ # Screen Savers
1534
+ [[ -d ~/Library/Screen\ Savers/"$app_name".saver ]] && files_to_clean+=("$HOME/Library/Screen Savers/$app_name.saver")
1535
+
1536
+ # Frameworks
1537
+ [[ -d ~/Library/Frameworks/"$app_name".framework ]] && files_to_clean+=("$HOME/Library/Frameworks/$app_name.framework")
1538
+
1539
+ # CoreData
1540
+ while IFS= read -r -d '' coredata; do
1541
+ files_to_clean+=("$coredata")
1542
+ done < <(find ~/Library/CoreData \( -name "*$bundle_id*" -o -name "*$app_name*" \) -print0 2> /dev/null)
1543
+
1544
+ # Autosave Information
1545
+ [[ -d ~/Library/Autosave\ Information/"$bundle_id" ]] && files_to_clean+=("$HOME/Library/Autosave Information/$bundle_id")
1546
+
1547
+ # Contextual Menu Items
1548
+ [[ -d ~/Library/Contextual\ Menu\ Items/"$app_name".plugin ]] && files_to_clean+=("$HOME/Library/Contextual Menu Items/$app_name.plugin")
1549
+
1550
+ # Receipts (user-level)
1551
+ while IFS= read -r -d '' receipt; do
1552
+ files_to_clean+=("$receipt")
1553
+ done < <(find ~/Library/Receipts \( -name "*$bundle_id*" -o -name "*$app_name*" \) -print0 2> /dev/null)
1554
+
1555
+ # Spotlight Plugins
1556
+ [[ -d ~/Library/Spotlight/"$app_name".mdimporter ]] && files_to_clean+=("$HOME/Library/Spotlight/$app_name.mdimporter")
1557
+
1558
+ # Scripting Additions
1559
+ while IFS= read -r -d '' scripting; do
1560
+ files_to_clean+=("$scripting")
1561
+ done < <(find ~/Library/ScriptingAdditions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1562
+
1563
+ # Color Pickers
1564
+ [[ -d ~/Library/ColorPickers/"$app_name".colorPicker ]] && files_to_clean+=("$HOME/Library/ColorPickers/$app_name.colorPicker")
1565
+
1566
+ # Quartz Compositions
1567
+ while IFS= read -r -d '' composition; do
1568
+ files_to_clean+=("$composition")
1569
+ done < <(find ~/Library/Compositions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1570
+
1571
+ # Address Book Plug-Ins
1572
+ while IFS= read -r -d '' plugin; do
1573
+ files_to_clean+=("$plugin")
1574
+ done < <(find ~/Library/Address\ Book\ Plug-Ins \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1575
+
1576
+ # Mail Bundles
1577
+ while IFS= read -r -d '' bundle; do
1578
+ files_to_clean+=("$bundle")
1579
+ done < <(find ~/Library/Mail/Bundles \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1580
+
1581
+ # Input Managers (app-specific only)
1582
+ while IFS= read -r -d '' manager; do
1583
+ files_to_clean+=("$manager")
1584
+ done < <(find ~/Library/InputManagers \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1585
+
1586
+ # Custom Sounds
1587
+ while IFS= read -r -d '' sound; do
1588
+ files_to_clean+=("$sound")
1589
+ done < <(find ~/Library/Sounds \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1590
+
1591
+ # Plugins
1592
+ while IFS= read -r -d '' plugin; do
1593
+ files_to_clean+=("$plugin")
1594
+ done < <(find ~/Library/Plugins \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1595
+
1596
+ # Private Frameworks
1597
+ while IFS= read -r -d '' framework; do
1598
+ files_to_clean+=("$framework")
1599
+ done < <(find ~/Library/PrivateFrameworks \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1600
+
1601
+ # Only print if array has elements to avoid unbound variable error
1602
+ if [[ ${#files_to_clean[@]} -gt 0 ]]; then
1603
+ printf '%s\n' "${files_to_clean[@]}"
1604
+ fi
1605
+ }
1606
+
1607
+ # Find system-level app files (requires sudo)
1608
+ find_app_system_files() {
1609
+ local bundle_id="$1"
1610
+ local app_name="$2"
1611
+ local -a system_files=()
1612
+
1613
+ # System Application Support
1614
+ [[ -d /Library/Application\ Support/"$app_name" ]] && system_files+=("/Library/Application Support/$app_name")
1615
+ [[ -d /Library/Application\ Support/"$bundle_id" ]] && system_files+=("/Library/Application Support/$bundle_id")
1616
+
1617
+ # System Launch Agents
1618
+ [[ -f /Library/LaunchAgents/"$bundle_id".plist ]] && system_files+=("/Library/LaunchAgents/$bundle_id.plist")
1619
+
1620
+ # System Launch Daemons
1621
+ [[ -f /Library/LaunchDaemons/"$bundle_id".plist ]] && system_files+=("/Library/LaunchDaemons/$bundle_id.plist")
1622
+
1623
+ # Privileged Helper Tools
1624
+ while IFS= read -r -d '' helper; do
1625
+ system_files+=("$helper")
1626
+ done < <(find /Library/PrivilegedHelperTools \( -name "$bundle_id*" \) -print0 2> /dev/null)
1627
+
1628
+ # System Preferences
1629
+ [[ -f /Library/Preferences/"$bundle_id".plist ]] && system_files+=("/Library/Preferences/$bundle_id.plist")
1630
+
1631
+ # Installation Receipts
1632
+ while IFS= read -r -d '' receipt; do
1633
+ system_files+=("$receipt")
1634
+ done < <(find /private/var/db/receipts \( -name "*$bundle_id*" \) -print0 2> /dev/null)
1635
+
1636
+ # System Logs
1637
+ [[ -d /Library/Logs/"$app_name" ]] && system_files+=("/Library/Logs/$app_name")
1638
+ [[ -d /Library/Logs/"$bundle_id" ]] && system_files+=("/Library/Logs/$bundle_id")
1639
+
1640
+ # System Frameworks
1641
+ [[ -d /Library/Frameworks/"$app_name".framework ]] && system_files+=("/Library/Frameworks/$app_name.framework")
1642
+
1643
+ # System Internet Plug-Ins
1644
+ while IFS= read -r -d '' plugin; do
1645
+ system_files+=("$plugin")
1646
+ done < <(find /Library/Internet\ Plug-Ins \( -name "$bundle_id*" -o -name "$app_name*" \) -print0 2> /dev/null)
1647
+
1648
+ # System QuickLook Plugins
1649
+ [[ -d /Library/QuickLook/"$app_name".qlgenerator ]] && system_files+=("/Library/QuickLook/$app_name.qlgenerator")
1650
+
1651
+ # System Receipts
1652
+ while IFS= read -r -d '' receipt; do
1653
+ system_files+=("$receipt")
1654
+ done < <(find /Library/Receipts \( -name "*$bundle_id*" -o -name "*$app_name*" \) -print0 2> /dev/null)
1655
+
1656
+ # System Spotlight Plugins
1657
+ [[ -d /Library/Spotlight/"$app_name".mdimporter ]] && system_files+=("/Library/Spotlight/$app_name.mdimporter")
1658
+
1659
+ # System Scripting Additions
1660
+ while IFS= read -r -d '' scripting; do
1661
+ system_files+=("$scripting")
1662
+ done < <(find /Library/ScriptingAdditions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1663
+
1664
+ # System Color Pickers
1665
+ [[ -d /Library/ColorPickers/"$app_name".colorPicker ]] && system_files+=("/Library/ColorPickers/$app_name.colorPicker")
1666
+
1667
+ # System Quartz Compositions
1668
+ while IFS= read -r -d '' composition; do
1669
+ system_files+=("$composition")
1670
+ done < <(find /Library/Compositions \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1671
+
1672
+ # System Address Book Plug-Ins
1673
+ while IFS= read -r -d '' plugin; do
1674
+ system_files+=("$plugin")
1675
+ done < <(find /Library/Address\ Book\ Plug-Ins \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1676
+
1677
+ # System Mail Bundles
1678
+ while IFS= read -r -d '' bundle; do
1679
+ system_files+=("$bundle")
1680
+ done < <(find /Library/Mail/Bundles \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1681
+
1682
+ # System Input Managers
1683
+ while IFS= read -r -d '' manager; do
1684
+ system_files+=("$manager")
1685
+ done < <(find /Library/InputManagers \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1686
+
1687
+ # System Sounds
1688
+ while IFS= read -r -d '' sound; do
1689
+ system_files+=("$sound")
1690
+ done < <(find /Library/Sounds \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1691
+
1692
+ # System Contextual Menu Items
1693
+ while IFS= read -r -d '' item; do
1694
+ system_files+=("$item")
1695
+ done < <(find /Library/Contextual\ Menu\ Items \( -name "$app_name*" -o -name "$bundle_id*" \) -print0 2> /dev/null)
1696
+
1697
+ # System Preference Panes
1698
+ [[ -d /Library/PreferencePanes/"$app_name".prefPane ]] && system_files+=("/Library/PreferencePanes/$app_name.prefPane")
1699
+
1700
+ # System Screen Savers
1701
+ [[ -d /Library/Screen\ Savers/"$app_name".saver ]] && system_files+=("/Library/Screen Savers/$app_name.saver")
1702
+
1703
+ # System Caches
1704
+ [[ -d /Library/Caches/"$bundle_id" ]] && system_files+=("/Library/Caches/$bundle_id")
1705
+ [[ -d /Library/Caches/"$app_name" ]] && system_files+=("/Library/Caches/$app_name")
1706
+
1707
+ # Only print if array has elements
1708
+ if [[ ${#system_files[@]} -gt 0 ]]; then
1709
+ printf '%s\n' "${system_files[@]}"
1710
+ fi
1711
+ }
1712
+
1713
+ # Calculate total size of files (consolidated from duplicates)
1714
+ calculate_total_size() {
1715
+ local files="$1"
1716
+ local total_kb=0
1717
+
1718
+ while IFS= read -r file; do
1719
+ if [[ -n "$file" && -e "$file" ]]; then
1720
+ local size_kb
1721
+ size_kb=$(du -sk "$file" 2> /dev/null | awk '{print $1}' || echo "0")
1722
+ ((total_kb += size_kb))
1723
+ fi
1724
+ done <<< "$files"
1725
+
1726
+ echo "$total_kb"
1727
+ }
1728
+
1729
+ # Get normalized brand name (bash 3.2 compatible using case statement)
1730
+ get_brand_name() {
1731
+ local name="$1"
1732
+
1733
+ # Brand name mapping for better user recognition
1734
+ case "$name" in
1735
+ "qiyimac" | "爱奇艺") echo "iQiyi" ;;
1736
+ "wechat" | "微信") echo "WeChat" ;;
1737
+ "QQ") echo "QQ" ;;
1738
+ "VooV Meeting" | "腾讯会议") echo "VooV Meeting" ;;
1739
+ "dingtalk" | "钉钉") echo "DingTalk" ;;
1740
+ "NeteaseMusic" | "网易云音乐") echo "NetEase Music" ;;
1741
+ "BaiduNetdisk" | "百度网盘") echo "Baidu NetDisk" ;;
1742
+ "alipay" | "支付宝") echo "Alipay" ;;
1743
+ "taobao" | "淘宝") echo "Taobao" ;;
1744
+ "futunn" | "富途牛牛") echo "Futu NiuNiu" ;;
1745
+ "tencent lemon" | "Tencent Lemon Cleaner") echo "Tencent Lemon" ;;
1746
+ "keynote" | "Keynote") echo "Keynote" ;;
1747
+ "pages" | "Pages") echo "Pages" ;;
1748
+ "numbers" | "Numbers") echo "Numbers" ;;
1749
+ *) echo "$name" ;; # Return original if no mapping found
1750
+ esac
1751
+ }