I directly contacted the author of the BetterZip QL, and together, we came up with a solution. Here it is.
In short:
- First, create a small helper app, that will be bundled inside the
generator. This app will be responsible for writing the preference
file.
- Make this app register a custom URL scheme and implement the handling associated with it.
- 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.
- The flag indicating that this app is an agent (see picture). It prevents the app to appear in the doc when running.
- The declaration of the custom URL scheme, with an
Editor
role. See also the picture for an example (here qlfitsconfig
)

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).

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:

Et voilĂ !