Keeping command line tool alive and still get mouse and keyboard events


I have swift dylib that I call from a simple cSharp program through mono. I need the dylib to stay alive and listen to keyboard, mouse, application events as well as receiving commands to move mouse, etc. Looking online I found some suggestion to keep the process running and while they do keep the process alive I'm unable to receive the events I add monitors for.
Code Block
NSEvent.addGlobalMonitorForEvents(matching: mEvent.eventMask, handler: globalEventCallBack"

I have tried Runloop.main.run DispatchGroup, Semaphore and while I understand why some don't work (they block the main thread and that's where the events are delivered) I don't understand others.
So here I'm in search of suggestions of how to keep my process alive and still get the events.

Replies

You’re going to face two challenges here:
  • AppKit

  • TCC

I’ll cover each in turn.



The API you’re using is part of AppKit and thus intended to be used by an AppKit app. Using it from a vanilla command-line tool may not work because it may rely on the AppKit machinery to be running.

If it is to work at all you’ll need to run the run loop. You wrote:

I have tried Runloop.main.run()

What behaviour did you see? Did that call return? Or did it just not deliver events?

However, my overall advice would be that you package this code into an app and run it from there. You can set LSBackgroundOnly to prevent the app from showing any UI.



In this context TCC stands for “transparency, consent, control”, and it’s a name we use to cover the user authorisation stuff you’ll find in System Preferences > Security & Privacy > Privacy. One of the privileges there is Input Monitoring, and the global event monitor requires that you have this privilege.

TCC is tricky because of the concept of responsibility. Normally when you use an API that requires a TCC privilege the system will display an alert asking the user to grant that. This can’t work if your ‘app’ is a command-line tool because there’s no way for the user to understand the context of that request. Likewise if you move this code to a background-only app.

To fix this the system tries to find the program responsible for your process. If you nest your tool, or your background-only app, within a standard app then the system will likely consider that app to be responsible for your process and use it to prompt for, and track, your TCC privileges.

For this to work properly you must sign your code with a stable signing identity. Without this the system has no ‘handle’ on which to hook these privileges.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
Code Block
Runloop.main.run()


What behaviour did you see? Did that call return? Or did it just not deliver events?

The application runs until I stop the Runloop, my function to add the Monitors for the Mouse events gets called with no errors but I don’t get the any of the events.

If you nest your tool, or your background-only app, within a standard app then the system will likely consider that app to be responsible for your process and use it to prompt for, and track, your TCC privileges.

I need to research this, it sounds like is what we need to do but I’m not sure if it can work with our existent code in cSharp.

Thank you for your help.

Do you know if there is a way to start Appkit from within my swift dylib?

Do you know if there is a way to start AppKit from within my swift
dylib?

You start AppKit by calling NSApplicationMain. Normally folks do this from the main function, but there’s no reason you couldn’t do it from within a dynamic library.

Doing this will effectively turn your process into an app, and that means:
  • You’ll want to make it background only, lest it show up in the Dock and so on.

  • You’ll need to package it within an app structure.

The easiest way to do this is to create a new target (using Xcode terms here, sorry) from one of the standard app templates.

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
I can start NSApplication.shared like this:
Code Block
let delegate = AppDelegate(mController)
NSApplication.shared.delegate = delegate
NSApplication.shared.run()
// _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv)

Note: I tried with NSApplicationMain but I get the error "No Info.plist file in application bundle or no NSPrincipalClass in the Info.plist file, exiting" even after creating a Info.plist file and adding the path to BuildSettings/Packaging/Infor.plist File/).

That works, It keeps the process going until I terminate it, I get all NSEvents and I can use any NS library but this creates another problem now. My cSharp code is unable to call code living in my swift dylib which works if I don't call NSApplication.shared.run (I get no errors or anything).

This is how it works.
In my cSharp file
Code Block
[DllImport("libvmylibrary.dylib")]
public static extern void myFunction();
main {
myFunction();
}

in my swift dylib
Code Block
@_cdecl("myFunction")
public func myFunction() {}

Then I just compile my cSharp code and then I run the .exe with mono.

I need to be able to keep the dynamic library running so I can use AppKit but I also need to be able call my swiftDylib from my cSharp code.
After further testing, I see that NSApplication.shared.run() just blocks the main thread and that is why the rest of my function weren’t being called. But if trigger more functions after NSApp is running they do work.

So from what we gave discussed is safe to say that:
  1. Anything related to Appkit will not work unless I have called NSApplication.shared.run()

  2. The best way to keep my app running in the background is with LSBackgroundOnly? I’m stuck here, it is an info plist value right? But how do I embed an Info.plist file into my dylib?







But how do I embed an Info.plist file into my dylib?

You don’t (well, you might be able to do but it wouldn’t be effective). If you want to use AppKit’s event handling system you’ll need to create a background-only application. That requires:
  • App-like packaging

  • Starting up AppKit by calling NSApplicationMain on the main thread

If you’re not able to meet these requirements then using AppKit is not going to work.



I’d like to take a step back here and clarify the context in which your code is running. You wrote:

I have swift dylib that I call from a simple cSharp program through
mono.

With regards your “cSharp program”, what type of program is this? An app that the user launches from the Finder? A command-line tool? Or something else?

Share and Enjoy

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

With regards your “cSharp program”, what type of program is this? An app that the user launches from the Finder? A command-line tool? Or something else?

It is going to be a mono.exe, that is why we thought of making a dylib so it works with it.

If you want to use AppKit’s event handling system you’ll need to create a background-only application. That requires:
App-like packaging
Starting up AppKit by calling NSApplicationMain on the main thread

If I do that is there a way to expose methods so my mono app can call them?

Or is there other way to access Appkits event handling?



It is going to be a mono.exe

I’m sorry but I don’t know what that means. How will the user run this? As a GUI app that they double click in the Finder? Or as a command-line tool that they run inside Terminal? Or some other way?

Share and Enjoy

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

Wanting to monitor NSEvents globally in a command line app (written in Swift) I stumbled upon this thread. I also expected RunLoop.main.run() to keep the app running and help the observer receive events, but it didn't work. No errors, but also no events received in the handler.

With the pointers from Quinn here and some more reading about how an AppKit app launched I realised there also is the NSApplication.shared.run() method to start the runloop for a NSApplication. That worked out!

So the following code is a minimal sample for a working command line app that is monitoring events:

import AppKit

@main
public struct CLIapp {
    public static func main() {
        NSEvent.addGlobalMonitorForEvents(matching: .any) { event in print("Event received") }
        NSApplication.shared.run() // This keeps the app running & makes sure events are received
    }
}