Thursday, May 7, 2009

Run at Print

Running a script when a user prints

Some organizations (particularly schools and universities) require users to go to a web page to authenticate print jobs before those jobs can actually be printed. However, there is no documented way of automatically directing a user to a web page after they have printed. This article will demonstrate one way of doing this.


This setup is a huge hack, and it is very likely that any system update that involves printers would overwrite it (requiring the hack to be re-installed). So this solution will best work on groups of computers that a system admin has complete control over. That said it also seems to currently be the best way of having a script run when something is printed.


Unfortunately the version of the printing system that comes with MacOS 10.5 (CUPS) does not have any obvious places to plug in to to get the behavior that we want, but there are a few places where we can hack into it to get the behavior we want. CUPS has a series of filters that print jobs get sent through to apply various transformations to the job to convert it to a format that the printer can understand. By causing the print job to take a little detour along this chain of filters we can capture the information we want, and still send the job on its way. The only filter that we can be sure that every print job goes through is the pstops (Postscript-to-Postscript) filter. In fact the CUPS system uses this filter to do a number of things, including print accounting and selecting page ranges.

But intercepting the job is just one part of what we need to do. Because the print filters run as root, we have to hand off a message to another process running as the user to act upon. Using a launchd item and a watched folder makes this process relatively easy. Our replacement filter will create empty files specific directory as a signal, and the launchd item will start a user-level process that will do most of the work and then erase the signal file.

Replacing the filter

To intercept the print job we have to move aside the pstops filter, and put our own filter in its place. But since we still want the functionality that pstops provides, we will call the normal pstops filter from within our replacement. The steps to do all this:

  1. Open and the go to the filters folder with:

    cd /usr/libexec/cups/filter

  2. Change the name of the original pstops filter to pstops.binary with

    sudo mv pstops pstops.binary

    Note that this will ask you for your password (you have to be logged in as an admin)

  3. Create our replacement item. You can do this a number of methods, to do it with pico in the teminal:

    sudo pico pstops

    copy-and-paste the following:

    # place the signal file, and make sure it can be deleted by the user-level process
    #	note: $PRINTER is a environmental variable set by CUPS
    /usr/bin/touch "$PRINTER_FOLDER/$PRINTER"
    /bin/chmod 0666 "$PRINTER_FOLDER/$PRINTER"
    # run the original pstops binary with the same input we were given
    exec $0.binary "$@"

    Then use <control>-x to exit pico, answer "y" when it asks to save, and use to accept the default name.

  4. Give our replacment filter the proper permissions with:

    sudo chmod 0555 pstops

    The owner and group should already be correct (root:wheel)

Creating the signal folder

The folder that we will use to transfer signals betwen the filter and the user-level process needs to exist before either side of our our process will work, so we create it ahead of time:

  1. Create the folder with:

    sudo mkdir /var/db/runAtPrint

    If you want to choose a differnt folder remember to change it in the pstops wrapper script, and in the launchd item in the next section.

  2. Make sure that it has the proper permissions so that both the filter and the user-level process can create/destroy itmes here with:

    sudo chmod 0777 /var/db/runAtPrint

Create the user-level script

The user-level script is what will pick up the signal files left by the filter, make sure that they are for printers on the proper server, and then call the user's choice of web browser. This part of the solution should probobably be cusomized to the indvidual institution, so this script is more of a reference implimentation. We are going to place the script into /Library/Scripts but in practice this script should go into the same place as any other management scripts your setup might have (the author usually create a folder at /Library/Management for this sort of stuff and a "Scripts" folder in that).

This reference script will ignore print jobs that go to printers that are not lpd queues on the server "" (a domain that does not exist), so at a bare minimum you will need to change the lines starting with "printingServer =" and "printingPage =" to reflect your environment. Any real use of this system will probably require at least a little more cusomization than that.

  1. Use pico in to create the script file:

    sudo pico /Library/Scripts/

    copy-and-paste the following:

    #!/usr/bin/env python
    import re, os, time
    ''' This watches the printer folder, and whenever there is an appropriate item
    	there launches a browser at the right page '''
    printerFolder		= '/var/db/runAtPrint'
    printingServer		= ''
    printingPage		= '' 
    busyTimeout		= 10 # wait no more than this number of seconds 
    lpstatRegex		= re.compile('^device for (?P<printerName>\S+): lpd://(?P<server>\S+?)/(?P<queueName>\S+)')
    printerBusyRegex	= re.compile('^printer \S+ now printing')
    # setup a list of printers that we will watch
    watchedPrinters		= {}
    for thisPrinter in os.popen('/usr/bin/lpstat -s'):
    	result =
    if result and"server") == printingServer:
    	watchedPrinters["printerName")] ="queueName")
    # process the files in the watched directory
    for thisPrinter in os.listdir(printerFolder):
    	if thisPrinter in watchedPrinters:
    		printerIsBusy = True
    		timeout = time.time() + busyTimeout
    		# wait until the print server is done spooling
    		while os.popen('/usr/bin/lpstat -p ' + thisPrinter).read() ) and time.time() < timeout:
    		# open a new window in the user's browser to the right page
    		os.system('/usr/bin/open %s%s' % (printingPage, watchedPrinters[thisPrinter]))
    	# remove the file even if it was not a printer we mange
    	os.unlink( os.path.join(printerFolder, thisPrinter) )

    Then use <control>-x to exit pico, answer "y" when it asks to save, and use to accept the default name.

  2. Make sure that the script has the proper permissions:

    sudo chmod 0755 /Library/Scripts/

Create the launchd item

We want the user-level script to fire off every time our filter places something in the /var/db/runAtPrint folder, and Apple's Launchd system provides a nice facility to do exactly that. But we need to give it a plist to set this up. There are a number of ways of doing this, and ususally the author would use Property List to create the file, but for simplicitie's sake we will continue to use pico to do so:

  1. Use pico in to create the launchd plist:

    sudo pico /Library/LaunchAgents/org.macenterprise.runAtPrint.plist

    copy-and-paste the following:

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "">
    <plist version="1.0">

    Then use <control>-x to exit pico, answer "y" when it asks to save, and use to accept the default name.

    Note: if you decide to change the name of this file, make sure that you keep the "Label" item in the plist in sync with the name. Launchd can get a little difficult to work with if the names to not match.

  2. Make sure that the launchd item has the proper permissions:

    sudo chmod 0644 /Library/LaunchAgents/org.macenterprise.runAtPrint.plist

    It should already have the proper group and owner (root:wheel).

Final Notes

At this point you should be able to log out, then back in, and if you did everything correctly the system should be running. As was mentioned in the Caution section, any Apple system update that changes printing has a chance of replacing the pstops file. In that case you would just need to move the new version to replace the old one you moved asside, and put the replacement script in its place again.

In case you missed it in that section: the user-level script does need to be customized to the environment that it will be running in. The reference script should be a good starting point for that customization.