8

I have been setting up a launchd.plist XML that is run every time a specific USB device is mounted. I followed the instructions on the xpc_events(3) man page and it is running the application whenever the device is mounted.

The problem I'm having is that the application is run again and again every 10 seconds as long as the device is still mounted. How can I set it up so it only runs once when the device is inserted in the USB port?

<?xml version="1.0" encoding="UTF-8"?>
<!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.myapp.agent</string>
    <key>Program</key>
    <string>/Applications/MyApp.app/Contents/MacOS/MyAgent</string>
    <key>LaunchEvents</key>
    <dict>
        <key>com.apple.iokit.matching</key>
        <dict>
            <key>com.apple.device-attach</key>
            <dict>
                <key>idVendor</key>
                <integer>2316</integer>
                <key>idProduct</key>
                <integer>4096</integer>
                <key>IOProviderClass</key>
                <string>IOUSBDevice</string>
                <key>IOMatchLaunchStream</key>
                <true/>
            </dict>
        </dict>
        <key>com.apple.notifyd.matching</key>
        <dict>
            <key>com.apple.interesting-notification</key>
            <dict>
                <key>Notification</key>
                <string>com.apple.interesting-notification</string>
            </dict>
        </dict>
    </dict>
</dict>
</plist>
Mitar
  • 6,756
  • 5
  • 54
  • 86
Christoffer
  • 25,035
  • 18
  • 53
  • 77

4 Answers4

9

I wrote a tutorial on this with detailed instructions and example files for triggering an arbitrary executable or shell script by the connection of an external device (usb/thunderbolt) to a Mac computer, without the respawning problem.

Like the authors approach, it relies on Apple's IOKit library for device detection and a daemon for running the desired executable. For the daemon to not be triggered repeatedly after connecting the device, a special stream handler (xpc_set_event_stream_handler) is used to "consume" the com.apple.iokit.matching event, as explained in the post by @ford and in his github repo.

In particular, the tutorial describes how to compile the xpc stream handler and how to reference it together with the executable in the daemon plist file and where to place all the relevant files with correct permissions.

For the files, please go here. For completeness, I have also pasted their content below.

Run shell script or executable triggered by device detection on a mac

Here I use the example of spoofing the MAC address of an ethernet adapter when it is connected to the Mac. This can be generalized to arbitrary executables and devices.

Put your shell script or executable into place

Adapt the shell script spoof-mac.sh

#!/bin/bash
ifconfig en12 ether 12:34:56:78:9A:BC

to your needs and make it executable:

sudo chmod 755 spoof-mac.sh

Then move it into /usr/local/bin, or some other directory:

cp spoof-mac.sh /usr/local/bin/

Building the stream handler

The stream handler xpc_set_event_stream_handler.m

//  Created by Ford Parsons on 10/23/17.
//  Copyright © 2017 Ford Parsons. All rights reserved.
//

#import <Foundation/Foundation.h>
#include <xpc/xpc.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
        xpc_set_event_stream_handler("com.apple.iokit.matching", NULL, ^(xpc_object_t _Nonnull object) {
            const char *event = xpc_dictionary_get_string(object, XPC_EVENT_KEY_NAME);
            NSLog(@"%s", event);
            dispatch_semaphore_signal(semaphore);
        });
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        if(argc >= 2) {
            execv(argv[1], (char **)argv+1);
        }
    }
}

is universal (no need to adapt) and can be built on a mac command line (with xcode installed):

gcc -framework Foundation -o xpc_set_event_stream_handler xpc_set_event_stream_handler.m

Let's place it into /usr/local/bin, like the main executable for the daemon.

cp xpc_set_event_stream_handler /usr/local/bin/

Setup the daemon

The plist file com.spoofmac.plist contains the properties of the daemon that will run the executable on device connect trigger.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>UserName</key>
    <string>root</string>
    <key>StandardErrorPath</key>
    <string>/tmp/spoofmac.stderr</string>
    <key>StandardOutPath</key>
    <string>/tmp/spoofmac.stdout</string>
    <key>Label</key>
    <string>com.spoofmac.program</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/bin/xpc_set_event_stream_handler</string>
        <string>/usr/local/bin/spoofmac.sh</string>
    </array>
    <key>LaunchEvents</key>
    <dict>
        <key>com.apple.iokit.matching</key>
        <dict>
            <key>com.apple.device-attach</key>
            <dict>
                <key>idVendor</key>
                <integer>32902</integer>
                <key>idProduct</key>
                <integer>5427</integer>
                <key>IOProviderClass</key>
                <string>IOPCIDevice</string>
                <key>IOMatchLaunchStream</key>
                <true/>
                <key>IOMatchStream</key>
                <true/>
            </dict>
        </dict>
    </dict>
</dict>
</plist>

It contains information for identifying the device you want to base your trigger on, like idVendor, idProduct, IOProviderClass. These can be figured out in the System Information App on your mac.

Screenshot System Information

Convert the hex identifiers to integers before inserting into the plist file (for example using int(0x8086) in python).

IOProviderClass should be either IOPCIDevice (Thunderbolt) or IOUSBDevice (USB).

The other relevant entry in the plist file is the location of xpc_set_event_stream_handler and the executable.

Other entries include the location of standard output (log) files and the executing user.

Since MAC spoofing requires root privileges, we put com.spoofmac.plist into /Library/LaunchDaemons:

cp com.spoofmac.plist /Library/LaunchDaemons/

not into a LaunchAgents folder. Launch agents ignore the UserName argument.

Insure that the owner of the file is root:

sudo chown root:wheel /Library/LaunchDaemons/com.spoofmac.plist

Launch the daemon

Activate the daemon:

launchctl load /Library/LaunchDaemons/com.spoofmac.plist

and you are good to go.

Unloading is done using launchctl unload.


GUI App

I have wrapped the functionality of the Launch Daemon described above in a more user-friendly Mac app, called "Stecker". In addition to device attachment, the app can also detect device detachment. It triggers the execution of selected Shortcuts from the macOS Shortcuts App. The app is very lightweight and relies only on IOKit notifications. In contrast to the launch approach, the direct use of the IOKit framework permits to filter for additional types of com.apple.iokit.matching events, such as device detachment.

lsrggr
  • 399
  • 6
  • 12
  • 1
    While this link may answer the question, it is better to include the essential parts of the answer here and provide the link for reference. Link-only answers can become invalid if the linked page changes. - [From Review](/review/low-quality-posts/19476062) – disinfor Apr 18 '18 at 20:58
  • 2
    I changed the post to include all of the external link content. – lsrggr Apr 19 '18 at 15:28
  • How do you debug when this does not work? I followed everything but just see entries in the error log `2022-03-22 16:40:35.217 xpc_set_event_stream_handler[65373:5653115] com.apple.device-attach`. I'm guessing there's an issue with the filter or the script but not sure, I've tried both the product/vendor IDs for the USB hub and the device itself. Also will this start at boot or do you need to load each time? – Jens Bodal Mar 22 '22 at 23:43
4

AIUI your application must call xpc_set_event_stream_handler to remove the event from the queue. You might also have to add <key>KeepAlive</key><false/> to the .plist, but I'm not sure about that.

Gordon Davisson
  • 118,432
  • 16
  • 123
  • 151
  • Incidentally, if you fail to set IOMatchLaunchStream to true, then xpc_set_event_stream_handler doesn't make any difference, and your app relaunches over and over as soon as you quit it. Just a helpful tip. – dgatwood Apr 14 '14 at 04:29
  • per the `launchd.plist` man page, the default of `KeepAlive` is `false` – Ford Oct 23 '17 at 20:04
  • 1
    Found this amazing utility that will call xpc_set_event_stream_handler to remove the event from the queue, and then call your desired application. Allowed me to come up with a solution without compiling any code. Check out https://github.com/snosrap/xpc_set_event_stream_handler – zen Jul 01 '19 at 19:13
1

I am trying to use something like this:

#include <xpc/xpc.h>
#include <unistd.h>
#include <asl.h>

int main(int argc, char *argv[]) {
    if (argc < 2) {
        return 1;
    }

    asl_log(NULL, NULL, ASL_LEVEL_DEBUG, "event_stream_handler: starting");

    xpc_set_event_stream_handler("com.apple.iokit.matching", dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^(xpc_object_t event) {
        const char *name = xpc_dictionary_get_string(event, XPC_EVENT_KEY_NAME);
        uint64_t id = xpc_dictionary_get_uint64(event, "IOMatchLaunchServiceID");
        asl_log(NULL, NULL, ASL_LEVEL_DEBUG, "event_stream_handler: received event: %s: %llu", name, id);

        execv(argv[1], argv + 1);
    });

    dispatch_main();

    return 0;
}

So a script which consumes the event and runs the script passed as an argument.

Mitar
  • 6,756
  • 5
  • 54
  • 86
  • I think calling `execv` inside the `xpc_set_event_stream_handler` handler prevents the event from being consumed. Calling `execv` outside of the handler worked great. I posted my code [below](https://stackoverflow.com/a/48895921/74530). – Ford Feb 20 '18 at 23:37
0

This works for me:

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
        xpc_set_event_stream_handler("com.apple.iokit.matching", NULL, ^(xpc_object_t _Nonnull object) {
            const char *event = xpc_dictionary_get_string(object, XPC_EVENT_KEY_NAME);
            dispatch_semaphore_signal(semaphore);
        });
        dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
        if(argc >= 2) {
            execv(argv[1], (char **)argv+1);
        }
    }
}

full source code here

Ford
  • 1,485
  • 3
  • 14
  • 20