Created
May 31, 2025 01:01
-
-
Save anvanvan/15342b3f37cba5744704a065be56bfbf to your computer and use it in GitHub Desktop.
Auto Time Machine Backup Script - Event-driven macOS backup automation
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
| #!/bin/bash | |
| #=============================================================================== | |
| # AUTO TIME MACHINE BACKUP SCRIPT | |
| #=============================================================================== | |
| # | |
| # DESCRIPTION: | |
| # Automatically detects when an external SSD is connected, mounts the Time | |
| # Machine sparsebundle, runs a backup, and safely ejects both volumes when | |
| # complete. Uses event-driven architecture (no polling) for better power | |
| # management and system responsiveness. | |
| # | |
| # FEATURES: | |
| # • Event-driven detection (no background polling) | |
| # • Automatic stale mount cleanup | |
| # • Smart backup completion detection (handles both quick and long backups) | |
| # • Multi-strategy volume ejection with retries | |
| # • Comprehensive logging and notifications | |
| # • Power management awareness | |
| # • Prevents multiple instances with lock files | |
| # | |
| # REQUIREMENTS: | |
| # • macOS with Time Machine enabled | |
| # • External SSD with Time Machine sparsebundle | |
| # • Desktop notifications enabled | |
| # • No Full Disk Access required (works without it) | |
| # | |
| # INSTALLATION: | |
| # 1. Download this script to a directory (e.g., ~/tools/bin/) | |
| # 2. Make it executable: chmod +x auto_timemachine.sh | |
| # 3. Edit configuration variables below to match your setup | |
| # 4. Create launch agent: cp com.user.auto-timemachine.plist ~/Library/LaunchAgents/ | |
| # 5. Load launch agent: launchctl load ~/Library/LaunchAgents/com.user.auto-timemachine.plist | |
| # | |
| # LAUNCH AGENT PLIST (save as ~/Library/LaunchAgents/com.user.auto-timemachine.plist): | |
| # <?xml version="1.0" encoding="UTF-8"?> | |
| # <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| # <plist version="1.0"> | |
| # <dict> | |
| # <key>Label</key> | |
| # <string>com.user.auto-timemachine</string> | |
| # <key>ProgramArguments</key> | |
| # <array> | |
| # <string>/bin/bash</string> | |
| # <string>/PATH/TO/YOUR/auto_timemachine.sh</string> | |
| # </array> | |
| # <key>StartOnMount</key> | |
| # <true/> | |
| # <key>WatchPaths</key> | |
| # <array> | |
| # <string>/Volumes</string> | |
| # </array> | |
| # <key>KeepAlive</key> | |
| # <false/> | |
| # <key>ThrottleInterval</key> | |
| # <integer>30</integer> | |
| # </dict> | |
| # </plist> | |
| # | |
| # USAGE: | |
| # • Manual run: ./auto_timemachine.sh | |
| # • Automatic: Install launch agent (runs when volumes mount) | |
| # • Monitor logs: tail -f ~/Library/Logs/auto_timemachine.log | |
| # • Check status: launchctl list | grep auto-timemachine | |
| # | |
| # TROUBLESHOOTING: | |
| # • If backup doesn't start: Check sparsebundle path and permissions | |
| # • If volumes don't eject: Manual eject may be needed for stubborn volumes | |
| # • If script runs repeatedly: Check ThrottleInterval in launch agent | |
| # • View detailed logs at ~/Library/Logs/auto_timemachine.log | |
| # | |
| # AUTHOR: Auto Time Machine Script | |
| # VERSION: 2.0 | |
| # LICENSE: MIT | |
| #=============================================================================== | |
| #=============================================================================== | |
| # CONFIGURATION SECTION | |
| # Edit these variables to match your setup | |
| #=============================================================================== | |
| # Path to your external SSD mount point | |
| SSD_VOLUME="/Volumes/SSD" | |
| # Path to your Time Machine sparsebundle on the SSD | |
| SPARSEBUNDLE_PATH="$SSD_VOLUME/Time Machine.sparsebundle" | |
| # Log file location | |
| LOG_FILE="$HOME/Library/Logs/auto_timemachine.log" | |
| # Lock file to prevent multiple instances | |
| LOCK_FILE="/tmp/auto_timemachine.lock" | |
| # Minimum battery percentage to run backup (when on battery power) | |
| MIN_BATTERY_PERCENT=20 | |
| #=============================================================================== | |
| # EXIT CODES | |
| #=============================================================================== | |
| EXIT_SUCCESS=0 | |
| EXIT_NO_SSD=1 | |
| EXIT_LOW_BATTERY=2 | |
| EXIT_ALREADY_RUNNING=3 | |
| EXIT_MOUNT_FAILED=4 | |
| EXIT_BACKUP_FAILED=5 | |
| #=============================================================================== | |
| # GLOBAL VARIABLES | |
| #=============================================================================== | |
| CAFFEINATE_PID="" # Process ID of caffeinate to prevent sleep during backup | |
| #=============================================================================== | |
| # SIGNAL HANDLING AND CLEANUP | |
| #=============================================================================== | |
| # Graceful cleanup function called on script termination | |
| cleanup() { | |
| log "Received signal, cleaning up..." | |
| rm -f "$LOCK_FILE" | |
| # Kill any background caffeinate process | |
| if [ -n "$CAFFEINATE_PID" ]; then | |
| kill $CAFFEINATE_PID 2>/dev/null | |
| fi | |
| exit 0 | |
| } | |
| # Set up signal traps for graceful shutdown | |
| trap cleanup SIGTERM SIGINT SIGHUP | |
| #=============================================================================== | |
| # UTILITY FUNCTIONS | |
| #=============================================================================== | |
| # Logging function with timestamps | |
| log() { | |
| local message="$1" | |
| echo "[$(date '+%Y-%m-%d %H:%M:%S')] $message" | tee -a "$LOG_FILE" | |
| } | |
| # Send macOS notification and speak message | |
| notify() { | |
| local title="$1" | |
| local message="$2" | |
| osascript -e "display notification \"$message\" with title \"$title\"" 2>/dev/null | |
| log "Notification: $title - $message" | |
| } | |
| #=============================================================================== | |
| # SYSTEM CHECKS AND VALIDATION | |
| #=============================================================================== | |
| # Check if another instance is already running | |
| check_lock() { | |
| if [ -f "$LOCK_FILE" ]; then | |
| local pid=$(cat "$LOCK_FILE" 2>/dev/null) | |
| if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then | |
| log "Another instance already running (PID: $pid)" | |
| exit $EXIT_ALREADY_RUNNING | |
| fi | |
| rm -f "$LOCK_FILE" | |
| fi | |
| echo $$ > "$LOCK_FILE" | |
| } | |
| # Check power state and battery level | |
| check_power_state() { | |
| # Check if on battery and battery level | |
| local power_source=$(pmset -g ps | head -1) | |
| if echo "$power_source" | grep -q "Battery Power"; then | |
| local battery_percent=$(pmset -g batt | grep -o '[0-9]*%' | tr -d '%') | |
| if [ "$battery_percent" -lt "$MIN_BATTERY_PERCENT" ]; then | |
| log "On battery with only $battery_percent% charge, skipping backup" | |
| exit $EXIT_LOW_BATTERY | |
| fi | |
| log "On battery power ($battery_percent%) but sufficient for backup" | |
| else | |
| log "On AC power, proceeding with backup" | |
| fi | |
| } | |
| # Quick check if target SSD and sparsebundle exist | |
| detect_target_ssd() { | |
| if [ ! -d "$SSD_VOLUME" ]; then | |
| log "Target SSD not found at $SSD_VOLUME" | |
| exit $EXIT_NO_SSD | |
| fi | |
| if [ ! -d "$SPARSEBUNDLE_PATH" ]; then | |
| log "Time Machine sparsebundle not found at $SPARSEBUNDLE_PATH" | |
| exit $EXIT_NO_SSD | |
| fi | |
| log "Target SSD detected with Time Machine sparsebundle" | |
| return 0 | |
| } | |
| #=============================================================================== | |
| # STALE MOUNT CLEANUP | |
| #=============================================================================== | |
| # Clean up any stale Time Machine sparsebundle mounts from previous runs | |
| cleanup_stale_mounts() { | |
| log "Checking for stale Time Machine mounts..." | |
| # Find any existing mount of our sparsebundle | |
| local stale_device=$(hdiutil info | awk ' | |
| /'"$(basename "$SPARSEBUNDLE_PATH")"'/ { found=1; next } | |
| found && /^\/dev\/disk[0-9]+/ { print $1; found=0; exit } | |
| ') | |
| if [ -n "$stale_device" ]; then | |
| log "Found stale mount at $stale_device, force detaching..." | |
| # Try multiple strategies to unmount | |
| local unmount_success=false | |
| # Strategy 1: Standard force detach | |
| if hdiutil detach "$stale_device" -force >/dev/null 2>&1; then | |
| log "Successfully detached with standard force" | |
| unmount_success=true | |
| else | |
| log "Standard force detach failed, trying aggressive approach..." | |
| # Strategy 2: Unmount all related volumes and partitions | |
| log "Attempting to unmount all related volumes..." | |
| # Get all child disks/partitions | |
| local related_disks=$(diskutil list "$stale_device" 2>/dev/null | grep "^[[:space:]]*[0-9]" | awk '{print $NF}') | |
| if [ -n "$related_disks" ]; then | |
| echo "$related_disks" | while read -r disk; do | |
| if [ -n "$disk" ]; then | |
| log "Force unmounting disk: $disk" | |
| diskutil unmount force "$disk" >/dev/null 2>&1 | |
| fi | |
| done | |
| sleep 2 | |
| fi | |
| # Also try to unmount any mounted volumes | |
| local mount_points=$(mount | grep "$stale_device" | awk '{print $3}') | |
| if [ -n "$mount_points" ]; then | |
| echo "$mount_points" | while read -r mount_point; do | |
| if [ -n "$mount_point" ]; then | |
| log "Force unmounting mount point: $mount_point" | |
| umount -f "$mount_point" >/dev/null 2>&1 | |
| fi | |
| done | |
| sleep 1 | |
| fi | |
| # Strategy 3: Kill the mounting process and any processes using the disk | |
| log "Checking for mounting process and processes using the disk..." | |
| # Get the mounting process ID from hdiutil info | |
| local mount_pid=$(hdiutil info | awk ' | |
| /'"$(basename "$SPARSEBUNDLE_PATH")"'/ { found=1; next } | |
| found && /process ID/ { gsub(/[^0-9]/, "", $NF); print $NF; found=0; exit } | |
| ') | |
| if [ -n "$mount_pid" ] && [ "$mount_pid" != "0" ]; then | |
| log "Killing mounting process: $mount_pid" | |
| kill -9 "$mount_pid" >/dev/null 2>&1 | |
| sleep 2 # Give more time for cleanup | |
| # Check if killing the process was enough | |
| local still_mounted=$(hdiutil info | awk ' | |
| /'"$(basename "$SPARSEBUNDLE_PATH")"'/ { found=1; next } | |
| found && /^\/dev\/disk[0-9]+/ { print $1; found=0; exit } | |
| ') | |
| if [ -z "$still_mounted" ]; then | |
| log "Successfully unmounted by killing mounting process" | |
| unmount_success=true | |
| fi | |
| fi | |
| # Only continue with more aggressive methods if still mounted | |
| if [ "$unmount_success" != "true" ]; then | |
| # Also check for any other processes using the disk | |
| local using_processes=$(lsof "$stale_device"* 2>/dev/null | awk 'NR>1 {print $2}' | sort -u) | |
| if [ -n "$using_processes" ]; then | |
| echo "$using_processes" | while read -r pid; do | |
| if [ -n "$pid" ] && [ "$pid" != "$mount_pid" ]; then | |
| log "Killing process $pid that's using the disk" | |
| kill -9 "$pid" >/dev/null 2>&1 | |
| fi | |
| done | |
| sleep 2 | |
| # Check again after killing other processes | |
| still_mounted=$(hdiutil info | awk ' | |
| /'"$(basename "$SPARSEBUNDLE_PATH")"'/ { found=1; next } | |
| found && /^\/dev\/disk[0-9]+/ { print $1; found=0; exit } | |
| ') | |
| if [ -z "$still_mounted" ]; then | |
| log "Successfully unmounted by killing additional processes" | |
| unmount_success=true | |
| fi | |
| fi | |
| fi | |
| # Strategy 4: Final force detach attempt (only if still mounted) | |
| if [ "$unmount_success" != "true" ]; then | |
| if hdiutil detach "$stale_device" -force -quiet >/dev/null 2>&1; then | |
| log "Successfully detached with final force attempt" | |
| unmount_success=true | |
| else | |
| # Last resort: try detaching by image path | |
| log "Final attempt: detaching by image path..." | |
| if hdiutil detach "$SPARSEBUNDLE_PATH" -force -quiet >/dev/null 2>&1; then | |
| log "Successfully detached by image path" | |
| unmount_success=true | |
| else | |
| # Final verification - maybe it actually worked despite error codes | |
| local final_check=$(hdiutil info | awk ' | |
| /'"$(basename "$SPARSEBUNDLE_PATH")"'/ { found=1; next } | |
| found && /^\/dev\/disk[0-9]+/ { print $1; found=0; exit } | |
| ') | |
| if [ -z "$final_check" ]; then | |
| log "Unmount actually succeeded despite command failures" | |
| unmount_success=true | |
| else | |
| log "All detach strategies failed - this may require manual intervention" | |
| return 1 | |
| fi | |
| fi | |
| fi | |
| fi | |
| fi | |
| if [ "$unmount_success" = "true" ]; then | |
| sleep 3 # Give system more time to clean up | |
| log "Stale mount cleanup completed successfully" | |
| fi | |
| else | |
| log "No stale mounts found" | |
| fi | |
| return 0 | |
| } | |
| #=============================================================================== | |
| # SPARSEBUNDLE MOUNTING | |
| #=============================================================================== | |
| # Mount the Time Machine sparsebundle with proper error handling and cleanup | |
| mount_sparsebundle() { | |
| log "Mounting Time Machine sparsebundle..." | |
| # Clean up any stale mounts first | |
| if ! cleanup_stale_mounts; then | |
| log "Failed to cleanup stale mounts, cannot proceed" | |
| return 1 | |
| fi | |
| # Verify no stale mounts remain | |
| local remaining_device=$(hdiutil info | awk ' | |
| /'"$(basename "$SPARSEBUNDLE_PATH")"'/ { found=1; next } | |
| found && /^\/dev\/disk[0-9]+/ { print $1; found=0; exit } | |
| ') | |
| if [ -n "$remaining_device" ]; then | |
| log "ERROR: Stale mount still exists at $remaining_device after cleanup" | |
| return 1 | |
| fi | |
| # Mount the sparsebundle with timeout protection | |
| local mount_output | |
| local tmpfile="/tmp/mount_output_$$" | |
| # Start mount in background and monitor (read-write for Time Machine) | |
| (hdiutil attach "$SPARSEBUNDLE_PATH" -noverify -readwrite 2>&1 > "$tmpfile") & | |
| local mount_pid=$! | |
| # Wait up to 60 seconds for mount to complete | |
| local count=0 | |
| while [ $count -lt 60 ] && kill -0 $mount_pid 2>/dev/null; do | |
| sleep 1 | |
| count=$((count + 1)) | |
| done | |
| # Kill if still running (timeout) | |
| if kill -0 $mount_pid 2>/dev/null; then | |
| kill $mount_pid 2>/dev/null | |
| rm -f "$tmpfile" | |
| log "Mount operation timed out after 60 seconds" | |
| return 1 | |
| fi | |
| # Check mount result | |
| if [ -f "$tmpfile" ]; then | |
| mount_output=$(cat "$tmpfile") | |
| rm -f "$tmpfile" | |
| local device=$(echo "$mount_output" | grep "/dev/disk" | tail -1 | awk '{print $1}') | |
| if [ -n "$device" ]; then | |
| log "Sparsebundle mounted successfully: $device" | |
| echo "$device" | |
| return 0 | |
| fi | |
| fi | |
| log "Failed to mount sparsebundle: $mount_output" | |
| return 1 | |
| } | |
| #=============================================================================== | |
| # TIME MACHINE BACKUP EXECUTION | |
| #=============================================================================== | |
| # Run Time Machine backup with intelligent monitoring and completion detection | |
| run_backup() { | |
| log "Starting Time Machine backup with power management..." | |
| # Prevent sleep during backup | |
| caffeinate -d -i & | |
| CAFFEINATE_PID=$! | |
| # Check current Time Machine status before starting | |
| local initial_status=$(tmutil status 2>/dev/null) | |
| log "Initial Time Machine status:" | |
| echo "$initial_status" >> "$LOG_FILE" | |
| # Start backup | |
| log "Initiating Time Machine backup..." | |
| if ! tmutil startbackup -b 2>/dev/null; then | |
| log "Failed to start Time Machine backup command" | |
| kill $CAFFEINATE_PID 2>/dev/null | |
| return 1 | |
| fi | |
| # Wait for backup to start OR detect if it already completed | |
| log "Checking backup status..." | |
| local start_wait=0 | |
| local backup_started=false | |
| local backup_completed=false | |
| while [ $start_wait -lt 60 ]; do | |
| local current_status=$(tmutil status 2>/dev/null) | |
| # Check if backup is currently running | |
| if echo "$current_status" | grep -q "Running = 1"; then | |
| backup_started=true | |
| log "Backup is currently running" | |
| break | |
| fi | |
| # Check if backup just completed (not running but recent activity) | |
| local backup_phase=$(echo "$current_status" | grep "BackupPhase" | awk -F'= ' '{print $2}' | tr -d ' ";') | |
| if [ -n "$backup_phase" ] && [ "$backup_phase" != "MountingBackupVol" ]; then | |
| # If there's a backup phase but not running, it might have just completed | |
| log "Backup appears to have completed quickly (phase: $backup_phase)" | |
| backup_completed=true | |
| break | |
| fi | |
| # Check if tmutil startbackup command completed successfully | |
| # If Running = 0 and Percent = -1, and we just started, it likely completed | |
| local running_status=$(echo "$current_status" | grep "Running" | awk -F'= ' '{print $2}' | tr -d ' ";') | |
| local percent_status=$(echo "$current_status" | grep "Percent" | awk -F'= ' '{print $2}' | tr -d ' ";') | |
| if [ "$running_status" = "0" ] && [ "$percent_status" = "-1" ] && [ $start_wait -gt 10 ]; then | |
| # Been checking for >10 seconds, backup not running, likely completed quickly | |
| log "Backup appears to have completed (Running=0, Percent=-1 after ${start_wait}s)" | |
| backup_completed=true | |
| break | |
| fi | |
| sleep 2 | |
| start_wait=$((start_wait + 2)) | |
| if [ $((start_wait % 10)) -eq 0 ]; then | |
| log "Still checking backup status... (${start_wait}s)" | |
| fi | |
| done | |
| if [ "$backup_completed" = "true" ]; then | |
| log "Backup completed quickly, skipping monitoring" | |
| kill $CAFFEINATE_PID 2>/dev/null | |
| return 0 | |
| elif [ "$backup_started" = "false" ]; then | |
| log "Backup failed to start within 60 seconds" | |
| kill $CAFFEINATE_PID 2>/dev/null | |
| return 1 | |
| fi | |
| # Monitor backup progress with detailed logging | |
| log "Monitoring backup progress..." | |
| local max_wait=7200 # 2 hours max | |
| local elapsed=0 | |
| local check_interval=30 # Check every 30 seconds initially | |
| local last_progress="" | |
| while [ $elapsed -lt $max_wait ]; do | |
| local current_status=$(tmutil status 2>/dev/null) | |
| # Check if still running | |
| if ! echo "$current_status" | grep -q "Running = 1"; then | |
| log "Backup is no longer running" | |
| break | |
| fi | |
| # Log progress information | |
| local progress=$(echo "$current_status" | grep -E "(BackupPhase|Percent)" | tr '\n' ' ') | |
| if [ -n "$progress" ] && [ "$progress" != "$last_progress" ]; then | |
| log "Backup progress: $progress" | |
| last_progress="$progress" | |
| fi | |
| # Increase check interval after first few minutes | |
| if [ $elapsed -gt 300 ]; then | |
| check_interval=120 # Check every 2 minutes after 5 minutes | |
| fi | |
| sleep $check_interval | |
| elapsed=$((elapsed + check_interval)) | |
| # Log periodic status | |
| if [ $((elapsed % 600)) -eq 0 ]; then # Every 10 minutes | |
| log "Backup still running... (${elapsed}s elapsed)" | |
| fi | |
| done | |
| # Stop caffeinate | |
| kill $CAFFEINATE_PID 2>/dev/null | |
| # Get final status and verify success | |
| local final_status=$(tmutil status 2>/dev/null) | |
| log "Final backup status:" | |
| echo "$final_status" >> "$LOG_FILE" | |
| # Check for errors in final status | |
| if echo "$final_status" | grep -qi "error"; then | |
| log "Backup completed with errors" | |
| return 1 | |
| fi | |
| # Verify backup completion (can't use tmutil latestbackup without Full Disk Access) | |
| if echo "$final_status" | grep -q "Running = 0"; then | |
| log "Backup process completed successfully" | |
| return 0 | |
| else | |
| log "Backup process may not have completed properly" | |
| return 1 | |
| fi | |
| } | |
| #=============================================================================== | |
| # VOLUME EJECTION | |
| #=============================================================================== | |
| # Clean ejection with verification and multiple strategies | |
| safe_eject() { | |
| local device="$1" | |
| local ejection_success=true | |
| log "Safely ejecting volumes..." | |
| # Eject Time Machine volume first | |
| if [ -n "$device" ]; then | |
| log "Ejecting Time Machine volume at $device..." | |
| # First, kill any processes holding the sparsebundle open | |
| local holding_processes=$(lsof "$SPARSEBUNDLE_PATH" 2>/dev/null | awk 'NR>1 {print $2}' | sort -u) | |
| if [ -n "$holding_processes" ]; then | |
| echo "$holding_processes" | while read -r pid; do | |
| if [ -n "$pid" ]; then | |
| log "Killing process $pid holding sparsebundle open" | |
| kill -9 "$pid" 2>/dev/null | |
| fi | |
| done | |
| sleep 3 # Give more time for processes to die | |
| fi | |
| # Also check for any processes using the mount point | |
| if [ -d "/Volumes/Time Machine" ]; then | |
| local mount_processes=$(lsof "/Volumes/Time Machine" 2>/dev/null | awk 'NR>1 {print $2}' | sort -u) | |
| if [ -n "$mount_processes" ]; then | |
| echo "$mount_processes" | while read -r pid; do | |
| if [ -n "$pid" ]; then | |
| log "Killing process $pid using Time Machine mount point" | |
| kill -9 "$pid" 2>/dev/null | |
| fi | |
| done | |
| sleep 2 | |
| fi | |
| fi | |
| local tm_eject_attempts=0 | |
| local tm_ejected=false | |
| # Use force detach strategies only (skip standard eject) | |
| while [ $tm_eject_attempts -lt 2 ] && [ "$tm_ejected" = "false" ]; do | |
| tm_eject_attempts=$((tm_eject_attempts + 1)) | |
| log "Time Machine ejection attempt $tm_eject_attempts of 2" | |
| # Strategy 1: Force detach | |
| if [ $tm_eject_attempts -eq 1 ]; then | |
| log "Strategy 1: Force detach Time Machine volume" | |
| hdiutil detach "$device" -force 2>/dev/null | |
| # Strategy 2: Force detach with quiet option | |
| elif [ $tm_eject_attempts -eq 2 ]; then | |
| log "Strategy 2: Force detach Time Machine volume (quiet)" | |
| hdiutil detach "$device" -force -quiet 2>/dev/null | |
| fi | |
| # Verify ejection by checking if device still exists | |
| if ! diskutil info "$device" >/dev/null 2>&1; then | |
| log "Time Machine volume ejected successfully with strategy $tm_eject_attempts" | |
| tm_ejected=true | |
| break | |
| else | |
| if [ $tm_eject_attempts -lt 2 ]; then | |
| log "Time Machine volume still attached, waiting before retry..." | |
| sleep 2 | |
| fi | |
| fi | |
| done | |
| if [ "$tm_ejected" = "false" ]; then | |
| log "ERROR: Failed to eject Time Machine volume after 2 attempts" | |
| ejection_success=false | |
| fi | |
| fi | |
| # Give system time between ejections | |
| sleep 2 | |
| # Eject SSD using the proven multi-strategy approach | |
| if [ -d "$SSD_VOLUME" ]; then | |
| log "Ejecting SSD at $SSD_VOLUME..." | |
| local ssd_eject_attempts=0 | |
| local ssd_ejected=false | |
| # Use force eject strategies only (skip standard eject) | |
| while [ $ssd_eject_attempts -lt 2 ] && [ "$ssd_ejected" = "false" ]; do | |
| ssd_eject_attempts=$((ssd_eject_attempts + 1)) | |
| log "SSD ejection attempt $ssd_eject_attempts of 2" | |
| # Strategy 1: Force eject | |
| if [ $ssd_eject_attempts -eq 1 ]; then | |
| log "Strategy 1: Force SSD eject" | |
| diskutil eject "$SSD_VOLUME" -force 2>/dev/null | |
| # Strategy 2: Unmount then eject | |
| elif [ $ssd_eject_attempts -eq 2 ]; then | |
| log "Strategy 2: Unmount then eject SSD" | |
| diskutil unmount "$SSD_VOLUME" 2>/dev/null | |
| sleep 1 | |
| diskutil eject "$SSD_VOLUME" 2>/dev/null | |
| fi | |
| # Check if ejection was successful | |
| if [ ! -d "$SSD_VOLUME" ]; then | |
| log "SSD ejection successful with strategy $ssd_eject_attempts" | |
| ssd_ejected=true | |
| break | |
| else | |
| if [ $ssd_eject_attempts -lt 2 ]; then | |
| log "SSD still mounted, waiting before retry..." | |
| sleep 3 | |
| fi | |
| fi | |
| done | |
| if [ "$ssd_ejected" = "false" ]; then | |
| log "ERROR: Failed to eject SSD after 2 attempts" | |
| ejection_success=false | |
| fi | |
| else | |
| log "SSD already unmounted" | |
| fi | |
| # Report specific ejection results | |
| local failed_volumes=() | |
| if [ -n "$device" ] && [ -e "$device" ] 2>/dev/null; then | |
| failed_volumes+=("Time Machine") | |
| fi | |
| if [ -d "$SSD_VOLUME" ]; then | |
| failed_volumes+=("SSD") | |
| fi | |
| if [ ${#failed_volumes[@]} -eq 0 ]; then | |
| log "All volumes ejected successfully" | |
| return 0 | |
| else | |
| local failed_list=$(printf "%s, " "${failed_volumes[@]}") | |
| failed_list=${failed_list%, } # Remove trailing comma | |
| log "Failed to eject: $failed_list" | |
| return 1 | |
| fi | |
| } | |
| #=============================================================================== | |
| # MAIN EXECUTION FUNCTION | |
| #=============================================================================== | |
| # Main execution flow - orchestrates the entire backup process | |
| main() { | |
| log "=== Auto Time Machine triggered ===" | |
| # Single-instance check | |
| check_lock | |
| # Quick exit if not our target SSD | |
| detect_target_ssd | |
| # Power management check | |
| check_power_state | |
| # Mount sparsebundle | |
| local device | |
| if ! device=$(mount_sparsebundle); then | |
| notify "Time Machine" "Failed to mount backup drive" | |
| exit $EXIT_MOUNT_FAILED | |
| fi | |
| # Run backup | |
| if run_backup; then | |
| notify "Time Machine" "✅ Backup completed successfully" | |
| else | |
| notify "Time Machine" "⚠️ Backup encountered issues" | |
| fi | |
| # Cleanup and ejection | |
| if safe_eject "$device"; then | |
| log "All volumes ejected successfully" | |
| else | |
| # Check specifically which volumes are still mounted | |
| local still_mounted=() | |
| if [ -d "$SSD_VOLUME" ]; then | |
| still_mounted+=("SSD") | |
| fi | |
| if [ -d "/Volumes/Time Machine" ]; then | |
| still_mounted+=("Time Machine") | |
| fi | |
| if [ ${#still_mounted[@]} -gt 0 ]; then | |
| local mounted_list=$(printf "%s, " "${still_mounted[@]}") | |
| mounted_list=${mounted_list%, } # Remove trailing comma | |
| log "Warning: $mounted_list still mounted" | |
| notify "Time Machine" "⚠️ Backup completed but $mounted_list volume(s) still mounted" | |
| else | |
| log "Ejection commands failed but volumes appear to be unmounted" | |
| fi | |
| fi | |
| rm -f "$LOCK_FILE" | |
| log "=== Auto Time Machine completed ===" | |
| } | |
| #=============================================================================== | |
| # SCRIPT INITIALIZATION | |
| #=============================================================================== | |
| # Create log directory if it doesn't exist | |
| mkdir -p "$(dirname "$LOG_FILE")" | |
| # Run main function | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment