Implementing NSServices for macOS

18th September 2021 | Programming

As far back as 2005, I was looking at adding support for NSServices to Permanent Eraser. However, due to poor design decisions, it was not possible to implement them due to timing issues. With Snow Leopard's improved support for NSServices, I created a plug-in service to allow a user to right-click on a file and be able to erase it with Permanent Eraser.

With the intended successor to Permanent Eraser 2, I have been experimenting with adding support for NSServices. NSServices have been around for a long time, so the available information is somewhat jumbled, especially with some additions which were added with Mac OS X 10.5.

Info.plist

The first step is to add NSServices key-value pair the Info.plist. Below is a simple example to configure a single service.


<key>NSServices</key>
<array>  
	<dict>
		<key>NSMenuItem</key>
		<dict>
			<key>default</key>
			<string>Service Menu Title</string>
		</dict>
		<key>NSMessage</key>
		<string>doSomeStuffMethodName</string>
		<key>NSPortName</key>
		<string>SomeApp</string>
		<key>NSRequiredContext</key>
		<dict/>
		<key>NSSendFileTypes</key>
		<array>
			<string>public.item</string>
		</array>
	</dict>
</array>

Here is another example with some excellent comments on what the various keys represent.


<key>NSServices</key>
<array>        
    <dict>
        <key>NSMenuItem</key>
        <dict>
            <key>default</key>
            <string>Folder Handling Demo</string>
        </dict>
        <key>NSMessage</key>
        <string>handleServices</string> <!-- This specifies the selector -->
        <key>NSPortName</key>
        <string>Tmp</string>       <!-- This is the name of the app -->

        <!-- Here we're limiting where the service will appear. -->
        <key>NSRequiredContext</key>
        <dict>
            <key>NSTextContent</key>
            <string>FilePath</string>
        </dict>
        <!-- This service is only really useful from the Finder. So
         we want the finder only to send us the URI "public.directory"
         which *will* include packages (on the off-chance you want to
         see the full package directory name) -->
        <key>NSSendFileTypes</key>
        <array>
            <!-- Check out "System-Declared Uniform Type Identifiers"
             in the Apple documentation for the various UTI types.
             In this example, all we want is a directory, which is
             a super-type to other types (e.g. public.folder) -->
            <string>public.folder</string>
        </array>
    </dict>
</array>

For Permanent Eraser, I have a more complex version set up.


<key>NSServices</key>
<array>
	<dict>
		<key>NSKeyEquivalent</key>
		<dict>
			<key>default</key>
			<string>E</string>
		</dict>
		<key>NSMenuItem</key>
		<dict>
			<key>default</key>
			<string>Erase File</string>
		</dict>
		<key>NSMessage</key>
		<string>eraseService</string>
		<key>NSPortName</key>
		<string>Permanent Eraser</string>
		<key>NSRequiredContext</key>
		<dict>
			<key>NSTextContent</key>
			<array>
				<string>FilePath</string>
			</array>
		</dict>
		<!--
		<key>NSSendFileTypes</key>
		<array>
			<string>public.file-url</string>
			<string>public.url</string>
			<string>public.item</string>
			<string>public.folder</string>
		</array>
		-->
		<key>NSSendTypes</key>
		<array>
			<string>NSStringPboardType</string>
			<string>NSURLPboardType</string>
			<string>public.utf8-plain-text</string>
			<string>public.url</string>
			<string>public.file-url</string>
		</array>
	</dict>
</array>

Commented out is the NSSendFileTypes key-value pair, which is similar in its functionality to NSSendTypes. According to the Services Implementation Guide the primary difference is that NSSendFileTypes only accepts Uniform Type Identifiers (UTIs), whereas NSSendTypes can accept the older style pasteboard types (e.g. NSStringPboardType, NSURLPboardType) and UTIs.

To localize strings for keys like NSMenuItem and NSServiceDescription, create a ServicesMenu.strings file with the translated strings. If a string (such as for NSServiceDescription) is particularly long, use a shorter token like SERVICE_DESCRIPTION for the key in the strings file.

The Code

From the AppDelegate's applicationDidFinishLaunching method, I call the following setupServiceProvider() method, which creates an instance of the ContextualMenuServiceProvider class and then calls NSUpdateDynamicServices() to dynamically refresh any new services.


func setupServiceProvider() {
	NSApplication.shared.servicesProvider = ContextualMenuServiceProvider()
	// Call this for sanity's sake to refresh the known services
	NSUpdateDynamicServices()
}
The ContextualMenuServiceProvider.swift file:

import Foundation
import Cocoa

class ContextualMenuServiceProvider: NSObject {

	@objc func eraseService(_ pasteboard: NSPasteboard, userData: String?, error: AutoreleasingUnsafeMutablePointer <NSString>) {
		
		// Just for reference, looking at the number of available pasteboard types
		if let pBoardTypes = pasteboard.types {
			NSLog("Number of pasteboard types: \(pBoardTypes.count)")
			NSLog("Pasteboard Types: \(pBoardTypes)")
		}
		
		// NSFilenamesPboardType is unavailable in Swift, use NSPasteboard.PasteboardType.fileURL
		guard let pboardInfo = pasteboard.string(forType: NSPasteboard.PasteboardType.fileURL) else { 
			NSLog("Could not find an appropriate pasteboard type")
			return
		}
	
		let urlPath = URL(fileURLWithPath: pboardInfo)
		let standardizedURL = URL(fileURLWithPath: pboardInfo).standardized
		let messageText = "Hola info \(pboardInfo) of type \(pboardInfoType) at \(urlPath.absoluteURL) with standardized URL \(standardizedURL)"
		NSLog(messageText)
	}
}

File System Path Types

For Permanent Eraser, I need to get the path for the selected file. When parsing out the returned file path from the pasteboard info, it returned an unintelligible path like: file:///.file/id=6571367.8622082855 . This is certainly not what I was expecting and resulted in some confusion until I learned more about the various methods that macOS can represent a file's path. What I was receiving here was a file reference URL. The advantage of this type is that it can point to the same file, even if the original file is moved (somewhat similar to how a Mac alias can still point to the correct file even if it is relocated). However, what I wanted was a path-based URL, which is easier for me to read and work with.

For most URLs, you build the URL by concatenating directory and file names together using the appropriate NSURL methods until you have the path to the item. A URL built in that way is referred to as a path-based URL because it stores the names needed to traverse the directory hierarchy to locate the item. (You also build string-based paths by concatenating directory and file-names together, with the results stored in a slightly different format than that used by the NSURL class.) In addition to path-based URLs, you can also create a file reference URL, which identifies the location of the file or directory using a unique ID.

All of the following entries are valid references to a file called MyFile.txt in a user’s Documents directory:

Path-based URL: file://localhost/Users/steve/Documents/MyFile.txt

File reference URL: file:///.file/id=6571367.2773272/

String-based path: /Users/steve/Documents/MyFile.txt

There are several ways to convert the file reference URL to a more readable format. Using a quick AppleScript from the Terminal, one can get the string-based path this way:

osascript -e 'get posix path of posix file "file:///.file/id=6571367.4833330"'

In Swift the conversion to a path-based URL is like:

let standardizedURL = URL(fileURLWithPath: pboardInfoFileURL).standardized

Testing

To test if your new service works, copy the application into the Applications folder and then launch your app to ensure that the system recognizes the new service. The NSUpdateDynamicServices() call is used to help refresh the system. However if it appears that macOS is not updating properly, then try running the following commands from the Terminal:

/System/Library/CoreServices/pbs -flush

To list the registered services, use pbs with the -dump_pboard option.

/System/Library/CoreServices/pbs -dump_pboard

References