Handle custom URL schemes in an OS X Java application
Asked Answered
V

3

6

The Info.plist of our Java-based application contains following entries:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist SYSTEM "file://localhost/System/Library/DTDs/PropertyList.dtd">
<plist version="0.9">
    <dict>
        ...
        <key>CFBundleURLTypes</key>
        <array>
            <dict>
                <key>CFBundleURLName</key>
                <string>myApp handler</string>
                <key>CFBundleURLSchemes</key>
                <array>
                    <string>myapp</string>
                </array>
            </dict>
        </array>
        ...
    </dict>
</plist>

It should handle an URL like myapp://foobar/bazz. Opening the application works fine, but how the application should obtain the clicked URL?

Vaucluse answered 19/11, 2013 at 14:48 Comment(0)
U
8

For Objective C the answer can be found here: When an OS X app is launched by a registered URL scheme, how do you access the full URL?

The solution for Java is to rewrite the ObjC code into plain C, then translate that into Java, with the help of a set of classes under org.eclipse.swt.internal.cocoa.*.

As a reference for the ObjC-to-C translation, we need Apple's Objective-C Runtime Reference.

Plain C version

First, let's translate

[[NSAppleEventManager sharedAppleEventManager]
    setEventHandler:targetObject
        andSelector:@selector(handleAppleEvent:withReplyEvent:)
      forEventClass:kInternetEventClass
         andEventID:kAEGetURL];

into plain C. To invoke a ObjC function in plain C, we use objc_msgSend(). Furthermore, @selector(method_footprint) is substituted by sel_registerName("method_footprint"), and classes are looked up with objc_getClass(). The types id and SEL are equivalent to a pointer (such as void*) or a full-size int (i.e. same size as a void*).

The result:

// id mgr = [NSAppleEventManager sharedAppleEventManager]
SEL sel_ sharedAppleEventManager = sel_registerName("sharedAppleEventManager");
id mgr = objc_msgSend (objc_getClass("NSAppleEventManager"), sharedAppleEventManager);

// and the rest
SEL sel_setEventHandler = sel_registerName("setEventHandler:andSelector:forEventClass:andEventID:");
SEL sel_handleAppleEvent = sel_registerName("handleAppleEvent:withReplyEvent:");
objc_msgSend (mgr, sel_setEventHandler, targetObject, sel_handleAppleEvent, kInternetEventClass, kAEGetURL);

As you can see, we have two subroutine invocations here: The first calls the sharedAppleEventManager message of the NSAppleEventManager class, retrieving a singleton object from that class. The second call is sending the setEventHandler... message to that object, passing 4 arguments (target object, target message, and two event specifiers).

The callback function's declaration, originally:

- (void)handleAppleEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent

looks like this in plain C:

void handleAppleEvent (id self, SEL selector, NSAppleEventDescriptor* event, NSAppleEventDescriptor* replyEvent)

This means that when the function gets called, it gets sent not only its object reference (id) but also its method footprint (selector).

The callback code looks like this in ObjC to get to the URL:

NSString url = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];

And in plain C:

id desc_id = objc_msgSend (event_id, sel_registerName("paramDescriptorForKeyword:"), '----');
id url_id = objc_msgSend (desc_id, desc_id, sel_registerName("stringValue"));

One part is still missing:

targetObject needs to be initialized before invoking the code above, and a method matching the handleAppleEvent:withReplyEvent: footprint needs to be created in that target object, and then linked to our plain C event handler (handleAppleEvent()).

This means that we have to create an Objective C class, add a method to it, and then create an object instance of it:

// create an NSObject subclass for our target object
char objcClassName[] = "ObjCAppleEventHandler";
id objcClass = objc_allocateClassPair (objc_getClass("NSObject"), objcClassName);

// add the callback method to the class
SEL sel_handleAppleEvent = sel_registerName("handleAppleEvent:withReplyEvent:");
class_addMethod (objcClass, sel_handleAppleEvent, handleAppleEvent, "i@:@@");

// register the class
objc_registerClassPair (objcClass)

// create an object instance
id targetObject = class_createInstance (objcClass, 0);

// ... here follows the above code with the setEventHandler invocation
// (note: `SEL sel_handleAppleEvent` appears twice - the 2nd one can be removed)

This concludes the plain C version.

(Note: The above code was written without testing it, so it may contain errors. The Java code below, however, has been tested.)

Java version

Translation from Plain C to Java is now fairly straight-forward.

The aforementioned ObjC Runtime functions are all available from org.eclipse.swt.internal.cocoa.OS.

First, some presets:

static final long class_NSAppleEventManager = OS.objc_getClass("NSAppleEventManager");
static final long sel_sharedAppleEventManager = OS.sel_registerName("sharedAppleEventManager");
static final long sel_setEventHandler = OS.sel_registerName("setEventHandler:andSelector:forEventClass:andEventID:");
static final long sel_handleAppleEvent = OS.sel_registerName("handleAppleEvent:withReplyEvent:");
static final long sel_paramDescriptorForKeyword = OS.sel_registerName("paramDescriptorForKeyword:");
static final long sel_stringValue = OS.sel_registerName("stringValue");

static final long kInternetEventClass = 0x4755524C; // 'GURL'
static final long kAEGetURL = 0x4755524C; // 'GURL'
static final long kCoreEventClass = 0x61657674; // 'aevt'
static final long kAEOpenApplication = 0x6F617070; // 'oapp'
static final long kAEReopenApplication = 0x72617070; // 'rapp'
static final long keyDirectObject = 0x2d2d2d2d; // '----'

The callback function:

static long handleAppleEvent (long id, long sel, long event_id, long reply_id) {
    // This is a handler for AppleEvents that are registered with [NSAppleEventManager setEventHandler:...]
    // It matches this selector (footprint):
    //   - (void)handleAppleEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)reply

    // Invoke [[event paramDescriptorForKeyword:keyDirectObject] stringValue] to get the direct object containing the URL
    long direct_desc_id = OS.objc_msgSend (event_id, sel_paramDescriptorForKeyword, keyDirectObject);
    long direct_str_id = OS.objc_msgSend (direct_desc_id, sel_stringValue);
    NSString nsStr = new NSString (direct_str_id);
    String str = nsStr.getString();
    // now 'str' contains the URL

    System.out.println ("handleAppleEvent invoked -- argument: "+url);
    return 0;
}

And the code to register the callback function:

// Get access to a callback function for receiving the sel_handleAppleEvent message
aeCallback = new Callback(Main.class, "handleAppleEvent", 4);
long aeProc = aeCallback.getAddress();

// Create a ObjC class that provides a method with the sel_handleAppleEvent footprint
String objcClassName = "ObjCAppleEventHandler";
long objcClass = OS.objc_allocateClassPair(OS.class_NSObject, objcClassName, 0);
OS.class_addMethod(objcClass, sel_handleAppleEvent, aeProc, "i@:@@");
OS.objc_registerClassPair(objcClass);
long objcHandlerInstance = OS.class_createInstance (objcClass, 0);

// Invoke [[NSAppleEventManager sharedAppleEventManager] setEventHandler:objcHandlerInstance andSelector:sel_handleAppleEvent forEventClass:kInternetEventClass andEventID:kAEGetURL]
long sharedAppleEventMgr = OS.objc_msgSend (class_NSAppleEventManager, sel_sharedAppleEventManager);
OS.objc_msgSend (sharedAppleEventMgr, sel_setEventHandler, objcHandlerInstance, sel_handleAppleEvent, kInternetEventClass, kAEGetURL);

What's left to do is to build an app bundle from this code and then add the CFBundleURLTypes entries to its Info.plist.

A complete sample source file can be downloaded here: http://files.tempel.org/Various/ObjectiveC-bridging.java.zip

Update answered 26/11, 2013 at 19:8 Comment(6)
'SEL' is not really the message signature, it is the method name. Signature would include types (e.g. NSMethodSignature).Salient
Oy. You've done some clever Objective-C runtime work, but this is not the right C (or Java) way of handling Apple Events. The C way of handling Apple Events is using the Carbon Apple Event Manager (which not deprecated). See the AEInstallEventHandler function in the Apple Event Manager: developer.apple.com/legacy/library/documentation/Carbon/…Canaday
The right Java way is probably using Apple's "com.apple.eawt" Java classes. See this article: developer.apple.com/library/mac/documentation/java/conceptual/…Canaday
@Canaday you make a lot of assumptions here. Have you tried yourself? Certainly the eawt package is not helping in this case.Update
No, I haven't implemented this in Java myself, but I don't think I'm making any incorrect assumptions here. It would be be much cleaner to use the Carbon Apple Event Manager (C calls) from my first link than to invoke Objective-C, which is a clever but hacky solution. I can't vouch for eawt, having never used it, but I can vouch for the Apple Event Manager.Canaday
Okay, since you have not tried this yourself, let me just say: 1. eawt does not offer the features we need here - we've tried that. 2. I know about the Carbon API you mention and I believe I had tried that before resolving to this option. IIRC, something about how the Java runtime uses the Cocoa APIs makes the Carbon way inoperable. Or it had something to do with the fact that I can not selectively pick the events that I need here. Or maybe I just made a mistake.Update
E
5

With Java 9, this is easy quite easy, and no longer requires Apple's EAWT classes or any ObjC hackery.

    Desktop.getDesktop().setOpenURIHandler((event) -> {
        System.out.println("Open URI: " + event.getURI());
        // do something with the URI
    });

The application needs to be bundled, and the CFBundleURLTypes key must be set.

<!-- Open URIs with scheme example:// -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
        <string>example</string>
    </array>
    <key>CFBundleURLName</key>
    <string></string>
  </dict>
</array>

Unfortunately this only captures the URI if the application is already running. If the application was launched by opening a URI, the event is not delivered (see comments on ed22's answer).

Elidaelidad answered 20/2, 2018 at 15:5 Comment(1)
For me, on MacOS 11.2.1 with Java 15, when clicking a link on a webpage with the custom protocol, a message pops up that asks to open the application (with a setting to "always allow links of this type to open in the associated app". The app is launched and the event is delivered.Wald
E
3

In case anyone wanted a version using com.apple.eawt.* This also uses reflection, so it will compile on any platform (Windows etc.). Make sure not to call the method registering the event handler on any non-Apple system ;)

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.net.URI;
import java.util.logging.Level;
import java.util.logging.Logger;

interface OpenUriAppleEventHandler {
    public void handleURI(URI uri);
}

class OpenURIEventInvocationHandler implements InvocationHandler {

    private OpenUriAppleEventHandler urlHandler;

    public OpenURIEventInvocationHandler(OpenUriAppleEventHandler urlHandler) {
        this.urlHandler = urlHandler;
    }

    @SuppressWarnings({ "rawtypes", "unchecked"})
    public Object invoke(Object proxy, Method method, Object[] args) {
        if (method.getName().equals("openURI")) {
            try {
                Class openURIEventClass = Class.forName("com.apple.eawt.AppEvent$OpenURIEvent");
                Method getURLMethod = openURIEventClass.getMethod("getURI");
                //arg[0] should be an instance of OpenURIEvent
                URI uri =  (URI)getURLMethod.invoke(args[0]);
                urlHandler.handleURI(uri);
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        }
        return null;
    }
}

public class OSXAppleEventHelper {
    /**
     * Call only on OS X
     */
    @SuppressWarnings({ "unchecked", "rawtypes" })
    public static void setOpenURIAppleEventHandler(OpenUriAppleEventHandler urlHandler) {
        try {
            Class applicationClass = Class.forName("com.apple.eawt.Application");
            Method getApplicationMethod = applicationClass.getDeclaredMethod("getApplication", (Class[])null);
            Object application = getApplicationMethod.invoke(null, (Object[])null);

            Class openURIHandlerClass = Class.forName("com.apple.eawt.OpenURIHandler", false, applicationClass.getClassLoader());
            Method setOpenURIHandlerMethod = applicationClass.getMethod("setOpenURIHandler", openURIHandlerClass);

            OpenURIEventInvocationHandler handler = new OpenURIEventInvocationHandler(urlHandler);
            Object openURIEvent = Proxy.newProxyInstance(openURIHandlerClass.getClassLoader(), new Class[] { openURIHandlerClass }, handler);
            setOpenURIHandlerMethod.invoke(application, openURIEvent);
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }
}

Use it like this:

//if(isOSX){
OSXAppleEventHelper.setOpenURIAppleEventHandler(new OpenUriAppleEventHandler() {

    @Override
    public void handleURI(URI url) {
        /* do something with the url */
    }
});
Earlap answered 29/7, 2015 at 8:19 Comment(20)
This appears to be a handler for reading the full URL for subsequent clicks of the URL once the application has already launched? Or am I misunderstanding? I have a JavaFX app and whilst adding the bottom code to the constructor of my startup class allows me to pick up the URL once the application has started, I need the launch parameters. Is this possible with this approach?Blowgun
StuartQ, yes, this registers a handler for the running application. Also, it needs to be called after AWT initialization (which seems to register it's own AppleEvent handlers). I don't know about launch parameters, but this is not the way to get them. If by "launch parameters" you mean application arguments passed when you start the app ,say from a command line, you can get those through String[] array passed to your application's main() method.Earlap
I mean when an application is launched by clicking on a URL, e.g. myapp://foobar/bazz as referenced in the opening question. I would be interested in obtaining foobar and/or bazz. In that instance, string[] array in main() is empty.Blowgun
Sure, see the "handleURI(URI url)" method above. The "url" parameter is what you want. It should contain the clicked url.Earlap
Unfortunately "handleURI(URI url)" doesn't give me what I need. It is not called on launch, only if you click the link after the application is up and running. By which time, it's too late.Blowgun
I am not sure but my bet is that you are not registering the handler early enough in your code and the AWT's event handler (or JavaFX's - I don't know about that one though) gets called instead.Earlap
It worked for me, I'm impressed!! It it really necessary to declare CFBundleURLTypes in Info.plist of a bundled application.Pipeline
I believe CFBundleURLTypes is necessary. The code above registers a handler, CFBundleURLTypes define what protocols the handler will actually handle. If you omit CFBundleURLTypes and it works for you please let me know ;)Earlap
@Blowgun did you find a solution to your problem? I am facing the same issue here, I only get the handleURI callback if the application is already running, not if the URI invocation launched the application.Elidaelidad
@Alex Suzuki This was quite a time ago, but try to call setOpenURIAppleEventHandler() AFTER the AWT is initialized.Earlap
@Earlap That didn't help either. I am actually trying to get this to work with Java 9. There EAWT should no longer be needed, you can just call Desktop.setOpenURIHandler(). However it seems to suffer from the same problem. I've filed a detailed bug report with Oracle for this.Elidaelidad
@Alex Suzuki Aren't you getting the URL in the args of main(String[] args) method at application startup? Maybe you could handle this case there?Earlap
@Earlap Unfortunately not. That would have been intuitive and nice.Elidaelidad
As documentation says, the app needs to be running to get the events: coderanch.com/how-to/javadoc/appledoc/api/com/apple/eawt/…Earlap
@Earlap thanks for digging out that link, Apple no longer hosts it. I wouldn't interpret it the way you did, though. Also there is a bug in OpenJDK for this issue (bugs.openjdk.java.net/browse/JDK-8015303) which claims it is resolved.Elidaelidad
@AlexSuzuki I'm also facing the same problem when the app is running then it gets the URI otherwise no event is triggered. Did you find the solution for it?Hawkes
@TouqeerShafi Nope, I don't have a simple one. One way you could do it is to split your app into a launcher and application part. The launcher gets the URI from the OS, and passes it on the running application via IPC. If the application is not running yet, it launches it, and passes it via command-line arguments, IPC or other mechanism.Elidaelidad
@AlexSuzuki, can you elaborate on the launcher part as I'm not familiar with it. Will the launcher can be triggered using the anchor tag from HTML (chrome browser)?Hawkes
@TouqeerShafi yes, the launcher is the application that is registered as a handler for OpenURI. It is a very small native macOS application, and therefore does not suffer any limitations imposed by Java.Elidaelidad
so the launcher application will be bundled with the JavaFX app or it should be developed natively as a separate app?Hawkes

© 2022 - 2024 — McMap. All rights reserved.