A tiny, fast macOS browser router. Set it as your default browser; it routes
each URL to the right browser based on rules in ~/.config/grinch.js (or
~/.grinch.js).
Inspired by Finicky and Finch — most of Finicky's config DSL works in Grinch unchanged. Differences are summarised at the bottom of this file.
- ~1500 LOC Rust + a small embedded JS prelude
- 5–220 ns hot-path resolve latency (50–1800× faster than Finch on the same workload; see Performance below)
- ~16 MB resident memory (vs ~140 MB for Finicky)
- Native
JavaScriptCore, no bundler, no transpiler, no Electron - Config is real JavaScript — simple cases look like data, full power available
Requires macOS 13 or later. The release build is a universal binary (Apple Silicon + Intel) signed and notarized with a Developer ID, so Gatekeeper won't warn on first launch.
Grab the latest Grinch-vX.Y.Z.dmg from
Releases, open it,
and drag Grinch.app onto the Applications shortcut shown in the DMG
window.
Or from the terminal:
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.appAlways install from the DMG to /Applications directly. Running
Grinch.app out of ~/Downloads (or anywhere else) triggers Gatekeeper
translocation, which makes the same app appear multiple times in the
default-browser picker.
Requires a recent Rust toolchain.
git clone https://github.com/jamtur01/grinch
cd grinch
make runPass UNIVERSAL=1 to make build to produce a fat binary locally
(needs both rustup targets: rustup target add aarch64-apple-darwin x86_64-apple-darwin).
Launch Grinch (🎄 in your menu bar), open System Settings → Desktop &
Dock → Default web browser and select Grinch. Edit ~/.config/grinch.js to
define your rules — see examples/grinch.example.js
for the full feature surface.
If your finicky.js works as a starting point, copy it across:
cp ~/.config/finicky.js ~/.config/grinch.js
# (a few `export default` → `module.exports =` and `await fetch()` adjustments
# may be needed; see "Differences from Finicky" below)Drop a JavaScript file at ~/.config/grinch.js (or ~/.grinch.js). It must
export a config object via CommonJS:
module.exports = {
default: ..., // required: fallback browser
browsers: { ... }, // optional: named-browser dictionary
rewrite: [ ... ], // optional: URL rewriters, applied in order
rules: [ ... ], // optional: routing rules, first match wins
};Finicky-style aliases are accepted everywhere: defaultBrowser, handlers,
browser work identically to default, rules, open.
A browser is one of:
| Form | Means |
|---|---|
"Google Chrome" |
App display name; Grinch resolves to bundle ID at config-load |
"com.google.Chrome" |
Bundle ID (any reverse-DNS string is treated as one) |
{ name: "..." } |
Same as a bare string |
{ name: "Google Chrome", profile: "Work" } |
Chromium profile shorthand — expanded to --profile-directory=Work for Chromium-family bundle IDs |
{ name: "...", args: ["--incognito"] } |
Bundle ID + extra launch args |
{ name: "...", openInBackground: true } |
Don't activate (keep focus where it is) |
(url, ctx) => "..." |
Dynamic — return any of the above |
null |
Suppress: do nothing |
The profile shorthand is auto-expanded for: Chrome, Brave, Edge, Vivaldi,
Arc, Opera, Chromium. For other apps it's silently dropped with a load-time
warning.
You can predefine browsers in a top-level browsers map:
const browsers = {
personal: { name: "Google Chrome", profile: "Personal" },
work: { name: "Google Chrome", profile: "Work" },
zen: "app.zen-browser.zen",
};then refer to them by key (open: "personal") or by reference
(open: browsers.personal).
A match: field accepts one matcher or an array of them (OR semantics — any
hit triggers).
| Syntax | Matches | Notes |
|---|---|---|
"github.com" |
hostname, exactly or as subdomain | Bare strings without * or / are hostname patterns. Most common form. |
"*.slack.com/*" |
wildcard, full URL | Strings containing * or / compile to a Finicky-style anchored regex |
"zoom.us/j/*" |
wildcard with implicit https?:// prefix |
|
"slack:*" |
URLs with the slack scheme | |
domain("a.com", "b.com") |
any of the listed hostnames or their subdomains | Compiled to a single fast check |
from("com.tinyspeck.slackmacgap") |
URL was opened by this app | Caller bundle ID; matches ctx.opener.bundleId |
running("us.zoom.xos") |
this app is currently running | Lazily computed once per resolve |
/regex/ |
regex against full URL | Case-insensitive |
(url, ctx) => bool |
anything | Slow path (~10 µs extra) — full power |
Helper return values like domain(...)/from(...)/running(...)/strip(...)
are data, not functions — Grinch recognises the marker shape at config-load
and compiles to native Rust matchers/rewriters. The JS bridge is only crossed
on the hot path for user-written (url, ctx) => ... predicates.
rewrite is an array. Every matching rewriter applies, in order.
| Form | Effect |
|---|---|
strip("utm_*", "fbclid") |
Strip these query params (trailing * is a prefix wildcard) |
{ match: ..., url: "https://..." } |
Replace URL when match hits |
{ match: ..., url: (url, ctx) => ... } |
Transform URL via JS |
{ match: ..., url: () => null } |
Drop the URL (suppress, open nothing) |
A url rewrite function receives a URL instance as its first argument and
the ctx as its second. It can return:
| Return | Effect |
|---|---|
string |
Use as the new URL |
URL instance (incl. mutated input) |
Use .href |
{protocol, host, pathname, search, hash, ...} |
Concatenate fields into a URL |
null / undefined |
Drop the URL |
new URL(href) works in user code. The polyfill is mutable: url.protocol = "https:",
url.hostname = "...", and url.searchParams.set("k", "v") are all reflected
in subsequent reads of .href.
rules (or handlers) is an array. First match wins.
rules: [
{ match: ..., open: ... }, // route to a browser
{ match: ..., open: null }, // suppress (open nothing)
{ match: ..., url: ..., open: ... }, // rewrite on match, then route
]open (Grinch) and browser (Finicky) are aliases.
The second argument to every user fn is ctx:
{
url: "https://...", // input URL passed to resolve (the "originalUrl")
originalUrl: "https://...", // alias of ctx.url
opener: {
bundleId: "com.microsoft.Outlook",
name: "Microsoft Outlook",
path: "/Applications/Microsoft Outlook.app/Contents/MacOS/Microsoft Outlook",
windowTitle: "...", // lazy: requires Accessibility permission
},
modifiers: {
shift: false, option: false, command: false, control: false,
},
}ctx.url is pinned to the URL passed into resolve() — it doesn't reflect
intermediate rewrites. The first argument (a URL instance) is the current
URL and is rebuilt per fn call.
opener.windowTitle is a lazy getter. The first time a rule reads it,
Grinch fetches the focused window title via the Accessibility API (~5 ms
XPC call). Configs that never reference windowTitle pay nothing. On first
launch, Grinch will prompt for Accessibility permission; until granted,
windowTitle returns "".
The only globals Grinch installs are the marker helpers — domain(),
from(), running(), strip() — and the URL polyfill. There is no
finicky.* namespace; the equivalent functionality is on the existing
primitives:
| Want | Use |
|---|---|
| Match hostname or subdomain | domain("github.com", ...) or just "github.com" |
| Match by opener bundle ID | from("com.microsoft.Outlook") or (url, ctx) => ctx.opener.bundleId === "..." |
| Match if app is running | running("us.zoom.xos") |
| Read modifier keys | (url, ctx) => ctx.modifiers.shift |
| Read opener metadata | ctx.opener.{bundleId, name, path, windowTitle} |
console.log/warn/error are no-ops — JavaScriptCore has no console;
output is discarded rather than bridged.
Click the 🎄 in the menu bar:
| Item | Action |
|---|---|
| Open Config (⌘O) | Opens the active config file in your default .js handler (VS Code / Cursor / etc.). |
| Reload Config (⌘R) | Re-evaluates the config without relaunching. Equivalent to kill -HUP $(pgrep -f Grinch.app/Contents/MacOS/Grinch). |
| Start at Login | Toggles SMAppService.mainApp registration. Off by default; the entry also appears in System Settings → General → Login Items so users can disable it from there. |
| Quit Grinch (⌘Q) | Exit. |
make build # build Grinch.app
make run # build + register + launch
make test URL="https://..." # dry-run a URL through the rules
make cleanThe binary also has --version (prints the crate version), --test <url>
(dry-run a URL through the rules), and --bench N <url> (in-process resolve
benchmarking).
Measured on Apple Silicon, macOS 25, release build, median of 10 runs at
100 k–200 k iterations per workload. Reproduce with bench/run.sh — the
configs and URLs that produced these numbers live in bench/configs/.
These are the workloads that hit the bulk of the rules-array — domain
matchers, regex, wildcards. No JS bridge crossings; the URL string is
borrowed (Cow::Borrowed) for the entire resolve when no rewrite fires,
and quick_host is skipped when the config has no host-using matcher.
| Workload | ns/op |
|---|---|
| Floor: empty rules, no rewrite | 5 |
| Default fallback, no query | 75 |
| Default fallback, strip removes a param | 216 |
Bare-hostname match ("github.com") |
52 |
domain() match |
57 |
| Regex match | 32 |
Wildcard match ("zoom.us/j/*") |
30 |
User-written predicates and rewrites cross into JavaScriptCore. URL-only
predicates ((url) => …) skip the __grinchMakeCtx build and skip
the LaunchServices IPC for frontmost_opener() upstream — only fns
declaring a second formal arg pay for ctx. The first JS-bridge call in
a resolve costs ~3 µs (URL polyfill + cached opener-field JSValues);
subsequent fn calls within the same resolve reuse the cached args.
| Workload | ns/op |
|---|---|
| Native rule wins early (no fn fires) | 51 |
Drop URL via () => null (url-only) |
2,705 |
| HTTP→HTTPS via URL mutation (url-only) | 4,303 |
?browser= dynamic open fn (url-only matcher) |
4,509 |
4 fn matchers reading ctx.opener |
5,568 |
Full Slack-web → slack:// rewrite |
5,687 |
| Resident | Peak | |
|---|---|---|
| Grinch | 16 MB | 17 MB |
Same hardware, same config, same URLs.
| Workload | Finch (Swift) | Grinch (Rust) | Speedup |
|---|---|---|---|
| Default fallback, no query | 9,308 ns | 75 ns | 124× |
| Default fallback, strip removes | 10,898 ns | 216 ns | 50× |
| Bare-hostname match | 5,242 ns | 52 ns | 101× |
Subdomain via domain() |
5,784 ns | 57 ns | 101× |
| Regex match | 1,454 ns | 32 ns | 45× |
| Wildcard match | 9,060 ns | 30 ns | 302× |
| Finch | Grinch | Finicky | |
|---|---|---|---|
| Resident memory | 14.6 MB | 15.5 MB | 142.5 MB |
| Peak memory | 15.5 MB | 16.6 MB | 391.2 MB |
| Source LOC | ~700 | ~1,500 | ~2,900 |
Grinch's wins over Finch come from native, allocation-aware Rust:
regex crate vs NSRegularExpression, byte-level subdomain matching,
Cow<'_, str> for the URL so a no-rewrite resolve allocates zero bytes,
config-time runtime-needs analysis that skips quick_host,
frontmost_opener(), and __grinchMakeCtx for configs that don't read
them, Rc<BrowserSpec> instead of deep clone on every match, ASCII-only
lowercase, and a strip short-circuit when nothing changes. On the slow
path, fn arity is sniffed at config load — (url) => … predicates skip
the JS ctx build and the LaunchServices opener IPC entirely. Finicky's
higher memory footprint is its bundled WebView config UI eagerly loading
WebKit, not engine weight — Finicky uses goja (Go JS) for resolve, which
crosses a JS bridge for every match.
The --bench numbers above measure resolve() in isolation. Real
clicks add a few more steps:
- macOS Apple Event dispatch: 1–5 ms from the originating app.
frontmost_opener(): ~100–500 µs of LaunchServices IPC, only when the engine reports it needs the opener (any rule usingfrom(),ctx.opener.*, or any user fn matcher). Configs with pure declarative matchers skip this entirely.current_modifier_flags(): ~100 ns kernel call, same gating — skipped unless a fn matcher might read modifiers.open_url(): ~few ms forNSWorkspace.openApplicationAtURLto hand off to the target browser.
So full click-to-browser latency is dominated by macOS event dispatch (~ms) and target-browser launch (~ms), not by Grinch. Grinch's contribution is two-to-four orders of magnitude smaller.
domain(), from(), strip(), etc. return marker objects like
{__type: "domain", hosts: [...]} that Rust recognises at config load
and compiles to native regex::Regex / HashSet<String> / etc. The
Rust↔JS bridge is only crossed for user-written (url, ctx) => ...
predicates and rewrites. Within a single resolve, the URL instance,
ctx object, parsed hostname, and fn_args NSArray are cached and
reused across callbacks.
LaunchServices lookups (URLForApplicationWithBundleIdentifier,
fullPathForApplication) and Chromium Local State parsing are also
cached — first call hits the system, subsequent calls are HashMap
probes. BrowserSpecs are held as Rc<…> internally so a successful
match is a refcount bump, not a String + Vec<String> deep clone.
If you're porting a Finicky config, these are the places you'll need to adjust:
module.exports = { ... }instead ofexport default { ... }. JavaScriptCore in Grinch evaluates scripts, not modules —import/exportsyntax doesn't parse.- No
await fetch(). The resolve hot path is sync. The FinickyshortenerExpanderpattern can't run; resolve a shortener separately if you need it. - No
finicky.*namespace. Grinch doesn't shipfinicky.matchHostnames,finicky.getModifierKeys,finicky.isAppRunning,finicky.notify,finicky.getBattery,finicky.getPowerInfo, orfinicky.getSystemInfo. Migrate to:finicky.matchHostnames(...)→domain(...)(note:domainmatches subdomains too; for exact-hostname-only use a regex like/^github\.com$/)finicky.getModifierKeys()→ctx.modifiersinside a fn matcherfinicky.isAppRunning(id)→ declarativerunning(id)matcher- The remaining stubs (notify / getBattery / getPowerInfo / getSystemInfo) never had meaningful implementations and have no replacement.
opener.windowTitlerequires Accessibility permission. First launch prompts; before granting, the field returns""and rules depending on it silently no-op.appTypeis auto-detected, not declarative. Names that look like bundle IDs (reverse-DNS) are treated as such; everything else goes throughNSWorkspace.fullPathForApplication. You can still setbundleId/idexplicitly.
Everything else — domain, from, running, strip, the URL polyfill,
arrays of matchers, null open, combined {match, url, browser} entries,
the LegacyURLObject rewrite return shape — is supported.
MIT — see LICENSE.