Skip to content

Instantly share code, notes, and snippets.

@ratijas
Last active June 2, 2016 08:13
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ratijas/653016f4710d339399e4ec75040cea91 to your computer and use it in GitHub Desktop.
Save ratijas/653016f4710d339399e4ec75040cea91 to your computer and use it in GitHub Desktop.
iTunes + AppleScript + async == launchd?

#pre (section zero)

all of a sudden, i wanted something strange: get track info in iTunes completely with keyboard. however, there's one little but annoying point: if you ever made it into "lyrics" tab and your cursor stuck in textarea -- adiós -- you'll never git it out of there without help of mouse. and so, being fed up with that, i finally took some time to deal with problem in top down manner.

##first try

first intension was to make simple AppleScript to interact with UI elements. take /Library/Scripts/UI Element Scripts/Probe Window.applescript as an example and you are good to go. here's first version of script:

tell application "System Events"
	tell process "iTunes"
		keystroke "i" using command down
		
		tell front window
			tell first tab group
				tell first radio button
					click
					set focused to true
				end tell
			end tell
		end tell
	end tell
end tell

something strange happened to synchronization and tab sometimes would not become selected and script would fall with error. window was not opening quick enough to become the front one before AppleScript reaches next tell blocks. simple delay between keystroke and tell fixed it:

repeat with n from 1 to 10
	if (count of tab groups of front window) is not 0 then exit repeat
	delay 0.1 -- wait for the info window to appear
end repeat

##second try

  • put this together
  • save as ~/Library/iTunes/Scripts/info (general).scpt
  • go to System Preferences -> Keyboard pane -> Keyboard Shortcuts (right tab) -> Application Shortcuts (bottommost row)
  • add shortcut for
    • Application: iTunes
    • Menu Title: "info (general)", without quotes
    • Keyboard Shortcut: ⌥⌘I (Cmd-Alt-I) for example

now go back to iTunes, press ⌥⌘I and... after some time watching spinning beach ball, get an error! further investigation and tests revealed that while 'outside' scripts just telling apps what to do, 'insiders' (e.g. those run from Scripts menu) are executed by actually iTunes itself, and must obey its threading rules or something. iTunes suspends scripts execution until Info window is closed. which is why by the time we get to repeat block, Info window already destructed and we're back to the library.

so what do we do now? get it abstract: we need some tool to execute from inside iTunes but be asynchronous so that it can interact with app while in 'suspend' mode. more details: tool must be written in AppleScript as primary language, we are on the Mac OS X.

##launchd / launchctl

so what does it mean 'we are on the Mac OS X'? it means we have all the power of launchd with us. basically it's a stuff that makes all other applications in the system running and restarting (in case of failure or something). launchd is the 'daemon' running in background, and launchctl is a frontend to manipulate the former. once the job is sent, launchd executes it in background, giving you back your terminal or whatever.

usually they are using property list based configuration files to let developers specify tasks and rules for starting them on different occasions. good enough, launchctl also supports syntax for sending jobs (tasks) right from command line, on-the-fly, no XML needed.

so, if we send a job with launchctl, we'll get control back to us right away, so that AppleScript can exit immediately (since it's not much useful in suspended mode). what's that task that we are sending to launchd? right! that's another AppleScript which actually does the job.

invocation of shell from AppleScript follows this syntax:

	do shell script "..." & (quoted form of "some argument that needs to be escaped")

invocation of launchctl, in its turn:

	launchctl submit -l label [-p executable] [-o path] [-e path] -- command [args]

invocation of AppleScript from shell:

    osascript [-e statement | programfile]

now the triangle's complete!

now it's time to do the dirty stuff. at first, i had an idea of putting all necessary AppleScript in one -e argument, but them i realized how crazy it would look like, and how many escaping would need to be done.

##third try

conditional execution. if we are called without arguments -- it's iTunes, then we fire ourself once more -- from launchd -- this time with some dumb argument just to be able to tell.

on run args
	if (count of args) is 0 then
		
		set path_to_me to quoted form of the POSIX path of the ((path to me) as string)

		do shell script "launchctl osascript " & path_to_me & " run"  -- 'run' is that one 'dump argument'
	else
		tell application "System Events"
			...
		end tell
	end if
end run

good? not yet. if we read manual carefully, we could see this line:

This mechanism also tells launchd to keep the program alive in the event of failure.

but we don't want our script to re-start forever!

##fourth try

that's why i came up with a pretty convenient function launchonce in my .zshrc. what it is doing is taking care of removing a job after it's done. additionally it writes log file located at ~/Library/Logs/launchonce/.

launchonce () {

  local logdir=~/Library/Logs/launchonce
  mkdir -p "$logdir"

  while [[ -z "$logfile" ]] || [[ -a "$logfile" ]] ; do  # get unique file name
    local id="tk.ratijas.$1.$RANDOM"
    local logfile="$logdir/$id.log"
  done

  local script=' "$@" ; launchctl remove "$0" '
  1="$(/usr/bin/which "$1")"

  launchctl submit -l "$id" -p /bin/sh -o "$logfile" -e "$logfile" -- /bin/sh -c "$script" "$id" "$@"

  echo "launchd job id:$id"
  echo "log:$logfile"
  # to get log:
  # launchonce ... | cut -d : -f 2-99
}

now include this function (with all double-quotes escaped) in do shell script and replace launchctl invocation with it, like this:

		do shell script "
launchonce () {

  local logdir=~/Library/Logs/launchonce
  mkdir -p \"$logdir\"

  while [[ -z \"$logfile\" ]] || [[ -a \"$logfile\" ]] ; do  # get unique file name
    local id=\"tk.ratijas.$1.$RANDOM\"
    local logfile=\"$logdir/$id.log\"
  done

  local script=' \"$@\" ; launchctl remove \"$0\" '
  1=\"$(/usr/bin/which \"$1\")\"

  launchctl submit -l \"$id\" -p /bin/sh -o \"$logfile\" -e \"$logfile\" -- /bin/sh -c \"$script\" \"$id\" \"$@\"

  echo \"launchd job id:$id\"
  echo \"log:$logfile\"
  # to get log:
  # launchonce ... | sed '2p;d' | cut -d : -f 2-99
}
launchonce osascript " & path_to_me & " run"

save it, go to iTunes and give it a try!

#conclusion

Mac OS X is powerful. AppleScript is mighty. but launchd rulez 'em all.

    ____        __  _   _           
   / __ \____ _/ /_(_) (_)___ ______
  / /_/ / __ `/ __/ / / / __ `/ ___/
 / _, _/ /_/ / /_/ / / / /_/ (__  )
/_/ |_|\__,_/\__/_/_/ /\__,_/____/
             me@ /___/ .tk

https://ratijas.tk/
on run args
if (count of args) is 0 then
set path_to_me to quoted form of the POSIX path of the ((path to me) as string)
do shell script "
launchonce () {
local logdir=~/Library/Logs/launchonce
mkdir -p \"$logdir\"
while [[ -z \"$logfile\" ]] || [[ -a \"$logfile\" ]] ; do # get unique file name
local id=\"tk.ratijas.$1.$RANDOM\"
local logfile=\"$logdir/$id.log\"
done
local script=' \"$@\" ; launchctl remove \"$0\" '
1=\"$(/usr/bin/which \"$1\")\"
launchctl submit -l \"$id\" -p /bin/sh -o \"$logfile\" -e \"$logfile\" -- /bin/sh -c \"$script\" \"$id\" \"$@\"
echo \"launchd job id:$id\"
echo \"log:$logfile\"
# to get log:
# launchonce ... | sed '2p;d' | cut -d : -f 2-99
}
launchonce osascript " & path_to_me & " run"
else
tell application "System Events"
tell process "iTunes"
keystroke "i" using command down
repeat with n from 1 to 10
if (count of tab groups of front window) is not 0 then exit repeat
delay 0.1 -- wait for the info window to appear
end repeat
tell front window
tell first tab group
tell first radio button
click
set focused to true
end tell
end tell
end tell
end tell
end tell
end if
end run
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment