Using the New UNUserNotification API in a Standalone Objective-C Program

971 Views Asked by At

I'm trying to throw notifications from a standalone Objective-C file. The NSUserNotification API will be deprecated after OSX 11, so I'm looking to switch to the newer UNUserNotification interface.

Unfortunately, I'm not able to find much on the topic from Googling. I have the following code that throws an error:

notif.m:

#import <stdio.h>
#import <Cocoa/Cocoa.h>
#import <UserNotifications/UserNotifications.h>
#import <objc/runtime.h>

int native_show_notification(char *title, char *msg) {
    UNMutableNotificationContent *content = [[UNMutableNotificationContent alloc] init];
    content.title = [NSString stringWithUTF8String:title];
    content.body = [NSString stringWithUTF8String:msg];
    content.sound = [UNNotificationSound defaultSound];

    UNTimeIntervalNotificationTrigger *trigger = [UNTimeIntervalNotificationTrigger triggerWithTimeInterval:5 repeats:NO];
    UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:@"NOTIFICATION" content:content trigger:trigger];
    UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];

    [center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
        if (!error) {
            printf("NOTIFICATION SUCCESS ASDF");
        }
    }];

    return 0;
}

int main() {
    native_show_notification("Foo" , "Bar");
}

Info.plist in the same directory:

<?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>CFBundleIdentifier</key>
  <string>com.microsoft.VSCode</string>
</dict>
</plist>

This is compiled using cc -framework Cocoa -framework UserNotifications -o app notif.m. The Info.plist is incorporated automatically, so there shouldn't be a bundling issue.

Unfortunately, after running ./app I get the following error:

Assertion failure in +[UNUserNotificationCenter currentNotificationCenter], UNUserNotificationCenter.m:54
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'bundleProxyForCurrentProcess is nil: mainBundle.bundleURL file:///path-to-folder-containing-the-source-files'

I'm new to MacOS/Objective-C development and am unable to parse this message. Couldn't understand things I could find on Google either. Any insights would be appreciated; thanks so much!

1

There are 1 best solutions below

0
On

Since you mentioned being new to macOS/Obj-C, let's start off with some background information:

  • There are different forms of applications for macOS. Like you are writing, there are "command-line" programs (i.e. GUI-less) that usually take the form of a single, standalone binary file.
  • There are also "app bundles", which are the applications ending with .app (like Firefox.app). Those are actually just directories following a specific format. Directly inside is a dir Contents, which at bare minimum contains an Info.plist file and MacOS dir (there might also always be a PkgInfo file, not entirely sure). And finally, inside the MacOS dir is the actual compiled executable.
  • (There are more forms but let's just keep it to these two)
  • Things like custom/3rd-party frameworks and libraries or any miscellaneous resources can be embedded within the app bundle, which is why you can copy them across Macs and it will just work (minus any preferences). Usually those go in Contents/Frameworks and Contents/Resources respectively but I believe you can use any names you want.
  • Preferences are usually stored in $HOME/Library/Preferences but if the application is sandboxed it has a container in $HOME/Library/Containers, which then contains Data/Library/Preferences.
  • The Info.plist (property list) file contains some information about the application, such as the "bundle identifier".
  • When you run e.g. Firefox through macOS's Finder, it actually calls upon "Launch Services" to actually start the browser. This reads the .plist file and sets up the environment to run the application in, then finally executes Firefox.app/Contents/MacOS/firefox.
  • Running stuff like ./app from a terminal does not pass through LS (Launch Services), but just immediately executes the code as-is. You did mention having an Info.plist in the same directory but since you're bypassing LS it's not even being used.
  • There's also the open command which does pass through LS. You can run open -a Firefox and it will look for an app bundle named exactly Firefox.app. You can do open -b org.mozilla.firefox to use the bundle identifier instead, which likely always remains the same and thus is safer for scripts. You can also simply run open /some/path/some.app to open that specific application.
  • Depending on how the application is set up, you can have open pass arguments along. Again with Firefox as an example, you can do open -a Firefox https://stackoverflow.com.

Now, on to your error. I had the same issue when developing an application of my own, which was actually an app bundle but I also usually run the contained binary directly since it's just easier. However, this too would always crash when trying to get currentNotificationCenter. Running it like open someapp.app did work just fine though. I compared the Console output when running it both ways, and when it crashes it shows no registered bundle with URL <private>. I'm guessing that by bypassing LS the "bundle proxy" won't be set up and things start crashing. Or the bundle proxy might always be running, but my application's bundle identifier simply wasn't registered with its "engine" (for lack of a better/known term). Other things such as creating dialog alerts do still work when running it directly, so it doesn't look like using just about any GUI feature immediately disqualifies you for running binaries outside of LS. I think it's a sort of security measure to prevent just any CLI-based program from posting notifications.

There are a couple of options:

  1. Perhaps just running open ./app will work. At least in my case it opens a new terminal window but the crash is gone and notifications do work. This might be due to it still being contained within an app bundle and LS detecting that, though.

  2. Otherwise you're probably going to have to need to create an app bundle instead. You can still create a GUI-less application that way but it might require a few additional steps to remove the GUI, depending on if you want to use Xcode or not. I've always used it to just create an "App" project, based on User Interface: XIB. The project creation wizard doesn't allow you to choose no interface so that's what you'll need to remove. You may be able to figure out how to do it without Xcode but I wouldn't know 100% of what Xcode does to actually output a working app bundle. So, for Xcode:

    1. In the project navigator (should be the left sidebar) click on the top level node (your project name).
    2. Select the "target" instead of the project itself in the inner left sidebar.
    3. Then on the top tab bar make sure General is selected.
    4. One of the first sections should be Deployment Info with an option Main Interface. Clear that box.
    5. Remove the actual XIB file from your project.
    6. Add a new row to Info.plist: Application is agent (UIElement) with value YES. The actual XML key is LSUIElement.
    7. Check the name of the Cocoa class that's used as the application's main "delegate". By default this should be simply AppDelegate.
    8. Move your original code into the AppDelegate class's applicationDidFinishLaunching function.
    9. The entry point in main.m should look something like this:
int main(int argc, const char * argv[]) {
    //@autoreleasepool {} // Probably not necessary, so can remove that
    //return NSApplicationMain(argc, argv); // Remove this too

    // Add all this, maybe change AppDelegate to match your class
    AppDelegate *appDelegate = [[AppDelegate alloc] init];
    NSApplication *application = [NSApplication sharedApplication];
    [application setDelegate: appDelegate];
    [application run]; // Is a blocking call, it never actually returns
    return EXIT_SUCCESS; // Won't be reached but we need to return some int to suppress errors/warnings =]
}

Now when you run the application, there's no icon on the Dock since it has been set as an "agent". It also doesn't even try to render any views because we removed those. Of course this won't quite work if you want to accept user input from the terminal. There may be something for that but I believe that's out of scope for this question.

There's just one detail remaining: running the application from the terminal through LS and being able to Ctrl+C/interrupt it. Probably the only way to achieve similar behaviour would be trap : INT; open -W someapp.app; killall someapp 2>/dev/null. The -W flag makes the open command block until the application quits, but interrupting it wouldn't actually send the signal to your application (but rather still to open). Hence the killall to kill any previous instances, redirected to null to suppress any errors if the application already quit on its own/abnormally. Just gotta make sure you don't call it loginwindow or something. =] The trap is there to ensure the interrupt signal only applies to the currently running command instead of the whole chain at once. You could even wrap that line in a good old .sh script, the trap also makes it so it doesn't abort the entire script either.