What it is
- Finicky v4 config compatibility. Most v4 configs work unchanged:
finicky.matchHostnames,finicky.notify,ctx.opener.*, theoptionsblock, profile shorthand. - ~1500 LOC Rust + a small embedded JS prelude.
- ~16 MB resident memory, ~1.5 MB universal binary.
- Native JavaScriptCore for config eval — no Electron, no bundler, no transpiler.
- Single DMG. Universal binary (Apple Silicon + Intel).
- Menu-bar app. Reload config or open it in your editor without leaving the menu bar. Parse errors surface as
⚠️with the failing line inline. - Hot-path resolve in nanoseconds, full click-to-browser pipeline in single-digit milliseconds.
- Debuggable. Every resolve can be logged as JSONL with the matched rule, opener, and modifier state.
--list-rulesprints the loaded rule table for cross-reference. - SSO / OAuth popups route through your rules. Apps that use
ASWebAuthenticationSession(Slack login, Claude Desktop, corporate auth) normally fall back to Safari when the default browser isn't a recognised browser. Grinch declares the right Info.plist capability and forwards auth URLs through the same resolve pipeline as regular clicks.
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)
| Workload | ns/op |
|---|---|
| Floor: empty rules, no rewrite | 6 |
| Default fallback, no query | 69 |
| Default fallback, strip removes a param | 194 |
Bare-hostname match ("github.com") | 44 |
domain() match | 50 |
| Regex match | 24 |
Wildcard match ("zoom.us/j/*") | 32 |
| 50 bare-hostname rules, last one wins | 302 |
Slow path ((url, ctx) => … fn matchers)
| Workload | ns/op |
|---|---|
| Native rule wins early (no fn fires) | 44 |
Drop URL via () => null | 2,400 |
| HTTP→HTTPS via URL mutation | 3,725 |
?browser= dynamic open fn | 4,640 |
4 fn matchers reading ctx.opener | 4,745 |
Full Slack-web → slack:// rewrite | 5,750 |
Footprint
| Grinch | Finch | Finicky | |
|---|---|---|---|
| Resident memory | 15.5 MB | 14.6 MB | 142.5 MB |
| Peak memory | 16.6 MB | 15.5 MB | 391.2 MB |
| Source LOC | ~1,500 | ~700 | ~2,900 |
| JS engine | system JSC | n/a (Swift) | bundled goja |
| Bundled UI | menu bar only | menu bar only | WebView app |
More
- Full README — every matcher, rewriter, browser-spec form, and helper
- Exhaustive example config
- Releases & changelog
- Issues
- Contributing