AppleScript executed multiple times during later instances of Time Based Trigger action

Describe the bug
I have a Repeating or Time Based Trigger which executes an AppleScript file on my system. This script just says the time out loud. BetterTouchTool is set to run it at times 22:00, 22:15, ... until 23:45. Usually the first cron runs are executed once (22:00), but nearing the 23:45 it is executed multiple times (about six times at 23:00).

I can tell because I hear the AppleScript say "it is 23 hours/it is/it is/it is/it is 23 hours/it is 23 hours" :crazy_face:

It feels like there are many cron jobs set, yet the configuration does not seem to reflect this. Am I missing something? Did I set the times wrong?

Affected input device (e.g. MacBook Trackpad, Magic Mouse/Trackpad, Touch Bar, etc.):
MacBook M1

Screenshots
If applicable, add screenshots to help explain your problem. (You can just paste or drag them here)

Device information:

  • Type of Mac: MacBook M1
  • macOS version: 11.4 (20F71)
  • BetterTouchTool version: 3.561

Additional information (e.g. StackTraces, related issues, screenshots, workarounds, etc.):
AppleScript below:

set originalVolume to output volume of (get volume settings)
set volume output volume 25
say "Il est " & getTimeInHoursAndMinutes() using "Thomas"
set volume output volume originalVolume

on getTimeInHoursAndMinutes()
	-- Get the "hour"
	set timeStr to time string of (current date)
	set Pos to offset of ":" in timeStr
	set theHour to characters 1 thru (Pos - 1) of timeStr as string
	set timeStr to characters (Pos + 1) through end of timeStr as string
	
	-- Get the "minute"
	set Pos to offset of ":" in timeStr
	set theMin to characters 1 thru (Pos - 1) of timeStr as string
	set timeStr to characters (Pos + 1) through end of timeStr as string
	log timeStr
	return (theHour & ":" & theMin) as string
end getTimeInHoursAndMinutes

While you're waiting for a bug fix, I thought I'd offer a few remarks and recommendations regarding your AppleScript:

1) Be mindful about setting text item delimiters prior to performing any list → string coercion

It's always advisable to ensure you explicitly set the value of the text item delimiters to something useful or something benign before coercing a list into a string . characters of... produces a list of characters. Since text item delimiters is a top-level AppleScript property, its value is a global environment value that persists for the lifetime of any single AppleScript process, and so will apply to any script that gets executed in that AppleScript instance. Script Editor instantiates a brand new process for different scripts and also for every newly compiled version of a single script, which allows execution to occur inside a fresh, unpolluted environment where this issue with text item delimiters will very rarely be seen. However, a lot of widely-used AppleScript runners still instantiate a single AppleScript process at application launch, and then re-use this one, shared environment to run each and every AppleScript within up until the point at which the application is quit. Namely, FastScripts, Keyboard Maestro, Alfred, and I imagine BTT all do this (although the upcoming release of FastScripts I believe will execute separate scripts in separate environments, but multiple executions of any single script will take place inside a single instance). Therefore, just try and be mindful about not polluting the global (top-level) namespace in your scripts (which is good advice to follow in any language that scopes its variables and functions); and be diligent with managing properties such as text item delimiters that can be set by one script and then later inadvertently have unintended effects applied to another script.

In this case, you can simply set the value of text item delimiters to something benign in the first line of your handler—benign being either an empty string (""), or an empty list ({}), meaning concatenation of list items can occur without the additional insertion of any delimiting characters:

on getTimeInHoursAndMinutes()
      set the text item delimiters to {}
                                  .
                                  .
                                  .    
      set theHour to characters 1 thru (Pos - 1) of timeStr as string
      set timeStr to characters (Pos + 1) through end of timeStr as string
                                  .
                                  .
                                  .    

2) Setting text item delimiters to something useful

This line appears twice in your handler, which means you had to perform the same sequence of operations twice over in order to extract your hour value, and then your minute value. You can do this in a single set of operations, using text item delimiters. In addition to being involved in joining list items together when coercing to a string, they are involved in the opposite action where a string is split into a list of text items at the occurrence of any of the delimiters you specify.

To demonstrate, I'll use the ISO-8601 international standard format for date-time strings, as it uses colons to separate the time components, which you evidently do as well. Additionally, it uses hyphens to separate the date components, and the character "T" to delimit the start of the time string portion of the date-time string. Therefore, those are the delimiters I'll want to use for this task, which will allow me to extract the hour and minute values in a single line:

set now to "2021-06-16T05:44:01" --> ISO-8601 formatted date/time
set the text item delimiters to {":", "-", "T"}
get the text items of now --> {"2021", "06", "16", "05", "44", "01"}
get {hours:text item -3, minutes:text item -2} of now 
       --> {hours:"05", minutes:"44"}
return the contents of the result's {hours, minutes} as text
       --> "05:44"

I put the hours and minutes into a list and coerced that into a string (well, text—same thing) to demonstrate the concatenation process under text item delimiters I had already set. I specifically made the first delimiter in the list the colon, as that is the one that gets used to join the items of a list together.

3) If you can avoid a list → string coercion, avoid it

characters of... results in a list class object, that necessitates your subsequent coercion back into a string. I also just did a similar thing above using text items instead of characters, which still needed to be coerced back to a string. Besides the text item delimiters trap that can be bothersome when it's not being useful, coercing values from one type class to another is a pretty expensive operation in any situation, but list objects are also one the most complex data structures AppleScript has. Whilst we can't avoid doing any coercions whatsoever (since you require a string value by the end, but we start with a date object), we can avoid using lists as a middle man, which spares us confronting the aforementioned inconveniences.

Instead of grabbing individual letters, we can grabbing a whole slice of text, i.e. a substring. Assuming your hour and minutes form a continuous substring within your time string, then we can splice it out as a single chunk of text in much the same way as you were able to target a specific character range with characters 1 thru...:

tell the (current date) as «class isot» ¬
	as string to return text 12 thru 16
    --> "06:25"

Done. That can be your entire handler, in a single line. It makes use of the handy type class known only as «class isot», which I suppose is short for ISO-(8601)-standard time, or so-called "iso-time", which returns the date ()that must be coerced into a string), where upon doing so, it will be formatted according to the ISO-8601 standard, i.e. "2021-06-16T06:25:34". This time, rather than use text item delimiters, I just took the characters between positions 12 and 16, except they were extracted as a single, unary string value that is already colon-delimited, so serves your needs ideally.

4) Although, this is how I would actually choose to do it

to getTimeInHoursAndMinutes()
    tell the (current date) ¬
             to return "" & ¬
              (its hours) & ¬
        ":" & (its minutes)
end getTimeInHoursAndMinutes

No list → string coercions, no text item delimiters, and no manipulation of characters or substrings.

Hi @CJK,

Thank you very much for your reply. The script wasn't mine originally, I understood the code but never bothered optimising. I'm a big fan of the reduced version you're proposing but I needed to add as string at the end of the return statement to have it right. In your version the voice says

"il est 14, 8"

instead of

"il est 14 heures 8"

I appreciate the in-depth details you provided. I know Swift and Java but AppleScript not that much.

Coming back on topic, I have the gut feeling that the crons are re-created (therefore duplicated) once per long-suspend of the computer (so if I have 3 closed lids in the day it will repeat 3 times), but it's very hard to prove while I need the computer so often. For now I've split into 2 separate triggers (one at 22:xx and one at 23:xx) to see if it changes something. If it still weird, I will split it again (one per quarter).

Cheers

The voice issue had nothing to do with string conversion; the problem was the leading zero of the minutes for correct pronunciation of say (otherwise it doesn't understand it's a time).

Current implementation is

to getTimeInHoursAndMinutes()
	tell the (current date)
		set theHours to its hours
		set theMinutes to its minutes
		
		if theMinutes < 10 then
			set theMinutes to "0" & theMinutes
		end if

		return "" & theHours & ":" & theMinutes
	end tell
end getTimeInHoursAndMinutes

Hadn't had the chance to test the new triggers yet, will come back if it happens.

Nice deduction. I couldn't see how coercion would have been involved, but I did overlook the leading zero. I actually didn't know AppleScript (or the text-to-speech API) was imbued with a little intelligence, to adapt what it verbalises based on context. That's neat.

Simply because I find one-liners in Applescript more alluring than in pretty much any other language:

tell (current date) to return "" & (its hours) & ¬
     "h" & ("0" & its minutes)'s text -1 thru -2

The cron conundrum is perplexing. Have you thought about setting it up as a launchd property list, which is better† than cron at managing scheduled tasks.

† For one thing, it doesn't poll to check its schedule every minute.

Have you thought about setting it up as a launchd property list

No, I wanted to leave as many responsibilities on BTT as possible for easy import/export of triggers (unless it's noticeably inefficient). Is there a way to ask BTT to make use of launchd instead of cron ?

One main difference I've read is that launchd would rerun missed opportunities (e.g. Mac being asleep) whereas cron lets them pass by, which is actually what I prefer. Can one tell launchd to not make up for lost opportunities after wakeup?

Just for follow-up I do confirm that having 2 separate BTT cron-triggers does what I want (one for 22hrs and one for 23hrs), even though it's a workaround.

How bad is cron's polling on today's CPUs?

Understandable.

Yes, of a sort. There's no in-built way to disable that feature in launchd itself, but if you're using it to execute a script—which would be the case here—you can implement a check to make sure the script was called at an appropriate time. It'd be the first task the script performs, because if it fails the test, you want the script to terminate there-and-then.

I don't know, to be honest.

I’m looking into this issue, hopefully a fix will be available next week.

Imprint | Privacy Policy