TweetFollow Us on Twitter

Writing Contextual Menu Plugins for OS X, part 2

Volume Number: 19 (2003)
Issue Number: 1
Column Tag: Mac OS X

Writing Contextual Menu Plugins for OS X, part 2

Running Unix commands and handling text selections

by Brent Simmons

In part one of this article (August 2002) we built a simple OS X contextual menu plugin that works in the Finder and provides a Copy Path command. It demonstrated the basics of implementing the COM-based interfaces for contextual menu plugins.

In this article we won't revisit the COM interface or other basics of contextual menu plugins--instead we'll go further, show how to run a Unix command from a contextual menu, how to send an update event to the Finder when a file changes, how to handle text selections, how to create a submenu with multiple commands, how to open a URL in a browser, and more.

The project file and source, built using the OS X 10.2 Developer Tools, can be downloaded from http://ranchero.com/downloads/mactech/SamplePlugin.sit.

Plugin Overview

This plugin (imaginatively named SamplePlugin) will provide three commands:

    1. Touch--when one or more files (or folders) are selected, a Touch command will appear in the contextual menu. It will run the Unix touch command, which sets the modification date of the selected files to the current date and time.

    2. Copy--when text is selected, this command will appear in a Text Samples submenu. It copies the selected text to the clipboard. (Note that while some applications already supply a Copy command in their contextual menu, many do not, and it's useful to have this command.)

    3. Search with Google--when text is selected, this command will appear in a Text Samples submenu. It runs a search for the selected text in Google in one's default Web browser.

Touch command


Figure 1. Touch command in Finder's contextual menu

The examineContext function in the plugin is called to give the plugin a chance to add commands to the contextual menu that's about to be displayed. This plugin does one of three things:

    1. It adds a Touch command if one or more files or folders are selected.

    2. It adds a Text Samples submenu, with Copy and Search with Google commands, if text is selected.

    3. It adds no commands if neither files nor text is selected.

Listing 1: checking the context

examineContext
static OSStatus examineContext (void *pluginInstance,
   const AEDesc *context, AEDescList *commandList)
   {
   if (isFileOrListOfFiles (context))
      addFileCommandsToMenu (commandList);
   else if (isTextSelection (context))
      addTextCommandsToMenu (commandList);
   return (noErr);
   }

examineContext first checks to see if one or more files or folders is selected. If so, it adds the file commands (the Touch command) to the contextual menu.

If the selection is not files or folders, then it checks to see if text is selected. If so, then it adds the Text Samples submenu and the Copy and Search with Google commands.

If neither of the above are true, it just returns noErr, having added no commands to the contextual menu.

The check to see if files are selected first checks to see if a single file or folder is selected. If not, then it checks to see if multiple files or folders are selected.

Listing 2: checking if files are selected

isFileOrListOfFiles
static Boolean isFileOrListOfFiles (const AEDesc *desc)
   {
   if (isFileOrFolder (desc))
      return (true);
   return (isListOfFiles (desc));
   }

isFileOrFolder checks a single AEDesc to see if it refers to a file or folder.

Listing 3: checking an AEDesc to see if it's a file or folder

isFileOrFolder
static Boolean isFileOrFolder (const AEDesc *desc)
   {
      return (descIsOfType (desc, typeFSRef));
   }

If the descriptor type is of typeFSRef, or can be coerced to typeFSRef, it's a file or folder, and so it returns true. descIsOfType performs this check. It's general--it can check for types other than typeFSRef. (In fact, this plugin also calls descIsOfType to check for a text selection.)

Listing 4: checking the type of an AEDesc

descIsOfType
static Boolean descIsOfType (const AEDesc *desc,
   OSType desiredType)
   {
   AEDesc tempdesc = {typeNull, NULL};
   if ((*desc).descriptorType == desiredType)
      return (true);
   
   if (AECoerceDesc (desc, desiredType, &tempdesc)
      == noErr) {
      AEDisposeDesc (&tempdesc);
      return (true);
      } /*if*/
   
   return (false);
   }

Why check for typeFSRef instead of typeAlias or typeFSS? Because Apple's message is that FSRefs are the way to go in OS X. It would probably work as well to substitute one of these other types--but when your platform vendor tells you to do it a certain way, it's probably a good idea to listen.

Lists of files

If it's not just one file or folder selected, isFileOrListOfFiles next checks to see if it's a list of files or folders selected.

isListOfFiles loops through each item in the AEDescList and calls isFileOrFolder for each item. If any calls to isFileOrFolder return false, then the selection therefore contains items other than files or folders, and so isListOfFiles returns false.

Listing 5: looping through a list

isListOfFiles
static Boolean isListOfFiles (const AEDesc* desc)
   {
   long numitems, i;
   OSErr err;
   
   err = AECountItems (desc, &numitems);
   if (err != noErr)
      return (false);
   
   for (i = 1; i <= numitems; i++ ) {
      AEKeyword keyword;
      AEDesc tempdesc = {typeNull, NULL};
      Boolean flFile = true;
      
      err = AEGetNthDesc (desc, i, typeWildCard,
         &keyword, &tempdesc);
      if (err != noErr)
         return (false);
      
      flFile = isFileOrFolder (&tempdesc);
      AEDisposeDesc (&tempdesc);
      if (!flFile)
         return (false);
      }
   
   return (true);
   }

AECountItems gets the number of items in the list. Then a for loop visits each item, calling isFileOrFolder with each. If isFileOrFolder returns false, then isListOfFiles returns false.

Otherwise it returns true, and then isFileOrListOfFiles returns true, and we're back to examineContext, which then calls addFileCommandsToMenu to add the Touch command to the contextual menu.

Adding the Touch menu command

To add the Touch command, this function calls pushCommand with the title of the command, its command ID, and the command list that will become the contextual menu that appears. The final parameter sent to pushCommand is NULL--if this item had a submenu it would be specified in the last parameter.

Listing 6: adding the localizable Touch command

addFileCommandsToMenu
static Boolean addFileCommandsToMenu
   (AEDescList *commandList)
   {
   CFStringRef touchCommand =
      CFCopyLocalizedString (CFSTR("Touch"),
      "Touch Menu Text");
   return (pushCommand (touchCommand, touchCommandID,
      commandList, NULL));
   }

Note that it gets the name of the Touch command by calling CFCopyLocalizedString. This looks up the name in MenuNames.strings (a resource that's included with the project you downloaded). This way you can easily localize the plugin by editing a strings file--which is definitely preferred to hard-coding the names of menu items in the source code.

We'll otherwise skip pushCommand for now--we'll talk about it later when we show how to build submenus. For now just know that it adds the Touch command to the contextual menu.

touchCommandID is defined in SamplePlugin.h. The command ID, since it's just used internally, does not have to be localized, of course.

After this function returns, examineContext returns noErr, and the system handles displaying and tracking the contextual menu.

Handling a command

handleSelection is called by the system to actually run a command chosen by the user.

Listing 7: handling the user's command

handleSelection
static OSStatus handleSelection (void *pluginInstance,
   AEDesc *context, SInt32 commandID)
   {
   switch (commandID) {
      
      case touchCommandID:
         runTouchCommand (context);
         break;
      
      case copyCommandID:
         runCopyCommand (context);
         break;
      
      case searchCommandID:
         runSearchCommand (context);
         break;
      }
   return (noErr);
   }

Based on the command ID associated with the given command, it calls the corresponding function. If Touch is chosen, it calls runTouchCommand. There are similar cases for the Copy and Search with Google commands (more about those later).

Running the Touch command

The goal of this function is to pass to the system a string of text as if typed on the command line. We're calling the Unix touch command with one or more files or folders as arguments. The string that gets passed to the system will look something like this:

/usr/bin/touch "/path/to/some/file" "/path/to/some/other/file"

Each separate file path is enclosed in quotes because there may be spaces in the name.

So the first thing runTouchCommand does is get the list of selected files as a string suitable for passing to the system as command line arguments. Then it actually calls the system to run the Touch command. Finally it sends an update event to the Finder so it updates its display.

Listing 8: running the command

runTouchCommand
static void runTouchCommand (const AEDesc *desc)
   {
   CFStringRef commandLineParams;
   
   if (!getFileListAsText (desc, &commandLineParams))
      return;
   callSystem (CFSTR("/usr/bin/touch"), commandLineParams);
   CFRelease (commandLineParams);
   sendUpdateEventToFinder (desc);
   }

getFileListAsText is somewhat similar to the isListOfFiles function--it loops through the selected files in the same way. It also double-checks that each item actually is a file or folder.

Listing 9: getting the list of selected files as text

getFileListAsText
static Boolean getFileListAsText (const AEDesc *desc,
   CFStringRef *fileList)
   {
   long numitems, i;
   OSErr err;
   Boolean flSuccess = false;
   CFMutableStringRef s = CFStringCreateMutable
      (kCFAllocatorDefault, 0);
   
   if (s == NULL)
      return (false);
      
   err = AECountItems (desc, &numitems);
   require_noerr (err, getFileListAsText_fail);
   
   for (i = 1; i <= numitems; i++ ) {
      AEKeyword keyword;
      AEDesc tempdesc = {typeNull, NULL};
      Boolean flFile = false;
      
      err = AEGetNthDesc (desc, i, typeWildCard, &keyword,
         &tempdesc);
      require_noerr (err, getFileListAsText_fail);
      
      flFile = (tempdesc.descriptorType == typeFSRef);
      if (!flFile) {
         err = AECoerceDesc (&tempdesc, typeFSRef, &tempdesc);
         require_noerr (err, getFileListAsText_fail);
         flFile = (tempdesc.descriptorType == typeFSRef);
         }
      
      if (!pushFileAsText (&tempdesc, s))
         flFile = false;
      AEDisposeDesc (&tempdesc);
      if (!flFile)
         return (false);
      }
   flSuccess = true;
   
   getFileListAsText_fail:
   if (flSuccess)
      *fileList = CFStringCreateCopy
         (kCFAllocatorDefault, s);
   else
      *fileList = NULL;
   
   CFRelease (s);
   return (flSuccess);
   }

One of the parameters, fileList, is a pointer to a CFStringRef that will contain the list of files as quote-enclosed arguments. For each item, pushFileAsText is called, which adds each item to a CFMutableString created at the top of this function.

At the end of the function the CFMutableString is copied to the fileList parameter, which is a non-mutable CFString. (That is, if everything went well. In case of a problem the function returns false and fileList is NULL.)

Adding a single file to the list

pushFileAsText takes one AEDesc that references a file or folder, gets its Unix path, escapes double quotes in the path, escapes $ characters in the path, puts double quotes around the path, adds a space, then adds the text to the passed-in CFMutableString.

Listing 10: adding a single file

pushFileAsText
static Boolean pushFileAsText (const AEDesc *desc,
   CFMutableStringRef s)
   {
   CFStringRef pathString, escapedPathString;
   
   if (!getPathStringFromFSRef (desc, &pathString))
      return (false);
   
   if (!escapeShellText (pathString, &escapedPathString)) {
      CFRelease (pathString);
      return (false);
      }
   
   CFStringAppend (s, CFSTR("\""));
   CFStringAppend (s, escapedPathString);
   CFStringAppend (s, CFSTR("\" "));
   CFRelease (pathString);
   CFRelease (escapedPathString);
   return (true);
   }

It calls getPathStringFromFSRef to get the path to the file as a CFString.

Then it escapes quotes and $ characters inside the path string--if there are quotes anywhere in the path they must be escaped, otherwise there would be quote mis-matches in the command line text. $ characters must be escaped to avoid variable interpolation. (For instance, if you had a file named $USER for some strange reason, the system would replace $USER with your actual user name. We don't want that to happen here.)

It then adds a quote to the mutable string, then adds the escaped path, then adds another quote and a space. You end up with a string like "/path/to/some/file" or "/path to some/file" or "/path to/\$some\" file/."

Getting the path string

getPathStringFromFSRef gets a Unix path string from an FSRef and puts it into a CFString.

Listing 11: getting a path string from an FSRef

getPathStringFromFSRef
static Boolean getPathStringFromFSRef (const AEDesc *desc,
   CFStringRef *pathString)
   {
   FSRef fileRef;
   Size dataSize = AEGetDescDataSize (desc);
     OSErr err;
   CFURLRef fileURL;
   
   err = AEGetDescData (desc, &fileRef, dataSize);
   if (err != noErr)
      return (false);
   
   fileURL = CFURLCreateFromFSRef (kCFAllocatorDefault,
      &fileRef);
   if (fileURL == NULL)
      return (false);
   
   *pathString = CFURLCopyFileSystemPath (fileURL,
      kCFURLPOSIXPathStyle);
   return (*pathString != NULL);
   }

First it gets the FSRef object from the AEDesc by calling AEGetDescData. CFURLCreateFromFSRef gets a CFURLRef from the FSRef--and from that it gets the Unix-style path by calling CFURLCopyFileSystemPath.

getCFString is a simple wrapper around CFStringCreateWithCString, which creates a CFString based on a C string.

Escaping text for the command line

Back to pushFileAsText: it next calls escapeShellText. It does a couple find-and-replace operations in a CFString. It escapes double quotes and $ characters to avoid quote mis-matches (when a file contains a double quote in its name) and variable interpolation (on the off chance you have a file named something like $USER).

Listing 12: escapeShellText

escapeShellText
static Boolean escapeShellText (CFStringRef source,
   CFStringRef *dest)
   {
   CFStringRef tempString;
   Boolean flSuccess = false;
   
   if (!replaceAll (CFSTR("\""), CFSTR("\\\""),
      source, &tempString))
      return (false);
   flSuccess = replaceAll (CFSTR("$"), CFSTR("\\$"),
      tempString, dest);
   if (flSuccess)
      CFRelease (tempString);
   return (flSuccess);
   }

replaceAll actually performs the replacements.

Listing 13: replaceAll

replaceAll
static Boolean replaceAll (CFStringRef searchFor, CFStringRef replaceWith, CFStringRef source, 
CFStringRef *dest)
   {
   CFMutableStringRef mutableString;
   CFRange range;
   
   mutableString = CFStringCreateMutableCopy
      (kCFAllocatorDefault, 0, source);
   if (mutableString == NULL)
      return (false);
   
   range = CFRangeMake (0,
      CFStringGetLength (mutableString));
   CFStringFindAndReplace (mutableString, searchFor,
      replaceWith, range, 0);
   
   *dest = CFStringCreateCopy (kCFAllocatorDefault,
      mutableString);
   CFRelease (mutableString);
   return (*dest != NULL);
   }

The first parameter to replaceAll is the string to find, the second parameter is the replacement string, the third parameter is the source string in which to search, and the last parameter is a pointer to a CFStringRef that will contain the result.

Aside: CFStrings

Question: why use CFStringRefs and CFMutableStringRefs? Why not traditional C strings or Pascal strings?

Because, as with FSRefs, Apple's message is that CFStrings are the way to go. But beyond that they have several benefits:

    1. They're easy to manipulate. Functions like CFStringAppend make it very easy, for instance, to add text to a string. Check out CFString.h--there is a gold mine of functions which make string manipulation easy.

    2. CFStrings are "toll-free bridged" with Cocoa NSStrings. This means that, among other things, when writing Cocoa code, you can call CFString functions with NSStrings as parameters. This is an example of a kind of convergence, where you can write code that works in Carbon apps as well as Cocoa apps.

    3. CFStrings take some of the headache out of supporting various character encodings. Support for Unicode and other text encodings is an important part of a modern operating system, and CFStrings can store Unicode text as well as other encodings.

Back to runTouchCommand

Now it's time to actually call the system to run the Touch command.

If getFileListAsText succeeds, runTouchCommand calls callSystem, which takes two parameters: the path to the command to execute and the command line parameters (as a single string).

The path to the Touch command is /usr/bin/touch. The command line parameters in this case is the list of files created in getFileListAsText.

Note the line (in runTouchCommand): callSystem (CFSTR("/usr/bin/touch"), commandLineParams);

The CFSTR("/usr/bin/touch") part is a shortcut for creating a CFStringRef from quoted text. (Cocoa developers will note that it's the equivalent of typing @"/usr/bin/touch" to create an NSString.)

Calling the system

It's simple to call the system to execute a command-line string. There's a function actually named system that takes a C string and executes the command as if typed in the Terminal.

(The man page for system is pretty short and worth checking out.)

Listing 14: calling the system

callSystem
static void callSystem (CFStringRef command,
   CFStringRef params)
   {
   char *buffer;
   CFMutableStringRef fullCommand;
   fullCommand = CFStringCreateMutableCopy
      (kCFAllocatorDefault, 0, command);
   if (fullCommand == NULL)
      return;
   CFStringAppend (fullCommand, CFSTR(" "));
   CFStringAppend (fullCommand, params);
   if (!getCString (&buffer, fullCommand,
      kCFStringEncodingUTF8))
      goto callSystem_exit;
      
   printf ("System call: ");
   printf (buffer);
   printf ("\n");
   
   system (buffer);
   
   callSystem_exit:
   CFRelease (fullCommand);
   if (buffer != NULL)
      free (buffer);
   }

Our callSystem function takes two CFStringRef parameters--the path to the command and the command-line parameters. In order to call the system command it needs to create a C string of this form: /path/to/command param1 param2 param3.

In this plugin it will look something like this: /usr/bin/touch "/path/to/file1" "/path/to/anotherFile"

The fullCommand string is a mutable CFString. It starts by copying the command (the /usr/bin/touch part) to itself.

Then it appends a space, since there needs to be a space between the command and the parameters. Then it appends the parameters.

At this point we have the string in the right form for the system, except that it's a CFString rather than a C string. So it calls getCString to get a C string.

Listing 15: getCString

getCString
static Boolean getCString (char **cStringToGet,
   CFStringRef cfString, CFStringEncoding encoding)
   {
   UInt32 lenText = sizeof (UniChar) *
      CFStringGetLength (cfString) + 1;
   *cStringToGet = malloc (lenText);
   if (*cStringToGet == NULL)
      return (false);
   
   if (!CFStringGetCString (cfString, *cStringToGet,
      lenText, encoding)) {
      free (*cStringToGet);
      *cStringToGet = NULL;
      return (false);
      }
   return (true);
   }

This function is a wrapper for CFStringGetCString, which gets a C string from a CFString. First this function allocates a buffer that's the length of the CFString plus one for zero-termination. Then it calls CFStringGetCString. If that call fails, the buffer is freed and the function returns false. If all's well it returns true.

Back to callSystem

Before calling the system function, notice the trio of printf commands. An important point: one way to debug contextual menus is to use printf commands. The output appears in the Console. (Which lives at /Applications/Utilities/Console.) If you launch it, then run the Touch command from a contextual menu, you'll see a copy of what gets passed to the system command in the Console.

Though not used here, another important function, similar to printf, is the CFShow command. This prints a description of a CoreFoundation object--such as a CFString--to the Console. (See CFShow and CFShowStr in CFString.h and in the documentation.)

Okay, now it finally calls the system with the C string from getCString. This runs the Unix touch command with the list of files as parameters.

This function cleans up, then control goes back to runTouchCommand, then back to handleSelection which returns noErr.

That's it for running a Unix command. You know the basics--you can do just about anything Unix-y from a contextual menu plugin now.

But it didn't work...

Oh yes it did. But it might not have appeared to work.

Here's why: sometimes the Finder doesn't update its display right away when you make a change to a file from a contextual menu. And so it may look like the command didn't work, even though it actually did.

So runTouchCommand sends the Finder a kAESync event via the sendUpdateEventToFinder function:

Listing 16: sending a kAESync event to the Finder

sendUpdateEventToFinder
static void sendUpdateEventToFinder (const AEDesc *desc)
   {
   AEAddressDesc finderAddressDesc = {typeNull, NULL};
   AppleEvent event = {typeNull, NULL};
   AppleEvent reply = {typeNull, NULL};
   OSType finderSignature = 'MACS';
   OSErr err;
   
   err = AECreateDesc (typeApplSignature, &finderSignature,
      sizeof (finderSignature), &finderAddressDesc);
   if (err != noErr)
      return;
      
   err = AECreateAppleEvent (kAEFinderSuite, kAESync,
      &finderAddressDesc, kAutoGenerateReturnID,
      kAnyTransactionID, &event);
   require_noerr (err, sendUpdateEventToFinder_fail);
   
   err = AEPutParamDesc (&event, keyDirectObject, desc);
   require_noerr (err, sendUpdateEventToFinder_fail2);
   
   AESend (&event, &reply, kAENoReply + kAECanInteract,
      kAENormalPriority, kAEDefaultTimeout, NULL, NULL);
   
   sendUpdateEventToFinder_fail2:
   AEDisposeDesc (&event); 
   
   sendUpdateEventToFinder_fail:
   AEDisposeDesc (&finderAddressDesc);
   }

It creates an Apple event that just asks the Finder to update its display. (See FinderRegistry.h for this and other commands you can send the Finder.)

This function passes to the Finder, as the direct object parameter, the same AEDesc context received in handleSelection. One assumes that the Finder knows what to do with this AEDesc since it came from the Finder in the first place.

kAENoReply is specified, since a reply wouldn't be useful, and we don't really care if the Finder failed to handle the event. We hope it handled the event, but if it didn't it's no big deal, since the Touch command succeeded anyway.

Text commands and submenus

Let's back all the way up to examineContext and show how to add a submenu to the contextual menu and how to handle our two text commands (Copy and Search with Google).

Recall that if it's not one or more files or folders that are selected, then examineContext checks to see if it's text that's selected. isTextSelection performs this check. If the check returns true, then examineContext calls addTextCommandsToMenu to add the submenu and its commands.


Figure 2. Text commands in BBEdit's contextual menu

isTextSelection

This function checks the AEDesc that examineContext got from the system to see if it's of type text or can be coerced to type text.

Listing 17: checking if a selection is a text selection

isTextSelection
static Boolean isTextSelection (const AEDesc *desc)
   {
      return (descIsOfType (desc, typeChar));
   }

typeChar, defined in AEDataModel.h, is 'TEXT.' This function calls descIsOfType, which was also called by isFileOrFolder to determine if an AEDesc refers to a file or folder.

If isTextSelection returns true, then examineContext calls addTextCommandsToMenu with the AEDescList from the system that will become the contextual menu.

Adding text commands

The plugin will build a menu item named Text Samples with a submenu. The submenu will contain two commands: Copy and Search with Google.

Here things are done in what may seem like a backward order. First the submenu (Copy and Search with Google commands) is created, then the menu item is created that will contain the submenu.

Listing 18: adding text commands to the menu

addTextCommandsToMenu
static Boolean addTextCommandsToMenu (AEDescList*
   commandList)
   {
   AEDescList submenuCommands = {typeNull, NULL};
   OSErr err;
   Boolean flSuccess = false;
   CFStringRef textSubmenuName =
      CFCopyLocalizedString (CFSTR("Text Samples"),
         "Text Samples Menu Text");
   err = AECreateList (NULL, 0, false, &submenuCommands);
   if (err != noErr)
      return (false);
   
   if (!buildTextSubmenu (&submenuCommands)) {
      AEDisposeDesc (&submenuCommands);
      return (false);
      }
   
   flSuccess = pushCommand (textSubmenuName, nil,
      commandList, &submenuCommands);
   AEDisposeDesc (&submenuCommands);
   return (flSuccess);
   }

The submenu is, like the commandList passed to examineContext from the system, an AEDescList that will specify one or more commands. submenuCommands will hold the submenu: buildTextSubmenu creates this submenu.

Listing 19: building a submenu

buildTextSubmenu
static Boolean buildTextSubmenu (AEDescList *commands)
   {
   CFStringRef searchCommand = CFCopyLocalizedString
      (CFSTR("Search with Google"), "Search Menu Text");
   CFStringRef copyCommand = CFCopyLocalizedString
      (CFSTR("Copy"), "Copy Menu Text");
   if (!pushCommand (copyCommand, copyCommandID,
      commands, nil))
      return (false);
   return (pushCommand (searchCommand, searchCommandID,
      commands, nil));
   }

It calls pushCommand twice, with the command names, command IDs, and the AEDescList that will contain the commands.

If it succeeds, then addTextCommandsToMenu calls pushCommand to add the Text Samples menu item that will contain the submenu.

Here's the important point: both the main contextual menu and any submenus are AEDescLists. That means that you add commands the same way, whether it's the main menu or any submenus. pushCommand is thus a reusable piece of code, useful whether you're building one or more submenus or not.

Listing 20: adding a command to a menu

pushCommand
static Boolean pushCommand (CFStringRef commandName,
   SInt32 commandID, AEDescList* commands,
   AEDescList *submenuToAttach)
   {
   OSStatus err = noErr;
   AERecord commandRecord = {typeNull, NULL};
   Boolean flSuccess = false;
   char *commandNameCString;
   
   err = AECreateList (NULL, 0, true, &commandRecord);
   require_noerr (err, pushCommand_fail);
   if (!getCString (&commandNameCString, commandName,
      kCFStringEncodingUTF8))
      goto pushCommand_fail;
   
   err = AEPutKeyPtr (&commandRecord, keyAEName,
      typeUTF8Text, commandNameCString,
      strlen (commandNameCString) + 1);
   free (commandNameCString);
   require_noerr (err, pushCommand_fail);
   
   if (commandID != NULL) {
      err = AEPutKeyPtr (&commandRecord,
         keyContextualMenuCommandID,
         typeLongInteger, &commandID, sizeof (commandID));
      require_noerr (err, pushCommand_fail);
      }
   
   if (submenuToAttach != NULL) {
      err = AEPutKeyDesc (&commandRecord,
         keyContextualMenuSubmenu, submenuToAttach);
      require_noerr (err, pushCommand_fail);
      }
      
   err = AEPutDesc (commands, 0, &commandRecord);
   if (err == noErr)
      flSuccess = true;
      
   pushCommand_fail:
   AEDisposeDesc (&commandRecord);
   return (flSuccess);
   }

When using pushCommand to add a menu item that contains submenu, just call it with a NULL commandID and a non-NULL AEDescList that specifies the submenu it contains (submenuCommands, in this case).

When using pushCommand to add a menu item that does not contain a submenu, specify its command ID, and send a NULL AEDescList, since it will have no submenu.

When addTextCommandsToMenu is finished, control returns to examineContext, which returns noErr. Then the system displays and tracks the contextual menu with the Text Samples menu item and its submenu.

Handling the text commands

Back to handleSelection--if the command ID is the ID of the copy command, runCopyCommand is called. If it's the command ID of the search command, runSearchCommand is called. Let's do runCopyCommand first.

The Copy command copies the selected text to the clipboard (unsurprisingly).

Listing 21: running the Copy command

runCopyCommand
static void runCopyCommand (const AEDesc *desc)
   {
   CFStringRef selectedText;
   
   if (!getTextFromDesc (desc, &selectedText,
      kCFStringEncodingMacRoman))
      return;
   writeStringToClipboard (selectedText);
   CFRelease (selectedText);
   }

First it gets the selected text from the AEDesc via getTextFromDesc, then it writes that text to the clipboard via writeStringToClipboard, then it cleans up and returns to handleSelection.

getTextFromDesc

Any time you're handling text selections you need to get the selected text. getTextFromDesc gets it as a CFString.

Listing 22: getting text from an AEDesc

getTextFromDesc
static Boolean getTextFromDesc (const AEDesc *desc,
   CFStringRef *text, CFStringEncoding encoding)
   {
   long len = AEGetDescDataSize (desc);
   char *s;
   Boolean flSuccess = false;
   
   s = malloc (len + 1);
   if (s == NULL)
      return (false);
   
   AEGetDescData (desc, s, len);
   s [len] = '\0';
   flSuccess = getCFString (text,
      (const char *) s, encoding);
   free (s);
   return (flSuccess);
   }

AEGetDescDataSize tells us how long the selected text is. It then allocates a buffer to get that text, then calls AEGetDescData to put it into the buffer. It zero-terminates the buffer so that it's a C string.

The function then calls getCFString to get a CFString based on the C string it got from the AEDesc.

After freeing the buffer (which is no longer needed) it returns true if the CFString was created and false otherwise.

getCFString is a simple wrapper around CFStringCreateWithCString.

Listing 23: getting a CFString from a C string

getCFString
static Boolean getCFString (CFStringRef *stringToGet, const char *cString, CFStringEncoding encoding)
   {
   *stringToGet = CFStringCreateWithCString
      (kCFAllocatorDefault, cString, encoding);
   return (*stringToGet != NULL);
   }
writeStringToClipboard

runCopyCommand then writes the text to the clipboard via writeStringToClipboard.

Listing 24: writing text to the clipboard

writeStringToClipboard
static void writeStringToClipboard (CFStringRef s)
   {
   ScrapRef scrap;
   char *cString;
   
   if (!getCString (&cString, s, kCFStringEncodingMacRoman))
      return;
   ClearCurrentScrap ();
   GetCurrentScrap (&scrap);
   PutScrapFlavor (scrap, kScrapFlavorTypeText,
      kScrapFlavorMaskNone, strlen (cString),
      (const void *) cString);
   free (cString);
   }

Note that this seems a little crazy--in getTextFromDesc we created a CFString from a C string, then in writeStringToClipboard we turn around and get a C string from a CFString. Why?

Well, in the interests of maintainability, I like having just one function that gets the selected text. Instead there could be one function that returns it as a CFString and another that returns it as a C string. I prefer getting a CFString because CFStrings are so easy to manipulate, and chances are you're going to want to do some operation on the selected text.

In this case, however, we're not manipulating the selected text in any way, just writing it to the clipboard.

Again, the writeStringToClipboard function could take a C string rather than a CFString, but my bet is that most of the time I'll have a CFString, since I use CFStrings so much.

In other words, what I've done is standardize on using CFStrings internally, so it's only at the edges--when getting text from an AEDesc, writing text to the clipboard, or calling the system function--where it's sometimes necessary to convert to or from a CFStringRef. (And, as you'll note later in the Search with Google command, there are cases where no conversion is necessary as more and more APIs take CFStrings.)

So writeStringToClipboard works in some ways like the callSystem command--it takes the CFStringRef and gets a C string. A difference here is what it does with it--it clears the current scrap (the clipboard), gets a reference to the current scrap, then writes the C string as text to the clipboard. Finally it frees the C string it got.

That's it for the Copy command. On to Search with Google.

Running the Search with Google command

This command gets the selected text then creates a search URL string based on a base URL. The search URL is then opened in one's default Web browser.

Listing 25: running the Search command

runSearchCommand
static void runSearchCommand (const AEDesc *desc)
   {
   CFStringRef selectedText, urlString;
   
   if (!getTextFromDesc (desc, &selectedText,
      kCFStringEncodingMacRoman))
      return;
   
   if (!createEncodedSearchURLString
      (CFSTR(googleSearchURL), selectedText, &urlString)) {
      CFRelease (selectedText);
      return;
      }
   
   openInBrowser (urlString);   
   CFRelease (selectedText);
   CFRelease (urlString);
   }

As in runCopyCommand, it calls getTextFromDesc to get the selected text as a CFString.

Then it creates the URL string to pass to the browser by calling createEncodedSearchURLString. Then it calls openInBrowser with that URL string to open it in the browser. Then it cleans up and returns.

Encoding a URL string

This function demonstrates some of the utility of CFStrings. It takes a string like http://foo.com?q= as the base URL and a string like "some selected text" as the search arguments. It creates a string that a Web browser would understand, as in http://foo.com?q=some+selected+text.

In our case the base URL is http://www.google.com/search?q= (defined in SamplePlugin.h), so the final URL string will look something like http://www.google.com/search?q=some+selected+text.

Listing 26: Encoding a URL string

createEncodedSearchURLString
static Boolean createEncodedSearchURLString (CFStringRef
   baseURL, CFStringRef searchArgs, CFStringRef *dest)
   {
   CFStringRef urlString, urlStringPlusEncoded;
   
   urlString = CFStringCreateWithFormat
      (kCFAllocatorDefault, NULL, ("%@%@"),
      baseURL, searchArgs);
   if (!replaceAll (CFSTR(" "), CFSTR("+"), urlString,
      &urlStringPlusEncoded)) {
      CFRelease (urlString);
      return (false);
      }
   
   *dest = CFURLCreateStringByAddingPercentEscapes
      (kCFAllocatorDefault, urlStringPlusEncoded, NULL, NULL,
      kCFStringEncodingISOLatin1);
            
   CFRelease (urlStringPlusEncoded);
   CFRelease (urlString);
   return (*dest != NULL);
   }

First it concatenates the base URL and the search args via CFStringCreateWithFormat. (Cocoa developers, note how similar this is to NSString's stringWithFormat method.)

Then it calls replaceAll to replace spaces with + characters (which is what Web browsers and servers want us to do).

Then it's necessary to URL-encode the URL string--characters such as e and so on have to be converted to percent-encoding. CFURLCreateStringByAddingPercentEscapes does this.

Finally it cleans up, and *dest is the encoded string.

Opening a URL in the default Web browser

runSearchCommand then calls openInBrowser to actually open the URL in one's default Web browser.

Listing 27: opening a URL

openInBrowser
static void openInBrowser (CFStringRef urlString)
   {
   CFURLRef urlRef = CFURLCreateWithString
      (kCFAllocatorDefault, urlString, NULL);
   if (urlRef != NULL) {
      LSOpenCFURLRef (urlRef, NULL);
      CFRelease (urlRef);
      }
   }

The sole parameter is a CFStringRef, the encoded URL string created in createEncodedSearchURLString. The system call for opening a URL in a browser wants a CFURLRef, which is created via CFURLCreateWithString. (Cocoa developers: this is like NSURL's URLWithString method.)

Then the LaunchServices function LSOpenCFURLRef is called. This useful function also opens local URLs, doing different things depending on what kind of URL it has. In this case it's an HTTP URL, and so it opens the URL in the default browser. Note that the code doesn't even know or care what the default browser is: the system handles it for us. (Cocoa developers note how this is like NSWorkspace's openURL method.)

You can get more control over how the default browser opens the URL by calling LSOpenFromURLSpec--for instance, you can tell the browser not to come to the front. But in this sample we use LSOpenCFURLRef because the default behavior is what we want anyway.

After calling openInBrowser, runSearchCommand cleans up and returns control to handleSelection, which returns noErr.

Room for improvement

There some things this plugin could do better--but since this an article rather than a book, we'll leave that up to you. A couple obvious things:

When getting text to write to the clipboard or run a search on, it naively assumes MacRoman text encoding, which is not necessarily the case. It's unlikely the results will be satisfactory on, for instance, Japanese systems.

Another issue is error reporting--this plugin just fails silently when something goes wrong. At a minimum it could write an error message to the Console. But since most people don't leave their Console running, a better choice would be to display a dialog box letting the user know the error.

Bonus Tips

Debugging

I mentioned earlier that printf, CFShow, and CFShowStr are useful for debugging--you can print to the Console to help you figure out what's going on.

But what if you want to really debug--that is, set breakpoints and step through your code? A plug-in isn't an application, so you can't just run and debug it as-is. Here's what you do:

From the Project menu choose New Custom Executable. Click the Set... button in the dialog that appears. Choose an application that supports system contextual menus. Me, I always use Script Editor, just because it launches very quickly on my machine. Click the Finish button.


Figure 3. Attaching an executable to the plugin

Then, before debugging your plugin, set a breakpoint somewhere--in examineContext, for instance. To begin debugging, choose Debug Executable from the Debug menu. Script Editor (or whatever app you chose) will launch.

Type some text in the app you chose, select it, then ctrl-click (or right-click) on the selected text. Project Builder's debugger should come to the front with execution stopped at your breakpoint. From there you can debug as normal.

(Unfortunately I have not figured out how to get this to work with the Finder, but it works very well for other apps.)

So your cycle becomes like this: build your plugin, delete the old version from your ~/Library/Contextual Menu Items/ folder, copy the new build to your Contextual Menu Items folder, choose Debug Executable, then debug. Lather, rinse, repeat.

Quitting apps

Remember that, when you rebuild a plugin and put it in the Contextual Menu Items folder, you need to quit any app you're testing with in order to force it to reload the plugin. What's the fastest way to quit an app that's not in front? Ctrl-click (or right-click) on its icon in the Dock and choose Quit from the pop-up menu.

This doesn't work with the Finder. However, you can enable a Quit menu item for the Finder so that you can type Cmd-Q in the Finder. In Terminal, type: defaults write com.apple.finder QuitMenuItem Enabled

You will have to log out then log back in for the changes to take effect. If later on you want to disable the Quit menu item in the Finder, in Terminal type: defaults write com.apple.finder QuitMenuItem Disabled

Quit the Finder and re-start it (by clicking its icon in the Dock) and the Quit menu item will be gone.

Conclusion

To summarize a few important points from this article:

    - To run a Unix command, call the system function with a C string, with text that looks like what you would type on the command line. Remember to add and escape quotes and $ characters as needed.

    - CFStrings are good to use for lots of reasons, but the best one is that it makes string manipulation easy.

    - Whether adding a command to the main contextual menu or to a submenu, you're doing the same thing, adding an item to an AEDescList. There's no difference.

    - There are other high-level routines like LSOpenCFURLRef that do things like open a URL in the default browser. If you find yourself doing things like writing code to support different browsers, there's a good chance you're working way too hard. Pay special attention to the CoreFoundation and LaunchServices frameworks. Even Cocoa developers can benefit by looking at these frameworks, since there are useful functions there that have no Cocoa equivalent but that work with NSStrings and NSURLs and so on.

    - printf, CFShow, and CFShowStr are useful for debugging--but, even better, you can truly debug a plugin by attaching an executable and setting a breakpoint.

    - Remember to quit apps you're testing with after rebuilding your plugin. You can even enable a Quit item for the Finder via the defaults Terminal command.

Thanks to...

For review and feedback for this article, thanks to Jim Correia, Quentin D. Carnicelli, and George Warner. Any remaining errors or oddities are mine.

References


Brent Simmons is a Seattle-based independent Mac OS X developer and writer. He runs a Mac developer news weblog at ranchero.com; he can be contacted at brent@ranchero.com.

 
AAPL
$119.00
Apple Inc.
+1.40
MSFT
$47.75
Microsoft Corpora
+0.28
GOOG
$540.37
Google Inc.
-0.71

MacTech Search:
Community Search:

Software Updates via MacUpdate

HoudahSpot 3.9.6 - Advanced file search...
HoudahSpot is a powerful file search tool built upon MacOS X Spotlight. Spotlight unleashed Create detailed queries to locate the exact file you need Narrow down searches. Zero in on files Save... Read more
RapidWeaver 6.0.3 - Create template-base...
RapidWeaver is a next-generation Web design application to help you easily create professional-looking Web sites in minutes. No knowledge of complex code is required, RapidWeaver will take care of... Read more
iPhoto Library Manager 4.1.10 - Manage m...
iPhoto Library Manager lets you organize your photos into multiple iPhoto libraries. Separate your high school and college photos from your latest summer vacation pictures. Or keep some photo... Read more
iExplorer 3.5.1.9 - View and transfer al...
iExplorer is an iPhone browser for Mac lets you view the files on your iOS device. By using a drag and drop interface, you can quickly copy files and folders between your Mac and your iPhone or... Read more
MacUpdate Desktop 6.0.3 - Discover and i...
MacUpdate Desktop 6 brings seamless 1-click installs and version updates to your Mac. With a free MacUpdate account and MacUpdate Desktop 6, Mac users can now install almost any Mac app on macupdate.... Read more
SteerMouse 4.2.2 - Powerful third-party...
SteerMouse is an advanced driver for USB and Bluetooth mice. It also supports Apple Mighty Mouse very well. SteerMouse can assign various functions to buttons that Apple's software does not allow,... Read more
iMazing 1.1 - Complete iOS device manage...
iMazing (was DiskAid) is the ultimate iOS device manager with capabilities far beyond what iTunes offers. With iMazing and your iOS device (iPhone, iPad, or iPod), you can: Copy music to and from... Read more
PopChar X 7.0 - Floating window shows av...
PopChar X helps you get the most out of your font collection. With its crystal-clear interface, PopChar X provides a frustration-free way to access any font's special characters. Expanded... Read more
Carbon Copy Cloner 4.0.3 - Easy-to-use b...
Carbon Copy Cloner backups are better than ordinary backups. Suppose the unthinkable happens while you're under deadline to finish a project: your Mac is unresponsive and all you hear is an ominous,... Read more
ForeverSave 2.1.3 - Universal auto-save...
ForeverSave auto-saves all documents you're working on while simultaneously doing backup versioning in the background. Lost data can be quickly restored at any time. Losing data, caused by... Read more

Latest Forum Discussions

See All

Bounce On Back (Games)
Bounce On Back 1.0 Device: iOS Universal Category: Games Price: $2.99, Version: 1.0 (iTunes) Description: | Read more »
Make Way for Fat Chicken, from the Maker...
Make Way for Fat Chicken, from the Makers of Scrap Squad Posted by Jessica Fisher on November 26th, 2014 [ permalink ] Relevant Games has announced they will be releasing their reverse tower defense game, | Read more »
Tripnary Review
Tripnary Review By Jennifer Allen on November 26th, 2014 Our Rating: :: TRAVEL BUCKET LISTiPhone App - Designed for the iPhone, compatible with the iPad Want to create a travel bucket list? Tripnary is a fun way to do exactly that... | Read more »
Ossian Studios’ RPG, The Shadow Sun, is...
Ossian Studios’ RPG, The Shadow Sun, is Now Available for $4.99 Posted by Jessica Fisher on November 26th, 2014 [ permalink ] Universal App - Designed for iPhone and iPad | Read more »
Mmmm, Tasty – Having the Angry Birds for...
The very first Angry Birds debuted on iOS back in 2009. When you sit back and tally up the number of Angry Birds games out there and the impact they’ve had on pop culture as a whole, you just need to ask yourself: “How would the birds taste... | Read more »
Rescue Quest Review
Rescue Quest Review By Jennifer Allen on November 26th, 2014 Our Rating: :: PATH BASED MATCH-3Universal App - Designed for iPhone and iPad Guide a wizard to safety by matching gems. Rescue Quest might not be an entirely original... | Read more »
You Can Play the Final Chapter of Lone W...
You Can Play the Final Chapter of Lone Wolf: Dawn Over V’taag Right Now Posted by Jessica Fisher on November 26th, 2014 [ permalink ] Universal App - Designed for iPhone and iPad | Read more »
Swords of Anima (Games)
Swords of Anima 1.0 Device: iOS Universal Category: Games Price: $2.99, Version: 1.0 (iTunes) Description: A new tactical turn-based RPG experience. Command the Savior Rex Squad in an epic journey of courage and deception. Can you... | Read more »
Audio Defence: Zombie Arena
Audio Defence: Zombie Arena By Lee Hamlet on November 26th, 2014 Our Rating: :: DRAGS ITS FEETUniversal App - Designed for iPhone and iPad From the makers of Papa Sangre comes a defense game that forces players to listen carefully... | Read more »
Tales from the Borderland​s Will be Comi...
Tales from the Borderland​s Will be Coming to iOS by the End of the Year Posted by Jessica Fisher on November 26th, 2014 [ permalink ] Telltale Games has announced | Read more »

Price Scanner via MacPrices.net

2014 1.4GHz Mac mini on sale for $449, save $...
 B&H Photo has the new 1.4GHz Mac mini on sale for $449.99 including free shipping plus NY tax only. Their price is $50 off MSRP, and it’s the lowest price available for this new model. Adorama... Read more
Early Black Friday pricing on 27-inch 5K iMac...
 B&H Photo continues to offer Black Friday sale prices on the 27″ 3.5GHz 5K iMac, in stock today and on sale for $2299 including free shipping plus NY sales tax only. Their price is $200 off MSRP... Read more
Early Black Friday sale prices on iPad Air 2,...
 MacMall is discounting iPad Air 2s by up to $75 off MSRP as part of their Black Friday sale. Shipping is free: - 16GB iPad Air WiFi: $459 $40 off - 64GB iPad Air WiFi: $559 $40 off - 128GB iPad Air... Read more
Early Black Friday MacBook Air sale prices, $...
 MacMall has posted early Black Friday MacBook Air sale prices. Save $101 on all models for a limited time: - 11″ 1.4GHz/128GB MacBook Air: $798 - 11″ 1.4GHz/256GB MacBook Air: $998 - 13″ 1.4GHz/... Read more
Why iPhone 6 Tablet/Laptop Cannibalization Is...
247wallst.com blogger Douglas A. McIntyre noted last week that according to research posted on the Applovin blog site the iPhone 6 is outselling the iPhone 6 Plus by a wide margin . Hardly a surprise... Read more
Worldwide Tablet Growth Expected to Slow to 7...
The global tablet market is expected to record massive deceleration in 2014 with year-over-year growth slowing to 7.2%, down from 52.5% in 2013, according to a new forecast from International Data... Read more
Touchscreen Glove Company Announces New Produ...
Surrey, United Kingdom based TouchAbility specializes in design and manufacture of a wide variety of products compatible with touchscreen devices including smartphones, tablets and computers. Their... Read more
OtterBox Alpha Glass Screen Protectors for iP...
To complement the bigger, sharper displays on the latest Apple devices, OtterBox has introduced Alpha Glass screen protectors to the iPhone 6 and iPhone 6 Plus. The fortified glass screen protectors... Read more
Early Black Friday Mac Pro sale, 6-Core 3.5GH...
 B&H Photo has the 6-Core 3.5GHz Mac Pro on sale today for $3499 including free shipping plus NY sales tax. Their price is $500 off MSRP, and it’s the lowest price available for this model from... Read more
Early Black Friday sale price: 15-inch 2.2GHz...
 B&H Photo has the 2014 15″ 2.2GHz Retina MacBook Pro on sale today for $1699.99. Shipping is free, and B&H charges NY sales tax only. Their price is $300 off MSRP, equalling Best Buy’s price... Read more

Jobs Board

*Apple* Solutions Consultant (ASC) - Apple (...
**Job Summary** The ASC is an Apple employee who serves as an Apple brand ambassador and influencer in a Reseller's store. The ASC's role is to grow Apple Read more
Senior Event Manager, *Apple* Retail Market...
…This senior level position is responsible for leading and imagining the Apple Retail Team's global event strategy. Delivering an overarching brand story; in-store, Read more
*Apple* Retail - Multiple Positions (US) - A...
Sales Specialist - Retail Customer Service and Sales Transform Apple Store visitors into loyal Apple customers. When customers enter the store, you're also the Read more
*Apple* Solutions Consultant (ASC) - Apple (...
**Job Summary** The ASC is an Apple employee who serves as an Apple brand ambassador and influencer in a Reseller's store. The ASC's role is to grow Apple Read more
*Apple* Solutions Consultant (ASC) - Apple (...
**Job Summary** The ASC is an Apple employee who serves as an Apple brand ambassador and influencer in a Reseller's store. The ASC's role is to grow Apple Read more
All contents are Copyright 1984-2011 by Xplain Corporation. All rights reserved. Theme designed by Icreon.