From f88b2116ab2861d57805198f47a42ce38d39ec1c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 18 Mar 2026 14:48:39 +0800 Subject: [PATCH] fix(check): use softwareupdate output for macOS update detection Replace LastRecommendedUpdatesAvailable defaults check with unified softwareupdate -l --no-scan parsing. Add caching layer and teach the parser to distinguish macOS updates, Background Security Improvements, Rapid Security Responses, and non-macOS entries so each gets an accurate title in the output. Fix reset_softwareupdate_cache() to also reset the in-memory SOFTWARE_UPDATE_LIST_LOADED flag, and add a regression test. Closes #585 --- lib/check/all.sh | 145 ++++++++++++++++-------- tests/clean_system_maintenance.bats | 166 +++++++++++++++++++--------- 2 files changed, 210 insertions(+), 101 deletions(-) diff --git a/lib/check/all.sh b/lib/check/all.sh index f38021b..2b4704b 100644 --- a/lib/check/all.sh +++ b/lib/check/all.sh @@ -212,6 +212,7 @@ reset_brew_cache() { reset_softwareupdate_cache() { clear_cache_file "$CACHE_DIR/softwareupdate_list" SOFTWARE_UPDATE_LIST="" + SOFTWARE_UPDATE_LIST_LOADED="false" } reset_mole_cache() { @@ -233,19 +234,89 @@ is_cache_valid() { # Cache software update list to avoid calling softwareupdate twice SOFTWARE_UPDATE_LIST="" +SOFTWARE_UPDATE_LIST_LOADED="false" + +software_update_has_entries() { + printf '%s\n' "$1" | grep -qE '^[[:space:]]*\* Label:' +} + +is_macos_software_update_text() { + local text + text=$(printf '%s' "$1" | LC_ALL=C tr '[:upper:]' '[:lower:]') + + case "$text" in + *macos* | *background\ security\ improvement* | *rapid\ security\ response* | *security\ response*) + return 0 + ;; + esac + + return 1 +} + +get_first_macos_software_update_summary() { + printf '%s\n' "$1" | awk ' + /^\* Label:/ { + label=$0 + sub(/^[[:space:]]*\* Label: */, "", label) + next + } + /^[[:space:]]*Title:/ { + title=$0 + sub(/^[[:space:]]*Title: */, "", title) + sub(/, Size:.*/, "", title) + combined=tolower(label " " title) + if (combined ~ /macos|background security improvement|rapid security response|security response/) { + print title + exit + } + } + ' +} get_software_updates() { local cache_file="$CACHE_DIR/softwareupdate_list" - - # Optimized: Use defaults to check if updates are pending (much faster) - local pending_updates - pending_updates=$(defaults read /Library/Preferences/com.apple.SoftwareUpdate LastRecommendedUpdatesAvailable 2> /dev/null || echo "0") - - if [[ "$pending_updates" -gt 0 ]]; then - echo "Updates Available" - else - echo "" + if [[ "${SOFTWARE_UPDATE_LIST_LOADED:-false}" == "true" ]]; then + printf '%s\n' "$SOFTWARE_UPDATE_LIST" + return 0 fi + + if is_cache_valid "$cache_file"; then + SOFTWARE_UPDATE_LIST=$(cat "$cache_file" 2> /dev/null || true) + SOFTWARE_UPDATE_LIST_LOADED="true" + printf '%s\n' "$SOFTWARE_UPDATE_LIST" + return 0 + fi + + local spinner_started=false + if [[ -t 1 && -z "${SOFTWAREUPDATE_SPINNER_SHOWN:-}" ]]; then + MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking system updates..." + spinner_started=true + export SOFTWAREUPDATE_SPINNER_SHOWN=1 + fi + + local output="" + local sw_status=0 + if output=$(run_with_timeout 10 softwareupdate -l --no-scan 2> /dev/null); then + SOFTWARE_UPDATE_LIST="$output" + ensure_user_file "$cache_file" + printf '%s' "$SOFTWARE_UPDATE_LIST" > "$cache_file" 2> /dev/null || true + else + sw_status=$? + SOFTWARE_UPDATE_LIST="" + if [[ -f "$cache_file" ]]; then + SOFTWARE_UPDATE_LIST=$(cat "$cache_file" 2> /dev/null || true) + fi + if [[ -n "${MO_DEBUG:-}" ]]; then + echo "[DEBUG] softwareupdate preload exit status: $sw_status" >&2 + fi + fi + + if [[ "$spinner_started" == "true" ]]; then + stop_inline_spinner + fi + + SOFTWARE_UPDATE_LIST_LOADED="true" + printf '%s\n' "$SOFTWARE_UPDATE_LIST" } check_homebrew_updates() { @@ -351,52 +422,30 @@ check_macos_update() { # Check whitelist if command -v is_whitelisted > /dev/null && is_whitelisted "check_macos_updates"; then return; fi - # Fast check using system preferences local updates_available="false" - if [[ $(get_software_updates) == "Updates Available" ]]; then - updates_available="true" + local macos_update_summary="" + local sw_output="" + sw_output=$(get_software_updates) - # Verify with softwareupdate using --no-scan to avoid triggering a fresh scan - # which can timeout. We prioritize avoiding false negatives (missing actual updates) - # over false positives, so we only clear the update flag when softwareupdate - # explicitly reports "No new software available" - local sw_output="" - local sw_status=0 - local spinner_started=false - if [[ -t 1 ]]; then - MOLE_SPINNER_PREFIX=" " start_inline_spinner "Checking macOS updates..." - spinner_started=true - fi + if [[ -n "${MO_DEBUG:-}" ]]; then + echo "[DEBUG] softwareupdate cached output lines: $(printf '%s\n' "$sw_output" | wc -l | tr -d ' ')" >&2 + fi - local softwareupdate_timeout=10 - if sw_output=$(run_with_timeout "$softwareupdate_timeout" softwareupdate -l --no-scan 2> /dev/null); then - : - else - sw_status=$? - fi - - if [[ "$spinner_started" == "true" ]]; then - stop_inline_spinner - fi - - # Debug logging for troubleshooting - if [[ -n "${MO_DEBUG:-}" ]]; then - echo "[DEBUG] softwareupdate exit status: $sw_status, output lines: $(echo "$sw_output" | wc -l | tr -d ' ')" >&2 - fi - - # Prefer avoiding false negatives: if the system indicates updates are pending, - # only clear the flag when softwareupdate returns a list without any update entries. - if [[ $sw_status -eq 0 && -n "$sw_output" ]]; then - if ! echo "$sw_output" | grep -qE '^[[:space:]]*\*'; then - updates_available="false" - fi + if software_update_has_entries "$sw_output"; then + macos_update_summary=$(get_first_macos_software_update_summary "$sw_output") + if [[ -n "$macos_update_summary" ]] || is_macos_software_update_text "$sw_output"; then + updates_available="true" fi fi export MACOS_UPDATE_AVAILABLE="$updates_available" if [[ "$updates_available" == "true" ]]; then - printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available" + if [[ -n "$macos_update_summary" ]]; then + printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "$macos_update_summary" + else + printf " ${GRAY}%s${NC} %-12s ${YELLOW}%s${NC}\n" "$ICON_WARNING" "macOS" "Update available" + fi else printf " ${GREEN}✓${NC} %-12s %s\n" "macOS" "System up to date" fi @@ -495,7 +544,7 @@ get_appstore_update_labels() { sub(/^[[:space:]]*\* Label: */, "", label) sub(/,.*/, "", label) lower=tolower(label) - if (index(lower, "macos") == 0) { + if (lower !~ /macos|background security improvement|rapid security response|security response/) { print label } } @@ -509,7 +558,7 @@ get_macos_update_labels() { sub(/^[[:space:]]*\* Label: */, "", label) sub(/,.*/, "", label) lower=tolower(label) - if (index(lower, "macos") != 0) { + if (lower ~ /macos|background security improvement|rapid security response|security response/) { print label } } diff --git a/tests/clean_system_maintenance.bats b/tests/clean_system_maintenance.bats index 0626f2d..64a647f 100644 --- a/tests/clean_system_maintenance.bats +++ b/tests/clean_system_maintenance.bats @@ -498,14 +498,12 @@ EOF [[ "$output" == *"COUNTS=0:0:0"* ]] } -@test "check_macos_update avoids slow softwareupdate scans" { +@test "check_macos_update reports background security improvements as macOS updates" { run bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/check/all.sh" -defaults() { echo "1"; } - run_with_timeout() { local timeout="${1:-}" shift @@ -518,7 +516,8 @@ run_with_timeout() { Software Update Tool Software Update found the following new or updated software: -* Label: macOS 99 +* Label: macOS Background Security Improvement (a)-25D771280a + Title: macOS Background Security Improvement (a), Version: 26.3.1 (a), Size: 208896KiB, Recommended: YES, Action: restart, OUT return 0 fi @@ -533,7 +532,7 @@ echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] + [[ "$output" == *"Background Security Improvement"* ]] [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] [[ "$output" != *"BAD_TIMEOUT:"* ]] } @@ -544,8 +543,6 @@ set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/check/all.sh" -defaults() { echo "1"; } - run_with_timeout() { local timeout="${1:-}" shift @@ -578,14 +575,12 @@ EOF [[ "$output" != *"BAD_TIMEOUT:"* ]] } -@test "check_macos_update keeps update flag when softwareupdate times out" { +@test "check_macos_update ignores non-macOS softwareupdate entries" { run bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/check/all.sh" -defaults() { echo "1"; } - run_with_timeout() { local timeout="${1:-}" shift @@ -594,40 +589,13 @@ run_with_timeout() { return 124 fi if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then - return 124 - fi - return 124 -} + cat <<'OUT' +Software Update Tool -start_inline_spinner(){ :; } -stop_inline_spinner(){ :; } - -check_macos_update -echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" -EOF - - [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] - [[ "$output" != *"BAD_TIMEOUT:"* ]] -} - -@test "check_macos_update keeps update flag when softwareupdate returns empty output" { - run bash --noprofile --norc << 'EOF' -set -euo pipefail -source "$PROJECT_ROOT/lib/core/common.sh" -source "$PROJECT_ROOT/lib/check/all.sh" - -defaults() { echo "1"; } - -run_with_timeout() { - local timeout="${1:-}" - shift - if [[ "$timeout" != "10" ]]; then - echo "BAD_TIMEOUT:$timeout" - return 124 - fi - if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then +Software Update found the following new or updated software: +* Label: Numbers-14.4 + Title: Numbers, Version: 14.4, Size: 51200KiB, Recommended: YES, Action: none, +OUT return 0 fi return 124 @@ -641,22 +609,69 @@ echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" EOF [ "$status" -eq 0 ] - [[ "$output" == *"Update available"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] + [[ "$output" == *"System up to date"* ]] + [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] [[ "$output" != *"BAD_TIMEOUT:"* ]] } -@test "check_macos_update skips softwareupdate when defaults shows no updates" { +@test "get_software_updates caches softwareupdate output in memory" { run bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/check/all.sh" -defaults() { echo "0"; } +calls=0 + +run_with_timeout() { + local timeout="${1:-}" + shift + if [[ "$timeout" != "10" ]]; then + echo "BAD_TIMEOUT:$timeout" + return 124 + fi + if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then + calls=$((calls + 1)) + cat <<'OUT' +Software Update Tool + +No new software available. +OUT + return 0 + fi + return 124 +} + +first="$(get_software_updates)" +second="$(get_software_updates)" +printf 'CALLS=%s\n' "$calls" +printf 'FIRST=%s\n' "$first" +printf 'SECOND=%s\n' "$second" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CALLS=1"* ]] + [[ "$output" == *"FIRST=Software Update Tool"* ]] + [[ "$output" == *"SECOND=Software Update Tool"* ]] + [[ "$output" != *"BAD_TIMEOUT:"* ]] +} + +@test "check_macos_update uses cached softwareupdate output when available" { + run bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/check/all.sh" +mkdir -p "$HOME/.cache/mole" +cat > "$HOME/.cache/mole/softwareupdate_list" <<'OUT' +Software Update Tool + +Software Update found the following new or updated software: +* Label: macOS 99 + Title: macOS 99, Version: 99.1, Size: 1024KiB, Recommended: YES, Action: restart, +OUT run_with_timeout() { echo "SHOULD_NOT_CALL_SOFTWAREUPDATE" - return 0 + return 124 } check_macos_update @@ -664,19 +679,64 @@ echo "MACOS_UPDATE_AVAILABLE=$MACOS_UPDATE_AVAILABLE" EOF [ "$status" -eq 0 ] - [[ "$output" == *"System up to date"* ]] - [[ "$output" == *"MACOS_UPDATE_AVAILABLE=false"* ]] + [[ "$output" == *"macOS 99, Version: 99.1"* ]] + [[ "$output" == *"MACOS_UPDATE_AVAILABLE=true"* ]] [[ "$output" != *"SHOULD_NOT_CALL_SOFTWAREUPDATE"* ]] } +@test "reset_softwareupdate_cache clears in-memory softwareupdate state" { + run bash --noprofile --norc << 'EOF' +set -euo pipefail +source "$PROJECT_ROOT/lib/core/common.sh" +source "$PROJECT_ROOT/lib/check/all.sh" + +calls_file="$HOME/softwareupdate_calls" +printf '0\n' > "$calls_file" +first_file="$HOME/first_updates.txt" +second_file="$HOME/second_updates.txt" +rm -f "$HOME/.cache/mole/softwareupdate_list" +SOFTWARE_UPDATE_LIST="" +SOFTWARE_UPDATE_LIST_LOADED="false" +run_with_timeout() { + local timeout="${1:-}" + shift + if [[ "${1:-}" == "softwareupdate" && "${2:-}" == "-l" && "${3:-}" == "--no-scan" ]]; then + local calls + calls=$(cat "$calls_file") + calls=$((calls + 1)) + printf '%s\n' "$calls" > "$calls_file" + cat < "$first_file" +reset_softwareupdate_cache +get_software_updates > "$second_file" +printf 'CALLS=%s\n' "$(cat "$calls_file")" +printf 'FIRST=%s\n' "$(cat "$first_file")" +printf 'SECOND=%s\n' "$(cat "$second_file")" +EOF + + [ "$status" -eq 0 ] + [[ "$output" == *"CALLS=2"* ]] + [[ "$output" == *"FIRST=Software Update Tool"* ]] + [[ "$output" == *"SECOND=Software Update Tool"* ]] + [[ "$output" == *"macOS 2"* ]] +} + @test "check_macos_update outputs debug info when MO_DEBUG set" { run bash --noprofile --norc << 'EOF' set -euo pipefail source "$PROJECT_ROOT/lib/core/common.sh" source "$PROJECT_ROOT/lib/check/all.sh" -defaults() { echo "1"; } - export MO_DEBUG=1 run_with_timeout() { @@ -696,7 +756,7 @@ check_macos_update 2>&1 EOF [ "$status" -eq 0 ] - [[ "$output" == *"[DEBUG] softwareupdate exit status:"* ]] + [[ "$output" == *"[DEBUG] softwareupdate cached output lines:"* ]] } @test "run_with_timeout succeeds without GNU timeout" {