2 - Now we have to create our service
3 - You should end with the following structure
💡In the previous post, we talked about “agents”, and here Xcode uses “services”. What is the difference between both? LaunchAgents are aligned with the traditional concept of daemons or background processes and run on behalf of the currently logged-in user. On their end, XPCServices provides services to a single application. They are typically used to divide an application into smaller parts.
Note that both are managed by launchd.
You will see a DemoServiceProtocol
file if you take a deeper look at the XPC service folder structure. This file determines the API that the service will expose.
A quick reminder from Swift documentation about protocol
:
A protocol defines a blueprint of methods, properties, and other requirements that suit a particular task or piece of functionality.
By default, Xcode generates this basic Protocol:
// Basic DemoServiceProtocol generated by Xcode
import Foundation
@objc protocol DemoServiceProtocol {
func uppercase(string: String, with reply: @escaping (String) -> Void)
}
For the sake of this post, we will keep it like that. We will add a new close
method:
// Adding a close method to the DemoServiceProtocol
import Foundation
@objc protocol DemoServiceProtocol {
func uppercase(string: String, with reply: @escaping (String) -> Void)
func close()
}
💡Why do we have a @objc decorator here? The @objc attribute in this Swift protocol ensures compatibility with NSXPCInterface which is a class from the Foundation framework (based on Objective-C). This API will let us close the service programmatically.
Now, if you open the DemoService
file, which should contain the implementation for the protocol we just defined, you will see this error:
That is fine, as we added a close
method in the protocol that we have to implement:
// DemoService class that conform to DemoServiceProtocol
class DemoService: NSObject, DemoServiceProtocol {
@objc func uppercase(string: String, with reply: @escaping (String) -> Void) {
let response = string.uppercased()
reply(response)
}
func close() {
exit(0)
}
}
🫡 I will explain later why I added this metho.
Xcode automatically generates this code for accepting incoming requests in the main.swift
file, but let’s untangle it.
The first part of the file is about the implementation of a ServiceDelegate
class that conforms to the NSXPCListenerDelegate
.
// ServiceDelegateServiceDelegate conforms to NSXPCListenerDelegate.
class ServiceDelegate: NSObject, NSXPCListenerDelegate {
// ...
}
The NSXPCListenerDelegate
has one single interface:
// Definition of NSXPCListenerDelegate protocol
public protocol NSXPCListenerDelegate : NSObjectProtocol {
// Accept or reject a new connection to the listener. This is a good
// time to set up properties on the new connection, like its exported
// object and interfaces. If a value of NO is returned, the connection
// object will be invalidated after this method returns. Be sure to resume
// the new connection and return YES when you are finished configuring it
// and are ready to receive messages. You may delay resuming the
// connection if you wish, but still return YES from this method if you
// want the connection to be accepted.
@available(macOS 10.8, *)
optional func listener(_ listener: NSXPCListener,
shouldAcceptNewConnection newConnection: NSXPCConnection
) -> Bool
}
The two things we have to hold here:
DemoServiceProtocol
protocol and DemoService
class.true
and resume the connection using .resume()
from NSXPCConection
object.Now, let’s take a look at the generated code…
Let’s go back to the main.swfit
file:
// Configure the connection.
newConnection.exportedInterface = NSXPCInterface(
with: XPCDemoServiceProtocol.self)
let exportedObject = XPCDemoService()
newConnection.exportedObject = exportedObject
In this part of the code, we simply:
XPCDemoServiceProtocol
XPCDemoService
Now, let’s inspect the second part of the code:
// Resume the connection
newConnection.resume()
return true
As read on the documentation, this is essential because until you call the resume
method, the connection won’t process any messages sent to it. Resuming the connection tells the XPC infrastructure that your service is ready to handle requests.
⚠️ This way of accepting incoming requests is unsafe, but we could explore a potential solution in another post.
Our agent is now ready to receive requests and perform jobs. We will now learn how to send requests to this service in our application…
Before jumping into the XPC client, let’s add a bit of UI to this application.
Open the ContentView.swift
file and paste this content
// SwiftUI code for the macOS application
struct ContentView: View {
@State private var inputText: String = ""
var body: some View {
VStack {
TextField("Enter a text...", text: $inputText)
.textFieldStyle(.plain)
.font(.system(size: 50))
.background(Color("Accent")s
.multilineTextAlignment(.center)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(.gray, lineWidth: 3)
)
.padding()
HStack {
Spacer()
Button("Uppercase it!") {
// This is where we will call the XPC service
}
.buttonStyle(.plain)
.font(.system(size: 20))
.padding()
.background(.mint)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(.gray, lineWidth: 1)
.foregroundColor(.red)
)
.padding()
Spacer()
// TODO: display uppercase result
}
}
}
}
You can also tweak the content of DemoApp.swift
file:
// Fix resizability of the macOS application
struct DemoAppApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.frame(minWidth: 500, // here
maxWidth: 500,
minHeight: 300,
maxHeight: 300)
}
.windowStyle(.hiddenTitleBar) // here
.windowResizability(.contentSize) // here
}
}
I aslo wrote this post about how to constraint resizability of the macOS application.
Wait! We have the design, but it is still empty. How could I consume the DemoServiceProtocol from here?
Now, let’s create a new file called XPCClient.swift
and add an XPCClientProtcol
protocol:
// Swift protocol for XPCClient
import Foundation
protocol XPCClientProtocol {
func uppercase(for inputString: String, completion: @escaping (String) -> Void)
func close()
}
This protocol is necessary for a reason I will evoke later. Now, let me write down the XPCClient
class:
// init and deinit method of the XPCClient class
class XPCClient: XPCClientProtocol {
private let connection: NSXPCConnection
init() {
connection = NSXPCConnection(serviceName: "com.tonygo.DemoService")
connection.remoteObjectInterface = NSXPCInterface(with: DemoServiceProtocol.self)
connection.resume()
}
deinit {
connection.invalidate()
}
}
In this code, we declare a private NSXPCConnection
object:
This class is the primary means of creating and configuring the communication mechanism between two processes.
To establish a connection, we have to:
NSXPCConnection
object with init(serviceName: String)
// How to instantiate a NSXPCConnection
connection = NSXPCConnection(serviceName: "com.tonygo.DemoService")
NSXPCInterface
object that describes the protocol for the object. Setting this property tells the NSXPCConnection
object what kind of messages or method calls the service can respond to.// Set a remote object to the NSXPCConnection
connection.remoteObjectInterface = NSXPCInterface(with: DemoServiceProtocol.self)
resume()
connection.resume()
You probably observed the deinit
method, within the class and as a reminder:
Deinitializers are called automatically, just before instance deallocation takes place.
This is perfect to do some additional cleanup, like calling invalidate
method:
// Invalidate NSXPCConnection after XPCClient deallocation.
deinit {
connection.invalidate()
}
After a connection is invalidated, no more messages may be sent or received.
By doing this, you should see this error highlighted by XCode:
This is because DemoServiceProtocol
is a translation unit that is only present in the DemoService project but not in the DemoApp one. Let’s fix this by sharing the translation unit with the DemoApp target:
Having an established connection is a requirement to be able to consume our remote object. Now, let’s expose a uppercase
method that we will use in our SwiftUI view.
Still in the XPCClient
file, add a uppercase
method:
// Adding a uppercase method, the XPCClient
func uppercase(for inputString: String, completion: @escaping (String) -> Void) {
let service = connection.remoteObjectProxyWithErrorHandler { error in
print("Error during remote connection: ", error)
} as! DemoServiceProtocol
service.uppercase(string: inputString, with: { (uppercasedString) in
completion(uppercasedString)
})
}
The proxy object created by remoteObjectProxyWithErrorHandler
is an intermediary that allows your application to communicate with the remote XPC service as if it were directly calling methods on an object in its process.
Then, we could consume our interface object by calling the service.uppercase
method.
Now, we could expose a close
method:
// Adding a close method, the XPCClient
func close() {
let service = connection.remoteObjectProxyWithErrorHandler { error in
print("Error during remote connection: ", error)
} as! DemoServiceProtocol
service.close()
}
But we duplicated a line; let’s try to refactorize this:
// Final implementation of XPCClient class
class XPCClient: XPCClientProtocol {
private let connection: NSXPCConnection
private let service: DemoServiceProtocol
init() {
connection = NSXPCConnection(serviceName: "com.tonygo.DemoService")
connection.remoteObjectInterface = NSXPCInterface(with:
DemoServiceProtocol.self)
connection.resume()
service = connection.remoteObjectProxyWithErrorHandler { error in
print("Error during remote connection: ", error)
} as! DemoServiceProtocol
}
deinit {
connection.invalidate()
}
func uppercase(for inputString: String, completion: @escaping (String) -> Void) {
service.uppercase(string: inputString, with: { (uppercasedString) in
completion(uppercasedString)
})
}
func close() {
service.close()
}
}
We are now ready to plug it back into the UI.
Before running the application, we should consume our XPCClient in our ContentView
.
First, let’s change the App class.
// Create an instance of XPCClient and pass it to the ContentView.
struct DemoAppApp: App {
let xpcClient = XPCClient(); // #1
var body: some Scene {
WindowGroup {
ContentView(xpcClient: xpcClient)
.frame(minWidth: 500, maxWidth: 500, minHeight: 300, maxHeight: 300)
.onReceive(NotificationCenter.default.publisher( // #2
for: NSApplication.willTerminateNotification)) { _ in
// MARK: this is necessary if you want to avoid zombies
// in preview mode
xpcClient.close()
}
}
.windowStyle(.hiddenTitleBar)
.windowResizability(.contentSize)
}
}
XPCClient
class we freshly createdDemoService
service👻 I used the close method to force the xpcClient created on the second line to exit. Otherwise, you will experience zombies using Preview.
ContentView
view has to be updated:
// Adding a state to store the result, call the uppercase method
// from XPCClient and display the result.
struct ContentView: View {
@State private var inputText: String = ""
@State private var upperText: String = "" // #1
let xpcClient: XPCClientProtocol // #2
var body: some View {
VStack {
TextField("Enter a text...", text: $inputText)
.textFieldStyle(.plain)
.font(.system(size: 50))
.background(Color("Accent"))
.multilineTextAlignment(.center)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(.gray, lineWidth: 2)
)
.padding()
HStack {
Spacer()
Button("Uppercase it!") {
xpcClient.uppercase(for: inputText) { result in // #3
DispatchQueue.main.async {
upperText = result
}
}
}
.buttonStyle(.plain)
.font(.system(size: 20))
.padding()
.background(.mint)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(.gray, lineWidth: 1)
.foregroundColor(.red)
)
.padding()
Spacer()
if upperText.count > 0 { // #4
HStack {
Label("Result:", systemImage: "bolt.fill")
.font(.system(size: 30))
.labelStyle(.iconOnly)
Text(upperText)
.font(.system(size: 30))
.padding()
}
Spacer()
}
}
}
}
}
xpcClient
as class parameteruppercase
methodYou’ll have noticed that we set the result in DispatchQueue.main.async
closure, ensuring that the UI update will happen on the main thread.
The last thing we have to do before testing is to fix this error:
Xcode highlights that the xpcClient parameter is requiredIf you want to use Xcode Preview, you must fix this. I propose you to create a mocked class that inherits from XPCClientProtocol
protocol and pass it to the ContentView
view:
// Adding a mocked class to the Preview
class MockedXPCCLient: XPCClientProtocol {
func uppercase(for inputString: String, completion: @escaping (String) -> Void) {}
func close() {}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
let mockedXPC = MockedXPCCLient()
ContentView(xpcClient: mockedXPC)
}
}
😜You are free to put the mocked class in the XPCClient file.
If you already did a preview within Xcode or tried to build the app before all these steps, you could see a few zombies. Aiming to check it, run this command:
# Grep **launchd** services
launchctl list | grep DemoApp
If you see a bunch of items like this:
89005 0 application.com.tonygo.DemoApp.134681508.135243372.0EF62977-0AFC-4F6F-A97C-F2BC594FFAD8
89007 0 application.com.tonygo.DemoApp.134681508.135243372.321A0DD7-78A2-4047-BC80-FFFA61D0D5C8
12830 0 application.com.tonygo.DemoApp.134681508.134686262.F095F3EB-A4E6-4ACD-A0C4-31CD8DD40B76
88885 0 application.com.tonygo.DemoApp.134681508.135243013.1CE7E235-F86C-46AF-A4C3-5BD0332B688C
You are good to delete them manually.
kill 89005 89007 12830 88885
But after this, you should not experience Zombies anymore (as far as I have experienced)
Now it is time to press the little build button on the top left of Xcode:
Now if you use launchctl list
again:
➜ ~ launchctl list | grep DemoApp
10635 0 application.com.tonygo.DemoApp.135241344.135277621.E7951696-E41D-402E-A858-C3EEBB45102D
But If you close your application:
➜ ~ launchctl list | grep DemoApp
No more DemoApp services.
We built a basic macOS application that interacts directly with an XPC service in Xcode. But we could move forward in the following posts:
Let me know which one you would like to see first.