Handling extended attributes

Handling extended attributes

Files and folders in macOS can be assigned “extended attributes“: properties that provide additional metadata. These data are heavily used by Apple’s search engine Spotlight, but you can also define your own extended attributes.

Listing, reading, writing and deleting them is possible through the shell command xattr and through four low-level functions that are accessible from JXA using the ObjC bridge:

  • setxattr sets the value of an extended attribute and creates it if it doesn’t exist already,
  • getxattr retrieves the value of an extended attribute,
  • delxattr deletes an extended attribute, and
  • listxattr lists the names of all extended attributes for a file or folder.

Setting and getting an extended attribute requires passing a pointer to the value. Trying to achieve this with JavaScript is not obvious, since it does not know about pointers. As explained before, there are ways to pass pointers from JXA to ObjC, but those do not work reliably with the functions mentioned above.

To use any of these functions, you’ll have to import the appropriate definitions with ObjC.import("sys/xattr");. All functions need a pointer to the filename as their first parameter. It is created by first converting the JavaScript filename to a NSString: $(filename). Then, the method UTF8String returns a C pointer to this string that can be used in the extended attribute functions.

Get extended attribute names #

To get the names of all extended attributes defined for a file or folder, you call listxattr twice. The first time, it will return the size of the buffer needed to store the attribute names, the second time you store them in this buffer. A sample function returning an array of extended attribute names looks like this:

function JSlistxattr(filename) {
// Import the headers for listxattr and malloc
  ObjC.import('sys/xattr');
  ObjC.import('stdlib');

// Re-define the parameters for listxattr and malloc
  ObjC.bindFunction('listxattr', ['int',['char*', 'void*',  'int','int']]);
  ObjC.bindFunction('malloc', ['void *',['int']]);

// get size for extended attribute name buffer
  const size = $.listxattr($(filename).UTF8String, null, 0, 0);

// allocate buffer and get names of all attributes 
  const buffer = $.malloc(size);
  const res = $.listxattr($(filename).UTF8String, buffer, size, 0);

  /* If an error occured, print it out and return an empty array */
  if (res === -1) {
    $.perror('');
    return []; 
  }

// Copy the buffer content to a JS array 
  const charArray = [];
  for (let i = 0; i < size; i++) {
    charArray.push(buffer[i]);
  }

/* Get the NULL-delimited strings from the character array and 
   save the strings in xattrArray. The string is removed from charArray */
  const xattrArray = [];
  while (charArray.length > 0) {
    const nullIndex = charArray.indexOf(0);
    const str = String.fromCharCode(...charArray.splice(0,nullIndex));
    charArray.splice(0,1); /* remove NULL */
    xattrArray.push(str);
  }
  return xattrArray;
}

The initial call to ObjC.bindFunction re-declares listxattr with parameter types that work in this context. Notably, the second parameter (the buffer) has to be of type void * here. In the first call to listxattr, the buffer is null which causes the function to return the number of bytes needed to store all attribute names.

Then malloc is used to create a buffer of the appropriate size which is passed to the second call of listxattr. On return, buffer contains the names of the extended attributes separated by NULL bytes. The rest of the code takes care of creating a JavaScript array of JavaScript strings from it.

In order to do so, the content of buffer is first copied to the JavaScript array charArray. That is necessary because buffer is not a JavaScript array: it only permits to access its element with [] but provides no Array methods like splice or properties like length.

Get extended attribute values #

While getting the names of extended attributes is relatively easy, retrieving their values is not because they can take on nearly every data type under the sun: they can be binary or XML property lists or character strings, integers or NSDate objects. So the code to retrieve extended attributes has to be different for different types of attributes.

A very simple example retrieves the “last used date”:

ObjC.import("objc");
ObjC.import('sys/xattr');
const date = $.NSDate.alloc.init;
const size = $.class_getInstanceSize($.NSDate);
res = $.getxattr($(filename).UTF8String, 
   $('com.apple.lastuseddate#PS').UTF8String, date, size, 0, 0);
console.log(date.descriptionWithLocale($.NSLocale.currentLocale).js);

The important thing here is to allocate and initialize an NSDate object first. It will act as a buffer in the call to getxattr. Then determine its size with the Objective-C runtime method class_getInstanceSize. Finally, call getxattr with the required parameters, passing it the filename and the name of the extended attribute as char * values by calling UTF8String on these objects.

Set extended attributes #

What has been said before about getting extended attributes is true about setting them, too: They can take about any conceivable type, and you have to know what type a particular attribute is expecting. Otherwise, it might be a lot less useful than you think.

One of the more helpful extended attributes is metadata.apple.com:kMDItemComment. It’s part of Apple’s meta data attributes that Spotlight can search for. It is not the same as the Finder comment, though. So opening the info dialog for a file in finder will not display this string. But you can still use Spotlight to find it. The snippet below shows how to set this extended attribute:

ObjC.import('sys/xattr');

function addTextAsComment(text, file) {
  ObjC.bindFunction('setxattr', 
  ['int',['char*', 'char*', 'char*', 'int','int','int']]);

  /* Build an XML property list with the passed in text
     as its sole value */
  const XMPList = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" 
      "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<string>${text}</string>
</plist>`
  /* Set the Item Comment for Spotlight */
  res = $.setxattr($(filename).UTF8String, 
    $("com.apple.metadata:kMDItemComment").UTF8String, 
    $(pListXML).UTF8String, pListXML.length, 0, 0);
  if (res === -1) $.perror('');
}

Here, the comment is passed to setxattr as part of an XML property list. Note also the usage of ObjC.bindFunction at the top of the function. It is required here because the declaration of setxattr in the system’s C header would clash with its usage here. bindFunction effectively re-declares the function parameters so that the later call simply works without producing any runtime errors.

A similar function can be used to add keywords to a file. It is again using an XML property list.

ObjC.import('sys/xattr');

function addKeywordsToFile(keywords, file) {
  ObjC.bindFunction('setxattr', 
  ['int',['char*', 'char*', 'char*', 'int','int','int']]);
  /* Build an XML property list containing 
    an array with the keywords */
  const pListStart = '<!DOCTYPE plist PUBLIC 
    "-//Apple//DTD PLIST 1.0//EN" 
    "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    <plist version="1.0"><array>';
  const pListEnd   = '</array></plist>'
  const pListKeywords = []
  keywords.forEach(k => {
    pListKeywords.push(`<string>${k}</string>`);
  })
  const pListXML = pListStart + pListKeywords.join("") + pListEnd;

  res = $.setxattr($(filename).UTF8String, 
    $("com.apple.metadata:kMDItemKeywords").UTF8String, 
    $(pListXML).UTF8String, pListXML.length, 0, 0);
  if (res === -1) $.perror('');
}