Finicky - scriptable macOS URL handler
A programmable URL handler becomes much more useful when its final destination can be any small executable in my dotfiles.
- Published
- Updated
- Reading time
- 10 min read
- Series
- Dotfiles
The macOS default browser setting looks like a choice between applications. Safari, Firefox, Chrome, perhaps another browser.
I wanted the choice to be a program.
My eventual URL path became:
macOS URL handler → Finicky → JavaScript rule → executable → destination
I like this chain because each arrow leaves me a seam I can understand and replace. macOS owns dispatch. Finicky owns URL parsing and routing policy. A normal executable owns the final effect. In my case, that executable forwards unknown URLs to a browser running outside my work machine.
This is exactly the kind of arrangement I want in dotfiles: visible, scriptable, composable, and replaceable one layer at a time.
Finicky is a programmable default browser
Finicky is an open-source macOS application that registers as the default browser. It does not need to render pages. It receives a URL, loads ~/.finicky.js, evaluates matching and rewrite rules, then opens the selected browser or application.
A basic configuration can route work domains to one browser and personal domains to another:
module.exports = {
defaultBrowser: "Safari",
handlers: [
{
match: finicky.matchHostnames(["github.com", "gitlab.com"]),
browser: "Firefox",
},
],
};
The JavaScript configuration is the attraction. Rules can be functions. URLs can be rewritten. Hostnames can be matched through Finicky’s parsed representation instead of fragile string slicing. The file can live beside shell functions, editor configuration, and the rest of my workstation policy.
I could have used a GUI router, and that would have been easier to start with. I know myself well enough to know where that ends: sooner or later I want to review a rule in Git, generate part of it, share it between machines, or make the destination something other than an installed browser.
I did not want another browser destination
My unknown-link policy required a command called SafeBrowser. Finicky should select it just as it selected Safari, then pass the URL as one argument.
At the beginning of 2025, the version I was using modeled destinations as application names, bundle identifiers, or application paths. That was sensible for a browser picker. It stopped one layer short of the composition I wanted.
So I forked Finicky at Overbryd/finicky and added a command application type. The public implementation commit is e380acbf01c….
The configuration schema gained appType: "command" and a command path. The native side carried that option into its browser descriptor. When selected, Finicky constructed an argument array containing the executable path and URL.
One implementation detail mattered enough to me not to compromise on it: the URL must remain data. The implementation does not produce one shell string such as:
/Users/me/.bin/safebrowser "${url}"
It uses Process, invokes /usr/bin/env, and supplies the command and URL as separate arguments. Shell metacharacters inside an attacker-controlled URL do not become shell syntax. My configuration also uses an absolute executable path, avoiding a PATH lookup for the security boundary.
I treat a command destination as privileged configuration. Whoever can modify .finicky.js or the target executable can redirect every external link or run code whenever one opens. I therefore keep both files under my ownership and out of reach of less-trusted processes.
Default to the script
The resulting configuration reverses the usual browser-router pattern. Instead of making Safari the default and adding special cases, I make SafeBrowser the default and add narrow local exceptions:
module.exports = {
defaultBrowser: {
name: "SafeBrowser",
appType: "command",
command: "/Users/me/.bin/safebrowser",
},
handlers: [
{
match: finicky.matchHostnames([
/.*\.?github\.com$/,
/.*\.?example-work-service\.com$/,
]),
browser: "Safari",
},
],
};
I like the failure direction of this policy. A new domain does not accidentally open in my work browser because I forgot to classify it. My omission produces less trust, not more: the URL follows the unknown route.
I review these rules as if the input were trying to fool them—because sometimes it is. I anchor patterns and match parsed hostnames rather than searching arbitrary strings. A rule matching github.com anywhere in the full URL could be fooled by a hostname such as github.com.attacker.example or by placing the trusted text in a query parameter.
I also keep the allowlist small. If my configuration contains half the internet, then I have not written a trust policy; I have built an unmaintainable alternate browser history.
The script is an adapter
SafeBrowser is deliberately ignorant of Finicky. It accepts one URL and arranges for another machine to open it:
#!/usr/bin/osascript
on run argv
set remoteMachine to first item of ¬
(read (POSIX path of (path to home folder as string) & ¬
".config/vm1.local/eppc") using delimiter "\n")
tell application "Finder" of machine remoteMachine ¬
to open location (item 1 of argv)
end run
The destination address lives outside the public dotfiles. The script uses Remote Apple Events to tell Finder in a macOS browser VM to open the URL.
I like this adapter because no layer knows too much:
- Finicky does not know how the VM is reached.
SafeBrowserdoes not know why this URL was selected.- the VM does not need Finicky installed;
- macOS does not need to know that its “default browser” is a policy engine.
I can replace Apple Events with SSH without rewriting matching rules. I can replace the VM while keeping the script’s one-URL contract. I can test the script directly from a terminal. I can point one rule at a different command for a temporary experiment.
I prefer composition over tight vertical integration. It lets me defer decisions, replace one layer, and hack on a new transport whenever I want. If a group of these pieces eventually stands the test of time, I can still compress them into one vertically integrated application, bundle it, and package it. I prefer that compression to be the last step, earned by experience, rather than the first architectural decision.
A URL is an argument, not a command
Arbitrary command support introduces a sharp edge, so the contract should stay narrow:
input: exactly one absolute http or https URL
output: destination receives that URL, or the command fails visibly
For my adapter, I want a deliberately unforgiving contract. It should reject missing arguments, extra arguments, unsupported schemes, unreasonable size, and invalid URL syntax. I do not want it logging query strings because they may contain tokens or recipient identifiers. I want a timeout when contacting another machine and a non-zero status when delivery fails.
My hard line is that the adapter must never interpolate the URL into shell source. This applies locally and across SSH. The following shape is dangerous:
ssh browser-vm "open '$url'"
Quoting untrusted data across two shell parsers is easy to get wrong. A forced remote command reading an encoded value from standard input, or a small protocol that keeps data separate from command text, is safer.
The AppleScript version also needs operational hardening. Remote Apple Events credentials are sensitive. Network access to the guest should be restricted. Failure must not silently fall back to opening the URL locally, because that would erase the policy exactly when isolation is unavailable.
Configuration as code is the real feature
The important part of this setup is not JavaScript as a language. It is that the routing decision is text I own.
A dotfiles-native policy gives me:
- history — I can see when a domain became locally trusted;
- review — a broad regular expression appears as a diff;
- reproduction — a new Mac receives the same handler and scripts;
- composition — rules can target browsers, profiles, applications, or commands;
- rollback — a broken policy is one Git operation away from its prior state;
- portability — the command behind a rule can evolve independently.
It also removes mystery. When a link opens in the wrong place, I inspect one rule file and one executable. I do not search preference panes, browser extensions, and remembered prompt choices.
I do not mistake configuration as code for automatic security. Finicky executes JavaScript, so my config is executable policy. My regexes can be wrong. My script can leak URLs. My public dotfiles can expose private hostnames or machine details. What I gain is not perfection; I gain the ability to inspect, review, and correct those risks.
Test the whole chain
I do not consider a URL router finished after clicking one familiar link and watching the right browser open. I want examples for each decision:
- an exact allowlisted hostname;
- a legitimate subdomain;
- a deceptive suffix such as
trusted.example.attacker.invalid; - an unknown HTTPS domain;
- plain HTTP;
- a URL with spaces or encoded characters;
- a query string containing shell metacharacters;
- a URL shortener;
- a non-web scheme;
- an unavailable browser VM.
The expected destination should be explicit for each case. Testing only the JavaScript match misses command dispatch. Testing only the script misses policy errors. The useful test crosses the same boundary as a real click.
I also want failure to remain safe. If Finicky cannot load its configuration or SafeBrowser cannot reach the guest, the result should be a visible failure—not an automatic local-browser fallback.
Keep the layers boring
“URL handler to Finicky to a script” sounds more complicated than choosing Safari in System Settings. Internally, each layer is simpler than a monolithic tool that parses URLs, manages every destination, authenticates to a VM, and opens a remote browser itself.
macOS already knows how to dispatch URLs. Finicky is good at programmable routing. A script is good at adapting one selected URL to a local environment. The guest is good at containing a browser.
That division is why the setup feels hacker-friendly rather than merely clever. Every piece has a small interface. Every policy lives in a file. Every implementation can be replaced with another command.
Finicky gave me control of the decision. My command extension kept the destination open-ended. Putting both pieces in public dotfiles made the setup durable without pretending it had reached its final form—and that is exactly how I prefer my tools.