Managing UTI and URL schemes via Launch Services' API from Swift Nov 27 2019

Apple provides the Launch Services API so we can interact with different applications from our current process. We can define URL schemes for our apps, and when that URL is opened, our app gets launched. We can also specify which application to open when a file associated with a specific Uniform Type Identifier (UTI) is being opened. This is registered in the Launch Services database. In this post, I'll show you how to interact with the Launch Services API to register URL schemes and UTIs.

First, let's review the basics of UTIs and URL schemes:

**Note: You can find this article's code on the GitHub Repository

The idea behind UTI and URL Scheme

There are many ways/cases to launch an application, for example:

The first case is trivial, you open the application, and it launches. Let's move to the other two cases.

Launch an application by opening a file with an associated UTI

If you have created a document-based application, you might be familiar with defining a UTI (Uniform Type Identifier). The UTI allows the operating system to identify a file type. There are many default UTI's in macOS that help us identify file types. The UTIs use a reverse domain naming convention. For example:

1
2
3
4
public.jpeg 
com.apple.quicktime-movie
public.plain-text
com.rderik.myapp

In Apple's ecosystem, when we open a file, the system checks the UTI to see if there is an application in charge of that document type and launches it.

That should be enough to get us started with UTIs. If you are interested in learning more about UTIs, read Apple's Uniform Type Identifiers Overview.

Let's move on and talk about URL schemes.

Launch an application by opening an URL scheme

Another way to trigger the launch of an application is by using a URL scheme. You can also define the URL scheme of your application in the Info.plist. This URL scheme indicates which application to open when we visit the URL. You can trigger the application launch by different methods. These are some of them:

The URL scheme can also include additional parameters that our application can interpret to take specific actions. For example, x-man-page://1/security will open the Terminal application with the man page for security(1). Or opening the Mail application to send an email via: mailto:info@test.com. You get the idea.

Ok, but how do we register this information to the operating system? Enter Launch Services.

Launch Services

We can use the Launch Services' API to manage information related to UTIs and URL schemes. You can read Apple's documentation on Launch Services if you want to dig deeper on all the options. I'll show you the basic operations here. Let's start by querying for information on a UTI.

We'll call our application rdls. Create a new folder and initialise it with the Swift Package Manager (SPM).

1
2
3
$ mkdir rdls
$ cd rdls
$ swift package init --type executable

To access the Launch Services API, we are going to need to import the AppKit module. The Launch Service API is a C API, so take that into account because you might need to work with Pointers. If you need a refresher on how to interoperate with C, check the post I wrote on Using BSD Sockets in Swift, especially the section "C interoperability".

We are going to use the function LSCopyDefaultApplicationURLForContentType(_:_:_:) that returns the application used to open a file with the specific UTI.

The signature for LSCopyDefaultApplicationURLForContentType looks like this:

1
2
3
func LSCopyDefaultApplicationURLForContentType(_ inContentType: CFString, 
                                             _ inRoleMask: LSRolesMask, 
                                             _ outError: UnsafeMutablePointer<Unmanaged<CFError>?>?) -> Unmanaged<CFURL>?

The role mask could be any combination of (Here is the documentation of LSRolesMask):

Let's work directly on our main.swift. If we were going to query the for a specific UTI, the code would look something like:

1
2
3
4
5
6
if let result = LSCopyDefaultRoleHandlerForContentType("public.jpeg" as CFString, .all) {
  let handler = result.takeRetainedValue() as String
  print(handler, terminator: "\n% ")
} else {
    print("handler not found for UTI 'public.jpeg'", terminator: "\n% ")
}

In this case, we want to get the default application to open a file that has the UTI of "public.jpeg", by default it should be com.apple.Preview unless you change the default application.

Let's make our tool more useful and make it a REPL where we can query and set options to our heart's content. This is the skeleton of our code (in main.swift):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import Foundation
import AppKit

func processCommand(_ fd: CFFileDescriptorNativeDescriptor = STDIN_FILENO) -> Int8 {
    let fileH = FileHandle(fileDescriptor: fd)
    let input = String(data: fileH.availableData,
                         encoding: .utf8)?.trimmingCharacters(
                            in: .whitespacesAndNewlines) ?? ""
    let inputS = input.components(separatedBy: " ")
    let (command, arguments) = (inputS[0], inputS.dropFirst().joined(separator: " "))
    switch command {
    case "exit":
      return -1
    case "get":
      if arguments.count < 1 {
        return 1
      }
      if let result = LSCopyDefaultRoleHandlerForContentType(arguments as CFString, .all) {
        let handler = result.takeRetainedValue() as String
        print(handler, terminator: "\n% ")
      } else {
          print("handler not found for UTI \(arguments)", terminator: "\n% ")
      }
      return 0
    case "":
        print("", terminator: "\n% ")
        return 0
    default:
      return 1
    }
}

print("Welcome to rdls a Launch Service tool", terminator: "\n% ")
fflush(__stdoutp)
outerLoop: while true {
    let result = processCommand()
    fflush(__stdoutp)
    switch result {
    case -1:
        break outerLoop
    case 1:
        print("Error reading command", terminator: "\n% ")
    default:
        break
    }
    fflush(__stdoutp)
}
print("Bye bye now.")

Our implementation is very simple, so we can focus on accessing the Launch Service API. That means, don't focus too much on the implementation of the REPL.

Our REPL now supports only two commands:

You can now run it and test it:

1
2
3
4
$ swift run
Welcome to rdls a Launch Service tool
% get public.jpeg
com.apple.Preview

Nice, he?

Ok, we can extend the get command to support querying for URL scheme also. We are going to use the function: LSCopyDefaultApplicationURLForURL, that has the following signature:

1
2
3
func LSCopyDefaultApplicationURLForURL(_ inURL: CFURL, 
                                     _ inRoleMask: LSRolesMask, 
                                     _ outError: UnsafeMutablePointer<Unmanaged<CFError>?>?) -> Unmanaged<CFURL>?

let's do that:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import Foundation
import AppKit

func processCommand(_ fd: CFFileDescriptorNativeDescriptor = STDIN_FILENO) -> Int8 {
    let fileH = FileHandle(fileDescriptor: fd)
    let input = String(data: fileH.availableData,
                         encoding: .utf8)?.trimmingCharacters(
                            in: .whitespacesAndNewlines) ?? ""
    let inputS = input.components(separatedBy: " ")
    let (command, arguments) = (inputS[0], inputS.dropFirst())
    switch command {
    case "exit":
      return -1
    case "get":
      if arguments.count < 2 {
        return 1
      }
      switch arguments[1] {
      case "uti":
        if let result = LSCopyDefaultRoleHandlerForContentType(arguments[2] as CFString, .all) {
          let handler = result.takeRetainedValue() as String
          print(handler, terminator: "\n% ")
        } else {
          print("handler not found for UTI: \(arguments[2])", terminator: "\n% ")
        }
      case "url":
        if let result = LSCopyDefaultHandlerForURLScheme(arguments[2] as CFString) {
          let handler = result.takeRetainedValue() as String
          print(handler, terminator: "\n% ")
        } else {
          print("handler not found for URL scheme: \(arguments[2])", terminator: "\n% ")
        }
      default:
        return 1
      }
      return 0
    case "":
        print("", terminator: "\n% ")
        return 0
    default:
      return 1
    }
}

print("Welcome to rdls a Launch Service tool", terminator: "\n% ")
fflush(__stdoutp)
outerLoop: while true {
    let result = processCommand()
    fflush(__stdoutp)
    switch result {
    case -1:
        break outerLoop
    case 1:
        print("Unknown command.\nUsage:\n  get [uti|url] <identifier>", terminator: "\n% ")
    default:
        break
    }
    fflush(__stdoutp)
}
print("Bye bye now.")

The idea is the same, the specific code for querying the URL scheme was:

1
2
3
4
5
if let result = LSCopyDefaultHandlerForURLScheme(arguments[2] as CFString) {
          let handler = result.takeRetainedValue() as String
          print(handler, terminator: "\n% ")
        } else {
          print("handler not found for URL scheme: \(arguments[2])", terminator: "\n% ")

Alright, now let's work on setting the default handler for a specific UTI.

We'll use the function LSSetDefaultRoleHandlerForContentType that has the following signature:

1
2
3
func LSSetDefaultRoleHandlerForContentType(_ inContentType: CFString, 
                                         _ inRole: LSRolesMask, 
                                         _ inHandlerBundleID: CFString) -> OSStatus

The critical piece of code will look like the following:

1
2
3
4
5
6
7
8
        let uti = arguments[2] as CFString
        let bundleID = arguments[3] as CFString
        let result = LSSetDefaultRoleHandlerForContentType(uti, .viewer, bundleID) 
        if result == 0 {
          print("Successfully set handler with bundle id: \(bundleID) for UTI: \(uti)", terminator: "\n% ")
        } else {
          print("Couldn't set handler with bundle id: \(bundleID) for UTI: \(uti)", terminator: "\n% ")
        }

In our REPL, we are only setting the role mask to viewer, but you can easily change that code to receive an additional parameter for the role mask. To check the possible values or OSStatus for Launch Service, check Xcode developer documentation (Shift+0 in Xcode) and go to CoreServices > LaunchServices > Result Codes. You'll find the definition of some of the possible values, for example: kLSApplicationNotFoundErr.

Ok, back to our code. Now let's see the code for assigning the default handler for a URL scheme.

We are going to use the function LSSetDefaultHandlerForURLScheme, that has the following signature:

1
2
func LSSetDefaultHandlerForURLScheme(_ inURLScheme: CFString, 
                                   _ inHandlerBundleID: CFString) -> OSStatus

The code is similar to what we did for Content-Type. It looks like this:

1
2
3
4
5
6
7
8
        let urlScheme = arguments[2] as CFString
        let bundleID = arguments[3] as CFString
        let result = LSSetDefaultHandlerForURLScheme(urlScheme, bundleID) 
        if result == 0 {
          print("Successfully set handler with bundle id: \(bundleID) for URL Scheme: \(urlScheme)", terminator: "\n% ")
        } else {
          print("Couldn't set handler with bundle id: \(bundleID) for URL Scheme: \(urlScheme)", terminator: "\n% ")
        }

Alright, here is the full code for main.swift:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import Foundation
import AppKit

func processCommand(_ fd: CFFileDescriptorNativeDescriptor = STDIN_FILENO) -> Int8 {
    let fileH = FileHandle(fileDescriptor: fd)
    let input = String(data: fileH.availableData,
                         encoding: .utf8)?.trimmingCharacters(
                            in: .whitespacesAndNewlines) ?? ""
    let inputS = input.components(separatedBy: " ")
    let (command, arguments) = (inputS[0], inputS.dropFirst())
    switch command {
    case "exit":
      return -1
    case "get":
      if arguments.count < 2 {
        return 1
      }
      switch arguments[1] {
      case "uti":
        if let result = LSCopyDefaultRoleHandlerForContentType(arguments[2] as CFString, .all) {
          let handler = result.takeRetainedValue() as String
          print(handler, terminator: "\n% ")
        } else {
          print("handler not found for UTI: \(arguments[2])", terminator: "\n% ")
        }
      case "url":
        if let result = LSCopyDefaultHandlerForURLScheme(arguments[2] as CFString) {
          let handler = result.takeRetainedValue() as String
          print(handler, terminator: "\n% ")
        } else {
          print("handler not found for URL scheme: \(arguments[2])", terminator: "\n% ")
        }
      default:
        return 1
      }
      return 0
    case "set":
      if arguments.count < 3 {
        return 1
      }
      switch arguments[1] {
      case "uti":
        let uti = arguments[2] as CFString
        let bundleID = arguments[3] as CFString
        let result = LSSetDefaultRoleHandlerForContentType(uti, .viewer, bundleID) 
        if result == 0 {
          print("Successfully set handler with bundle id: \(bundleID) for UTI: \(uti)", terminator: "\n% ")
        } else {
          print("Couldn't set handler with bundle id: \(bundleID) for UTI: \(uti)", terminator: "\n% ")
        }
      case "url":
        let urlScheme = arguments[2] as CFString
        let bundleID = arguments[3] as CFString
        let result = LSSetDefaultHandlerForURLScheme(urlScheme, bundleID) 
        if result == 0 {
          print("Successfully set handler with bundle id: \(bundleID) for URL Scheme: \(urlScheme)", terminator: "\n% ")
        } else {
          print("Couldn't set handler with bundle id: \(bundleID) for URL Scheme: \(urlScheme)", terminator: "\n% ")
        }
      default:
        return 1
      }
      return 0
    case "":
        print("", terminator: "\n% ")
        return 0
    default:
      return 1
    }
}

print("Welcome to rdls a Launch Service tool", terminator: "\n% ")
fflush(__stdoutp)
outerLoop: while true {
    let result = processCommand()
    fflush(__stdoutp)
    switch result {
    case -1:
        break outerLoop
    case 1:
        print("Unknown command.\nUsage:\n  get [uti|url] <identifier>\n  set [uti|url] <identifier> <handler's bundle id>", terminator: "\n% ")
    default:
        break
    }
    fflush(__stdoutp)
}
print("Bye bye now.")

Now we can run it:

1
2
3
4
5
6
7
8
9
$ swift run
Welcome to rdls a Launch Service tool
% get uti public.plain-text
com.apple.TextEdit
# I use Ulysses for my writing, so Lets set it to Ulysses
% set uti public.plain-text com.ulyssesapp.mac
Successfully set handler with bundle id: com.ulyssesapp.mac for UTI: public.plain-text
% get uti public.plain-text
com.ulyssesapp.mac

Perfect, in a different window, we can create a plain text file in the desktop and open it and see if it launches Ulysses.

1
2
$ echo "Hola" > ~/Desktop/plain.txt
$ open ~/Desktop/plain.txt

It should have been opened in Ulysses :). Let's revert it, run our application:

1
2
3
$ swift run
Welcome to rdls a Launch Service tool
% set uti public.plain-text com.apple.TextEdit

Let's now test if it works for URL schemes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ swift run
Welcome to rdls a Launch Service tool
% get url x-man-page
com.apple.Terminal
# Let's change it to iTerm2 
% set url x-man-page com.googlecode.iterm2
Successfully set handler with bundle id: com.googlecode.iterm2 for URL Scheme: x-man-page
% get url x-man-page
com.googlecode.iterm2
# we can exit and test to open a man page
% exit
$ open x-man-page://1/security
# It'll open iTerm, but iTerm doesn't know how to handle that parameter
# Let's restore it to Terminal
$ swift run
Welcome to rdls a Launch Service tool
% set url x-man-page com.apple.Terminal
Successfully set handler with bundle id: com.apple.Terminal for URL Scheme: x-man-page
% exit

Alright, it's working. Congratulations you now can query and set up UTI and URL scheme from the rdls tool.

Final thoughts

Well, as you can see, we can change the default application for a specific UTI or URL scheme using the program we built. There are some concerns tho. If someone with bad intentions were to create such an app that changes the application that the user expects to run, bad things could happen. There is still the problem of delivering and installing such a program, but it's worth knowing.

I encourage you to explore other functionality provided by the Launch Services API. There are more endpoints to explore. Also, you can use the CLI tool lsregister found inside: /System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/ to dump the content of the Launch Services database and find lots of useful information.

That's it for this post. I hope you find it useful.

**Note: You can find the full code on the GitHub Repository

Related topics/notes of interest


** If you want to check what else I'm currently doing, be sure to follow me on twitter @rderik or subscribe to the newsletter. If you want to send me a direct message, you can send it to derik@rderik.com.