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.

 

Community Search:
MacTech Search:

Software Updates via MacUpdate

Latest Forum Discussions

See All

Make the passage of time your plaything...
While some of us are still waiting for a chance to get our hands on Ash Prime - yes, don’t remind me I could currently buy him this month I’m barely hanging on - Digital Extremes has announced its next anticipated Prime Form for Warframe. Starting... | Read more »
If you can find it and fit through the d...
The holy trinity of amazing company names have come together, to release their equally amazing and adorable mobile game, Hamster Inn. Published by HyperBeard Games, and co-developed by Mum Not Proud and Little Sasquatch Studios, it's time to... | Read more »
Amikin Survival opens for pre-orders on...
Join me on the wonderful trip down the inspiration rabbit hole; much as Palworld seemingly “borrowed” many aspects from the hit Pokemon franchise, it is time for the heavily armed animal survival to also spawn some illegitimate children as Helio... | Read more »
PUBG Mobile teams up with global phenome...
Since launching in 2019, SpyxFamily has exploded to damn near catastrophic popularity, so it was only a matter of time before a mobile game snapped up a collaboration. Enter PUBG Mobile. Until May 12th, players will be able to collect a host of... | Read more »
Embark into the frozen tundra of certain...
Chucklefish, developers of hit action-adventure sandbox game Starbound and owner of one of the cutest logos in gaming, has released their roguelike deck-builder Wildfrost. Created alongside developers Gaziter and Deadpan Games, Wildfrost will... | Read more »
MoreFun Studios has announced Season 4,...
Tension has escalated in the ever-volatile world of Arena Breakout, as your old pal Randall Fisher and bosses Fred and Perrero continue to lob insults and explosives at each other, bringing us to a new phase of warfare. Season 4, Into The Fog of... | Read more »
Top Mobile Game Discounts
Every day, we pick out a curated list of the best mobile discounts on the App Store and post them here. This list won't be comprehensive, but it every game on it is recommended. Feel free to check out the coverage we did on them in the links below... | Read more »
Marvel Future Fight celebrates nine year...
Announced alongside an advertising image I can only assume was aimed squarely at myself with the prominent Deadpool and Odin featured on it, Netmarble has revealed their celebrations for the 9th anniversary of Marvel Future Fight. The Countdown... | Read more »
HoYoFair 2024 prepares to showcase over...
To say Genshin Impact took the world by storm when it was released would be an understatement. However, I think the most surprising part of the launch was just how much further it went than gaming. There have been concerts, art shows, massive... | Read more »
Explore some of BBCs' most iconic s...
Despite your personal opinion on the BBC at a managerial level, it is undeniable that it has overseen some fantastic British shows in the past, and now thanks to a partnership with Roblox, players will be able to interact with some of these... | Read more »

Price Scanner via MacPrices.net

You can save $300-$480 on a 14-inch M3 Pro/Ma...
Apple has 14″ M3 Pro and M3 Max MacBook Pros in stock today and available, Certified Refurbished, starting at $1699 and ranging up to $480 off MSRP. Each model features a new outer case, shipping is... Read more
24-inch M1 iMacs available at Apple starting...
Apple has clearance M1 iMacs available in their Certified Refurbished store starting at $1049 and ranging up to $300 off original MSRP. Each iMac is in like-new condition and comes with Apple’s... Read more
Walmart continues to offer $699 13-inch M1 Ma...
Walmart continues to offer new Apple 13″ M1 MacBook Airs (8GB RAM, 256GB SSD) online for $699, $300 off original MSRP, in Space Gray, Silver, and Gold colors. These are new MacBook for sale by... Read more
B&H has 13-inch M2 MacBook Airs with 16GB...
B&H Photo has 13″ MacBook Airs with M2 CPUs, 16GB of memory, and 256GB of storage in stock and on sale for $1099, $100 off Apple’s MSRP for this configuration. Free 1-2 day delivery is available... Read more
14-inch M3 MacBook Pro with 16GB of RAM avail...
Apple has the 14″ M3 MacBook Pro with 16GB of RAM and 1TB of storage, Certified Refurbished, available for $300 off MSRP. Each MacBook Pro features a new outer case, shipping is free, and an Apple 1-... Read more
Apple M2 Mac minis on sale for up to $150 off...
Amazon has Apple’s M2-powered Mac minis in stock and on sale for $100-$150 off MSRP, each including free delivery: – Mac mini M2/256GB SSD: $499, save $100 – Mac mini M2/512GB SSD: $699, save $100 –... Read more
Amazon is offering a $200 discount on 14-inch...
Amazon has 14-inch M3 MacBook Pros in stock and on sale for $200 off MSRP. Shipping is free. Note that Amazon’s stock tends to come and go: – 14″ M3 MacBook Pro (8GB RAM/512GB SSD): $1399.99, $200... Read more
Sunday Sale: 13-inch M3 MacBook Air for $999,...
Several Apple retailers have the new 13″ MacBook Air with an M3 CPU in stock and on sale today for only $999 in Midnight. These are the lowest prices currently available for new 13″ M3 MacBook Airs... Read more
Multiple Apple retailers are offering 13-inch...
Several Apple retailers have 13″ MacBook Airs with M2 CPUs in stock and on sale this weekend starting at only $849 in Space Gray, Silver, Starlight, and Midnight colors. These are the lowest prices... Read more
Roundup of Verizon’s April Apple iPhone Promo...
Verizon is offering a number of iPhone deals for the month of April. Switch, and open a new of service, and you can qualify for a free iPhone 15 or heavy monthly discounts on other models: – 128GB... Read more

Jobs Board

IN6728 Optometrist- *Apple* Valley, CA- Tar...
Date: Apr 9, 2024 Brand: Target Optical Location: Apple Valley, CA, US, 92308 **Requisition ID:** 824398 At Target Optical, we help people see and look great - and Read more
Medical Assistant - Orthopedics *Apple* Hil...
Medical Assistant - Orthopedics Apple Hill York Location: WellSpan Medical Group, York, PA Schedule: Full Time Sign-On Bonus Eligible Remote/Hybrid Regular Apply Now Read more
*Apple* Systems Administrator - JAMF - Activ...
…**Public Trust/Other Required:** None **Job Family:** Systems Administration **Skills:** Apple Platforms,Computer Servers,Jamf Pro **Experience:** 3 + years of Read more
Liquor Stock Clerk - S. *Apple* St. - Idaho...
Liquor Stock Clerk - S. Apple St. Boise Posting Begin Date: 2023/10/10 Posting End Date: 2024/10/14 Category: Retail Sub Category: Customer Service Work Type: Part Read more
Top Secret *Apple* System Admin - Insight G...
Job Description Day to Day: * Configure and maintain the client's Apple Device Management (ADM) solution. The current solution is JAMF supporting 250-500 end points, Read more
All contents are Copyright 1984-2011 by Xplain Corporation. All rights reserved. Theme designed by Icreon.