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:
- The task only runs once every 24 hours, on average
- The margin of error on that timeline is ±12 hours
- 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!