From 85cd0253d592d3ba899389205c71fd2f0588f0b7 Mon Sep 17 00:00:00 2001 From: NeedmeFordev <124189514+spider-yamet@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:35:45 +0900 Subject: [PATCH] feat(uninstall): remove app diagnostic reports from /Library/Logs/DiagnosticReports (fixes #441) (#443) * Implement deleting files from DiagnosticReports * fix(uninstall): avoid diagnostic size double-count and set -e exit --------- Co-authored-by: tw93 --- lib/core/app_protection.sh | 36 ++++++ lib/uninstall/batch.sh | 45 ++++++-- tests/test_diagnostic_reports_standalone.sh | 122 ++++++++++++++++++++ 3 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 tests/test_diagnostic_reports_standalone.sh diff --git a/lib/core/app_protection.sh b/lib/core/app_protection.sh index 82e330e..9577539 100755 --- a/lib/core/app_protection.sh +++ b/lib/core/app_protection.sh @@ -1156,6 +1156,42 @@ find_app_files() { return 0 } +get_diagnostic_report_paths_for_app() { + local app_path="$1" + local app_name="$2" + local directory="$3" + local prefix="" + local exec_name="" + local nospace_name="${app_name// /}" + + [[ -z "$app_path" || -z "$app_name" || -z "$directory" ]] && return 0 + [[ ! -d "$directory" ]] && return 0 + + if [[ -f "$app_path/Contents/Info.plist" ]]; then + exec_name=$(defaults read "$app_path/Contents/Info.plist" CFBundleExecutable 2> /dev/null || echo "") + if [[ -z "$exec_name" ]]; then + exec_name=$(grep -A1 "CFBundleExecutable" "$app_path/Contents/Info.plist" 2> /dev/null | grep "" | sed -n 's/.*\([^<]*\)<\/string>.*/\1/p' | head -1) + fi + fi + prefix="${exec_name:-$nospace_name}" + [[ -z "$prefix" || ${#prefix} -lt 3 ]] && return 0 + + local dir_abs + dir_abs=$(cd "$directory" 2> /dev/null && pwd -P 2> /dev/null) || return 0 + while IFS= read -r -d '' f; do + [[ -z "$f" ]] && continue + local base + base=$(basename "$f" 2> /dev/null) + [[ "$base" != "$prefix"* ]] && continue + case "$base" in + *.ips | *.crash | *.spin) ;; + *) continue ;; + esac + printf '%s\n' "$f" + done < <(find "$dir_abs" -maxdepth 1 -type f -name "${prefix}*" -print0 2> /dev/null || true) + return 0 +} + # Locate system-level application files find_app_system_files() { local bundle_id="$1" diff --git a/lib/uninstall/batch.sh b/lib/uninstall/batch.sh index 4996089..93a1fa1 100755 --- a/lib/uninstall/batch.sh +++ b/lib/uninstall/batch.sh @@ -258,20 +258,28 @@ batch_uninstall_applications() { needs_sudo=true fi - # Size estimate includes related and system files. local app_size_kb=$(get_path_size_kb "$app_path" || echo "0") local related_files=$(find_app_files "$bundle_id" "$app_name" || true) + local diag_user + diag_user=$(get_diagnostic_report_paths_for_app "$app_path" "$app_name" "$HOME/Library/Logs/DiagnosticReports" || true) + [[ -n "$diag_user" ]] && related_files=$( + [[ -n "$related_files" ]] && echo "$related_files" + echo "$diag_user" + ) local related_size_kb=$(calculate_total_size "$related_files" || echo "0") # system_files is a newline-separated string, not an array. # shellcheck disable=SC2178,SC2128 local system_files=$(find_app_system_files "$bundle_id" "$app_name" || true) + local diag_system + diag_system=$(get_diagnostic_report_paths_for_app "$app_path" "$app_name" "/Library/Logs/DiagnosticReports" || true) # shellcheck disable=SC2128 local system_size_kb=$(calculate_total_size "$system_files" || echo "0") - local total_kb=$((app_size_kb + related_size_kb + system_size_kb)) + local diag_system_size_kb=$(calculate_total_size "$diag_system" || echo "0") + local total_kb=$((app_size_kb + related_size_kb + system_size_kb + diag_system_size_kb)) ((total_estimated_size += total_kb)) || true # shellcheck disable=SC2128 - if [[ -n "$system_files" ]]; then + if [[ -n "$system_files" || -n "$diag_system" ]]; then needs_sudo=true fi @@ -290,7 +298,9 @@ batch_uninstall_applications() { encoded_files=$(printf '%s' "$related_files" | base64 | tr -d '\n' || echo "") local encoded_system_files encoded_system_files=$(printf '%s' "$system_files" | base64 | tr -d '\n' || echo "") - app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name") + local encoded_diag_system + encoded_diag_system=$(printf '%s' "$diag_system" | base64 | tr -d '\n' || echo "") + app_details+=("$app_name|$app_path|$bundle_id|$total_kb|$encoded_files|$encoded_system_files|$has_sensitive_data|$needs_sudo|$is_brew_cask|$cask_name|$encoded_diag_system") done if [[ -t 1 ]]; then stop_inline_spinner; fi @@ -313,7 +323,7 @@ batch_uninstall_applications() { fi for detail in "${app_details[@]}"; do - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo_flag is_brew_cask cask_name encoded_diag_system <<< "$detail" local app_size_display=$(bytes_to_human "$((total_kb * 1024))") local brew_tag="" @@ -323,6 +333,12 @@ batch_uninstall_applications() { # Show detailed file list for ALL apps (brew casks leave user data behind) local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") + local diag_system_display + diag_system_display=$(decode_file_list "$encoded_diag_system" "$app_name") + [[ -n "$diag_system_display" ]] && system_files=$( + [[ -n "$system_files" ]] && echo "$system_files" + echo "$diag_system_display" + ) echo -e " ${GREEN}${ICON_SUCCESS}${NC} ${app_path/$HOME/~}" @@ -409,9 +425,10 @@ batch_uninstall_applications() { local current_index=0 for detail in "${app_details[@]}"; do ((current_index++)) - IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name <<< "$detail" + IFS='|' read -r app_name app_path bundle_id total_kb encoded_files encoded_system_files has_sensitive_data needs_sudo is_brew_cask cask_name encoded_diag_system <<< "$detail" local related_files=$(decode_file_list "$encoded_files" "$app_name") local system_files=$(decode_file_list "$encoded_system_files" "$app_name") + local diag_system=$(decode_file_list "$encoded_diag_system" "$app_name") local reason="" local suggestion="" @@ -511,11 +528,17 @@ batch_uninstall_applications() { if [[ -z "$reason" ]]; then remove_file_list "$related_files" "false" > /dev/null - # If brew successfully uninstalled the cask, avoid deleting - # system-level files Mole discovered. Brew manages its own - # receipts/symlinks and we don't want to fight it. - if [[ "$used_brew_successfully" != "true" ]]; then - remove_file_list "$system_files" "true" > /dev/null + if [[ "$used_brew_successfully" == "true" ]]; then + remove_file_list "$diag_system" "true" > /dev/null + else + local system_all="$system_files" + if [[ -n "$diag_system" ]]; then + if [[ -n "$system_all" ]]; then + system_all+=$'\n' + fi + system_all+="$diag_system" + fi + remove_file_list "$system_all" "true" > /dev/null fi # Clean up macOS defaults (preference domains). diff --git a/tests/test_diagnostic_reports_standalone.sh b/tests/test_diagnostic_reports_standalone.sh new file mode 100644 index 0000000..4aad835 --- /dev/null +++ b/tests/test_diagnostic_reports_standalone.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# Standalone test for get_diagnostic_report_paths_for_app (Issue #441). Run: bash tests/test_diagnostic_reports_standalone.sh + +set +e +set +u + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +if [[ ! -f "$PROJECT_ROOT/lib/core/app_protection.sh" ]]; then + PROJECT_ROOT="$(pwd)" + SCRIPT_DIR="$PROJECT_ROOT/tests" +fi +cd "$PROJECT_ROOT" + +source_crlf_safe() { + local f="$1" + if [[ -f "$f" ]]; then + # shellcheck source=/dev/null + source /dev/stdin <<< "$(sed 's/\r$//' < "$f")" + fi +} + +source_crlf_safe "$PROJECT_ROOT/lib/core/base.sh" +source_crlf_safe "$PROJECT_ROOT/lib/core/app_protection.sh" +set +e +set +u + +FAILED=0 +PASSED=0 + +assert_contains() { + local haystack="$1" + local needle="$2" + local name="${3:-assert}" + if [[ "$haystack" == *"$needle"* ]]; then + echo " OK $name" + ((PASSED++)) + return 0 + fi + echo " FAIL $name (expected to find: $needle)" + ((FAILED++)) + return 1 +} + +assert_empty() { + local val="$1" + local name="${2:-assert}" + if [[ -z "$val" ]]; then + echo " OK $name (empty as expected)" + ((PASSED++)) + return 0 + fi + echo " FAIL $name (expected empty, got: $val)" + ((FAILED++)) + return 1 +} + +echo "Testing get_diagnostic_report_paths_for_app (DiagnosticReports uninstall)" +echo "" + +out=$(get_diagnostic_report_paths_for_app "/Applications/Foo.app" "Foo" "/nonexistent/dir" 2> /dev/null || true) +assert_empty "$out" "missing directory returns empty" + +TMP_EMPTY=$(mktemp -d 2> /dev/null || mktemp -d -t mole-test 2> /dev/null || echo "") +[[ -z "$TMP_EMPTY" ]] && TMP_EMPTY="/tmp/mole-test-$$" && mkdir -p "$TMP_EMPTY" +out=$(get_diagnostic_report_paths_for_app "" "Ab" "$TMP_EMPTY" 2> /dev/null || true) +assert_empty "$out" "empty app_path returns empty" +rm -rf "$TMP_EMPTY" 2> /dev/null || true + +TMP_DIAG=$(mktemp -d 2> /dev/null || mktemp -d -t mole-diag 2> /dev/null || echo "/tmp/mole-diag-$$") +TMP_APP=$(mktemp -d 2> /dev/null || mktemp -d -t mole-app 2> /dev/null || echo "/tmp/mole-app-$$") +mkdir -p "$TMP_DIAG" "$TMP_APP" +mkdir -p "$TMP_APP/Contents" +printf '%s' 'CFBundleExecutableMyApp' > "$TMP_APP/Contents/Info.plist" + +touch "$TMP_DIAG/MyApp_2025-02-10-120000_host.ips" +touch "$TMP_DIAG/MyApp.crash" +touch "$TMP_DIAG/MyApp_2025-02-10-120001_host.spin" +touch "$TMP_DIAG/OtherApp_2025-02-10.ips" +touch "$TMP_DIAG/MyApp_log.txt" + +out=$(get_diagnostic_report_paths_for_app "$TMP_APP" "My App" "$TMP_DIAG" 2> /dev/null || true) + +assert_contains "$out" "MyApp_2025-02-10-120000" "returns .ips file" +assert_contains "$out" "MyApp.crash" "returns .crash file" +assert_contains "$out" "MyApp_2025-02-10-120001" "returns .spin file" +assert_contains "$out" ".ips" "output contains .ips path" +if [[ "$out" == *"OtherApp"* ]]; then + echo " FAIL should not return OtherApp" + ((FAILED++)) +else + echo " OK does not return OtherApp" + ((PASSED++)) +fi +if [[ "$out" == *"MyApp_log.txt"* ]]; then + echo " FAIL should not return non-diagnostic extension" + ((FAILED++)) +else + echo " OK does not return .txt file" + ((PASSED++)) +fi + +rm -rf "$TMP_DIAG" "$TMP_APP" 2> /dev/null || true + +TMP_DIAG2=$(mktemp -d 2> /dev/null || mktemp -d -t mole-diag2 2> /dev/null || echo "/tmp/mole-diag2-$$") +TMP_APP2=$(mktemp -d 2> /dev/null || mktemp -d -t mole-app2 2> /dev/null || echo "/tmp/mole-app2-$$") +mkdir -p "$TMP_DIAG2" "$TMP_APP2" +mkdir -p "$TMP_APP2/Contents" +touch "$TMP_DIAG2/TestApp_2025-02-10.ips" + +out=$(get_diagnostic_report_paths_for_app "$TMP_APP2" "Test App" "$TMP_DIAG2" 2> /dev/null || true) +assert_contains "$out" "TestApp_" "fallback to nospace app name matches file" + +rm -rf "$TMP_DIAG2" "$TMP_APP2" 2> /dev/null || true + +echo "" +echo "Result: $PASSED passed, $FAILED failed" +if [[ $FAILED -gt 0 ]]; then + exit 1 +fi +echo "All DiagnosticReports tests passed." +exit 0