Search code examples
macososx-yosemitequicklook

How to store preferences for a HTML-based QuickLook generator on OSX?


I used to do it with a custom PrefPane that has to be installed separately, but that's not satisfactory.

I have a HTML-based QuickLook generator, that create thumbnails and previews of some inhomogeneous content (files that have a long ASCII header, and a various number of binary extensions each of them with some header).

Inside the QL method OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options), I tried to use [NSUserDefaults standardUserDefaults], but it has no effect, and writes no preference file, probably because we are inside a bundle, and not an app.

Any idea how to achieve this? I know that some excellent QL generators do it, such as BetterZip QL. I tried to reverse engineer the BetterZip QL, but with no success.


Solution

  • I directly contacted the author of the BetterZip QL, and together, we came up with a solution. Here it is.

    In short:

    1. First, create a small helper app, that will be bundled inside the generator. This app will be responsible for writing the preference file.
    2. Make this app register a custom URL scheme and implement the handling associated with it.
    3. Make your HTML-based QL open a specially-formatted URL, using that custom scheme, using Javascript.

    Ok, now in details.

    First create a small helper app target inside your Xcode workspace/project. My QL generator was named QLFits, I chose QLFitsConfig.

    By default, there is a MainMenu.xib associated that app. Keep it. It is used by the Info.plist, and it can be useful for debugging. As a matter of fact, to debug the custom URL scheme, you can add a NSWindow to that xib, and put labels which could be used to display debug messages. I found no real other way to log or display debug messages when debugging this problem.

    But at the end, you have a small windowless app. There are two configuration things that this app must have.

    1. The flag indicating that this app is an agent (see picture). It prevents the app to appear in the doc when running.
    2. The declaration of the custom URL scheme, with an Editor role. See also the picture for an example (here qlfitsconfig)

    Snapshot of the Info.plist of the help app

    Next, the implementation of the app needs to register the URL scheme to tell the system there is an app that is capable of opening it. Below the implementation of my "appDidFinishLaunching" method in the AppDelegate of the app.

    There is three parts: The registration of the handler of the custom URL scheme. The instantiation of a NSUserDefaults object with a suite name that is shared with the QL generator. And finally, the registration of the default values of the preferences (using a .plist file bundled with the app).

    - (void)applicationDidFinishLaunching:(NSNotification *)aNotification
    {
        NSAppleEventManager *appleEventManager = [NSAppleEventManager sharedAppleEventManager];
        [appleEventManager setEventHandler:self andSelector:@selector(handleAppleEvent:withReplyEvent:) forEventClass:kInternetEventClass andEventID:kAEGetURL];
    
        [[NSUserDefaults standardUserDefaults] addSuiteNamed:suiteName];
        NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:suiteName];
    
        NSString *optionsPath = [[NSBundle mainBundle] pathForResource:@"defaultOptions" ofType:@"plist"];
        NSDictionary *defaultOptions = [NSDictionary dictionaryWithContentsOfFile:optionsPath];
        [defaults registerDefaults:defaultOptions];
    }
    

    The suiteName variable is a static NSString with a reverse-DNS format: static NSString *suiteName = @"com.onekiloparsec.qlfitsconfig.user-defaults-suite";

    Then, the app needs to act upon the triggering of the event. Hence, one must do something with the event, and use that event to store the preference. Here is the implementation. Note that the signature of the method must be precisely that one, not because we declare it so above, but because that's the only one recognised by the system.

    - (void)handleAppleEvent:(NSAppleEventDescriptor *)event withReplyEvent:(NSAppleEventDescriptor *)replyEvent
    {
        NSString *URLString = [[event paramDescriptorForKeyword:keyDirectObject] stringValue];
        if (URLString) {
            NSURL *URL = [NSURL URLWithString:URLString];
            if (URL) {
                NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:suiteName];
    
                for (NSString *component in [URL pathComponents]) {
                    if ([component containsString:@"="]) {
                        NSArray *keyValue = [component componentsSeparatedByString:@"="];
                        [defaults setObject:keyValue.lastObject forKey:keyValue.firstObject];
                    }
                }
    
                [defaults synchronize];
            }
        }
    }
    

    The basic idea is that we will provide preferences through URL parameters as key-value pairs. Hence, we transformed here that URL string into pair of preferences, that are stored as strings.

    That's all for the app. To test and debug it, you need to build and run it (check with the /Utilities/Activity Monitor.app that it is running, for instance). You can type the following commands into a Terminal to see what happens:

    $ open qlfitsconfig://save/option1=value1/option2=value2
    

    And if you have kept the window with labels mentioned above, you can use them to display/debug what event your app receives.

    Now, back to the QL generator. Include the config app as a "Target Dependency" in the "Build Phases" of the generator. Moreover, add a new "Copy Files" Build Phase (after the Copy Bundle Resources build phase) to copy that helper app inside the QL bundle (see picture).

    Copy File build phase of the QL generator to bundle the helper app inside it.

    Now, in the code, more precisely, inside the method preview method: OSStatus GeneratePreviewForURL(void *thisInterface, QLPreviewRequestRef preview, CFURLRef url, CFStringRef contentTypeUTI, CFDictionaryRef options).

    At the beginning of it, I make sure the config help app is actually registered with the Launch Services of the system. To find it, one must use the bundle identifier of the QL generator. Note especially how the URL of the app is constructed, based on where it is copied in the Build phase (the Helpers directory).

    NSBundle *bundle = [NSBundle bundleWithIdentifier:@"com.onekiloparsec.QLFits3"];
    NSURL *urlConfig = [NSURL fileURLWithPath:[[bundle bundlePath] stringByAppendingPathComponent:@"Contents/Helpers/QLFitsConfig.app"]];
    LSRegisterURL((__bridge CFURLRef) urlConfig, true);
    

    The last line is using legacy APIs, but I couldn't make the new ones working. This is a weakness, and one should probably find a better way at some point.

    Now, if some preferences were already saved, one can access them with an instance of NSUserDefaults assuming we initialise it with the same suite name as defined in the helper app. Example:

        static NSString *suiteName = @"com.onekiloparsec.qlfitsconfig.user-defaults-suite";
        NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:suiteName];
    
        BOOL alwaysShowHeaders = YES;
        if ([defaults stringForKey:@"alwaysShowHeaders"]) {
            alwaysShowHeaders = [[defaults stringForKey:@"alwaysShowHeaders"] isEqualToString:@"1"];
        }
    

    That's it for the Obj-C code.

    The last part is the Javascript code. In my QL generator (whose code can be checked on GitHub), I use a template.html file containing all the html and JS code. You can organise yourself differently here.

    I first intended to change the QL preferences when checkboxes were toggled. But it appeared to not work (no events are triggered). The only way I made it work is that once my checkboxes where set, the user is requested to "save" the preferences using a button. And I save the preferences upon the clicking of that button. Here is the JS code inside my template.html

    <script>
    function saveConfig (a) {
        a.href  = "qlfitsconfig://save";
        a.href += "/alwaysShowHeaders=" + (document.getElementById("alwaysShowHeadersInput").checked ? "1" : "0");
        a.href += "/showSummaryInThumbnails=" + (document.getElementById("showSummaryInThumbnailsInput").checked ? "1" : "0");
        return true;
    }
    </script>
    

    alwaysShowHeadersInput and showSummaryInThumbnailsInput are the 'id' of my checkboxes in the HTML code. And the save button is triggering the saveConfig function.

    And the button must be declared inside an a tag:

    <a href="#" onClick="saveConfig(this);return true;" style="float:right;"><input id="save" type="button" value="Save"></a>
    

    Here is what the preferences look like in my QL window:

    The bottom of my QL window with the preferences checkboxes, and the save button

    Et voilà!