Setup LaunchAgent in Xcode

Hi there :)

I try to put an Xcode project in place within a LaunchAgent.

The ultimate goal is to have an "application" with two component:

  • macOS application with just an basic UI
  • all the logic happens in a LaunchAgent that runs on background and is launch at startup.

The macOS app uses XPC to send messages to the agent that will run either the app is opened or not.

I struggled at first having this error (for the agent):

An XPC Service cannot be run directly.

Then I found using MachServices key in the .plist of the agent fixes the issue, plus:

let listener = NSXPCListener.init(machServiceName: "com.tonygo.NetworkMonitorAgent")

Then I wonder:

  • Do we have somewhere a documentation about how to setup a LaunchAgent in Xcode
  • I create the plist of the agent on side and run it manually, I could do this in a more automatic way
  • How could I package a macOS applciation that will contains the agent, install it and load the agent?

Note: This is mainly for learning and understanding what we could do at each level (XPCService, LaunchAgents, LaunchDaemon, etc.).

Replies

Do we have somewhere a documentation about how to setup a LaunchAgent in Xcode

No. The documentation for this stuff is sadly lacking )-:

I create the plist of the agent on side and run it manually, I could do this in a more automatic way

Yes. Use SMAppService for this.

How could I package a macOS applciation that will contains the agent, install it and load the agent?

Yes. SMAppService supports this specifically.

For a specific example of how to set this up, see this post.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Again thanks @eskimo , for your answers :)

Before diving into SMAppService, I try to load the agent manually and start a macOS app that will connect with the Agent. (I'd like to use a minimalist setup at first and add layers as much as I face limitations, this is the way I'd like to discover new topics :) )

I faced several issues:

  • Sandbox: NetworkMonitorApp(2330) deny(1) mach-lookup com.tonygo.NetworkMonitorAgent from the agent log, then I add com.apple.security.temporary-exception.mach-lookup.global-name (I Imagine that SMAppService will remove this) - The message dissapeard.
  • I still have a The connection to service named com.tonygo.NetworkMonitorAgent was invalidated from this process. from th macOS app.

Note: I also authorize Server and Client connextion from sandbox or even remove the sandbox, but it did not change...

Either it is not possible at all to do this kind of unsafe XPC connection, or I miss something :)

I Imagine that SMAppService will remove this

It will not.

If you plan to ship with sandboxing enabled, add an app group to your app and your agent and then make the XPC service name an immediate child of the app group name. For example, if your app group is com.example.waffle-varnish.group then your XPC service name might be com.example.waffle-varnish.group.xpc. Remember that, for sandboxed programs, the app group must be authorised by an entitlement. See App Groups: macOS vs iOS: Fight!.

For the moment, however, I recommend that you disable the App Sandbox. As you said, it’s best to start with the “minimalist setup at first”.

As to your invalidation problem, that’s likely because the service name isn’t being registered properly. What does your agent’s property list look like? And how are you loading it?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

As to your invalidation problem, that’s likely because the service name isn’t being registered properly. What does your agent’s property list look like? And how are you loading it?

The code is:

import Foundation
import OSLog

class NetworkMonitorXPCService: NSObject, NetworkMonitorXPCProtocol, NSXPCListenerDelegate {
    func getNetworkStatus(reply: @escaping (Bool) -> Void) {
        // Implement your logic to determine the network status
        let networkStatus: Bool = true
        reply(networkStatus)
    }
    
    /// This method is where the NSXPCListener configures, accepts, and resumes a new incoming NSXPCConnection.
    func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
        Logger.agent.debug("In listener")
        
        // Configure the connection.
        // First, set the interface that the exported object implements.
        newConnection.exportedInterface = NSXPCInterface(with: NetworkMonitorXPCProtocol.self)
        
        Logger.agent.debug("After Exported Interface")
        
        // Next, set the object that the connection exports. All messages sent on the connection to this service will be sent to the exported object to handle. The connection retains the exported object.
        newConnection.exportedObject = self
        
        Logger.agent.debug("After Exported Object")
        
        // Resuming the connection allows the system to deliver more incoming messages.
        newConnection.resume()
        
        Logger.agent.debug("After Exported Resume")
        
        // Returning true from this method tells the system that you have accepted this connection. If you want to reject the connection for some reason, call invalidate() on the connection and return false.
        return true
    }
}

Logger.agent.info("Start agent")

let delegate = NetworkMonitorXPCService()
let listener = NSXPCListener.init(machServiceName: MACH_SERVICE_NAME) // declared as: let MACH_SERVICE_NAME = "com.tonygo.NetworkMonitorAgent"
listener.delegate = delegate
listener.resume()

Logger.agent.info("Agent started")

RunLoop.main.run()

I have this .plist file:

<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.tonygo.NetworkMonitorAgent</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Users/tonygorez/Desktop/Builds/Release/NetworkMonitor</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <false/>
    <key>MachServices</key>
	<dict>
		<key>com.tonygo.NetworkMonitorAgent</key>
		<true/>
	</dict>
</dict>
</plist>

For the loading I just run:

launchctl load ~/Library/LaunchAgents/com.tonygo.NetworkMonitorAgent.plist
➜  ~ launchctl list | grep tonygo

11588	0	com.tonygo.NetworkMonitorAgent

I also take a look at the console. (see joined screen)

For the loading I just run:

You’re doing that from Terminal app, logged in to the same GUI login context as the client app, right?

I also take a look at the console.

Do you see any additional log messages in the system log when you run your app and try to connect?

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

You’re doing that from Terminal app, logged in to the same GUI login context as the client app, right?

Yeah.

Do you see any additional log messages in the system log when you run your app and try to connect?

No, but I observe another thing, once the macOS is up, I see two launchd agents in the console.

➜  ~ launchctl list | grep tonygo

22718	0	application.com.tonygo.NetworkMonitorApp.138426740.138426852.1D8FCF73-1582-44D3-AA62-4888F3AC0D07
22433	0	com.tonygo.NetworkMonitorAgent

But this probably the app itself as, the build identifier is different and even if I comment the whole NSXPCConnection logic and re-run (after a clean build) again, I see the Xcode agent. So this is probably not related.

Maybe should I publish the code somewhere?

I spotted it; this is probably the moment you throw virtual tomatoes at me, haha.

This seems to come from how I instantiate the Client class:

The code that does not work:

struct ContentView: View {
    @State private var networkStatus: String = "Off"
    
    var body: some View {
        Text("Network status: \(networkStatus)")
            .onAppear {
                NetworkMonitorXPCClient().getNetworkStatus { status in
                    DispatchQueue.main.async {
                        self.networkStatus = status ? "Online" : "Offline"
                    }
                }
            }
    }
}

The code that works:

struct ContentView: View {
    @State private var networkStatus: String = "Off"
    
    private var xpc = NetworkMonitorXPCClient()
    
    var body: some View {
        Text("Network status: \(networkStatus)")
            .onAppear {
                xpc.getNetworkStatus { status in
                    DispatchQueue.main.async {
                        self.networkStatus = status ? "Online" : "Offline"
                    }
                }
            }
    }
}

This maybe due to how the onAppear code is executed?

Hey 👋

The next to me is to enable the App Sandbox again and use App groups.

So I did the following:

  • Create an app group on Xcode

  • Change the entitlements of each target

  • Change the MACH_SERVICE_NAME var (used to establish connection)
let MACH_SERVICE_NAME = "W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc"
  • Change the name of launchd agent in the .plist and change the name of the file
➜  LaunchAgents launchctl list | grep tonygo\

49603	0	W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc

I see logs of the loaded agent, But I still have the lookup error...

Did I miss something @eskimo ?

Note: I used "$(TeamIdentifierPrefix)com.tonygo.NetworkMonitorApp.xpc" and not "$(TeamIdentifierPrefix).com.tonygo.NetworkMonitorApp.xpc"

  • Yep. I've made that mistake myself many times )-:

  • Unfortunately, it still does not work :/

Add a Comment

This seems to come from how I instantiate the Client class:

Probably. Both of your examples seem kinda wonky to me. I’d expect NetworkMonitorXPCClient to be a long-lived thing, and thus you shouldn’t instantiate on appear (your first example) or every time the view is created (your second). Rather, I’d expect it to either be part of your model or set up to persist across multiple creations of your view (using @State or the older @StateObject).

Create an app group on Xcode

You have double dots in that screen shot, that is, this:

W4MF6H9XZ6..com.tonygo.NetworkMonitorApp

when I’d expect this:

W4MF6H9XZ6.com.tonygo.NetworkMonitorApp

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Probably. Both of your examples seem kinda wonky to me.

Yeah this is garbage code anyway. I'll prolly use a singleton or instantiate it in the App stuct.

You have double dots in that screen shot, that is, this:

I already fixed this :) But it did not fixed it.

I put the code into this repo: https://github.com/tony-go/NetworkMonitor

The only missing part is the ~/Library/LaunchAgents/W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc.plist file:

<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc</string>
    <key>ProgramArguments</key>
    <array>
      <string>/Users/tonygorez/Desktop/Builds/Release/NetworkMonitor</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <false/>
    <key>MachServices</key>
        <dict>
                <key>com.tonygo.NetworkMonitorAgent</key>
                <true/>
        </dict>
</dict>
</plist>
    <key>Label</key>
    <string>W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc</string>

It’s unusual to prefix your job label with your Team ID.

    <key>MachServices</key>
    <dict>
        <key>com.tonygo.NetworkMonitorAgent</key>
        <true/>
    </dict>

In contrast, if your app is sandboxed, as is the case with the example you linked to, this must be prefixed by an app group.

Looking at the code snippet you posted, you have this:

let MACH_SERVICE_NAME = "W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc"

This confirms that you have your launchd property list set up backwards. You’re using this as the name of your named XPC endpoint, which means it must match MachService property, not the Label.

For context, a launchd job’s label is how launchd tracks the job. In contrast, the MachServices property lists all of the names that get registered in the Mach bootstrap service. If you’re using XPC [1] this value matches the name of your named XPC endpoint.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] Rather than raw Mach messaging, which is something I recommend that you avoid.

In contrast, if your app is sandboxed, as is the case with the example you linked to, this must be prefixed by an app group.

This is not really clear to me how it should look like, AFAIU the app group is W4MF6H9XZ6.com.tonygo.NetworkMonitorApp, then if I had to prefix it, it could be: W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.com.tonygo.NetworkMonitorAgent that seems way to strange tho.

Also I'm not sure about where the .xpc suffix should stand. It looks like it means the XPC endpoint for this particular app group... Not sure.

--

I try these combinations:

let MACH_SERVICE_NAME = "W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc

and

   <key>MachServices</key>
	<dict>
		<key>W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc</key>
		<true/>
	</dict>

let MACH_SERVICE_NAME = "W4MF6H9XZ6.com.tonygo.NetworkMonitorApp

and

   <key>MachServices</key>
	<dict>
		<key>W4MF6H9XZ6.com.tonygo.NetworkMonitorApp</key>
		<true/>
	</dict>

let MACH_SERVICE_NAME = "W4MF6H9XZ6.com.tonygo.NetworkMonitorApp

and

   <key>MachServices</key>
	<dict>
		<key>W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.xpc</key>
		<true/>
	</dict>

The Label property identifies your job to launchd. It’s traditionally a reverse DNS name that’s unique to your product.

The MachServices property lists the named XPC endpoints provided by your job. It usually has a single. The value is typically another reverse DNS name. It does not have to match the Label property, but sometimes it does.

If the client is sandboxed then there is a very specific constraints on the XPC endpoint name: It must be an immediate child of an app group of which the client is a member. For sandboxed apps, you must indicate membership using an unrestricted entitlement. See App Groups: macOS vs iOS: Fight!

That doc also explains that, on the Mac, app groups traditionally start with your Team ID.

Putting this all together:

  • com.tonygo.NetworkMonitorApp would be fine for Label.

  • You’ll want an app group like W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.group.

  • And an XPC endpoint name like W4MF6H9XZ6.com.tonygo.NetworkMonitorApp.group.xpc.

Note that there’s nothing special about the .group suffix. What matters is that the group starts with your Team ID and doesn’t collide with anything also.

There’s also nothing special about the .xpc suffix. What matters is that the XPC endpoint name be an immediate child of the app group.

So, in my test project for this stuff I have:

  • Label set to com.example.apple-samplecode.SandboxedAgent.agent.

  • An app group of SKMME9E2Y8.com.example.apple-samplecode.SandboxedAgent.group.

  • An XPC endpoint name of SKMME9E2Y8.com.example.apple-samplecode.SandboxedAgent.group.agent.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Hi @eskimo 👋

Sorry for the late reply. I was for the last few days. Thanks for your answer.

It worked :) Now I'd like to get rid of the manual launchctl load haha. Then I start exploring SMAppService.

Here is the code: https://github.com/tony-go/NetworkMonitor/tree/sma-app-service/

When I try to register the agent I had this error:

2024-02-09 19:28:18.503557+0100 NetworkMonitorApp[83508:3975925] [all] Unable to read plist at: /Users/tonygorez/Library/Developer/Xcode/DerivedData/NetworkMonitor-ebjjqrpyyilqingmpoykggtnpdjb/Build/Products/Debug/NetworkMonitorApp.app/Contents/Library/LaunchAgents/com.tonygo.NetworkMonitorAgent

Unable to register Error Domain=SMAppServiceErrorDomain Code=108 "Unable to read plist: com.tonygo.NetworkMonitorAgent" UserInfo={NSLocalizedFailureReason=Unable to read plist: com.tonygo.NetworkMonitorAgent}

So I checked that the file is present in the app bundle (I added a copy script for this):

➜  ~ tree /Users/tonygorez/Library/Developer/Xcode/DerivedData/NetworkMonitor-ebjjqrpyyilqingmpoykggtnpdjb/Build/Products/Debug/NetworkMonitorApp.app/        
/Users/tonygorez/Library/Developer/Xcode/DerivedData/NetworkMonitor-ebjjqrpyyilqingmpoykggtnpdjb/Build/Products/Debug/NetworkMonitorApp.app/
└── Contents
    ├── Info.plist
    ├── Library
    │   └── LaunchAgents
    │       └── com.tonygo.NetworkMonitorAgent.plist
    ├── MacOS
    │   └── NetworkMonitorApp
    ├── PkgInfo
    ├── Resources
    │   └── NetworkMonitor
    └── _CodeSignature
        └── CodeResources

I also check file permissions:

➜  ~ ls -la /Users/tonygorez/Library/Developer/Xcode/DerivedData/NetworkMonitor-ebjjqrpyyilqingmpoykggtnpdjb/Build/Products/Debug/NetworkMonitorApp.app/Contents/Library/LaunchAgents/com.tonygo.NetworkMonitorAgent.plist
-rw-r--r--  1 tonygorez  staff  513 Feb  9 19:14 /Users/tonygorez/Library/Developer/Xcode/DerivedData/NetworkMonitor-ebjjqrpyyilqingmpoykggtnpdjb/Build/Products/Debug/NetworkMonitorApp.app/Contents/Library/LaunchAgents/com.tonygo.NetworkMonitorAgent.plist

The plist format too:

➜  ~ plutil -lint /Users/tonygorez/Library/Developer/Xcode/DerivedData/NetworkMonitor-ebjjqrpyyilqingmpoykggtnpdjb/Build/Products/Debug/NetworkMonitorApp.app/Contents/Library/LaunchAgents/com.tonygo.NetworkMonitorAgent.plist
/Users/tonygorez/Library/Developer/Xcode/DerivedData/NetworkMonitor-ebjjqrpyyilqingmpoykggtnpdjb/Build/Products/Debug/NetworkMonitorApp.app/Contents/Library/LaunchAgents/com.tonygo.NetworkMonitorAgent.plist: OK

And finaly the com.apple.security.files.user-selected.read-only entitlements: https://github.com/tony-go/NetworkMonitor/blob/sma-app-service/NetworkMonitorApp/NetworkMonitorApp.entitlements#L11-L12

I run out of ideas haha :)

I took my inspiration from this: https://developer.apple.com/documentation/servicemanagement/updating-your-app-package-installer-to-use-the-new-service-management-api

I hope you a new track haha 😅

Have a good evening

Cheers