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
Table of Contents
The idea behind UTI and URL Scheme
There are many ways/cases to launch an application, for example:
- Starting the app directly by running its executable file.
- Opening a file with a UTI associated with the application.
- Visiting/Opening a URL scheme associated with the application.
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:
- Open the URL from your browser's URL bar.
- Using the
open
command from the shell. - From Swift using
UIApplication
'sopen(_:options:completionHandler:)
function.
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
):
none
: "Requests the role None (the application cannot open the item, but provides an icon and a kind string for it)."viewer
: "Requests the role Viewer (the application can read and present the item, but cannot manipulate or save it)."editor
: "Requests the role Editor (the application can read, present, manipulate, and save the item)."shell
: "Requests the role Shell (the application can execute the item)."all
: "Accepts any role with respect to the item."
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:
get <UTI>
: returns the bundle identifier of the application that is currently set as the default for that UTI.exit
: exits the program.
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
- On a security related note you can check Objective-see blog post on Remote Mac Exploitation Via Custom URL Schemes.
- It's encouraged that when you want to refer to a system defined UTI (like
com.apple.application
) you should use the constants (e.g.kUTTypeApplication
) declared inUTCoreTypes.h
inside the Launch Services framework. - Apple's documentation on Uniform Type Identifiers.
- Apple's LSRoleMask documentation.
- Apple's Launch Service developer documentation
- Launch Services Programming guide.
- UIApplication - documentation, we could use the
open
function to open a URL scheme on iOS. - NSWorkspace - documentation, we can use the
open
function to open a URL scheme on macOS. - SwiftDefaultApps A Swift repository that provides a UI tool to set default applications.