Skip to content

Instantly share code, notes, and snippets.

@anvanvan
Created May 31, 2025 01:01
Show Gist options
  • Select an option

  • Save anvanvan/15342b3f37cba5744704a065be56bfbf to your computer and use it in GitHub Desktop.

Select an option

Save anvanvan/15342b3f37cba5744704a065be56bfbf to your computer and use it in GitHub Desktop.
Auto Time Machine Backup Script - Event-driven macOS backup automation
#!/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