Running XPC Activities On Demand

While doing research for an upcoming post, I've been trying to observe the behavior of a particular task defined in a system process. There are a couple of facets that make this challenging though:

  1. The task only runs once every 24 hours, on average
  2. The margin of error on that timeline is ±12 hours
  3. The task won't run until the device thinks it's convenient to do so

Luckily, this isn't even as limited as this type of task could be — some similar activities will choose to run only when the battery level is high enough, or when the device's primary screen is off! What are these tasks, how & where are they defined, and how can we more easily invoke them on our own terms?

The end result of this post is a tool for jailbroken devices that allows you to trigger XPC Activities on demand — but even if you're not on a jailbroken device, hopefully the path to building it is educational!


Table of Contents


XPC Activites

The task described above is an example of an XPC Activity, which allows for work to be deferred until the operating system deems it reasonable to run based on some provided criteria. The API is only public on macOS, but the underlying functionality is available (and used extensively by system services) across all Apple platforms.

These activities offer an extensive amount of configuration options, meaning they can cover practically any scheduling need; ranging from a task that only runs once in a while under certain conditions, all the way to a callback that should be invoked as soon as possible once the device isn't under heavy load.

Basic XPC Activity Setup

With only a bit of code, we can create a basic example of registering an XPC Activity on iOS:

// These are just the bare minimum needed for an example;
// if actually using XPC in an iOS app, probably just copy
// headers from `MacOSX.sdk/usr/include/xpc/` instead
typedef void * xpc_object_t;
typedef void * xpc_activity_t;
typedef void (^xpc_activity_handler_t)(xpc_activity_t activity);
extern xpc_object_t xpc_dictionary_create(const char * const *keys, const xpc_object_t *values, size_t count);
extern void xpc_activity_register(const char *identifier, xpc_object_t criteria, xpc_activity_handler_t handler);
extern void xpc_dictionary_set_int64(xpc_object_t xdict, const char *key, int64_t value);
extern void xpc_dictionary_set_string(xpc_object_t xdict, const char *key, const char *string);

// The actual XPC Activity setup
- (void)registerXPCActivity {
    xpc_object_t criteria = xpc_dictionary_create(NULL, NULL, 0);
    xpc_dictionary_set_int64(criteria, "Delay", 5);
    xpc_dictionary_set_int64(criteria, "GracePeriod", 1);
    xpc_dictionary_set_string(criteria, "Priority", "Utility");

    xpc_activity_register("co.bryce.XPCActivity",
                          criteria,
                          ^(xpc_activity_t activity) {
        NSLog(@"XPC Activity Fired!");
    });
}

The second half of this snippet is the critical part. This setup will trigger the NSLog call after around 4-6 seconds (or more, if iOS chooses!)

It's a fine basic case, but it doesn't highlight the advantages of XPC Activities very well.

Launch Daemon Activities

A better example might be found in iOS’ Launch Daemons, several of which define XPC Activities as part of their configuration plists:

# You can run this as-is if your `xcode-select` points to
# Xcode 11+; otherwise, you may need to find
# `com.apple.cache_delete.plist` within Xcode yourself
$ plutil -p "`xcode-select -p`/Platforms/iPhoneOS.platform\
/Library/Developer/CoreSimulator/Profiles/Runtimes\
/iOS.simruntime/Contents/Resources/RuntimeRoot\
/System/Library/LaunchDaemons/com.apple.cache_delete.plist"

# ...
  LaunchEvents" => {
    "com.apple.xpc.activity" => {
      "com.apple.CacheDelete.daily" => {
        "Interval" => 86400
        "Priority" => "Maintenance"
        "Repeating" => 1
      }
    }
  }
  "ProgramArguments" => [
    0 => "/System/Library/PrivateFrameworks/CacheDelete.framework/deleted"
  ]
# ...

There's often more included in these plist files, but the relevant keys for us are the two included above: LaunchEvents (which includes the configuration for our XPC Activity) and ProgramArguments (which identifies the process that owns this activity — in this case, deleted).

deleted will have registered an activity handler very similar to the one in our Basic XPC Activity setup above — the main difference being that it won't have to define its criteria dictionary in-code.

// Use `XPC_ACTIVITY_CHECK_IN` instead of a criteria
// dictionary. This uses existing criteria for this
// activity if available (in our case, that existing
// criteria is defined by the `plist` above)
xpc_activity_register("com.apple.CacheDelete.daily",
                      XPC_ACTIVITY_CHECK_IN,
                      ^(xpc_activity_t activity) {
    // When using `XPC_ACTIVITY_CHECK_IN` as registration
    // criteria, our handler will also be invoked with the
    // state `XPC_ACTIVITY_STATE_CHECK_IN`;
    // hence this explicit check to make sure
    // we're in the real "run" state
    if (xpc_activity_get_state(activity)
            == XPC_ACTIVITY_STATE_RUN) {
        NSLog(@"XPC Activity Fired!");
    }
});

Invoking XPC Activities

Let's say we want to invoke the com.apple.CacheDelete.daily activity defined above — after all, we don't want to wait around for a day for the activity to run as scheduled.

Option 1: Change The Schedule

This is admittedly the easiest option; we found 86400 in the plist above, let's just change it to a 300 to get the task running every five minutes.

This works for the most part, but isn't nearly as handy as being able to properly control invocation of the activity. It doesn't control exactly when the task is run, doesn't scale well to multiple activities, doesn't bypass criteria like battery or screen checks — and maybe most importantly, I wouldn't actually want to schedule something like iOS-wide cache deletion to run every five minutes (especially as the kind of person who would forget to change it back). Let's see what other options we have.

Option 2: lldb + dasd

Meet dasd — short for DuetActivitySchedulerDaemon, it handles the decision-making around both which activities to run and when to run them.

Using Console.app, you can actually see all sorts of interesting information about what dasd is up to, including deciding for or against running specific activities based on things like CPU load, thermal state, and more:

com.apple.nearbyd.regdownload:8F2313:[
	{name: CPUUsagePolicy, policyWeight: 5.000, response: {Decision: Can Proceed, Score: 0.50, Rationale: [{cpuLevel == 50}]}}
	{name: DeviceActivityPolicy, policyWeight: 10.000, response: {Decision: Can Proceed, Score: 0.43}}
	{name: MemoryPressurePolicy, policyWeight: 5.000, response: {Decision: Can Proceed, Score: 0.50, Rationale: [{[memoryPressure]: Required:2.00, Observed:1.00},]}}
 ] sumScores:29.853333, denominator:40.520000, FinalDecision: Can Proceed FinalScore: 0.736756}

Given that dasd handles the execution of these activities, it should be possible to find a method within it that we can invoke to kick off an activity ourselves.

After some digging, it looks like there is a method built just for our case: ‑[_DASDaemonClient forceRunActivities:].

_DASDaemonClient is the main entry point here, but it's worth noting that this method just invokes the same selector on the class _DASDaemon, which will be a bit easier for us to access for now. If we attach a debugger to dasd, we should be able to invoke this method directly:

(lldb) e (void)[[_DASDaemon sharedInstance]
       forceRunActivities:@[@"com.apple.CacheDelete.daily"]]

And… it works! We can see deleted start logging information about the cache deletion process.

This works pretty well and would be a reasonable place to stop.

Option 3: Command Line Tool

Really though, while attaching a debugger to dasd to trigger an activity works, what we really want is to just be able to run a command to trigger an activity, no fuss needed:

$ xpc-activity-run com.apple.CacheDelete.daily
`com.apple.CacheDelete.daily` triggered successfully!

Let's see if we can build that.


Building a Command Line Tool

What we fundamentally want is a tool that can communicate with dasd to trigger a given activity.

Finding a Communication Method

To start with, let's figure out what that communication method should be. Looking in dasd's Launch Daemon configuration (in com.apple.dasd.plist), we can see that it defines a Mach Service:

# ...
  "MachServices" => {
    "com.apple.duetactivityscheduler" => 1
  }
# ...

This means we should be able to send pre-defined messages to it via XPC, the parent technology that powers the XPC Activites we're already looking at!

For anyone else who works mostly on the iOS side and hasn't worked much directly with XPC before — objc.io's article on it is fantastic and largely kept me sane here. For this post, we only need to know that XPC will allow us to send a pre-defined set of messages to dasd through the use of a proxy object.

The first question we have to face, then, is which messages we can send. dasd will specify a protocol with ‑[NSXPCConnection setExportedInterface:] which defines the methods we can invoke on the caller's side. We can attach a debugger to dasd to figure out what that interface is:

(lldb) b -[NSXPCConnection setExportedInterface:]
Breakpoint 1: where = Foundation`-[NSXPCConnection setExportedInterface:]

// Later, on breakpoint triggered:
(lldb) po $arg3
<NSXPCInterface: 0x147f7ef70>
Protocol: _DASActivityOmnibusScheduling

Now we know the name of the protocol that lists the methods we're allowed to call: _DASActivityOmnibusScheduling.

That protocol just encompasses a few other protocols — one of which contains the forceRunActivities method we called from lldb earlier!

Calling the XPC Service

The fact that forceRunActivities is included in the exported interface means we can build a basic command line tool to invoke that method via XPC, without having to call it from lldb:

@protocol DASDRemoteInterface <NSObject>
- (void)forceRunActivities:(NSArray *)arg1;
@end

int main(int argc, char *argv[], char *envp[]) {
    // Set up our xpc connection
    NSXPCConnection *xpcConnection = [[NSXPCConnection alloc] initWithMachServiceName:@"com.apple.duetactivityscheduler" options:0];
    xpcConnection.remoteObjectInterface = [NSXPCInterface interfaceWithProtocol:@protocol(DASDRemoteInterface)];
    [xpcConnection resume];

    // Instantiate our remote object
    id<DASDRemoteInterface> scheduler = [xpcConnection synchronousRemoteObjectProxyWithErrorHandler:^(NSError *error) {
        NSLog(@"Received Error: %@", [error localizedDescription]);
    }];

    // Run the activity!
    [scheduler forceRunActivities:@[@"com.apple.CacheDelete.daily"]];
}

We can compile and install this onto a device, but there is one more necessary step — to communicate with dasd over XPC, we need to sign our binary with the entitlement com.apple.duet.activityscheduler.allow. We can do this on a jailbroken device using ldid or jtool.

And this works! We can verify with Console.app that dasd & deleted are behaving as expected.

However, there's very little feedback if something goes wrong — namely, there's no way to tell if the activity name we gave actually exists! This presents a real problem: from working with XPC Activities before, I found that recurring ones weren't always rescheduled immediately, so it's easy to think you're running an activity when in reality, dasd has no knowledge of its existence, and does nothing when you ask it to run.

Without some sort of feedback mechanism, this tool isn't nearly as useful as it could be. Can we add a way to get that feedback?


Getting Feedback from dasd

We've found that dasd already offers a helpful method in the form of:

-[_DASDaemonClient forceRunActivities:]

… but if we want a way to get results after running activities, then finding a way to give dasd a method like this:

-[_DASDaemonClient forceRunActivities:completion:]`

would be a good place to start.

Adding a New Method

The easiest way to add our new method would be to create a jailbreak tweak with Theos, which allows us to easily define new methods (or replacements for existing method implementations!) in whatever classes we'd like, including ones in system processes like dasd:

%hook _DASDaemonClient
%new
- (void)forceRunActivities:(NSArray *)activities completion:(void (^)(NSError *))completion {
    // Just forward the call for now, we'll deal
    // with the completion block later
    [self forceRunActivities:activities];
}
%end

You can learn more about the above syntax here, but for our purposes it's fairly straightforward: we're saying there will be a class called _DASDaemonClient, and that we want to add a new method to it.

We can deal with calling that completion block later on, but let's start by seeing if this setup is sufficient for XPC. We already know that you have to give XPC a protocol that contains all our expected methods, but it's unclear if that's actually used as a source of truth, or if we can send arbitrary messages regardless. Let's try by modifying our command line tool from earlier:

int main(int argc, char *argv[], char *envp[]) {
    // ...
    [scheduler forceRunActivities:@[@"com.apple.CacheDelete.daily"]
                       completion:^(NSError *error){/*...*/}];
}

Looking in the console, we can see that this is indeed not sufficient:

Exception: <NSXPCDecoder: 0x104830400> received a message
  or reply block that is not in the interface of the remote
  object (forceRunActivities:completion:), dropping.

Adding to the Protocol

We've added a new method to the remote object, but we can't yet call it. NSXPCInterface stores a list of valid selectors when you initialize it with a protocol. We need to either pass in a protocol that contains the selector we need, modify the internal data store to add the selector, or patch this check to allow additional selectors through. After poking around a bit within NSXPCInterface, it looks like the first option will be easiest.

We unfortunately can't add a new method to an existing protocol — there is an Objective-C runtime function called protocol_addMethodDescription, but it only works for protocols that are currently being created and haven't been registered yet.

We do have a pretty easy alternative though, which is to simply create a new protocol — we can then make it adopt the original protocol, along with a second (new) protocol containing any additional methods we need.

_DASActivityOmnibusSchedulingPlusAdditions
├─ _DASActivityOmnibusScheduling // <-- original protocol
│   ├─ forceRunActivities:
│   └─ otherImportantMethods:
└─ _DASActivityOmnibusSchedulingAdditions
    └─ forceRunActivities:completion:

And here's the implementation in code:

// Protocol containing methods we want to add
@protocol _DASActivityOmnibusSchedulingAdditions
- (void)forceRunActivities:(NSArray *)activities completion:(void (^)(NSError *))completion;
@end

// Construction of the new combined protocol
Protocol *modifiedProtocol = objc_allocateProtocol("_DASActivityOmnibusSchedulingPlusAdditions");
protocol_addProtocol(modifiedProtocol, objc_getProtocol("_DASActivityOmnibusScheduling"));
protocol_addProtocol(modifiedProtocol, objc_getProtocol("_DASActivityOmnibusSchedulingAdditions"));
objc_registerProtocol(modifiedProtocol);

Now we can hook into NSXPCInterface and provide our new protocol whenever someone is attempting to use _DASActivityOmnibusScheduling:

%hook NSXPCInterface
- (void)setProtocol:(Protocol *)originalProtocol {
    Protocol *exportedProtocol = objc_getProtocol("_DASActivityOmnibusScheduling");
    if (protocol_isEqual(originalProtocol, exportedProtocol)) {
        Protocol *newProtocol = /* create new protocol per the above */
        return %orig(newProtocol);
    }
    return %orig;
}
%end

Running our command line tool again, it looks like we've made some amount of progress:

-[_DASDaemonClient forceRunActivities:completion:]:
  unrecognized selector sent to instance 0x1054568d0

Actually Adding a New Method

We had previously attempted to add our new method to _DASDaemonClient, but now that XPC is letting our messages through, we can see that our attempt didn't actually work.

Listening for New Classes

After some investigation, it looks like the issue here is that _DASDaemonClient isn't immediately loaded alongside dasd — it's not part of dasd itself, and not part of any frameworks that dasd explicitly depends on.

Instead, it's part of a bundle that is manually loaded just after dasd launches:

// Example code; _basically_ how `dasd` starts up
class _DASDaemonInterface {
    class func startDASDaemon() {
        let bundleURL = URL(fileURLWithPath: "/System/Library/DuetActivityScheduler/Scheduler/DuetActivitySchedulerDaemon.bundle")
        let bundle = Bundle(url: bundleURL)!
        bundle.principalClass!.init()
    }
}
_DASDaemonInterface.startDASDaemon()

Given this context, we need to find a way to know when _DASDaemonClient is loaded, and then try to add a method to it. Luckily, NSBundle posts a notification when new classes are loaded; we can listen to this notification to know when to attempt to add our new method.

%hook _DASDaemonInterface

// This is the method that loads the bundle, so we
// set up our notification here (before invoking the
// original method implementation with `%orig`)
+ (void)startDASDaemon {
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(bundleDidLoadNotification:) name:NSBundleDidLoadNotification object:nil];
    %orig;
}

%new
+ (void)bundleDidLoadNotification:(NSNotification*)notification {
    NSArray* loadedClasses = [notification.userInfo objectForKey:NSLoadedClasses];
    if ([loadedClasses containsObject:@"_DASDaemonClient"]) {
        // add our new method here
    }
}

%end

Now that we have an if statement in which we know _DASDaemonClient is loaded, we have a spot from which we can safely add our new method.

Stealing Another Class’ Implementation

There doesn't appear to be any syntax through which %hook can be told to hook into a class that's loaded lazily; similarly, the underlying function MSHookMessageEx doesn't appear to have any built-in niceties for it either.

We should be able to make this work using %hookf + MSHookMessageEx, but since we aren't really getting to use any syntactic sugar at this point, and we have a pretty straightforward use case, I'd rather just stick to Obj-C runtime methods here.

We can use class_addMethod to add a new method to our class now that it's been loaded:

class_addMethod(NSClassFromString(@"_DASDaemonClient"),
    @selector(forceRunActivities:completion:),
    method_getImplementation(newMethod),
    method_getTypeEncoding(newMethod));

But we're missing some necessary information for the third and fourth parameters — where do we get newMethod from?

Since we'll be calling class_addMethod from _DASDaemonInterface, I decided it would be easiest to add the method implementation there. I'm not entirely sure if that falls under best practice, but I think we can agree that we're well past that point anyway.

%hook _DASDaemonInterface
%new
- (void)forceRunActivities:(NSArray *)activities completion:(void (^)(NSError *))completion {
    // TOOD: Check a bunch of preconditions, call
    // `completion` with an error if any fail, otherwise
    // call off to the original `forceRunActivities`
}
%end

This then gives us our full _DASDaemonClient method addition code:

Class targetClass = NSClassFromString(@"_DASDaemonClient");
SEL newSelector = @selector(forceRunActivities:completion:);

Class donorClass = @class(_DASDaemonInterface);
Method newMethod = class_getInstanceMethod(donorClass,
                                           newSelector);

class_addMethod(targetClass,
    newSelector,
    method_getImplementation(newMethod),
    method_getTypeEncoding(newMethod));

And now, we finally have a method that we can add our feedback logic to.

I've started by adding a few basic checks, namely that the activities being triggered actually exist (the main goal of this whole endeavor), along with a check that the calling process has the necessary entitlements. There are likely more checks that can be added in the future, including hooking into methods like ‑[_DASDaemon activityCompleted:] to get an even stronger indicator of success.

I'll skip covering the details in this post, since I think getting to the point of being able to implement these checks in the first place was the main point of interest — but if you're interested, you can see the implementation of these basic checks here.


Wrapping Up

This was a long path toward fixing a problem for which we already had a working solution about… a third of the way through this post.

But I'm happy with the end result regardless — I don't know how much I'll have to work with XPC Activities in the future, but for my current projects it's already been a big help, and I hope the tool can be useful to others if they find themselves with a similar need.

The complete source for the tool is available here.


Say Hello!