Tuesday, September 2, 2008

iSync—Periodically!

My free-from-AT&T (because I am cheap) Sony Ericsson Z310a has Bluetooth comms, so with the help of a third-party iSync plugin, I can synchronize my phone’s contacts and calendar with my computer’s Address Book and iCal.

But I use Google Calendar because it allows me to access my calendar from my laptop or from my computer at work. Up until recently, that meant that using a web browser, because iCal had read-only access. So I would occasionally launch iCal and manually refresh the subscription to my GCal calendar, and I would even less frequently use iSync to push the calendar from iCal onto my phone. But usually I’d just leave Google Calendar up in a browser tab and not even run iCal.

Well a few weeks ago, Google finally added CalDAV support to GCal, which means that I can now use iCal as my calendar program and still have the benefit of viewing my calendar from any computer with WWW access. Great news!

This had the side effect of illuminating the fact that I won’t remember to sync my cell phone as often as I should; I typically only remember to do it once every week or two. Clearly, an automated solution is warranted. Because I am lazy.

Enter launchd.

launchd is OS X’s neato unified replacement for init, rc, inetd, xinetd, at, cron, and pretty much anything else that pertains to the launching of programs in response to the meeting of some sort of criteria (incoming network connection, file modification, file added to directory, time elapse, time of day, &c.). It understands service prerequisites and launches stuff as needed and in parallel, so booting is much faster than it is with Linux’s serial execution of rc.d scripts in alphabetical order of file name (what a hack). Anyway, it’s really great and magical and its only downside is that it continues Apple’s trend of using Property List XML files for everything under the sun.

Luckily, Peter Borg (Swedish, not evil alien) wrote the wonderful Lingon, which provides a simple GUI for me to describe what I want my LaunchAgent to do and generates the appropriate XML behind the scenes (and happily uses intelligible indentation, unlike most automatic code generators). It also knows where to put the resultant file (~/Library/LaunchAgents).

iSync is a scriptable application, so I can execute a command like
osascript -e 'tell application "iSync" to synchronize'

and it will start to (asynchronously) perform a sync. Unfortunately, iSync doesn’t quit when it’s finished. I need to wait for the sync to finish, and then tell it to quit. Something like (all on one line):
osascript -e 'tell application "iSync" to synchronize' >/dev/null && while [[ `osascript -e 'tell application "iSync" to get syncing'` = true ]]; do sleep 1; done && osascript -e 'tell application "iSync" to quit'

(I tried it as one big AppleScript, but the first "get syncing" returned false. There’s a race condition or something. It appears to always work with sequential invocations of osascript.)

A LaunchAgent can invoke any single command-line command (or launch an app), but it can’t do fancy stuff like pipes or shell boolean interpretation. So, something like the incantation above is out of the question. It has to go in a file as an executable shell script.

So, I gave Lingon a unique name for my agent, I pointed it to the location of my script, and I clicked the check box for "At a specific date:" and told it to run every day at midnight. When I saved the file, Lingon told me that I would have to log out and log back in for my new agent to work. But it isn’t true. launchctl to the rescue!
launchctl load path/to/file.plist


Note that if you make a change to the XML, you have to launchctl unload and launchctl load again for launch to notice the change.

Here’s the final XML:

<?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>local.jhope.PeriodicISync</key>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/isync</string>
  </array>
  <key>StartCalendarInterval</key>
  <dict>
    <key>Hour</key>
    <integer>0</integer>
    <key>Minute</key>
    <integer>0</integer>
  </dict>
</dict>
</plist>


For the shell script itself, I decided to write one in scsh (just because; yes, I know the scheme shell is total overkill for a script this short, but how often do you suppose people have Scheme code calling AppleScript?). It looks like this:

#!/opt/local/bin/scsh \
-o threads -e main -s
!#

;; I have to load the threads structure to get the sleep function

;; Get iSync to start syncing
(define (synchronize)
  (run/strings (osascript -e "tell application \"iSync\" to synchronize")))

;; Tell iSync to quit
(define (kill-isync)
  (run (osascript -e "tell application \"iSync\" to quit")))

;; Returns #t if iSync is still syncing
(define (synchronizing?)
  (let ((result (run/strings
                 (osascript -e "tell application \"iSync\" to get syncing"))))
    (string= (car result) "true")))

;; Waits for iSync to finish and then tells it to quit
;; (polling once per second)
(define (kill-isync-when-done)
  (sleep 1000)
  (if (synchronizing?)
      (kill-isync-when-done)
      (kill-isync)))

;; Start a sync and then kill iSync when it's done.
(define (main prog+args)
  (synchronize)
  (kill-isync-when-done))


Of course, I could also have just defined a single function:

(define (main prog+args)
  (run/strings (osascript -e "tell app \"iSync\" to synchronize"))
  (let loop ()
    (sleep 1000)
    (if (string= (car (run/strings (osascript -e "tell app \"iSync\" to get syncing")))
                 "true")
        (loop)
        (run (osascript -e "tell app \"iSync\" to quit")))))

but it seems to me like that reduces script length at the expense of hiding the logical operations that the separate functions illuminate.

Regardless, I now have iSync update my phone every night without me having to do anything. Qapla'.

No comments: