diff --git a/bin/clean.sh b/bin/clean.sh index 6d11602..36d114f 100755 --- a/bin/clean.sh +++ b/bin/clean.sh @@ -970,6 +970,7 @@ perform_cleanup() { start_section "Uninstalled app data" clean_orphaned_app_data clean_orphaned_system_services + clean_orphaned_launch_agents end_section # ===== 13. Apple Silicon optimizations ===== diff --git a/lib/clean/apps.sh b/lib/clean/apps.sh index e9f193c..adedcae 100644 --- a/lib/clean/apps.sh +++ b/lib/clean/apps.sh @@ -1,6 +1,8 @@ #!/bin/bash # Application Data Cleanup Module set -euo pipefail + +readonly ORPHAN_AGE_THRESHOLD=${ORPHAN_AGE_THRESHOLD:-${MOLE_ORPHAN_AGE_DAYS:-60}} # Args: $1=target_dir, $2=label clean_ds_store_tree() { local target="$1" @@ -282,9 +284,19 @@ clean_orphaned_app_data() { file_patterns+=("$base_path/$pat") done if [[ ${#file_patterns[@]} -gt 0 ]]; then + local _nullglob_state + _nullglob_state=$(shopt -p nullglob || true) + shopt -s nullglob for item_path in "${file_patterns[@]}"; do local iteration_count=0 - for match in $item_path; do + local old_ifs=$IFS + IFS=$'\n' + local -a matches=($item_path) + IFS=$old_ifs + if [[ ${#matches[@]} -eq 0 ]]; then + continue + fi + for match in "${matches[@]}"; do [[ -e "$match" ]] || continue ((iteration_count++)) if [[ $iteration_count -gt $MOLE_MAX_ORPHAN_ITERATIONS ]]; then @@ -299,12 +311,14 @@ clean_orphaned_app_data() { if [[ -z "$size_kb" || "$size_kb" == "0" ]]; then continue fi - safe_clean "$match" "Orphaned $label: $bundle_id" - ((orphaned_count++)) - ((total_orphaned_kb += size_kb)) + if safe_clean "$match" "Orphaned $label: $bundle_id"; then + ((orphaned_count++)) + ((total_orphaned_kb += size_kb)) + fi fi done done + eval "$_nullglob_state" fi done stop_section_spinner @@ -517,3 +531,197 @@ clean_orphaned_system_services() { fi } + +# ============================================================================ +# Orphaned LaunchAgent/LaunchDaemon Cleanup (Generic Detection) +# ============================================================================ + +# Extract program path from plist (supports both ProgramArguments and Program) +_extract_program_path() { + local plist="$1" + local program="" + + program=$(plutil -extract ProgramArguments.0 raw "$plist" 2> /dev/null) + if [[ -z "$program" ]]; then + program=$(plutil -extract Program raw "$plist" 2> /dev/null) + fi + + echo "$program" +} + +# Extract associated bundle identifier from plist +_extract_associated_bundle() { + local plist="$1" + local associated="" + + # Try array format first + associated=$(plutil -extract AssociatedBundleIdentifiers.0 raw "$plist" 2> /dev/null) + if [[ -z "$associated" ]] || [[ "$associated" == "1" ]]; then + # Try string format + associated=$(plutil -extract AssociatedBundleIdentifiers raw "$plist" 2> /dev/null) + # Filter out dict/array markers + if [[ "$associated" == "{"* ]] || [[ "$associated" == "["* ]]; then + associated="" + fi + fi + + echo "$associated" +} + +# Check if a LaunchAgent/LaunchDaemon is orphaned using multi-layer verification +# Returns 0 if orphaned, 1 if not orphaned +is_launch_item_orphaned() { + local plist="$1" + + # Layer 1: Check if program path exists + local program=$(_extract_program_path "$plist") + + # No program path - skip (not a standard launch item) + [[ -z "$program" ]] && return 1 + + # Program exists -> not orphaned + [[ -e "$program" ]] && return 1 + + # Layer 2: Check AssociatedBundleIdentifiers + local associated=$(_extract_associated_bundle "$plist") + if [[ -n "$associated" ]]; then + # Check if associated app exists via mdfind + if run_with_timeout 2 mdfind "kMDItemCFBundleIdentifier == '$associated'" 2> /dev/null | head -1 | grep -q .; then + return 1 # Associated app found -> not orphaned + fi + + # Extract vendor name from bundle ID (com.vendor.app -> vendor) + local vendor=$(echo "$associated" | cut -d'.' -f2) + if [[ -n "$vendor" ]] && [[ ${#vendor} -ge 3 ]]; then + # Check if any app from this vendor exists + if find /Applications ~/Applications -maxdepth 2 -iname "*${vendor}*" -type d 2> /dev/null | grep -iq "\.app"; then + return 1 # Vendor app exists -> not orphaned + fi + fi + fi + + # Layer 3: Check Application Support directory activity + if [[ "$program" =~ /Library/Application\ Support/([^/]+)/ ]]; then + local app_support_name="${BASH_REMATCH[1]}" + + # Check both user and system Application Support + for base in "$HOME/Library/Application Support" "/Library/Application Support"; do + local support_path="$base/$app_support_name" + if [[ -d "$support_path" ]]; then + # Check if there are files modified in last 7 days (active usage) + local recent_file=$(find "$support_path" -type f -mtime -7 2> /dev/null | head -1) + if [[ -n "$recent_file" ]]; then + return 1 # Active Application Support -> not orphaned + fi + fi + done + fi + + # Layer 4: Check if app name from program path exists + if [[ "$program" =~ /Applications/([^/]+)\.app/ ]]; then + local app_name="${BASH_REMATCH[1]}" + # Look for apps with similar names (case-insensitive) + if find /Applications ~/Applications -maxdepth 2 -iname "*${app_name}*" -type d 2> /dev/null | grep -iq "\.app"; then + return 1 # Similar app exists -> not orphaned + fi + fi + + # Layer 5: PrivilegedHelper special handling + if [[ "$program" =~ ^/Library/PrivilegedHelperTools/ ]]; then + local filename=$(basename "$plist") + local bundle_id="${filename%.plist}" + + # Extract app hint from bundle ID (com.vendor.app.helper -> vendor) + local app_hint=$(echo "$bundle_id" | sed 's/com\.//; s/\..*helper.*//') + + if [[ -n "$app_hint" ]] && [[ ${#app_hint} -ge 3 ]]; then + # Look for main app + if find /Applications ~/Applications -maxdepth 2 -iname "*${app_hint}*" -type d 2> /dev/null | grep -iq "\.app"; then + return 1 # Helper's main app exists -> not orphaned + fi + fi + fi + + # All checks failed -> likely orphaned + return 0 +} + +# Clean orphaned user-level LaunchAgents +# Only processes ~/Library/LaunchAgents (safer than system-level) +clean_orphaned_launch_agents() { + local launch_agents_dir="$HOME/Library/LaunchAgents" + + [[ ! -d "$launch_agents_dir" ]] && return 0 + + start_section_spinner "Scanning orphaned launch agents..." + + local -a orphaned_items=() + local total_orphaned_kb=0 + + # Scan user LaunchAgents + while IFS= read -r -d '' plist; do + local filename=$(basename "$plist") + + # Skip Apple's LaunchAgents + [[ "$filename" == com.apple.* ]] && continue + + local bundle_id="${filename%.plist}" + + # Check if orphaned using multi-layer verification + if is_launch_item_orphaned "$plist"; then + local size_kb=$(get_path_size_kb "$plist") + orphaned_items+=("$bundle_id|$plist") + ((total_orphaned_kb += size_kb)) + fi + done < <(find "$launch_agents_dir" -maxdepth 1 -name "*.plist" -print0 2> /dev/null) + + stop_section_spinner + + local orphaned_count=${#orphaned_items[@]} + + if [[ $orphaned_count -eq 0 ]]; then + return 0 + fi + + # Clean the orphaned items automatically + local removed_count=0 + local dry_run_count=0 + local is_dry_run=false + if [[ "${MOLE_DRY_RUN:-0}" == "1" ]]; then + is_dry_run=true + fi + for item in "${orphaned_items[@]}"; do + IFS='|' read -r bundle_id plist_path <<< "$item" + + if [[ "$is_dry_run" == "true" ]]; then + ((dry_run_count++)) + log_operation "clean" "DRY_RUN" "$plist_path" "orphaned launch agent" + continue + fi + + # Try to unload first (if currently loaded) + launchctl unload "$plist_path" 2> /dev/null || true + + # Remove the plist file + if safe_remove "$plist_path" false; then + ((removed_count++)) + log_operation "clean" "REMOVED" "$plist_path" "orphaned launch agent" + else + log_operation "clean" "FAILED" "$plist_path" "permission denied" + fi + done + + if [[ "$is_dry_run" == "true" ]]; then + if [[ $dry_run_count -gt 0 ]]; then + local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') + echo " ${YELLOW}${ICON_DRY_RUN}${NC} Would remove $dry_run_count orphaned launch agent(s), ${cleaned_mb}MB" + note_activity + fi + else + if [[ $removed_count -gt 0 ]]; then + local cleaned_mb=$(echo "$total_orphaned_kb" | awk '{printf "%.1f", $1/1024}') + echo " ${GREEN}${ICON_SUCCESS}${NC} Removed $removed_count orphaned launch agent(s), ${cleaned_mb}MB" + note_activity + fi + fi +} diff --git a/tests/clean_apps.bats b/tests/clean_apps.bats index 8de3f71..6308f0e 100644 --- a/tests/clean_apps.bats +++ b/tests/clean_apps.bats @@ -80,15 +80,137 @@ EOF set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/clean/apps.sh" -ls() { return 1; } -stop_section_spinner() { :; } +rm -rf "$HOME/Library/Caches" clean_orphaned_app_data EOF [ "$status" -eq 0 ] - [[ "$output" == *"Skipped: No permission"* ]] + [[ "$output" == *"No permission"* ]] } +@test "clean_orphaned_app_data handles paths with spaces correctly" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +# Mock scan_installed_apps - return empty (no installed apps) +scan_installed_apps() { + : > "$1" +} + +# Mock mdfind to return empty (no app found) +mdfind() { + return 0 +} + +# Ensure local function mock works even if timeout/gtimeout is installed +run_with_timeout() { shift; "$@"; } + +# Mock safe_clean (normally from bin/clean.sh) +safe_clean() { + rm -rf "$1" + return 0 +} + +# Create required Library structure for permission check +mkdir -p "$HOME/Library/Caches" + +# Create test structure with spaces in path (old modification time: 61 days ago) +mkdir -p "$HOME/Library/Saved Application State/com.test.orphan.savedState" +# Create a file with some content so directory size > 0 +echo "test data" > "$HOME/Library/Saved Application State/com.test.orphan.savedState/data.plist" +# Set modification time to 61 days ago (older than 60-day threshold) +touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Saved Application State/com.test.orphan.savedState" 2>/dev/null || true + +# Disable spinner for test +start_section_spinner() { :; } +stop_section_spinner() { :; } + +# Run cleanup +clean_orphaned_app_data + +# Verify path with spaces was handled correctly (not split into multiple paths) +if [[ -d "$HOME/Library/Saved Application State/com.test.orphan.savedState" ]]; then + echo "ERROR: Orphaned savedState not deleted" + exit 1 +else + echo "SUCCESS: Orphaned savedState deleted correctly" +fi +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"SUCCESS"* ]] +} + +@test "clean_orphaned_app_data only counts successful deletions" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +# Mock scan_installed_apps - return empty +scan_installed_apps() { + : > "$1" +} + +# Mock mdfind to return empty (no app found) +mdfind() { + return 0 +} + +# Ensure local function mock works even if timeout/gtimeout is installed +run_with_timeout() { shift; "$@"; } + +# Create required Library structure for permission check +mkdir -p "$HOME/Library/Caches" + +# Create test files (old modification time: 61 days ago) +mkdir -p "$HOME/Library/Caches/com.test.orphan1" +mkdir -p "$HOME/Library/Caches/com.test.orphan2" +# Create files with content so size > 0 +echo "data1" > "$HOME/Library/Caches/com.test.orphan1/data" +echo "data2" > "$HOME/Library/Caches/com.test.orphan2/data" +# Set modification time to 61 days ago +touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan1" 2>/dev/null || true +touch -t "$(date -v-61d +%Y%m%d%H%M.%S 2>/dev/null || date -d '61 days ago' +%Y%m%d%H%M.%S)" "$HOME/Library/Caches/com.test.orphan2" 2>/dev/null || true + +# Mock safe_clean to fail on first item, succeed on second +safe_clean() { + if [[ "$1" == *"orphan1"* ]]; then + return 1 # Fail + else + rm -rf "$1" + return 0 # Succeed + fi +} + +# Disable spinner +start_section_spinner() { :; } +stop_section_spinner() { :; } + +# Run cleanup +clean_orphaned_app_data + +# Verify first item still exists (safe_clean failed) +if [[ -d "$HOME/Library/Caches/com.test.orphan1" ]]; then + echo "PASS: Failed deletion preserved" +fi + +# Verify second item deleted +if [[ ! -d "$HOME/Library/Caches/com.test.orphan2" ]]; then + echo "PASS: Successful deletion removed" +fi + +# Check that output shows correct count (only 1, not 2) +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"PASS: Failed deletion preserved"* ]] + [[ "$output" == *"PASS: Successful deletion removed"* ]] +} + + @test "is_critical_system_component matches known system services" { run bash --noprofile --norc <<'EOF' set -euo pipefail @@ -160,3 +282,144 @@ EOF [[ "$output" != *"rm-called"* ]] [[ "$output" != *"launchctl-called"* ]] } + +@test "is_launch_item_orphaned detects orphan when program missing" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.test.orphan.plist" + +cat > "$tmp_plist" << 'PLIST' + + + + + Label + com.test.orphan + ProgramArguments + + /nonexistent/app/program + + + +PLIST + +run_with_timeout() { shift; "$@"; } + +if is_launch_item_orphaned "$tmp_plist"; then + echo "orphan" +fi + +rm -rf "$tmp_dir" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"orphan"* ]] +} + +@test "is_launch_item_orphaned protects when program exists" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.test.active.plist" +tmp_program="$tmp_dir/program" +touch "$tmp_program" + +cat > "$tmp_plist" << PLIST + + + + + Label + com.test.active + ProgramArguments + + $tmp_program + + + +PLIST + +run_with_timeout() { shift; "$@"; } + +if is_launch_item_orphaned "$tmp_plist"; then + echo "orphan" +else + echo "not-orphan" +fi + +rm -rf "$tmp_dir" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"not-orphan"* ]] +} + +@test "is_launch_item_orphaned protects when app support active" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +tmp_dir="$(mktemp -d)" +tmp_plist="$tmp_dir/com.test.appsupport.plist" + +mkdir -p "$HOME/Library/Application Support/TestApp" +touch "$HOME/Library/Application Support/TestApp/recent.txt" + +cat > "$tmp_plist" << 'PLIST' + + + + + Label + com.test.appsupport + ProgramArguments + + $HOME/Library/Application Support/TestApp/Current/app + + + +PLIST + +run_with_timeout() { shift; "$@"; } + +if is_launch_item_orphaned "$tmp_plist"; then + echo "orphan" +else + echo "not-orphan" +fi + +rm -rf "$tmp_dir" +rm -rf "$HOME/Library/Application Support/TestApp" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"not-orphan"* ]] +} + +@test "clean_orphaned_launch_agents skips when no orphans" { + run env HOME="$HOME" PROJECT_ROOT="$PROJECT_ROOT" bash --noprofile --norc <<'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/clean/apps.sh" + +mkdir -p "$HOME/Library/LaunchAgents" + +start_section_spinner() { :; } +stop_section_spinner() { :; } +note_activity() { :; } +get_path_size_kb() { echo "1"; } +run_with_timeout() { shift; "$@"; } + +clean_orphaned_launch_agents +EOF + + [ "$status" -eq 0 ] +}