Grinch

A small, fast macOS browser router. Sends each URL to the right browser via a JavaScript config — most Finicky v4 configs work unchanged.

macOS 13+ · Universal binary

What it is

Install

Grab the DMG, drag Grinch.app to /Applications, then set Grinch as your default browser in System Settings.

DMG=$(curl -fsSL https://api.github.com/repos/jamtur01/grinch/releases/latest \
  | grep -oE '"browser_download_url": "[^"]*\.dmg"' | cut -d'"' -f4)
curl -fsSL "$DMG" -o /tmp/grinch.dmg
hdiutil attach -nobrowse -quiet /tmp/grinch.dmg
ditto "/Volumes/Grinch "*/Grinch.app /Applications/Grinch.app
hdiutil detach "/Volumes/Grinch "* -quiet
open /Applications/Grinch.app

Always install to /Applications directly — running out of ~/Downloads triggers Gatekeeper translocation, which makes the same app appear multiple times in the default-browser picker.

Or build from source:

git clone https://github.com/jamtur01/grinch
cd grinch
make run

Configure

Drop a JavaScript file at ~/.grinch.js, ~/.config/grinch.js, or ~/.config/grinch/grinch.js (Grinch checks each in that order; first found wins). First matching rule wins.

module.exports = {
  default: "org.mozilla.firefox",

  rules: [
    // Slack invitations open in Chrome. `name:` is optional — it labels
    // the rule in --list-rules and the request log; doesn't affect routing.
    { match: from("com.tinyspeck.slackmacgap"),
      open: "Google Chrome",
      name: "slack-to-chrome" },

    // GitHub goes to a work Chrome profile. Bare-string match covers
    // `github.com` and all subdomains (api., gist., raw., …).
    { match: "github.com",
      open: { name: "Google Chrome", profile: "Work" } },

    // Zoom links open the native client.
    { match: /^https:\/\/[a-z0-9-]+\.zoom\.us\/j\//,
      open: "us.zoom.xos" },
  ],

  rewrite: [
    // Force HTTPS on a few hosts.
    { match: /^http:\/\/(developer\.apple\.com|news\.ycombinator\.com)/,
      url: ({ url }) => ({ ...url, protocol: "https" }) },
  ],
};

Reload after editing: kill -HUP $(pgrep -f Grinch.app/Contents/MacOS/Grinch) — or click Reload Config in the menu.

When a click goes the wrong way

A handful of CLI flags + a JSONL log answer "why did that click go there?":

List the loaded rules

Grinch --list-rules prints every rule with its index, label, and target — pair with the JSONL log to map a matchedRule.index back to the actual entry in your config.

0: zoom.us/j/* | *.zoom.us/j/* → us.zoom.xos
1: [slack-to-chrome] from:com.tinyspeck.slackmacgap → com.google.Chrome
2: github.com → com.google.Chrome --profile-directory=Work
3: fn: (url, ctx) => ctx.modifiers.shift → com.google.Chrome --profile-directory=Work

Trace every resolve

Set options: { logRequests: true } and Grinch writes one JSONL line per click to ~/Library/Logs/Grinch/Grinch_<timestamp>.log:

{
  "ts": 1778518645.634,
  "url": "https://github.com/foo",
  "final": "https://github.com/foo",
  "rewritten": false,
  "browser": "com.google.Chrome",
  "args": ["--profile-directory=Work"],
  "opener": { "bundleId": "com.tinyspeck.slackmacgap", "name": "Slack", "pid": 731 },
  "modifiers": { "shift": false, "option": false, "command": false, "control": false },
  "matchedRule": { "index": 2, "name": "github.com" }
}

The opener is identified via the GURL Apple Event's keySenderPIDAttr, so it's the app that actually sent the URL — not whichever app happens to be frontmost when Grinch's callback fires.

Headless checks

Grinch --validate loads the config and exits 0 (with the resolved path and rule count) or 1 (with the captured load error). Designed for editor save-hooks and CI:

$ Grinch --validate
grinch: config OK — /Users/james/.config/grinch.js
rules:  12

$ Grinch --validate
grinch: config invalid — SyntaxError: Unexpected identifier 'rules'. (line 4)
path:   /Users/james/.config/grinch.js
$ echo $?
1

Discovering browser bundle IDs

Grinch --list-browsers prints every app registered with LaunchServices to handle https:// URLs, with the bundle ID you'd write in a config alongside the display name the OS shows:

com.apple.Safari               Safari
com.google.Chrome              Google Chrome
com.brave.Browser              Brave Browser
com.microsoft.edgemac          Microsoft Edge
app.zen-browser.zen            Zen
...

Routing arbitrary URLs from scripts

Grinch registers a grinch: URL scheme so external tools (Shortcuts, AppleScript, shell scripts) can ask it to route a URL through the user's rules without setting it as the system default:

open grinch:https://example.com/path
open 'grinch:tel:+15551234567'        # tel: / webcal: / feed: are claimed too

Performance

A few benchmark data points from bench/run.sh. Worth knowing that real-world click-to-browser latency is dominated by macOS plumbing (Apple Event dispatch + NSWorkspace.openApplicationAtURL, both in the few-millisecond range), so engine-only numbers don't translate 1:1 into a faster-feeling click — but they're a useful window into what the engine is doing on its own.

Apple Silicon, macOS 26, release build, median of 10 runs at 100k–200k iterations per workload. Configs and URLs in bench/configs/.

Hot path (declarative-only configs)

Workloadns/op
Floor: empty rules, no rewrite6
Default fallback, no query69
Default fallback, strip removes a param194
Bare-hostname match ("github.com")44
domain() match50
Regex match24
Wildcard match ("zoom.us/j/*")32
50 bare-hostname rules, last one wins302

Slow path ((url, ctx) => … fn matchers)

Workloadns/op
Native rule wins early (no fn fires)44
Drop URL via () => null2,400
HTTP→HTTPS via URL mutation3,725
?browser= dynamic open fn4,640
4 fn matchers reading ctx.opener4,745
Full Slack-web → slack:// rewrite5,750

Footprint

GrinchFinchFinicky
Resident memory15.5 MB14.6 MB142.5 MB
Peak memory16.6 MB15.5 MB391.2 MB
Source LOC~1,500~700~2,900
JS enginesystem JSCn/a (Swift)bundled goja
Bundled UImenu bar onlymenu bar onlyWebView app