Last active
April 25, 2026 01:45
-
-
Save skooch/31e20abeeaa3144a6eb4368942f9997f to your computer and use it in GitHub Desktop.
XCode Clean Up Script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # ============================================================================= | |
| # XCode Cache, Simulator & SDK Cleanup Script | |
| # by @skooch | |
| # ============================================================================= | |
| # | |
| # Removes old SDKs, simulators, simulator images, caches, and device support | |
| # files while preserving only what's needed for the deployment target and the | |
| # latest Xcode SDK (removing everything in between). | |
| # | |
| # Usage: | |
| # ./scripts/cleanup-simulators.sh # Dry run (default) | |
| # ./scripts/cleanup-simulators.sh --execute # Actually delete | |
| # ./scripts/cleanup-simulators.sh --info # Show space usage only | |
| # ./scripts/cleanup-simulators.sh --help # Show help | |
| # | |
| # Override auto-detected versions: | |
| # --ios-min=16 Override iOS deployment target version | |
| # --ios-max=18 Override iOS latest SDK version | |
| # --watchos-min=10 Override watchOS deployment target version | |
| # --watchos-max=11 Override watchOS latest SDK version | |
| # --ios-versions=18.0,26.2 Explicit iOS versions to keep (overrides min/max) | |
| # --watchos-versions=10.6,26.2 | |
| # Explicit watchOS versions to keep (overrides min/max) | |
| # | |
| # Directory reference (~/Library/Developer/): | |
| # | |
| # Xcode/ | |
| # DerivedData/ Build intermediates, indexes. Regenerated on build. | |
| # Archives/ .xcarchive bundles from Product > Archive. | |
| # iOS DeviceSupport/ Debug symbols for physical iOS devices by version. | |
| # watchOS DeviceSupport/ Debug symbols for physical watchOS devices by version. | |
| # iOS Device Logs/ Crash logs pulled from physical devices. | |
| # Products/ Exported .ipa files. | |
| # UserData/Previews/ SwiftUI preview simulator data. | |
| # UserData/IB/ Interface Builder sandbox simulators. | |
| # | |
| # CoreSimulator/ | |
| # Devices/ Per-simulator data (apps, media, keychain, etc.). | |
| # Each subfolder is a UUID matching a booted device. | |
| # Caches/ Shared runtime caches across simulators. | |
| # | |
| # /Library/Developer/CoreSimulator/ (system-level, requires sudo) | |
| # Images/ Downloadable .dmg disk images for sim runtimes. | |
| # Profiles/Runtimes/ Mounted .simruntime bundles (iOS 17.simruntime, etc.). | |
| # Cryptex/Images/ Cryptex-based runtime images (newer Xcode). | |
| # | |
| # XCPGDevices/ Xcode Playground simulator data. | |
| # XCTestDevices/ Ephemeral XCTest device artifacts (per-device UUID folders). | |
| # | |
| # Caches: | |
| # ~/Library/Caches/com.apple.dt.Xcode Xcode module/index caches. | |
| # | |
| # Logs: | |
| # ~/Library/Logs/CoreSimulator/ Simulator syslog + NSLog output. | |
| # | |
| # ============================================================================= | |
| # --- Configuration ----------------------------------------------------------- | |
| DRY_RUN=true | |
| VERBOSE=false | |
| INFO_ONLY=false | |
| NUKE_EVERYTHING=false | |
| INTERACTIVE=false | |
| TOTAL_FREED=0 | |
| # Override values (empty = auto-detect) | |
| OVERRIDE_IOS_MIN="" | |
| OVERRIDE_IOS_MAX="" | |
| OVERRIDE_WATCHOS_MIN="" | |
| OVERRIDE_WATCHOS_MAX="" | |
| OVERRIDE_IOS_VERSIONS="" | |
| OVERRIDE_WATCHOS_VERSIONS="" | |
| PREFERRED_MODELS_CSV="" | |
| PREFERRED_MODELS_EXPLICIT=false | |
| EXCLUDED_PHASES_CSV="" | |
| VALID_EXCLUDED_PHASES_CSV="shutdown-simulators,delete-unavailable-simulators,auxiliary-simulators,runtime-cleanup,non-kept-runtime-devices,device-model-trim,ios-device-support,watchos-device-support,derived-data,archives,ios-device-logs,core-simulator-cache-logs,xctestdevices-dead,xcode-caches,playground-data,swiftui-preview,spm-caches" | |
| # Full versions set by auto-detection or overrides (e.g. 18.0, 26.2) | |
| IOS_MIN_VERSION="" | |
| IOS_MAX_VERSION="" | |
| WATCHOS_MIN_VERSION="" | |
| WATCHOS_MAX_VERSION="" | |
| # Comma-separated normalized version sets that determine what to keep. | |
| KEEP_IOS_VERSIONS_CSV="" | |
| KEEP_WATCHOS_VERSIONS_CSV="" | |
| # --- Colors ------------------------------------------------------------------ | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| CYAN='\033[0;36m' | |
| BOLD='\033[1m' | |
| DIM='\033[2m' | |
| NC='\033[0m' | |
| # --- Helpers ----------------------------------------------------------------- | |
| log() { echo -e "${CYAN}[INFO]${NC} $*"; } | |
| log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } | |
| log_ok() { echo -e "${GREEN}[ OK ]${NC} $*"; } | |
| log_del() { echo -e "${RED}[DEL ]${NC} $*"; } | |
| log_skip() { echo -e "${BLUE}[SKIP]${NC} $*"; } | |
| log_head() { echo -e "\n${BOLD}=== $* ===${NC}"; } | |
| log_size() { echo -e "${DIM}[SIZE]${NC} $*"; } | |
| usage() { | |
| echo "Usage: $(basename "$0") [OPTIONS]" | |
| echo "" | |
| echo "Options:" | |
| echo " --execute Actually delete files (default is dry run)" | |
| echo " --everything Remove ALL simulators, runtimes, SDKs, caches - nothing kept" | |
| echo " --interactive Prompt for each runtime keep/delete decision" | |
| echo " --perferred-models=LIST" | |
| echo " Comma-separated simulator models to keep/create" | |
| echo " (exact names from simctl device types)" | |
| echo " --ios-versions=LIST" | |
| echo " Comma-separated iOS versions to keep installed" | |
| echo " (overrides --ios-min and --ios-max)" | |
| echo " --watchos-versions=LIST" | |
| echo " Comma-separated watchOS versions to keep installed" | |
| echo " (overrides --watchos-min and --watchos-max)" | |
| echo " --excluded=LIST Comma-separated phase IDs to skip during cleanup" | |
| echo " --info Show space usage for each category (no deletions)" | |
| echo " --verbose Show extra detail" | |
| echo " --help Show this help" | |
| echo "" | |
| echo "Version overrides (default: auto-detected from project & Xcode):" | |
| echo " --ios-min=VER iOS deployment target version (e.g. 18.0 or 18)" | |
| echo " --ios-max=VER iOS latest SDK version (e.g. 26.2 or 26)" | |
| echo " --watchos-min=VER watchOS deployment target version (e.g. 11.0 or 11)" | |
| echo " --watchos-max=VER watchOS latest SDK version (e.g. 26.2 or 26)" | |
| echo "" | |
| echo "By default the script keeps runtimes for deployment target + latest SDK." | |
| echo "Use --ios-versions/--watchos-versions to keep an explicit version set." | |
| echo "Without --perferred-models, the script defaults to latest flagship" | |
| echo "iPhone + Apple Watch simulator models." | |
| echo "" | |
| echo "With --everything, ALL simulator runtimes, devices, SDKs, caches, and" | |
| echo "support files are removed regardless of version. Use with --execute to" | |
| echo "actually delete." | |
| echo "" | |
| echo "Valid options for --perferred-models on this machine:" | |
| print_valid_preferred_models | |
| echo "" | |
| echo "Valid options for --excluded:" | |
| print_valid_excluded_phases | |
| echo "" | |
| echo "Without --execute, the script only reports what it would delete." | |
| exit "${1:-0}" | |
| } | |
| dir_size_bytes() { | |
| if [[ -d "$1" ]]; then | |
| du -sk "$1" 2>/dev/null | awk '{print $1 * 1024}' || echo 0 | |
| else | |
| echo 0 | |
| fi | |
| } | |
| dir_size_human() { | |
| if [[ -d "$1" ]]; then | |
| du -sh "$1" 2>/dev/null | awk '{print $1}' || echo "0B" | |
| else | |
| echo "0B" | |
| fi | |
| } | |
| dir_size_bytes_sudo() { | |
| if [[ -e "$1" ]]; then | |
| sudo du -sk "$1" 2>/dev/null | awk '{print $1 * 1024}' || echo 0 | |
| else | |
| echo 0 | |
| fi | |
| } | |
| dir_size_human_sudo() { | |
| if [[ -e "$1" ]]; then | |
| sudo du -sh "$1" 2>/dev/null | awk '{print $1}' || echo "0B" | |
| else | |
| echo "0B" | |
| fi | |
| } | |
| bytes_to_human() { | |
| local bytes="$1" | |
| if [[ "$bytes" -gt $((1024 * 1024 * 1024)) ]]; then | |
| echo "$(echo "scale=1; $bytes / 1073741824" | bc) GB" | |
| elif [[ "$bytes" -gt $((1024 * 1024)) ]]; then | |
| echo "$(echo "scale=1; $bytes / 1048576" | bc) MB" | |
| elif [[ "$bytes" -gt 1024 ]]; then | |
| echo "$(echo "scale=1; $bytes / 1024" | bc) KB" | |
| else | |
| echo "${bytes} bytes" | |
| fi | |
| } | |
| remove_path() { | |
| local path="$1" | |
| local label="${2:-$path}" | |
| if [[ ! -e "$path" ]]; then | |
| return | |
| fi | |
| local size | |
| size=$(dir_size_human "$path") | |
| local size_bytes | |
| size_bytes=$(dir_size_bytes "$path") | |
| if $INFO_ONLY; then | |
| return | |
| fi | |
| if $DRY_RUN; then | |
| log_del "(dry run) Would remove: ${label} [${size}]" | |
| else | |
| rm -rf "$path" | |
| log_del "Removed: ${label} [${size}]" | |
| fi | |
| TOTAL_FREED=$((TOTAL_FREED + size_bytes)) | |
| } | |
| sudo_remove_path() { | |
| local path="$1" | |
| local label="${2:-$path}" | |
| if [[ ! -e "$path" ]]; then | |
| return | |
| fi | |
| local size | |
| size=$(dir_size_human "$path") | |
| if [[ "$size" == "0B" ]]; then | |
| size=$(dir_size_human_sudo "$path") | |
| fi | |
| local size_bytes | |
| size_bytes=$(dir_size_bytes "$path") | |
| if [[ "$size_bytes" -eq 0 ]]; then | |
| size_bytes=$(dir_size_bytes_sudo "$path") | |
| fi | |
| if $INFO_ONLY; then | |
| return | |
| fi | |
| if $DRY_RUN; then | |
| log_del "(dry run) Would sudo remove: ${label} [${size}]" | |
| else | |
| if sudo rm -rf "$path" 2>/dev/null; then | |
| log_del "Removed (sudo): ${label} [${size}]" | |
| else | |
| log_warn "Could not remove ${label} (SIP-protected or in use) - skipping" | |
| return 0 | |
| fi | |
| fi | |
| TOTAL_FREED=$((TOTAL_FREED + size_bytes)) | |
| } | |
| run_cmd() { | |
| local desc="$1" | |
| shift | |
| if $INFO_ONLY; then | |
| return | |
| fi | |
| if $DRY_RUN; then | |
| log_del "(dry run) ${desc}: $*" | |
| else | |
| log "Running: ${desc}" | |
| "$@" 2>/dev/null || true | |
| fi | |
| } | |
| confirm_runtime_delete() { | |
| local platform="$1" | |
| local version="$2" | |
| local default_delete="$3" | |
| local prompt default_choice choice delete_runtime | |
| if ! $INTERACTIVE; then | |
| $default_delete | |
| return | |
| fi | |
| if $default_delete; then | |
| default_choice="Y/n" | |
| prompt="Delete runtime ${platform} ${version}? [${default_choice}] " | |
| else | |
| default_choice="y/N" | |
| prompt="Delete runtime ${platform} ${version}? [${default_choice}] " | |
| fi | |
| read -r -p "$prompt" choice | |
| if [[ -z "$choice" ]]; then | |
| $default_delete | |
| return | |
| fi | |
| if [[ "$choice" =~ ^[Yy]$ ]]; then | |
| delete_runtime=true | |
| else | |
| delete_runtime=false | |
| fi | |
| $delete_runtime | |
| } | |
| # Unmount a simulator runtime disk image before deletion. | |
| # .simruntime bundles and .dmg images may be mounted; deleting while mounted | |
| # causes "resource busy" errors. | |
| unmount_image() { | |
| local path="$1" | |
| if $INFO_ONLY; then | |
| return | |
| fi | |
| # Check if this path (or a volume associated with it) is mounted | |
| local mount_point="" | |
| # For .simruntime bundles, check if any volume is mounted from this path | |
| if [[ "$path" == *.simruntime ]]; then | |
| mount_point=$(hdiutil info 2>/dev/null | grep -B 10 "$path" | grep '/Volumes/' | awk '{print $NF}' || true) | |
| fi | |
| # Also check if the path itself or its parent is a mount point | |
| if [[ -z "$mount_point" ]]; then | |
| mount_point=$(mount 2>/dev/null | grep "$path" | awk '{print $3}' || true) | |
| fi | |
| if [[ -n "$mount_point" ]]; then | |
| if $DRY_RUN; then | |
| log "(dry run) Would unmount: ${mount_point}" | |
| else | |
| log "Unmounting: ${mount_point}" | |
| hdiutil detach "$mount_point" -force 2>/dev/null || \ | |
| diskutil unmount force "$mount_point" 2>/dev/null || \ | |
| log_warn "Failed to unmount ${mount_point} - deletion may fail" | |
| fi | |
| fi | |
| # For .dmg files, check if they're attached | |
| if [[ "$path" == *.dmg ]]; then | |
| local dev_node | |
| dev_node=$(hdiutil info 2>/dev/null | grep -B 20 "$(basename "$path")" | grep '/dev/disk' | head -1 | awk '{print $1}' || true) | |
| if [[ -n "$dev_node" ]]; then | |
| if $DRY_RUN; then | |
| log "(dry run) Would detach disk image: $(basename "$path")" | |
| else | |
| log "Detaching disk image: $(basename "$path")" | |
| hdiutil detach "$dev_node" -force 2>/dev/null || \ | |
| log_warn "Failed to detach $(basename "$path") - deletion may fail" | |
| fi | |
| fi | |
| fi | |
| } | |
| # Normalize a version string to dot-separated numeric components and | |
| # trim trailing ".0" groups so 18, 18.0, and 18.0.0 compare equally. | |
| normalize_version() { | |
| local input="$1" | |
| local cleaned parts normalized i | |
| cleaned=$(echo "$input" | tr '_-' '.' | sed -E 's/[^0-9.]+/./g; s/\.\.+/./g; s/^\.//; s/\.$//') | |
| if [[ -z "$cleaned" ]]; then | |
| echo "" | |
| return | |
| fi | |
| IFS='.' read -r -a parts <<< "$cleaned" | |
| normalized=() | |
| for i in "${parts[@]}"; do | |
| [[ "$i" =~ ^[0-9]+$ ]] || continue | |
| normalized+=("$((10#$i))") | |
| done | |
| if [[ "${#normalized[@]}" -eq 0 ]]; then | |
| echo "" | |
| return | |
| fi | |
| while [[ "${#normalized[@]}" -gt 1 ]]; do | |
| local last_index=$(( ${#normalized[@]} - 1 )) | |
| [[ "${normalized[$last_index]}" -eq 0 ]] || break | |
| unset "normalized[$last_index]" | |
| done | |
| (IFS='.'; echo "${normalized[*]}") | |
| } | |
| versions_equal() { | |
| local a b | |
| a=$(normalize_version "$1") | |
| b=$(normalize_version "$2") | |
| [[ -n "$a" && -n "$b" && "$a" == "$b" ]] | |
| } | |
| # Check if version $1 is strictly greater than $2 (using sort -V). | |
| version_gt() { | |
| local a b | |
| a=$(normalize_version "$1") | |
| b=$(normalize_version "$2") | |
| [[ -n "$a" && -n "$b" && "$a" != "$b" ]] || return 1 | |
| local highest | |
| highest=$(printf '%s\n%s\n' "$a" "$b" | sort -V | tail -1) | |
| [[ "$highest" == "$a" ]] | |
| } | |
| # Check if two versions share the same major.minor (e.g. 26.4 matches 26.4.1). | |
| versions_match_minor() { | |
| local a b | |
| a=$(normalize_version "$1") | |
| b=$(normalize_version "$2") | |
| [[ -n "$a" && -n "$b" ]] || return 1 | |
| # Truncate both to major.minor for comparison | |
| local a_minor b_minor | |
| a_minor=$(echo "$a" | cut -d. -f1-2) | |
| b_minor=$(echo "$b" | cut -d. -f1-2) | |
| [[ "$a_minor" == "$b_minor" ]] | |
| } | |
| trim_whitespace() { | |
| local value="$1" | |
| value="${value#"${value%%[![:space:]]*}"}" | |
| value="${value%"${value##*[![:space:]]}"}" | |
| echo "$value" | |
| } | |
| csv_contains_exact() { | |
| local csv="$1" | |
| local needle="$2" | |
| local entry | |
| [[ -z "$csv" ]] && return 1 | |
| IFS=',' read -r -a entries <<< "$csv" | |
| for entry in "${entries[@]}"; do | |
| [[ "$entry" == "$needle" ]] && return 0 | |
| done | |
| return 1 | |
| } | |
| csv_append_unique_exact() { | |
| local csv="$1" | |
| local value="$2" | |
| if [[ -z "$value" ]]; then | |
| echo "$csv" | |
| return | |
| fi | |
| if csv_contains_exact "$csv" "$value"; then | |
| echo "$csv" | |
| elif [[ -z "$csv" ]]; then | |
| echo "$value" | |
| else | |
| echo "${csv},${value}" | |
| fi | |
| } | |
| csv_contains_version() { | |
| local csv="$1" | |
| local needle="$2" | |
| local entry | |
| [[ -z "$csv" ]] && return 1 | |
| IFS=',' read -r -a entries <<< "$csv" | |
| for entry in "${entries[@]}"; do | |
| versions_equal "$entry" "$needle" && return 0 | |
| done | |
| return 1 | |
| } | |
| csv_append_unique_version() { | |
| local csv="$1" | |
| local value="$2" | |
| if [[ -z "$value" ]]; then | |
| echo "$csv" | |
| return | |
| fi | |
| if csv_contains_version "$csv" "$value"; then | |
| echo "$csv" | |
| elif [[ -z "$csv" ]]; then | |
| echo "$value" | |
| else | |
| echo "${csv},${value}" | |
| fi | |
| } | |
| format_csv_for_display() { | |
| local csv="$1" | |
| if [[ -z "$csv" ]]; then | |
| echo "(none)" | |
| else | |
| echo "${csv//,/, }" | |
| fi | |
| } | |
| parse_version_csv() { | |
| local raw="$1" | |
| local parsed="" | |
| local token trimmed normalized | |
| IFS=',' read -r -a tokens <<< "$raw" | |
| for token in "${tokens[@]}"; do | |
| trimmed=$(trim_whitespace "$token") | |
| [[ -z "$trimmed" ]] && continue | |
| normalized=$(normalize_version "$trimmed") | |
| if [[ -z "$normalized" ]]; then | |
| log_warn "Ignoring invalid version in list: ${trimmed}" | |
| continue | |
| fi | |
| parsed=$(csv_append_unique_version "$parsed" "$normalized") | |
| done | |
| echo "$parsed" | |
| } | |
| parse_models_csv() { | |
| local raw="$1" | |
| local parsed="" | |
| local token trimmed | |
| IFS=',' read -r -a tokens <<< "$raw" | |
| for token in "${tokens[@]}"; do | |
| trimmed=$(trim_whitespace "$token") | |
| [[ -z "$trimmed" ]] && continue | |
| parsed=$(csv_append_unique_exact "$parsed" "$trimmed") | |
| done | |
| echo "$parsed" | |
| } | |
| normalize_phase_id() { | |
| local phase="$1" | |
| phase=$(trim_whitespace "$phase") | |
| phase=$(echo "$phase" | tr '[:upper:]' '[:lower:]' | tr '_ ' '--') | |
| echo "$phase" | |
| } | |
| parse_excluded_phases_csv() { | |
| local raw="$1" | |
| local parsed="" | |
| local token normalized | |
| IFS=',' read -r -a tokens <<< "$raw" | |
| for token in "${tokens[@]}"; do | |
| normalized=$(normalize_phase_id "$token") | |
| [[ -z "$normalized" ]] && continue | |
| parsed=$(csv_append_unique_exact "$parsed" "$normalized") | |
| done | |
| echo "$parsed" | |
| } | |
| phase_is_excluded() { | |
| local phase="$1" | |
| local normalized | |
| normalized=$(normalize_phase_id "$phase") | |
| [[ -n "$normalized" ]] && csv_contains_exact "$EXCLUDED_PHASES_CSV" "$normalized" | |
| } | |
| print_valid_excluded_phases() { | |
| local phase | |
| IFS=',' read -r -a phases <<< "$VALID_EXCLUDED_PHASES_CSV" | |
| for phase in "${phases[@]}"; do | |
| echo " - ${phase}" | |
| done | |
| } | |
| validate_excluded_phases() { | |
| if [[ -z "$EXCLUDED_PHASES_CSV" ]]; then | |
| return | |
| fi | |
| local phase | |
| IFS=',' read -r -a phases <<< "$EXCLUDED_PHASES_CSV" | |
| for phase in "${phases[@]}"; do | |
| if ! csv_contains_exact "$VALID_EXCLUDED_PHASES_CSV" "$phase"; then | |
| echo "Error: invalid phase in --excluded: ${phase}" >&2 | |
| echo "Run with --help to see valid phase IDs." >&2 | |
| exit 1 | |
| fi | |
| done | |
| } | |
| begin_phase() { | |
| local phase_id="$1" | |
| local title="$2" | |
| log_head "$title" | |
| if phase_is_excluded "$phase_id"; then | |
| log_skip "Excluded phase: ${phase_id}" | |
| return 1 | |
| fi | |
| return 0 | |
| } | |
| should_keep_platform_version() { | |
| local platform="$1" | |
| local version="$2" | |
| local keep_csv entry | |
| if $NUKE_EVERYTHING; then | |
| return 1 | |
| fi | |
| if [[ "$platform" == "ios" ]]; then | |
| keep_csv="$KEEP_IOS_VERSIONS_CSV" | |
| else | |
| keep_csv="$KEEP_WATCHOS_VERSIONS_CSV" | |
| fi | |
| [[ -z "$keep_csv" ]] && return 1 | |
| IFS=',' read -r -a entries <<< "$keep_csv" | |
| for entry in "${entries[@]}"; do | |
| versions_equal "$version" "$entry" && return 0 | |
| done | |
| return 1 | |
| } | |
| print_valid_preferred_models() { | |
| if ! command -v xcrun &>/dev/null || ! command -v python3 &>/dev/null; then | |
| echo " (xcrun/python3 not available to enumerate device models)" | |
| return | |
| fi | |
| local devicetypes_json | |
| if ! devicetypes_json=$(xcrun simctl list devicetypes --json 2>/dev/null); then | |
| echo " (unable to query simulator device types on this machine)" | |
| return | |
| fi | |
| local models | |
| models=$(echo "$devicetypes_json" | python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| names = set() | |
| for dt in data.get('devicetypes', []): | |
| ident = dt.get('identifier', '') | |
| name = dt.get('name', '') | |
| if '.iPhone-' in ident or '-Watch-' in ident: | |
| names.add(name) | |
| for name in sorted(names): | |
| print(name) | |
| " 2>/dev/null || true) | |
| if [[ -z "$models" ]]; then | |
| echo " (no iPhone/Apple Watch simulator models found)" | |
| return | |
| fi | |
| while IFS= read -r model; do | |
| [[ -z "$model" ]] && continue | |
| echo " - ${model}" | |
| done <<< "$models" | |
| } | |
| resolve_preferred_model_devicetype() { | |
| local model="$1" | |
| xcrun simctl list devicetypes --json 2>/dev/null | python3 -c " | |
| import sys, json | |
| model = sys.argv[1] | |
| data = json.load(sys.stdin) | |
| for dt in data.get('devicetypes', []): | |
| ident = dt.get('identifier', '') | |
| name = dt.get('name', '') | |
| if name != model: | |
| continue | |
| if '.iPhone-' in ident: | |
| print(f'{ident}|ios') | |
| raise SystemExit(0) | |
| if '-Watch-' in ident: | |
| print(f'{ident}|watchos') | |
| raise SystemExit(0) | |
| " "$model" 2>/dev/null || true | |
| } | |
| model_is_preferred() { | |
| local model="$1" | |
| if [[ -z "$PREFERRED_MODELS_CSV" ]]; then | |
| return 1 | |
| fi | |
| csv_contains_exact "$PREFERRED_MODELS_CSV" "$model" | |
| } | |
| runtime_ids_for_platform() { | |
| local platform="$1" | |
| xcrun simctl list runtimes --json 2>/dev/null | python3 -c " | |
| import sys, json | |
| platform = sys.argv[1] | |
| data = json.load(sys.stdin) | |
| for runtime in data.get('runtimes', []): | |
| avail = runtime.get('isAvailable') | |
| if avail is False: | |
| continue | |
| ident = runtime.get('identifier', '') | |
| version = runtime.get('version', '') | |
| if platform == 'ios' and '.iOS-' not in ident: | |
| continue | |
| if platform == 'watchos' and '.watchOS-' not in ident: | |
| continue | |
| print(f'{ident}|{version}') | |
| " "$platform" 2>/dev/null || true | |
| } | |
| detect_default_preferred_models() { | |
| if ! command -v xcrun &>/dev/null || ! command -v python3 &>/dev/null; then | |
| echo "" | |
| return | |
| fi | |
| local devicetypes_json | |
| if ! devicetypes_json=$(xcrun simctl list devicetypes --json 2>/dev/null); then | |
| echo "" | |
| return | |
| fi | |
| echo "$devicetypes_json" | python3 -c " | |
| import json | |
| import re | |
| import sys | |
| data = json.load(sys.stdin) | |
| iphone_best = None | |
| watch_best = None | |
| for dt in data.get('devicetypes', []): | |
| ident = dt.get('identifier', '') | |
| name = dt.get('name', '') | |
| # Prefer latest iPhone Pro tier; fallback across available tiers. | |
| m_iphone = re.search(r'iPhone-(\d+)(.*)$', ident) | |
| if m_iphone: | |
| gen = int(m_iphone.group(1)) | |
| suffix = m_iphone.group(2) | |
| if '-SE' in suffix: | |
| pass | |
| else: | |
| if '-Pro' in suffix and '-Pro-Max' not in suffix: | |
| tier = 4 | |
| elif '-Pro-Max' in suffix: | |
| tier = 3 | |
| elif '-Plus' in suffix: | |
| tier = 2 | |
| else: | |
| tier = 1 | |
| rank = (gen, tier) | |
| if iphone_best is None or rank > iphone_best[0]: | |
| iphone_best = (rank, name) | |
| # Prefer latest Apple Watch Series, largest case size. | |
| m_watch = re.search(r'Watch-Series-(\d+)(?:-(\d+))?mm?$', ident) | |
| if m_watch: | |
| series = int(m_watch.group(1)) | |
| size = int(m_watch.group(2) or 0) | |
| rank = (series, size) | |
| if watch_best is None or rank > watch_best[0]: | |
| watch_best = (rank, name) | |
| models = [] | |
| if iphone_best and watch_best: | |
| models.append(iphone_best[1]) | |
| models.append(watch_best[1]) | |
| print(','.join(models)) | |
| " 2>/dev/null || true | |
| } | |
| highest_targeted_version() { | |
| local platform="$1" | |
| local keep_csv | |
| if [[ "$platform" == "ios" ]]; then | |
| keep_csv="$KEEP_IOS_VERSIONS_CSV" | |
| else | |
| keep_csv="$KEEP_WATCHOS_VERSIONS_CSV" | |
| fi | |
| echo "$keep_csv" | tr ',' '\n' | sed '/^$/d' | sort -V | tail -1 | |
| } | |
| runtime_id_for_platform_version() { | |
| local platform="$1" | |
| local target_version="$2" | |
| local runtime_line runtime_id runtime_version | |
| while IFS= read -r runtime_line; do | |
| [[ -z "$runtime_line" ]] && continue | |
| runtime_id="${runtime_line%%|*}" | |
| runtime_version="${runtime_line##*|}" | |
| versions_equal "$runtime_version" "$target_version" || continue | |
| echo "$runtime_id" | |
| return | |
| done < <(runtime_ids_for_platform "$platform") | |
| echo "" | |
| } | |
| model_has_device_on_kept_versions() { | |
| local model="$1" | |
| local platform="$2" | |
| local runtime_line runtime_id runtime_version | |
| while IFS= read -r runtime_line; do | |
| [[ -z "$runtime_line" ]] && continue | |
| runtime_id="${runtime_line%%|*}" | |
| runtime_version="${runtime_line##*|}" | |
| should_keep_platform_version "$platform" "$runtime_version" || continue | |
| if device_exists_for_model_runtime "$model" "$runtime_id"; then | |
| return 0 | |
| fi | |
| done < <(runtime_ids_for_platform "$platform") | |
| return 1 | |
| } | |
| available_runtime_versions_csv() { | |
| local platform="$1" | |
| local available_versions="" | |
| local runtime_line runtime_version | |
| while IFS= read -r runtime_line; do | |
| [[ -z "$runtime_line" ]] && continue | |
| runtime_version="${runtime_line##*|}" | |
| runtime_version=$(normalize_version "$runtime_version") | |
| available_versions=$(csv_append_unique_version "$available_versions" "$runtime_version") | |
| done < <(runtime_ids_for_platform "$platform") | |
| echo "$available_versions" | |
| } | |
| device_exists_for_model_runtime() { | |
| local model="$1" | |
| local runtime_id="$2" | |
| xcrun simctl list devices --json 2>/dev/null | python3 -c " | |
| import sys, json | |
| model = sys.argv[1] | |
| runtime_id = sys.argv[2] | |
| data = json.load(sys.stdin) | |
| for dev in data.get('devices', {}).get(runtime_id, []): | |
| if dev.get('isAvailable') is False: | |
| continue | |
| if dev.get('name') == model: | |
| raise SystemExit(0) | |
| raise SystemExit(1) | |
| " "$model" "$runtime_id" 2>/dev/null | |
| } | |
| configure_keep_versions() { | |
| KEEP_IOS_VERSIONS_CSV="" | |
| KEEP_WATCHOS_VERSIONS_CSV="" | |
| if [[ -n "$OVERRIDE_IOS_VERSIONS" ]]; then | |
| KEEP_IOS_VERSIONS_CSV=$(parse_version_csv "$OVERRIDE_IOS_VERSIONS") | |
| if [[ -z "$KEEP_IOS_VERSIONS_CSV" ]]; then | |
| echo "Error: --ios-versions did not include any valid versions" >&2 | |
| exit 1 | |
| fi | |
| else | |
| KEEP_IOS_VERSIONS_CSV=$(csv_append_unique_version "$KEEP_IOS_VERSIONS_CSV" "$(normalize_version "$IOS_MIN_VERSION")") | |
| KEEP_IOS_VERSIONS_CSV=$(csv_append_unique_version "$KEEP_IOS_VERSIONS_CSV" "$(normalize_version "$IOS_MAX_VERSION")") | |
| fi | |
| if [[ -n "$OVERRIDE_WATCHOS_VERSIONS" ]]; then | |
| KEEP_WATCHOS_VERSIONS_CSV=$(parse_version_csv "$OVERRIDE_WATCHOS_VERSIONS") | |
| if [[ -z "$KEEP_WATCHOS_VERSIONS_CSV" ]]; then | |
| echo "Error: --watchos-versions did not include any valid versions" >&2 | |
| exit 1 | |
| fi | |
| else | |
| KEEP_WATCHOS_VERSIONS_CSV=$(csv_append_unique_version "$KEEP_WATCHOS_VERSIONS_CSV" "$(normalize_version "$WATCHOS_MIN_VERSION")") | |
| KEEP_WATCHOS_VERSIONS_CSV=$(csv_append_unique_version "$KEEP_WATCHOS_VERSIONS_CSV" "$(normalize_version "$WATCHOS_MAX_VERSION")") | |
| fi | |
| } | |
| validate_preferred_models() { | |
| if [[ -z "$PREFERRED_MODELS_CSV" ]]; then | |
| return | |
| fi | |
| if ! xcrun simctl list devicetypes --json >/dev/null 2>&1; then | |
| log_warn "Could not validate --perferred-models because simctl device types are unavailable" | |
| return | |
| fi | |
| local model info | |
| IFS=',' read -r -a models <<< "$PREFERRED_MODELS_CSV" | |
| for model in "${models[@]}"; do | |
| info=$(resolve_preferred_model_devicetype "$model") | |
| if [[ -z "$info" ]]; then | |
| echo "Error: invalid model in --perferred-models: ${model}" >&2 | |
| echo "Run with --help to see valid options on this machine." >&2 | |
| exit 1 | |
| fi | |
| done | |
| } | |
| warn_missing_requested_versions() { | |
| local requested_csv="$1" | |
| local platform="$2" | |
| local available_csv requested | |
| [[ -z "$requested_csv" ]] && return | |
| available_csv=$(available_runtime_versions_csv "$platform") | |
| IFS=',' read -r -a requested_versions <<< "$requested_csv" | |
| for requested in "${requested_versions[@]}"; do | |
| csv_contains_version "$available_csv" "$requested" && continue | |
| if [[ "$platform" == "ios" ]]; then | |
| log_warn "Requested iOS runtime ${requested} is not currently installed" | |
| else | |
| log_warn "Requested watchOS runtime ${requested} is not currently installed" | |
| fi | |
| done | |
| } | |
| extract_prefixed_version() { | |
| local input="$1" | |
| local prefix="$2" | |
| local version | |
| version=$(echo "$input" | sed -nE "s/.*${prefix}[[:space:]_.-]*([0-9]+([._-][0-9]+){0,2}).*/\\1/p" | head -1) | |
| echo "${version//[-_]/.}" | |
| } | |
| extract_leading_version() { | |
| local input="$1" | |
| local version | |
| version=$(echo "$input" | sed -nE 's/^([0-9]+([.][0-9]+){0,2}).*/\1/p' | head -1) | |
| echo "$version" | |
| } | |
| should_keep_named_version() { | |
| local name="$1" | |
| local parsed platform version | |
| parsed=$(parse_named_platform_version "$name") || return 1 | |
| platform="${parsed%%|*}" | |
| version="${parsed##*|}" | |
| should_keep_platform_version "$platform" "$version" | |
| } | |
| parse_named_platform_version() { | |
| local name="$1" | |
| local platform version | |
| if [[ "$name" == *"iOS"* ]]; then | |
| platform="ios" | |
| version=$(extract_prefixed_version "$name" "iOS") | |
| elif [[ "$name" == *"watchOS"* ]]; then | |
| platform="watchos" | |
| version=$(extract_prefixed_version "$name" "watchOS") | |
| else | |
| return 1 | |
| fi | |
| version=$(normalize_version "$version") | |
| [[ -n "$version" ]] || return 1 | |
| echo "${platform}|${version}" | |
| } | |
| should_keep_runtime_key() { | |
| local runtime_key="$1" | |
| local platform version | |
| if [[ "$runtime_key" == *"iOS-"* ]]; then | |
| platform="ios" | |
| version=$(extract_prefixed_version "$runtime_key" "iOS") | |
| elif [[ "$runtime_key" == *"watchOS-"* ]]; then | |
| platform="watchos" | |
| version=$(extract_prefixed_version "$runtime_key" "watchOS") | |
| else | |
| return 1 | |
| fi | |
| should_keep_platform_version "$platform" "$version" | |
| } | |
| should_keep_folder_version() { | |
| local platform="$1" | |
| local folder_name="$2" | |
| local folder_version | |
| folder_version=$(extract_leading_version "$folder_name") | |
| [[ -n "$folder_version" ]] || return 1 | |
| should_keep_platform_version "$platform" "$folder_version" && return 0 | |
| if $NUKE_EVERYTHING; then | |
| return 1 | |
| fi | |
| local keep_csv entry | |
| if [[ "$platform" == "ios" ]]; then | |
| keep_csv="$KEEP_IOS_VERSIONS_CSV" | |
| else | |
| keep_csv="$KEEP_WATCHOS_VERSIONS_CSV" | |
| fi | |
| [[ -z "$keep_csv" ]] && return 1 | |
| IFS=',' read -r -a entries <<< "$keep_csv" | |
| for entry in "${entries[@]}"; do | |
| versions_match_minor "$folder_version" "$entry" && return 0 | |
| done | |
| return 1 | |
| } | |
| # Clean a DeviceSupport directory, keeping only the latest patch version | |
| # for each kept major.minor. E.g. if 26.4 and 26.4.1 both exist and 26.4 | |
| # is in the keep list, only 26.4.1 is kept. | |
| clean_device_support_dir() { | |
| local platform="$1" | |
| local ds_dir="$2" | |
| local label_prefix="$3" | |
| declare -A latest_per_minor # minor -> "version|path" | |
| # First pass: find the latest patch version per kept minor | |
| for entry in "$ds_dir"/*/; do | |
| [[ -d "$entry" ]] || continue | |
| local folder_name folder_version minor | |
| folder_name=$(basename "$entry") | |
| folder_version=$(extract_leading_version "$folder_name") | |
| [[ -n "$folder_version" ]] || continue | |
| if ! should_keep_folder_version "$platform" "$folder_name"; then | |
| continue | |
| fi | |
| minor=$(normalize_version "$folder_version" | cut -d. -f1-2) | |
| local existing="${latest_per_minor[$minor]:-}" | |
| if [[ -z "$existing" ]]; then | |
| latest_per_minor[$minor]="$folder_version|$entry" | |
| else | |
| local existing_version="${existing%%|*}" | |
| if version_gt "$folder_version" "$existing_version"; then | |
| latest_per_minor[$minor]="$folder_version|$entry" | |
| fi | |
| fi | |
| done | |
| # Second pass: keep only latest patch per minor, remove everything else | |
| for entry in "$ds_dir"/*/; do | |
| [[ -d "$entry" ]] || continue | |
| local folder_name folder_version minor | |
| folder_name=$(basename "$entry") | |
| folder_version=$(extract_leading_version "$folder_name") | |
| if [[ -z "$folder_version" ]]; then | |
| if $NUKE_EVERYTHING; then | |
| remove_path "$entry" "${label_prefix}: ${folder_name}" | |
| else | |
| $VERBOSE && log_skip "Keeping ${label_prefix} (no version): ${folder_name}" | |
| fi | |
| continue | |
| fi | |
| minor=$(normalize_version "$folder_version" | cut -d. -f1-2) | |
| local best="${latest_per_minor[$minor]:-}" | |
| if [[ -z "$best" ]]; then | |
| # This minor is not in the keep list | |
| remove_path "$entry" "${label_prefix}: ${folder_name}" | |
| else | |
| local best_path="${best#*|}" | |
| if [[ "$entry" == "$best_path" ]]; then | |
| log_skip "Keeping ${label_prefix}: ${folder_name}" | |
| else | |
| remove_path "$entry" "${label_prefix}: ${folder_name} (superseded)" | |
| fi | |
| fi | |
| done | |
| } | |
| is_system_image_metadata_entry() { | |
| case "$1" in | |
| Inbox|bundle|mnt|images.plist|.DS_Store) | |
| return 0 | |
| ;; | |
| *) | |
| return 1 | |
| ;; | |
| esac | |
| } | |
| # --- Auto-detection ---------------------------------------------------------- | |
| # Find project root (look for .xcodeproj in repo root or current dir) | |
| find_pbxproj() { | |
| local script_dir | |
| script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| local repo_root="${script_dir}/.." | |
| # Try repo root first | |
| for proj in "$repo_root"/*.xcodeproj/project.pbxproj; do | |
| if [[ -f "$proj" ]]; then | |
| echo "$proj" | |
| return | |
| fi | |
| done | |
| # Try current directory | |
| for proj in ./*.xcodeproj/project.pbxproj; do | |
| if [[ -f "$proj" ]]; then | |
| echo "$proj" | |
| return | |
| fi | |
| done | |
| echo "" | |
| } | |
| detect_deployment_targets() { | |
| local pbxproj | |
| pbxproj=$(find_pbxproj) | |
| if [[ -z "$pbxproj" ]]; then | |
| log_warn "Could not find .xcodeproj - using defaults for deployment targets" | |
| IOS_MIN_VERSION="${OVERRIDE_IOS_MIN:-18.0}" | |
| WATCHOS_MIN_VERSION="${OVERRIDE_WATCHOS_MIN:-11.0}" | |
| return | |
| fi | |
| if [[ -z "$OVERRIDE_IOS_MIN" ]]; then | |
| local ios_target | |
| ios_target=$(grep 'IPHONEOS_DEPLOYMENT_TARGET' "$pbxproj" 2>/dev/null \ | |
| | sed -nE 's/.*= *([0-9]+([.][0-9]+){0,2}).*/\1/p' \ | |
| | sort -V | head -1) | |
| IOS_MIN_VERSION="${ios_target:-18.0}" | |
| else | |
| IOS_MIN_VERSION="$OVERRIDE_IOS_MIN" | |
| fi | |
| if [[ -z "$OVERRIDE_WATCHOS_MIN" ]]; then | |
| local watchos_target | |
| watchos_target=$(grep 'WATCHOS_DEPLOYMENT_TARGET' "$pbxproj" 2>/dev/null \ | |
| | sed -nE 's/.*= *([0-9]+([.][0-9]+){0,2}).*/\1/p' \ | |
| | sort -V | head -1) | |
| WATCHOS_MIN_VERSION="${watchos_target:-11.0}" | |
| else | |
| WATCHOS_MIN_VERSION="$OVERRIDE_WATCHOS_MIN" | |
| fi | |
| } | |
| detect_latest_sdk() { | |
| if [[ -z "$OVERRIDE_IOS_MAX" ]]; then | |
| local ios_sdk | |
| ios_sdk=$(xcodebuild -showsdks 2>/dev/null \ | |
| | grep -o 'iphoneos[0-9]*\.[0-9]*' \ | |
| | sed 's/iphoneos//' \ | |
| | sort -t. -k1,1n -k2,2n | tail -1) | |
| if [[ -n "$ios_sdk" ]]; then | |
| IOS_MAX_VERSION="$ios_sdk" | |
| else | |
| IOS_MAX_VERSION="${IOS_MIN_VERSION}" | |
| log_warn "Could not detect iOS SDK version from Xcode; defaulting to deployment target (${IOS_MIN_VERSION})" | |
| fi | |
| else | |
| IOS_MAX_VERSION="$OVERRIDE_IOS_MAX" | |
| fi | |
| if [[ -z "$OVERRIDE_WATCHOS_MAX" ]]; then | |
| local watchos_sdk | |
| watchos_sdk=$(xcodebuild -showsdks 2>/dev/null \ | |
| | grep -o 'watchos[0-9]*\.[0-9]*' \ | |
| | sed 's/watchos//' \ | |
| | sort -t. -k1,1n -k2,2n | tail -1) | |
| if [[ -n "$watchos_sdk" ]]; then | |
| WATCHOS_MAX_VERSION="$watchos_sdk" | |
| else | |
| WATCHOS_MAX_VERSION="${WATCHOS_MIN_VERSION}" | |
| log_warn "Could not detect watchOS SDK version from Xcode; defaulting to deployment target (${WATCHOS_MIN_VERSION})" | |
| fi | |
| else | |
| WATCHOS_MAX_VERSION="$OVERRIDE_WATCHOS_MAX" | |
| fi | |
| } | |
| # --- Info mode: show space usage per category -------------------------------- | |
| show_info() { | |
| local ios_keep_display watchos_keep_display | |
| ios_keep_display=$(format_csv_for_display "$KEEP_IOS_VERSIONS_CSV") | |
| watchos_keep_display=$(format_csv_for_display "$KEEP_WATCHOS_VERSIONS_CSV") | |
| echo -e "${BOLD}Blinq iOS Developer Environment - Space Usage Report${NC}" | |
| if $NUKE_EVERYTHING; then | |
| echo -e "${RED}${BOLD}*** EVERYTHING MODE - nothing will be kept ***${NC}" | |
| else | |
| echo -e "Detected versions to keep: iOS ${ios_keep_display} | watchOS ${watchos_keep_display}" | |
| if [[ -n "$PREFERRED_MODELS_CSV" ]]; then | |
| echo -e "Preferred models: $(format_csv_for_display "$PREFERRED_MODELS_CSV")" | |
| fi | |
| if [[ -n "$EXCLUDED_PHASES_CSV" ]]; then | |
| echo -e "Excluded phases (cleanup mode): $(format_csv_for_display "$EXCLUDED_PHASES_CSV")" | |
| fi | |
| fi | |
| echo "" | |
| local grand_total=0 | |
| local category_bytes=0 | |
| # Helper: print a category with size | |
| info_category() { | |
| local label="$1" | |
| local path="$2" | |
| local action="${3:-Would be removed}" | |
| local use_sudo="${4:-false}" | |
| if [[ -e "$path" ]]; then | |
| local size | |
| if $use_sudo; then | |
| size=$(dir_size_human_sudo "$path") | |
| else | |
| size=$(dir_size_human "$path") | |
| fi | |
| if $use_sudo; then | |
| category_bytes=$(dir_size_bytes_sudo "$path") | |
| else | |
| category_bytes=$(dir_size_bytes "$path") | |
| fi | |
| printf " ${BOLD}%-45s${NC} %8s ${DIM}(%s)${NC}\n" "$label" "$size" "$action" | |
| else | |
| category_bytes=0 | |
| printf " ${DIM}%-45s${NC} %8s\n" "$label" "N/A" | |
| fi | |
| } | |
| # --- Simulator Runtimes --- | |
| echo -e "${BOLD}--- Simulator Runtimes (system-level) ---${NC}" | |
| RUNTIMES_DIR="/Library/Developer/CoreSimulator/Profiles/Runtimes" | |
| if [[ -d "$RUNTIMES_DIR" ]]; then | |
| for runtime_bundle in "$RUNTIMES_DIR"/*.simruntime; do | |
| [[ -e "$runtime_bundle" ]] || continue | |
| local bundle_name | |
| bundle_name=$(basename "$runtime_bundle") | |
| local keep=false action="Would be REMOVED" | |
| should_keep_named_version "$bundle_name" && keep=true | |
| if $keep; then | |
| action="KEEPING" | |
| fi | |
| info_category "$bundle_name" "$runtime_bundle" "$action" true | |
| if ! $keep; then | |
| grand_total=$((grand_total + category_bytes)) | |
| fi | |
| done | |
| else | |
| echo -e " ${DIM}Runtime profiles directory not found${NC}" | |
| fi | |
| IMAGES_DIR="/Library/Developer/CoreSimulator/Images" | |
| if [[ -d "$IMAGES_DIR" ]]; then | |
| for img in "$IMAGES_DIR"/*; do | |
| [[ -e "$img" ]] || continue | |
| local img_name | |
| img_name=$(basename "$img") | |
| if is_system_image_metadata_entry "$img_name"; then | |
| continue | |
| fi | |
| local keep=false action="Would be REMOVED" | |
| should_keep_named_version "$img_name" && keep=true | |
| if $keep; then | |
| action="KEEPING" | |
| fi | |
| info_category "$img_name" "$img" "$action" true | |
| if ! $keep; then | |
| grand_total=$((grand_total + category_bytes)) | |
| fi | |
| done | |
| fi | |
| CRYPTEX_DIR="/Library/Developer/CoreSimulator/Cryptex/Images" | |
| if [[ -d "$CRYPTEX_DIR" ]]; then | |
| for img in "$CRYPTEX_DIR"/*; do | |
| [[ -e "$img" ]] || continue | |
| local img_name | |
| img_name=$(basename "$img") | |
| if is_system_image_metadata_entry "$img_name"; then | |
| continue | |
| fi | |
| local keep=false action="Would be REMOVED" | |
| if should_keep_named_version "$img_name"; then | |
| keep=true | |
| elif ! parse_named_platform_version "$img_name" >/dev/null && ! $NUKE_EVERYTHING; then | |
| keep=true | |
| fi | |
| if $keep; then | |
| action="KEEPING" | |
| fi | |
| info_category "$img_name" "$img" "$action" true | |
| if ! $keep; then | |
| grand_total=$((grand_total + category_bytes)) | |
| fi | |
| done | |
| fi | |
| # --- User-level directories --- | |
| echo "" | |
| echo -e "${BOLD}--- User-level Developer Directories ---${NC}" | |
| local paths_and_labels=( | |
| "$HOME/Library/Developer/Xcode/DerivedData|DerivedData (build artifacts)|Would be removed" | |
| "$HOME/Library/Developer/Xcode/Archives|Xcode Archives|Would be removed" | |
| "$HOME/Library/Developer/Xcode/iOS Device Logs|iOS Device Logs|Would be removed" | |
| "$HOME/Library/Developer/Xcode/UserData/Previews|SwiftUI Preview Data|Would be removed" | |
| "$HOME/Library/Developer/XCPGDevices|Playground Device Data|Would be removed" | |
| "$HOME/Library/Developer/XCTestDevices|XCTestDevices Data|Would be removed" | |
| "$HOME/Library/Developer/CoreSimulator/Caches|CoreSimulator Caches|Would be removed" | |
| "$HOME/Library/Logs/CoreSimulator|CoreSimulator Logs|Would be removed" | |
| "$HOME/Library/Caches/com.apple.dt.Xcode|Xcode Caches|Would be removed" | |
| "$HOME/Library/Caches/org.swift.swiftpm|SPM Cache|Would be removed" | |
| "$HOME/Library/org.swift.swiftpm|SPM Library Data|Would be removed" | |
| ) | |
| for entry in "${paths_and_labels[@]}"; do | |
| IFS='|' read -r path label action <<< "$entry" | |
| info_category "$label" "$path" "$action" | |
| grand_total=$((grand_total + category_bytes)) | |
| done | |
| # --- DeviceSupport with version breakdown --- | |
| # Info-mode helper: mirrors clean_device_support_dir logic | |
| info_device_support() { | |
| local platform="$1" | |
| local ds_dir="$2" | |
| local label_prefix="$3" | |
| if [[ ! -d "$ds_dir" ]]; then | |
| echo -e " ${DIM}${label_prefix} not found${NC}" | |
| return | |
| fi | |
| declare -A info_latest_per_minor | |
| for entry_dir in "$ds_dir"/*/; do | |
| [[ -d "$entry_dir" ]] || continue | |
| local folder_name folder_version minor | |
| folder_name=$(basename "$entry_dir") | |
| folder_version=$(extract_leading_version "$folder_name") | |
| [[ -n "$folder_version" ]] || continue | |
| if ! should_keep_folder_version "$platform" "$folder_name"; then | |
| continue | |
| fi | |
| minor=$(normalize_version "$folder_version" | cut -d. -f1-2) | |
| local existing="${info_latest_per_minor[$minor]:-}" | |
| if [[ -z "$existing" ]]; then | |
| info_latest_per_minor[$minor]="$folder_version|$entry_dir" | |
| else | |
| local existing_version="${existing%%|*}" | |
| if version_gt "$folder_version" "$existing_version"; then | |
| info_latest_per_minor[$minor]="$folder_version|$entry_dir" | |
| fi | |
| fi | |
| done | |
| for entry_dir in "$ds_dir"/*/; do | |
| [[ -d "$entry_dir" ]] || continue | |
| local folder_name folder_version minor action | |
| folder_name=$(basename "$entry_dir") | |
| folder_version=$(extract_leading_version "$folder_name") | |
| if [[ -z "$folder_version" ]]; then | |
| if $NUKE_EVERYTHING; then | |
| action="Would be REMOVED" | |
| else | |
| action="KEEPING" | |
| fi | |
| info_category "${label_prefix} $folder_name" "$entry_dir" "$action" | |
| if [[ "$action" == "Would be REMOVED" ]]; then | |
| grand_total=$((grand_total + category_bytes)) | |
| fi | |
| continue | |
| fi | |
| minor=$(normalize_version "$folder_version" | cut -d. -f1-2) | |
| local best="${info_latest_per_minor[$minor]:-}" | |
| if [[ -z "$best" ]]; then | |
| action="Would be REMOVED" | |
| else | |
| local best_path="${best#*|}" | |
| if [[ "$entry_dir" == "$best_path" ]]; then | |
| action="KEEPING" | |
| else | |
| action="Would be REMOVED (superseded)" | |
| fi | |
| fi | |
| info_category "${label_prefix} $folder_name" "$entry_dir" "$action" | |
| if [[ "$action" != "KEEPING" ]]; then | |
| grand_total=$((grand_total + category_bytes)) | |
| fi | |
| done | |
| } | |
| echo "" | |
| echo -e "${BOLD}--- iOS DeviceSupport ---${NC}" | |
| info_device_support "ios" "$HOME/Library/Developer/Xcode/iOS DeviceSupport" "iOS" | |
| echo "" | |
| echo -e "${BOLD}--- watchOS DeviceSupport ---${NC}" | |
| info_device_support "watchos" "$HOME/Library/Developer/Xcode/watchOS DeviceSupport" "watchOS" | |
| # --- Summary --- | |
| echo "" | |
| echo -e "${BOLD}--- Summary ---${NC}" | |
| echo -e " Total reclaimable space: ${RED}${BOLD}$(bytes_to_human "$grand_total")${NC}" | |
| echo "" | |
| echo -e " ${DIM}Run with --execute to reclaim this space, or without flags for a dry run.${NC}" | |
| } | |
| # --- Pre-flight checks ------------------------------------------------------- | |
| preflight() { | |
| if ! command -v xcrun &>/dev/null; then | |
| echo "Error: xcrun not found. Is Xcode installed?" >&2 | |
| exit 1 | |
| fi | |
| if ! command -v xcodebuild &>/dev/null; then | |
| echo "Error: xcodebuild not found. Is Xcode installed?" >&2 | |
| exit 1 | |
| fi | |
| } | |
| # --- Parse arguments --------------------------------------------------------- | |
| while [[ $# -gt 0 ]]; do | |
| arg="$1" | |
| case "$arg" in | |
| --execute) DRY_RUN=false ;; | |
| --everything) NUKE_EVERYTHING=true ;; | |
| --interactive) INTERACTIVE=true ;; | |
| --perferred-models=*|--preferred-models=*) | |
| PREFERRED_MODELS_CSV="${arg#*=}" | |
| PREFERRED_MODELS_EXPLICIT=true ;; | |
| --verbose) VERBOSE=true ;; | |
| --info) INFO_ONLY=true ;; | |
| --ios-min=*) OVERRIDE_IOS_MIN="${arg#*=}" ;; | |
| --ios-max=*) OVERRIDE_IOS_MAX="${arg#*=}" ;; | |
| --ios-versions=*) OVERRIDE_IOS_VERSIONS="${arg#*=}" ;; | |
| --watchos-min=*) OVERRIDE_WATCHOS_MIN="${arg#*=}" ;; | |
| --watchos-max=*) OVERRIDE_WATCHOS_MAX="${arg#*=}" ;; | |
| --watchos-versions=*) OVERRIDE_WATCHOS_VERSIONS="${arg#*=}" ;; | |
| --excluded=*) EXCLUDED_PHASES_CSV="${arg#*=}" ;; | |
| --excluded) | |
| shift | |
| if [[ $# -eq 0 ]]; then | |
| echo "Error: --excluded requires a comma-separated value" >&2 | |
| exit 1 | |
| fi | |
| EXCLUDED_PHASES_CSV="$1" | |
| ;; | |
| --help|-h) usage ;; | |
| *) echo "Unknown option: $arg" >&2; usage 1 ;; | |
| esac | |
| shift | |
| done | |
| # --- Main -------------------------------------------------------------------- | |
| preflight | |
| if $INTERACTIVE && ! [[ -t 0 ]]; then | |
| log_warn "--interactive requires an interactive terminal; continuing with automatic selection" | |
| INTERACTIVE=false | |
| fi | |
| # Auto-detect versions | |
| detect_deployment_targets | |
| detect_latest_sdk | |
| configure_keep_versions | |
| if [[ -n "$PREFERRED_MODELS_CSV" ]]; then | |
| PREFERRED_MODELS_CSV=$(parse_models_csv "$PREFERRED_MODELS_CSV") | |
| fi | |
| if [[ -n "$EXCLUDED_PHASES_CSV" ]]; then | |
| EXCLUDED_PHASES_CSV=$(parse_excluded_phases_csv "$EXCLUDED_PHASES_CSV") | |
| fi | |
| validate_excluded_phases | |
| if ! $PREFERRED_MODELS_EXPLICIT; then | |
| default_models_csv=$(detect_default_preferred_models) | |
| if [[ -n "$default_models_csv" ]]; then | |
| PREFERRED_MODELS_CSV=$(parse_models_csv "$default_models_csv") | |
| else | |
| log_warn "Could not auto-detect default flagship iPhone/Apple Watch models; keeping all iPhone/Apple Watch models" | |
| fi | |
| fi | |
| validate_preferred_models | |
| if [[ -n "$OVERRIDE_IOS_VERSIONS" ]]; then | |
| warn_missing_requested_versions "$KEEP_IOS_VERSIONS_CSV" "ios" | |
| fi | |
| if [[ -n "$OVERRIDE_WATCHOS_VERSIONS" ]]; then | |
| warn_missing_requested_versions "$KEEP_WATCHOS_VERSIONS_CSV" "watchos" | |
| fi | |
| # --- Info mode --------------------------------------------------------------- | |
| if $INFO_ONLY; then | |
| show_info | |
| exit 0 | |
| fi | |
| # --- Normal cleanup mode ----------------------------------------------------- | |
| echo -e "${BOLD}Blinq iOS Simulator & SDK Cleanup${NC}" | |
| if $NUKE_EVERYTHING; then | |
| echo -e "${RED}${BOLD}*** EVERYTHING MODE ***${NC}" | |
| echo -e "${RED}ALL simulators, runtimes, SDKs, device support, caches, and build${NC}" | |
| echo -e "${RED}artifacts will be removed. Nothing will be preserved.${NC}" | |
| echo -e "${RED}You will need to re-download simulator runtimes from Xcode afterward.${NC}" | |
| else | |
| ios_keep_display=$(format_csv_for_display "$KEEP_IOS_VERSIONS_CSV") | |
| watchos_keep_display=$(format_csv_for_display "$KEEP_WATCHOS_VERSIONS_CSV") | |
| echo -e "Keeping: iOS ${ios_keep_display} | watchOS ${watchos_keep_display}" | |
| if [[ -n "$PREFERRED_MODELS_CSV" ]]; then | |
| echo -e "Preferred models: $(format_csv_for_display "$PREFERRED_MODELS_CSV")" | |
| fi | |
| if [[ -n "$EXCLUDED_PHASES_CSV" ]]; then | |
| echo -e "Excluded phases: $(format_csv_for_display "$EXCLUDED_PHASES_CSV")" | |
| fi | |
| echo -e "${DIM}(all other runtime versions will be removed)${NC}" | |
| fi | |
| if $DRY_RUN; then | |
| echo -e "${YELLOW}MODE: DRY RUN (pass --execute to actually delete)${NC}" | |
| else | |
| if $NUKE_EVERYTHING; then | |
| echo "" | |
| echo -e "${RED}${BOLD}WARNING: This will delete ALL Xcode simulators, runtimes, and caches.${NC}" | |
| echo -e "${RED}You will need to re-download runtimes and recreate simulators afterward.${NC}" | |
| echo "" | |
| read -r -p "Type 'nuke' to confirm complete removal: " confirm | |
| if [[ "$confirm" != "nuke" ]]; then | |
| echo "Aborted. (You must type 'nuke' to confirm --everything mode.)" | |
| exit 0 | |
| fi | |
| else | |
| echo -e "${RED}MODE: EXECUTE (files will be permanently deleted)${NC}" | |
| echo "" | |
| read -r -p "Are you sure? (y/N) " confirm | |
| if [[ ! "$confirm" =~ ^[Yy]$ ]]; then | |
| echo "Aborted." | |
| exit 0 | |
| fi | |
| fi | |
| fi | |
| # ---- 1. Shutdown all running simulators ------------------------------------ | |
| if begin_phase "shutdown-simulators" "Shutting down running simulators"; then | |
| run_cmd "Shutting down all simulators" xcrun simctl shutdown all | |
| fi | |
| # ---- 2. Delete unavailable simulators (ones whose runtime is gone) --------- | |
| if begin_phase "delete-unavailable-simulators" "Removing unavailable simulator devices"; then | |
| run_cmd "Deleting unavailable simulators" xcrun simctl delete unavailable | |
| fi | |
| # ---- 3. Clean up extra simulator sets (playgrounds, previews, IB, testing) -- | |
| if begin_phase "auxiliary-simulators" "Cleaning auxiliary simulator sets"; then | |
| for simset in playgrounds previews interfacebuilder testing; do | |
| run_cmd "Cleaning $simset simulators" xcrun simctl --set "$simset" delete all | |
| done | |
| fi | |
| # ---- 4. Remove old simulator runtimes (keep only min + max) ---------------- | |
| if begin_phase "runtime-cleanup" "Removing simulator runtimes outside kept versions"; then | |
| # Use simctl runtime list if available (Xcode 15+) | |
| if xcrun simctl runtime list &>/dev/null 2>&1; then | |
| while IFS= read -r line; do | |
| if [[ "$line" =~ ^[[:space:]]*(iOS|watchOS|tvOS|visionOS|xrOS)[[:space:]]+([0-9]+\.[0-9]+) ]]; then | |
| platform="${BASH_REMATCH[1]}" | |
| version="${BASH_REMATCH[2]}" | |
| keep=false | |
| if [[ "$platform" == "iOS" ]]; then | |
| should_keep_platform_version "ios" "$version" && keep=true | |
| elif [[ "$platform" == "watchOS" ]]; then | |
| should_keep_platform_version "watchos" "$version" && keep=true | |
| fi | |
| if $INTERACTIVE; then | |
| should_delete=true | |
| if $keep; then | |
| should_delete=false | |
| fi | |
| if confirm_runtime_delete "$platform" "$version" "$should_delete"; then | |
| keep=false | |
| else | |
| keep=true | |
| fi | |
| fi | |
| if $keep; then | |
| log_skip "Keeping runtime: ${platform} ${version}" | |
| continue | |
| fi | |
| if [[ "$line" =~ ([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}) ]]; then | |
| runtime_id="${BASH_REMATCH[1]}" | |
| run_cmd "Deleting runtime ${platform} ${version}" xcrun simctl runtime delete "$runtime_id" | |
| fi | |
| fi | |
| done < <(xcrun simctl runtime list 2>/dev/null) | |
| else | |
| log_warn "xcrun simctl runtime list not available; skipping runtime cleanup via simctl" | |
| fi | |
| RUNTIMES_DIR="/Library/Developer/CoreSimulator/Profiles/Runtimes" | |
| if [[ -d "$RUNTIMES_DIR" ]]; then | |
| log_head "Cleaning system-level runtime bundles in ${RUNTIMES_DIR}" | |
| for runtime_bundle in "$RUNTIMES_DIR"/*.simruntime; do | |
| [[ -e "$runtime_bundle" ]] || continue | |
| bundle_name=$(basename "$runtime_bundle") | |
| keep=false | |
| should_keep_named_version "$bundle_name" && keep=true | |
| if $keep; then | |
| log_skip "Keeping runtime bundle: ${bundle_name}" | |
| else | |
| unmount_image "$runtime_bundle" | |
| sudo_remove_path "$runtime_bundle" "Runtime bundle: ${bundle_name}" | |
| fi | |
| done | |
| fi | |
| IMAGES_DIR="/Library/Developer/CoreSimulator/Images" | |
| if [[ -d "$IMAGES_DIR" ]]; then | |
| log_head "Cleaning simulator disk images in ${IMAGES_DIR}" | |
| for img in "$IMAGES_DIR"/*; do | |
| [[ -e "$img" ]] || continue | |
| img_name=$(basename "$img") | |
| if is_system_image_metadata_entry "$img_name"; then | |
| $VERBOSE && log_skip "Skipping simulator image metadata entry: ${img_name}" | |
| continue | |
| fi | |
| keep=false | |
| should_keep_named_version "$img_name" && keep=true | |
| if $keep; then | |
| log_skip "Keeping image: ${img_name}" | |
| else | |
| unmount_image "$img" | |
| sudo_remove_path "$img" "Simulator image: ${img_name}" | |
| fi | |
| done | |
| fi | |
| CRYPTEX_DIR="/Library/Developer/CoreSimulator/Cryptex/Images" | |
| if [[ -d "$CRYPTEX_DIR" ]]; then | |
| log_head "Cleaning cryptex runtime images in ${CRYPTEX_DIR}" | |
| for img in "$CRYPTEX_DIR"/*; do | |
| [[ -e "$img" ]] || continue | |
| img_name=$(basename "$img") | |
| if is_system_image_metadata_entry "$img_name"; then | |
| $VERBOSE && log_skip "Skipping cryptex metadata entry: ${img_name}" | |
| continue | |
| fi | |
| keep=false | |
| if should_keep_named_version "$img_name"; then | |
| keep=true | |
| elif ! parse_named_platform_version "$img_name" >/dev/null && ! $NUKE_EVERYTHING; then | |
| keep=true | |
| log_warn "Could not determine version for: ${img_name} (keeping)" | |
| fi | |
| if $keep; then | |
| log_skip "Keeping cryptex image: ${img_name}" | |
| else | |
| unmount_image "$img" | |
| sudo_remove_path "$img" "Cryptex image: ${img_name}" | |
| fi | |
| done | |
| fi | |
| fi | |
| # ---- 5. Remove old simulator devices that use unwanted runtimes ------------ | |
| if begin_phase "non-kept-runtime-devices" "Removing simulator devices on non-kept runtimes"; then | |
| if xcrun simctl list devices --json &>/dev/null; then | |
| while IFS= read -r runtime_key; do | |
| keep=false | |
| should_keep_runtime_key "$runtime_key" && keep=true | |
| if ! $keep; then | |
| while IFS= read -r udid; do | |
| [[ -z "$udid" ]] && continue | |
| device_name=$(xcrun simctl list devices --json 2>/dev/null | \ | |
| python3 -c " | |
| import sys, json | |
| runtime_key, target_udid = sys.argv[1], sys.argv[2] | |
| data = json.load(sys.stdin) | |
| for dev in data.get('devices', {}).get(runtime_key, []): | |
| if dev['udid'] == target_udid: | |
| print(dev.get('name', 'Unknown')) | |
| break | |
| " "$runtime_key" "$udid" 2>/dev/null || echo "Unknown") | |
| run_cmd "Deleting device $device_name ($udid)" xcrun simctl delete "$udid" | |
| done < <(xcrun simctl list devices --json 2>/dev/null | \ | |
| python3 -c " | |
| import sys, json | |
| runtime_key = sys.argv[1] | |
| data = json.load(sys.stdin) | |
| for dev in data.get('devices', {}).get(runtime_key, []): | |
| print(dev['udid']) | |
| " "$runtime_key" 2>/dev/null) | |
| else | |
| $VERBOSE && log_skip "Keeping devices on runtime: ${runtime_key}" | |
| fi | |
| done < <(xcrun simctl list devices --json 2>/dev/null | \ | |
| python3 -c " | |
| import sys, json | |
| data = json.load(sys.stdin) | |
| for key in data.get('devices', {}): | |
| print(key) | |
| " 2>/dev/null) | |
| fi | |
| fi | |
| # ---- 6. Trim simulator devices (or to --perferred-models when set) --------- | |
| if begin_phase "device-model-trim" "Trimming simulator devices to preferred models"; then | |
| if $NUKE_EVERYTHING; then | |
| run_cmd "Deleting all simulator devices" xcrun simctl delete all | |
| else | |
| if [[ -n "$PREFERRED_MODELS_CSV" ]]; then | |
| log "Removing simulators not in --perferred-models from kept runtimes" | |
| else | |
| log "Removing non-iPhone/Apple Watch simulators from kept runtimes" | |
| fi | |
| # simctl outputs: " DeviceName (UDID) (State)" | |
| device_re='^[[:space:]]+(.+)[[:space:]]+\(([0-9A-F-]{36})\)[[:space:]]+\(([^)]+)\)' | |
| while IFS= read -r line; do | |
| if [[ "$line" =~ $device_re ]]; then | |
| name=$(trim_whitespace "${BASH_REMATCH[1]}") | |
| udid="${BASH_REMATCH[2]}" | |
| keep_device=false | |
| if [[ -n "$PREFERRED_MODELS_CSV" ]]; then | |
| model_is_preferred "$name" && keep_device=true | |
| elif [[ "$name" == iPhone* ]] || [[ "$name" == Apple\ Watch* ]]; then | |
| keep_device=true | |
| fi | |
| if $keep_device; then | |
| $VERBOSE && log_skip "Keeping simulator: ${name}" | |
| continue | |
| fi | |
| run_cmd "Deleting non-target simulator: ${name}" xcrun simctl delete "$udid" | |
| fi | |
| done < <(xcrun simctl list devices available 2>/dev/null) | |
| if [[ -n "$PREFERRED_MODELS_CSV" ]]; then | |
| log "Ensuring preferred simulator models exist on targeted versions" | |
| IFS=',' read -r -a preferred_models <<< "$PREFERRED_MODELS_CSV" | |
| for model in "${preferred_models[@]}"; do | |
| model_info=$(resolve_preferred_model_devicetype "$model") | |
| [[ -z "$model_info" ]] && continue | |
| devicetype_id="${model_info%%|*}" | |
| model_platform="${model_info##*|}" | |
| if model_has_device_on_kept_versions "$model" "$model_platform"; then | |
| $VERBOSE && log_skip "Simulator already exists on kept versions: ${model}" | |
| continue | |
| fi | |
| target_version=$(highest_targeted_version "$model_platform") | |
| target_runtime_id=$(runtime_id_for_platform_version "$model_platform" "$target_version") | |
| if [[ -z "$target_runtime_id" ]]; then | |
| log_warn "No installed ${model_platform} runtime matches target ${target_version} for ${model}" | |
| continue | |
| fi | |
| run_cmd "Creating simulator ${model} (${target_version})" \ | |
| xcrun simctl create "$model" "$devicetype_id" "$target_runtime_id" | |
| done | |
| fi | |
| fi | |
| fi | |
| # ---- 7. iOS DeviceSupport (physical device debug symbols) ------------------ | |
| if begin_phase "ios-device-support" "Cleaning ~/Library/Developer/Xcode/iOS DeviceSupport"; then | |
| IOS_DS_DIR="$HOME/Library/Developer/Xcode/iOS DeviceSupport" | |
| if [[ -d "$IOS_DS_DIR" ]]; then | |
| clean_device_support_dir "ios" "$IOS_DS_DIR" "iOS DeviceSupport" | |
| else | |
| log "Directory not found: ${IOS_DS_DIR}" | |
| fi | |
| fi | |
| # ---- 8. watchOS DeviceSupport --------------------------------------------- | |
| if begin_phase "watchos-device-support" "Cleaning ~/Library/Developer/Xcode/watchOS DeviceSupport"; then | |
| WATCHOS_DS_DIR="$HOME/Library/Developer/Xcode/watchOS DeviceSupport" | |
| if [[ -d "$WATCHOS_DS_DIR" ]]; then | |
| clean_device_support_dir "watchos" "$WATCHOS_DS_DIR" "watchOS DeviceSupport" | |
| else | |
| log "Directory not found: ${WATCHOS_DS_DIR}" | |
| fi | |
| fi | |
| # ---- 9. DerivedData ------------------------------------------------------- | |
| if begin_phase "derived-data" "Cleaning ~/Library/Developer/Xcode/DerivedData"; then | |
| DERIVED_DATA="$HOME/Library/Developer/Xcode/DerivedData" | |
| if [[ -d "$DERIVED_DATA" ]]; then | |
| for entry in "$DERIVED_DATA"/*/; do | |
| [[ -d "$entry" ]] || continue | |
| folder_name=$(basename "$entry") | |
| if [[ "$folder_name" == "ModuleCache.noindex" ]]; then | |
| remove_path "$entry" "DerivedData/ModuleCache.noindex" | |
| else | |
| remove_path "$entry" "DerivedData: ${folder_name}" | |
| fi | |
| done | |
| else | |
| log "Directory not found: ${DERIVED_DATA}" | |
| fi | |
| fi | |
| # ---- 10. Archives ---------------------------------------------------------- | |
| if begin_phase "archives" "Cleaning ~/Library/Developer/Xcode/Archives"; then | |
| ARCHIVES_DIR="$HOME/Library/Developer/Xcode/Archives" | |
| if [[ -d "$ARCHIVES_DIR" ]]; then | |
| remove_path "$ARCHIVES_DIR" "Xcode Archives" | |
| if ! $DRY_RUN; then | |
| mkdir -p "$ARCHIVES_DIR" | |
| fi | |
| else | |
| log "Directory not found: ${ARCHIVES_DIR}" | |
| fi | |
| fi | |
| # ---- 11. iOS Device Logs --------------------------------------------------- | |
| if begin_phase "ios-device-logs" "Cleaning ~/Library/Developer/Xcode/iOS Device Logs"; then | |
| IOS_LOGS="$HOME/Library/Developer/Xcode/iOS Device Logs" | |
| if [[ -d "$IOS_LOGS" ]]; then | |
| remove_path "$IOS_LOGS" "iOS Device Logs" | |
| else | |
| log "Directory not found: ${IOS_LOGS}" | |
| fi | |
| fi | |
| # ---- 12. CoreSimulator caches and logs ------------------------------------- | |
| if begin_phase "core-simulator-cache-logs" "Cleaning CoreSimulator caches and logs"; then | |
| CS_CACHES="$HOME/Library/Developer/CoreSimulator/Caches" | |
| if [[ -d "$CS_CACHES" ]]; then | |
| remove_path "$CS_CACHES" "CoreSimulator/Caches" | |
| fi | |
| CS_LOGS="$HOME/Library/Logs/CoreSimulator" | |
| if [[ -d "$CS_LOGS" ]]; then | |
| remove_path "$CS_LOGS" "Logs/CoreSimulator" | |
| fi | |
| fi | |
| # ---- 13. XCTestDevices dead data ------------------------------------------- | |
| if begin_phase "xctestdevices-dead" "Cleaning XCTestDevices data"; then | |
| XCTEST_DIR="$HOME/Library/Developer/XCTestDevices" | |
| if [[ -d "$XCTEST_DIR" ]]; then | |
| for dev_dir in "$XCTEST_DIR"/*/; do | |
| [[ -d "$dev_dir" ]] || continue | |
| remove_path "$dev_dir" "XCTestDevices/$(basename "$dev_dir")" | |
| done | |
| else | |
| log "Directory not found: ${XCTEST_DIR}" | |
| fi | |
| fi | |
| # ---- 14. Xcode caches ------------------------------------------------------ | |
| if begin_phase "xcode-caches" "Cleaning Xcode caches"; then | |
| XCODE_CACHE="$HOME/Library/Caches/com.apple.dt.Xcode" | |
| if [[ -d "$XCODE_CACHE" ]]; then | |
| remove_path "$XCODE_CACHE" "Caches/com.apple.dt.Xcode" | |
| fi | |
| fi | |
| # ---- 15. Playground device data -------------------------------------------- | |
| if begin_phase "playground-data" "Cleaning Xcode Playground data"; then | |
| XCPG_DEVICES="$HOME/Library/Developer/XCPGDevices" | |
| if [[ -d "$XCPG_DEVICES" ]]; then | |
| remove_path "$XCPG_DEVICES" "XCPGDevices (Playground data)" | |
| fi | |
| fi | |
| # ---- 16. Xcode Previews (SwiftUI) ----------------------------------------- | |
| if begin_phase "swiftui-preview" "Cleaning SwiftUI Preview simulators"; then | |
| PREVIEWS_DIR="$HOME/Library/Developer/Xcode/UserData/Previews" | |
| if [[ -d "$PREVIEWS_DIR" ]]; then | |
| remove_path "$PREVIEWS_DIR" "UserData/Previews (SwiftUI)" | |
| fi | |
| fi | |
| # ---- 17. SPM caches -------------------------------------------------------- | |
| if begin_phase "spm-caches" "Cleaning Swift Package Manager caches"; then | |
| SPM_CACHE="$HOME/Library/Caches/org.swift.swiftpm" | |
| if [[ -d "$SPM_CACHE" ]]; then | |
| remove_path "$SPM_CACHE" "SPM cache" | |
| fi | |
| SPM_LIB="$HOME/Library/org.swift.swiftpm" | |
| if [[ -d "$SPM_LIB" ]]; then | |
| remove_path "$SPM_LIB" "SPM library data" | |
| fi | |
| fi | |
| # ---- 18. Summary ----------------------------------------------------------- | |
| log_head "Summary" | |
| total_human=$(bytes_to_human "$TOTAL_FREED") | |
| if $DRY_RUN; then | |
| echo -e "${YELLOW}Estimated space to reclaim: ${BOLD}${total_human}${NC}" | |
| echo -e "${YELLOW}Run with --execute to actually delete files.${NC}" | |
| else | |
| echo -e "${GREEN}Total space reclaimed: ${BOLD}${total_human}${NC}" | |
| fi | |
| # Show what's left | |
| echo "" | |
| log "Remaining simulators:" | |
| xcrun simctl list devices available 2>/dev/null | head -30 || true | |
| echo "" | |
| log "Remaining runtimes:" | |
| xcrun simctl runtime list 2>/dev/null | head -10 || \ | |
| xcrun simctl list runtimes 2>/dev/null | head -10 || true | |
| echo "" | |
| echo -e "${GREEN}Done.${NC}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment