MACRO: Time Machine Assistant, v1.1




PURPOSE

This macro provides Time Machine information and control.

No configurations is required as all information is retrieved from tmutil and diskutil.

I created Time Machine Assistant for myself and others that I help with Time Machine. For those of us that periodically connect an external backup drive to a MacBook Pro or MacBook Air, it is important to check the status of Time Machine before disconnecting the external drive.

Also, in some cases, it’s nice to run Time Machine immediately before disconnecting the drive.

IMPLEMENTATION NOTE

This macro includes an Execute an AppleScript that contains the script the reports and controls Time Machine. Since all of the macro logic is contained in this script, it can be run like any other AppleScript. I use FastScripts 3 because it provides some nice AppleScript-related enhancements.

TESTED WITH

• Keyboard Maestro 11.0.2
• Sonoma 14.4.1 (23E224)/MacBookPro18,2
• Sonoma 14.4.1 (23E224)/VariableMacBookPro16,1
• Mojave 10.14.16/Macmini6,2
• High Sierra 10.13.6/iMac11,1445

VERSION HISTORY

1.0 - initial version
1.1
a) Modified the method to determine myName so that the name is successfully returned when the script runs within Keyboard Maestro.
b) Updated the Purpose.

The latest version of this macro is available on the Keyboard Maestro Forum.


Download: Time Machine Assistant.kmmacros (27 KB)

Macro-Image


Macro-Notes
  • Macros are always disabled when imported into the Keyboard Maestro Editor.
    • The user must ensure the macro is enabled.
    • The user must also ensure the macro's parent macro-group is enabled.

System Information
  • macOS 14.4.1 (23E224)
  • Keyboard Maestro v11.0.2

Here's the AppleScript that is included in the macro.

( expand / collapse )
-- ············································································································
-- Title            : Time Machine Assistant, v1.1
-- Modified	     : 2024-04-29
-- Author	     : Jim Sauer, [@_jims](https://forum.keyboardmaestro.com/u/_jims/summary)
-- Purpose
-- This script provides Time Machine information and control. 
--
-- No configurations is required as all information is retrieved from tmutil 
-- and diskutil.
--
-- This macro provides Time Machine information and control.
--
-- I created Time Machine Assistant for myself and others that I help 
-- with Time Machine. For those of us that periodically connect an external 
-- backup drive to a MacBook Pro or MacBook Air, it is important to check 
-- the status of Time Machine before disconnecting the external drive.
--
-- Also, in some cases, it’s nice to run Time Machine immediately before 
-- disconnecting the drive.
--
-- Tested With. : Sonoma 14.4.1 (23E224)/MacBookPro18,2
-- ············································································································
-- Version History
-- 1.0 - initial version
-- 1.1 
-- a) Modified the method to determine myName so that the name
--    is successfully returned when the script runs within
--    Keyboard Maestro.
-- b) Updated the Purpose.
--
-- The latest version of this macro is available on the 
-- [Keyboard Maestro Forum](https://forum.keyboardmaestro.com/)
-- ············································································································

set myName to getMyName()

set tmDestinationsInfo to getTmDestinationsInfo()

set tmDestinationsDiskutilInfo to getTmDestinationsDiskutilInfo(tmDestinationsInfo)

set tmStatus to getTmStatus(tmDestinationsInfo, tmDestinationsDiskutilInfo)

set buttonList to {}

if tmStatus's backup_phase is not "" then
	
	if tmStatus's encryption then
		set encryptionStr to "Yes"
	else
		set encryptionStr to "No"
	end if
	
	set dialogResult to display dialog ¬
		"Time Machine Status : " & tmStatus's backup_phase & return & return & ¬
		"Backup Volume : " & tmStatus's label & return & ¬
		"Encryption : " & encryptionStr & return ¬
		with title myName buttons {"Interrupt & Eject", "Wait & Eject", "Cancel"} ¬
		default button {"Cancel"}
	
	if button returned of dialogResult is "Interrupt & Eject" then
		
		do shell script "tmutil stopbackup"
		do shell script "diskutil unmountDisk " & quoted form of tmStatus's mount_point
		display notification "Time Machine backup has been interrupted and " & ¬
			quoted form of tmStatus's label & " has been ejected." with title myName
		
	else if button returned of dialogResult is "Wait & Eject" then
		
		set timeoutLimit to 10
		set startTime to current date
		
		repeat
			set tmStatus to getTmStatus(tmDestinationsInfo, tmDestinationsDiskutilInfo)
			if tmStatus's backup_phase is "" then
				exit repeat
			end if
			delay 1
			if ((current date) - startTime) > timeoutLimit then
				display dialog "Time Machine to " & tmStatus's label & ¬
					" was running. An attempt to stop it failed after " & timeoutLimit & ¬
					" seconds." with title myName buttons {"OK"} default button {"OK"}
				return "Timeout"
			end if
		end repeat
		
		do shell script "diskutil unmountDisk " & quoted form of tmStatus's mount_point
		display notification "Time Machine backup has been interrupted and '" & ¬
			tmStatus's label & "' has been ejected." with title myName
		
		
	end if
	
	return
	
end if

set connected_cnt to 0
set mounted_cnt to 0
set ejected_cnt to 0
set toUserMustReconnect_cnt to 0

set volumeListString to ""

repeat with volume in tmDestinationsDiskutilInfo
	
	set mountedStatus to ""
	
	if volume's connected then
		set connected_cnt to connected_cnt + 1
		if volume's status is "mounted" then
			set mounted_cnt to mounted_cnt + 1
		else if volume's status is "ejected" then
			set ejected_cnt to ejected_cnt + 1
		else if volume's status is "to use, must reconnect" then
			set toUserMustReconnect_cnt to toUserMustReconnect_cnt + 1
		end if
	end if
	
	set volumeListString to volumeListString & "• " & volume's label & " (" & volume's status & ")" & return
	
end repeat

if (mounted_cnt + ejected_cnt) < 1 then
	
	set dialogResult to display dialog "Time Machine volumes:" & return & return & ¬
		volumeListString & return & ¬
		"Time Machine is not running and there a no volumes to eject." with title myName buttons {"OK"} ¬
		default button {"OK"}
	
	return
	
else
	
	set end of buttonList to "Cancel"
	
	if mounted_cnt > 0 then
		set beginning of buttonList to "Eject Volume"
	end if
	
	
	set mountNote to ""
	
	if (mounted_cnt + ejected_cnt + toUserMustReconnect_cnt) > 0 then
		set beginning of buttonList to "Start Time Machine"
		if ejected_cnt > 0 then
			set mountNote to return & "Note: When starting Time Machine, ejected volumes will be automatically mounted." & return
		end if
		
	end if
	
	set defaultButton to "Cancel"
	
	set dialogResult to display dialog "Time Machine volumes:" & return & return & ¬
		volumeListString & mountNote ¬
		with title myName buttons buttonList default button defaultButton
	
	if button returned of dialogResult is "Eject Volume" then
		
		set connected_cnt to 0
		set mounted_cnt to 0
		set ejected_cnt to 0
		set toUserMustReconnect_cnt to 0
		
		set volumeListString to ""
		
		-- The state of one or more of the volumes could have potentially have changed,
		-- thus regenerate tmDestinationsDiskutilInfo (e.g., by the user)
		set tmDestinationsDiskutilInfo to getTmDestinationsDiskutilInfo(tmDestinationsInfo)
		
		repeat with volume in tmDestinationsDiskutilInfo
			
			set mountedStatus to ""
			
			if volume's connected then
				set connected_cnt to connected_cnt + 1
				if volume's status is "mounted" then
					set mounted_cnt to mounted_cnt + 1
					set theLabel to volume's label
					set mountedStatus to volume's status
					set mountedMountPoint to volume's mount_point
					set mountedId to volume's id
					set mountedDeviceIndentifier to volume's device_identifier
				else if volume's status is "ejected" then
					set ejected_cnt to ejected_cnt + 1
				else if volume's status is "to use, must reconnect" then
					set toUserMustReconnect_cnt to toUserMustReconnect_cnt + 1
				end if
			end if
			
			if volume's status is not "unavailable" then
				set volumeListString to volumeListString & volume's label & " (" & volume's status & ")" & return
			end if
			
		end repeat
		
		if mounted_cnt > 1 then
			
			set volumeList to paragraphs of volumeListString
			set volumesString to listToString(volumeList)
			set thePrompt to "Select a volume to eject:"
			set selectedVolume to do shell script "osascript -e 'return choose from list {" & volumesString & "} with prompt \"" & thePrompt & "\" default items {\"Cancel\"} with title \"" & myName & "\"'"
			if selectedVolume is "false" then
				
				return "User Cancelled"
				
			else
				
				set theLabel to do shell script "echo " & quoted form of selectedVolume & " | awk 'BEGIN{FS=\" \\\\(\"}{print $1}'"
				
				repeat with volume in tmDestinationsDiskutilInfo
					if theLabel is equal to volume's label then
						set mountedMountPoint to volume's mount_point
					end if
				end repeat
				
			end if
			
		end if
		
		try
			do shell script "diskutil unmountDisk " & quoted form of mountedMountPoint
			display notification quoted form of theLabel & ¬
				" has been ejected." with title myName
		on error
			display dialog quoted form of theLabel & " could not be ejected." with title myName
		end try
		
	else if button returned of dialogResult is "Start Time Machine" then
		
		set connected_cnt to 0
		set mounted_cnt to 0
		set ejected_cnt to 0
		set toUserMustReconnect_cnt to 0
		
		set volumeListString to ""
		
		-- The state of one or more of the volumes could have potentially have changed,
		-- thus regenerate tmDestinationsDiskutilInfo (e.g., by the user)
		set tmDestinationsDiskutilInfo to getTmDestinationsDiskutilInfo(tmDestinationsInfo)
		
		repeat with volume in tmDestinationsDiskutilInfo
			
			set mountedStatus to ""
			
			if volume's connected then
				set connected_cnt to connected_cnt + 1
				if volume's status is "mounted" then
					set mounted_cnt to mounted_cnt + 1
				else if volume's status is "ejected" then
					set ejected_cnt to ejected_cnt + 1
				else if volume's status is "to use, must reconnect" then
					set toUserMustReconnect_cnt to toUserMustReconnect_cnt + 1
				end if
				
				if volume's status is "mounted" or volume's status is "ejected" then
					
					set theLabel to volume's label
					set mountedOrMoutableStatus to volume's status
					set mountedOrMoutableMountPoint to volume's mount_point
					set mountedOrMoutableId to volume's id
					set mountedOrMoutableDeviceIndentifier to volume's device_identifier
					
				end if
				
			end if
			
			if volume's status is not "unavailable" then
				set volumeListString to volumeListString & volume's label & " (" & volume's status & ")" & return
			end if
			
		end repeat
		
		if (mounted_cnt + ejected_cnt) > 1 then
			
			set volumeList to paragraphs of volumeListString
			set volumesString to listToString(volumeList)
			if ejected_cnt > 0 then
				set thePrompt to "Select a Time Machine volume (ejected volumes will be automatically mounted):"
			else
				set thePrompt to "Select a Time Machine volume:"
			end if
			set selectedVolume to do shell script "osascript -e 'return choose from list {" & volumesString & "} with prompt \"" & thePrompt & "\" default items {\"Cancel\"} with title \"" & myName & "\"'"
			
			if selectedVolume is "false" then
				
				return "User Cancelled"
				
			else
				
				set theLabel to do shell script "echo " & quoted form of selectedVolume & " | awk 'BEGIN{FS=\" \\\\(\"}{print $1}'"
				
				repeat with volume in tmDestinationsDiskutilInfo
					if theLabel is equal to volume's label then
						set mountedOrMoutableStatus to volume's status
						set mountedOrMoutableId to volume's id
						set mountedOrMoutableDeviceIndentifier to volume's device_identifier
					end if
				end repeat
				
			end if
			
		end if
		
		if mountedOrMoutableStatus is not "mounted" then
			
			do shell script "diskutil mountDisk " & quoted form of mountedOrMoutableDeviceIndentifier
			
		end if
		
		-- It's possible that Time Machine automatically started during the period
		-- that the above dialogs were open. If it automatically started for the volume
		-- that was selected, let it continue. If it was another volume, stop it before
		-- starting Time Machine for the selected volume.
		
		set tmStatus to getTmStatus(tmDestinationsInfo, tmDestinationsDiskutilInfo)
		
		if tmStatus's destination_id is mountedOrMoutableId then
			
			display notification "Time Machine to " & quoted form of theLabel & ¬
				" is already running." with title myName
			
		else if tmStatus's backup_phase is not "" then
			
			set prevLabel to tmStatus's label
			
			do shell script "tmutil stopbackup"
			
			set timeoutLimit to 10
			set startTime to current date
			
			repeat
				set tmStatus to getTmStatus(tmDestinationsInfo, tmDestinationsDiskutilInfo)
				if tmStatus's backup_phase is "" then
					exit repeat
				end if
				delay 1
				if ((current date) - startTime) > timeoutLimit then
					display dialog "Time Machine to " & quoted form of prevLabel & ¬
						" was already running. An attempt to stop it failed after " & timeoutLimit & ¬
						" seconds." with title myName buttons {"OK"} default button {"OK"}
					return "Timeout"
				end if
			end repeat
			
			display notification "Time Machine to " & quoted form of prevLabel & ¬
				" was running and was stopped." with title myName
			
			delay 2.0
			
			do shell script "tmutil startbackup --destination " & quoted form of mountedOrMoutableId
			display notification "Time Machine to " & quoted form of theLabel & ¬
				" started." with title myName
			
		else
			
			do shell script "tmutil startbackup --destination " & quoted form of mountedOrMoutableId
			display notification "Time Machine to " & quoted form of theLabel & ¬
				" started." with title myName
			
		end if
		
	end if
	
end if


-- == Handlers =================================================

on getMyName()
	tell application "Finder"
		set myPath to (path to me) as alias
		set myFileName to name of myPath
		set myName to (text 1 thru ((offset of "." in myFileName) - 1) of myFileName)
	end tell
	
	if myName begins with "Keyboard-Maestro-Script" then
		set kmInst to system attribute "KMINSTANCE"
		tell application "Keyboard Maestro Engine"
			set kmMyName to getvariable "local_myName" instance kmInst
		end tell
		set myName to kmMyName
	end if
	
	return myName
end getMyName

on listToString(theList)
	-- Convert the AppleScript list to a string
	set str to ""
	repeat with i from 1 to count of theList
		set str to str & "\"" & item i of theList & "\"" & ", "
	end repeat
	-- Remove the trailing comma
	set str to text 1 thru -3 of str
	return str
end listToString

-- Information gathered here, will not change during the execution of this script
on getTmDestinationsInfo()
	
	set tmudi_raw to do shell script "tmutil destinationinfo"
	set tmutilDestinationinfo to do shell script "echo " & quoted form of tmudi_raw & " | sed 's/> ===/=====/g'"
	
	set AppleScript's text item delimiters to "===================================================="
	set theItms to text items of tmutilDestinationinfo
	
	set tmDestinationsInfo to {}
	
	repeat with cItm in theItms
		set AppleScript's text item delimiters to return
		set cItmLines to text items of cItm
		set cItmRec to {label:"", id:""}
		
		repeat with cLine in cItmLines
			if cLine starts with "Name" then
				set label of cItmRec to do shell script "echo " & quoted form of cLine & " | awk -F': ' '{print $2}'"
			else if cLine starts with "ID" then
				set id of cItmRec to do shell script "echo " & quoted form of cLine & " | awk -F': ' '{print $2}'"
			end if
		end repeat
		
		if label of cItmRec is not "" then
			set end of tmDestinationsInfo to cItmRec
		end if
	end repeat
	
	return tmDestinationsInfo
	
end getTmDestinationsInfo

-- Information here will update as Time Machine changes
on getTmDestinationsDiskutilInfo(tmDestinationsInfo)
	
	set tmDestinationsDiskutilInfo to {}
	
	repeat with i in tmDestinationsInfo
		
		set iRecord to {label:"", id:"", device_identifier:"", mount_point:"", encryption:"", connected:"", status:""}
		
		set device_identifier to ""
		set mount_point to ""
		set encryption to ""
		set status to ""
		set connected to true
		
		try
			set du to do shell script "diskutil info -plist " & quoted form of i's label
			
			set device_identifier to do shell script "echo " & quoted form of du & " | tr '\\015' '\\012' | awk -F'<|>' '/<key>DeviceIdentifier<\\/key>/{getline; print $3}'"
			set mount_point to do shell script "echo " & quoted form of du & " | tr '\\015' '\\012' | awk -F'<|>' '/<key>MountPoint<\\/key>/{getline; print $3}'"
			set encryption to do shell script "echo " & quoted form of du & " | tr '\\015' '\\012' | awk -F'<|>' '/<key>Encryption<\\/key>/{getline; print}'"
		on error
			set connected to false
		end try
		
		set label of iRecord to i's label
		set id of iRecord to i's id
		set device_identifier of iRecord to device_identifier
		set mount_point of iRecord to mount_point
		
		set encryption to (encryption contains "true")
		set encryption of iRecord to encryption
		
		if connected then
			set connected of iRecord to true
			if mount_point is not "" then
				set status of iRecord to "mounted"
			else
				if encryption then
					set status of iRecord to "to use, must reconnect"
				else
					set status of iRecord to "ejected"
				end if
			end if
		else
			set connected of iRecord to false
			set status of iRecord to "unavailable"
		end if
		
		if label of iRecord is not "" then
			set end of tmDestinationsDiskutilInfo to iRecord
		end if
		
	end repeat
	
	return tmDestinationsDiskutilInfo
	
end getTmDestinationsDiskutilInfo

-- Information here will update as Time Machine changes
on getTmStatus(tmDestinationsInfo, tmDestinationsDiskutilInfo)
	
	set tmStatus to {backup_phase:"", destination_id:"", label:"", mount_point:"", destination_identifier:"", encryption:""}
	
	set tms to do shell script "tmutil status"
	
	set backup_phase of tmStatus ¬
		to do shell script "echo " & quoted form of tms & " | tr '\\015' '\\012' | grep BackupPhase | awk -F' = ' '{print $2}' | tr -d ';'"
	
	set destination_id of tmStatus ¬
		to do shell script "echo " & quoted form of tms & " | tr '\\015' '\\012' | grep DestinationID | awk -F' = ' '{print $2}' | tr -d ';' | tr -d '\"'"
	
	repeat with i in tmDestinationsInfo
		if i's id is equal to destination_id of tmStatus then
			set label of tmStatus to i's label
			exit repeat
		end if
	end repeat
	
	repeat with i in tmDestinationsDiskutilInfo
		if i's label is equal to label of tmStatus then
			set mount_point of tmStatus to i's mount_point
			set destination_identifier of tmStatus to i's device_identifier
			set encryption of tmStatus to i's encryption
			exit repeat
		end if
	end repeat
	
	return tmStatus
	
end getTmStatus