Configuration
Your config is a JavaScript file that tells Grinch where each link should open. This page covers every part of it.
How routing works
Grinch receives a URL, applies every matching rewrite
in order, and then checks rules from top to bottom. The
first matching rule selects a browser or app.
If none matches, default selects the destination. A rule or the
default can also suppress a URL. A rule's open function that
returns null or undefined falls through to the next
rule. If a default function returns either value, Grinch opens
nothing.
A config exports one object:
module.exports = {
default: "com.apple.Safari", // required; use null to open nothing
browsers: {}, // optional named browser targets
rewrite: [], // optional URL transformations
rules: [], // optional routing rules
options: {}, // optional app and logging settings
};
Rule, rewrite, and matcher lists must be arrays with at most 65,536 entries. An invalid list fails the config load; a failed reload keeps the previous config.
examples/grinch.example.js
is a feature reference with more examples. Grinch looks for the config in
several places; see which config
Grinch uses.
Only use configs you trust. Grinch evaluates the file as JavaScript.
Rules
rules is an array. Each rule has a match condition
and an open destination. Grinch checks them in order and stops at
the first match:
rules: [
{ match: "github.com", open: "com.google.Chrome", name: "work" },
{ match: "example.com", open: null }, // suppress this URL
],
A rule can also specify url to rewrite a matching URL before
opening it; see URL rewrites for the supported return
values. Unlike a literal open: null, a function that returns
null or undefined does not suppress the URL: Grinch
continues to the next rule.
The optional name labels a rule without changing routing. It
appears in Grinch --list-rules and in the
matchedRule.name field of diagnostic-log resolve
events, which helps when a config has several function matchers: their
automatic label joins the function's source onto one line. The full label
is truncated after 80 characters.
A rule's own fields are match, open (or
Finicky's browser), url, and name.
Launch options belong inside the browser
target:
// Wrong: links that match this rule open nothing
{ match: "github.com", open: "Google Chrome", profile: "Work" },
// Right
{ match: "github.com", open: { name: "Google Chrome", profile: "Work" } },
If profile, args, incognito,
openInNewWindow, or openInBackground sits directly
on a rule, Grinch suppresses that rule and records a
config warning. Other
unknown fields on a rule or in a browser target object are ignored with a
config warning.
Match types
A match field accepts one matcher or an array of them. Any
matcher in the array can trigger the rule.
| Syntax | Matches |
|---|---|
"github.com" | The hostname, exactly or as a subdomain. Bare strings without *, /, or a port are hostname patterns. Differs from Finicky; see below. |
"*.slack.com/*" | Wildcard against the full URL. Strings containing * or / compile to an anchored regex, as in Finicky. |
"zoom.us/j/*" | Wildcard with an optional common scheme, including http:// and https://. |
"localhost:3000" | URLs for that host and port, with any path. A dotted name followed by a numeric port is also treated as a host; use a regex for an opaque scheme with the same shape. |
"slack:*" | URLs with the slack scheme. |
domain("a.com", "b.com") | Any listed hostname or its subdomains. Compiled to a single byte-level check. |
finicky.matchHostnames("github.com") | The exact hostname only, not subdomains. Finicky-compatible. Use it when you need exact-hostname matching. |
from("com.tinyspeck.slackmacgap") | URLs opened by this app. Matches the caller's bundle ID, ctx.opener.bundleId. |
running("us.zoom.xos") | Any URL while this app is running. Computed once per URL, only when needed. |
/regex/ | A regex against the full URL. Uses Rust regex syntax and supports the i and m flags; lookarounds are not supported and produce a config warning. Character classes such as \w and word boundaries use Unicode rules, which differ from JavaScript. |
(url, ctx) => bool | Your own condition. See the ctx object. |
Wildcard scheme and hostname matching ignores ASCII case; paths, queries,
and fragments remain case-sensitive. Regexes match the URL as received and are
case-sensitive unless you add the i flag.
A wildcard without a scheme accepts http, https,
ftp, mailto, file, tel,
sms, and data. Use a pattern such as
"*://example.com/*" to match other hierarchical schemes.
Bare-string matchers
A bare string such as "github.com" matches the hostname and its
subdomains: https://github.com/, https://api.github.com/,
and https://gist.github.com/ alike.
Finicky v4 matches the same syntax with === against the full
URL, so match: "github.com" never fires there. For an exact
hostname in Grinch, use finicky.matchHostnames("github.com") or a
function such as (url) => url.hostname === "github.com". A
regular expression is tested against the full URL, not just the hostname.
To match several hosts and their subdomains at once, use
domain("github.com", "gitlab.com").
Helpers such as domain(), from(),
running(), and strip() return data, not functions.
Grinch recognises them when it loads the config and compiles them to native
matchers and rewriters. Only your own (url, ctx) => …
functions run JavaScript for each link.
Browser targets
A browser target tells Grinch where to open a link. Most of the time, use
the app's name or bundle ID in default or a rule's
open:
{ match: "github.com", open: "Google Chrome" },
{ match: "zoom.us", open: "us.zoom.xos" },
The first rule opens GitHub in Chrome; the second sends Zoom links to the
Zoom app. Grinch --list-browsers lists the bundle IDs of apps
registered to open web links.
Choose a profile
To use separate work and personal profiles, name the profile:
{ match: "github.com", open: { name: "Google Chrome", profile: "Work" } },
Grinch supports profiles in Chromium-based browsers, including Chrome,
Brave, Edge, Arc, Vivaldi, and Chromium, and in Firefox-based browsers,
including Firefox, LibreWolf, Waterfox, and Zen. "Google Chrome:Work"
is a shorter form for a literal target. For Chrome, "Default" and
"Profile N" also identify a profile by its directory name.
Dynamic functions returning strings don't use the Name:Profile
shorthand.
Grinch doesn't select profiles in other browsers, including Safari. If a target
names a profile for one of them, whether as an object, with the
Name:Profile shorthand, or in an object a function returns,
Grinch doesn't open the link and records a profile error. Remove the
profile or use a Chromium or Firefox browser.
If a Chrome profile can't be found, Grinch doesn't open the link rather than
risk the wrong profile. Check the profile name and
Open Diagnostic Log, then choose Reload Config
after fixing the name or the browser's profile data. To find the directory
name, open chrome://version in that profile and look at
Profile Path. For Firefox, use the name shown in its
profile picker, or use its full profile directory path. To find the path,
open about:support in that Firefox profile and choose
Show in Finder next to Profile Folder.
In Finder, select the folder and press Option + Command +
C to copy its path. Paths beginning with ~/ are also
supported.
How Grinch resolves browser profiles
Chrome display names are looked up in the browser's Local State
file. Default and Profile N (ASCII digits) are
explicit directory names and need no profile-file access. Other values must
match a display name or directory key in Local State. An
unreadable file, unknown name, or duplicate display name records a profile_error and
suppresses the launch, including for dynamic targets; Grinch does not try a
later rule or the default. Use the profile directory when display names are duplicated.
Firefox profiles use names from profiles.ini or the newer
profile groups. If profiles.ini exists but can't be read, or
Grinch can't read the
profile groups, a name matches multiple profiles, or a profile directory
is missing, Grinch stops the launch; use a full directory path to resolve
duplicate names. Legacy profile names
take precedence when both systems contain the same name. Unknown names are
passed to Firefox with a warning when an inventory is available. If
profiles.ini is missing, the name is passed to Firefox. A profile
on any other browser records
a profile_error and suppresses the launch.
If macOS denies access to a browser's profile files, the error says so:
allow Grinch to access that browser under System Settings →
Privacy & Security → Files & Folders, then choose
Reload Config. Reloading clears cached profile data and
read failures. The full supported browser-family lists are in
src/chromium.rs and
src/firefox.rs.
Change how a link opens
Use an object when a link needs a profile or another launch option:
{ match: "private.example.org", open: { name: "Google Chrome", incognito: true } },
{ match: "news.example.com", open: { name: "org.mozilla.firefox", openInNewWindow: true } },
incognito and openInNewWindow work with supported
Chromium and Firefox browsers; Safari doesn't support them. Set
openInBackground: true to open a link without activating the
target app. Pass browser command-line flags with
args: ["--incognito"]. An invalid argument list suppresses
that target and produces a config warning.
To open a particular app bundle, use its path, for example
"/Applications/Foo.app" or
{ name: "~/Apps/Foo.app", appType: "path" }. Use
{ name: "com.example.Browser", appType: "bundleId" } to treat a
name as a bundle ID without an app-name lookup. A target of null
or { appType: "none" } opens nothing. A function can choose a
target dynamically; a rule's function returning null or
undefined tries the next rule.
Reuse a target
Name targets that several rules share:
const browsers = {
personal: "com.apple.Safari",
work: { name: "com.google.Chrome", profile: "Default" },
};
module.exports = {
default: "personal",
browsers,
rules: [{ match: "github.com", open: "work" }],
};
The exported browsers map makes "personal" and
"work" available by name. The JavaScript reference
browsers.work works too.
URL rewrites
rewrite is an array of transformations applied before routing.
Every matching rewriter applies, in order. This removes common tracking
parameters before the rules see the URL:
rewrite: [strip("utm_*", "fbclid")],
Object entries need both a match and a url;
missing fields or an empty match produce a config warning. The standalone
helpers below run without a match.
| Form | Effect |
|---|---|
strip("utm_*", "fbclid") | Remove these query parameters. A trailing * matches a prefix. |
safelinks() | Unwrap corporate SafeLinks and URL Defense wrappers. |
teams_launcher() | Turn Microsoft Teams launcher URLs into msteams: links. |
{ match: …, url: "https://…" } | Replace the URL when match hits. |
{ match: …, url: (url, ctx) => … } | Transform the URL with JavaScript. |
{ match: …, url: () => null } | Drop the URL and open nothing. |
Unwrap corporate links
safelinks() is a top-level entry with no match. It
recognises the most common corporate URL wrappers and extracts the real
destination:
- Microsoft 365 Defender SafeLinks:
*.safelinks.protection.outlook.com/?url=… - Microsoft Teams external-link interstitial:
statics.teams.cdn.office.net/evergreen-assets/safelinks/?url=… - Proofpoint URL Defense v2:
urldefense.proofpoint.com/v2/url?u=… - Proofpoint URL Defense v3:
urldefense.com/v3/__<encoded>__;<marker>!!…, plus the FedRAMPurldefense.ustenant
Other hosts pass through unchanged, so it's safe at the top of the rewrite
chain. [safelinks(), strip("utm_*")] unwraps a Defender-tracked
Outlook link, then strips utm_* from the inner URL. Links wrapped
twice, such as Defender then Proofpoint, are unwrapped up to two levels
deep.
teams_launcher() handles the Teams launcher page that calendar
invites and shared links often use:
https://teams.microsoft.com/dl/launcher/launcher.html?url=%2F_%23%2Fl%2Fmeetup-join%2F19%3A…
Its url parameter decodes to a relative path
(/_#/l/meetup-join/19:…), so safelinks() can't use
it. teams_launcher() removes the web-app prefix and rebuilds the
link as msteams:/l/meetup-join/19:…. Add a rule that sends
msteams: links to Teams; otherwise the default target is used.
Other hosts and paths pass through. Use it alongside
safelinks():
rewrite: [ safelinks(), teams_launcher(), strip("utm_*") ]
rules: [{ match: /^msteams:/, open: "com.microsoft.teams2" }]
Rewrite functions
A url rewrite function receives a URL instance as its first
argument. If it declares a second argument, it also receives
context. It can return:
| Return | Effect |
|---|---|
string | Use as the new URL. |
URL instance, including the mutated input | Use its .href. |
{protocol, host, pathname, search, hash, …} | Join the fields into a URL. |
null | Drop the URL and open nothing. |
undefined or return; | Leave the URL unchanged, as in Finicky v4. |
new URL(href) works in your code. The URL object is mutable:
url.protocol = "https:", url.hostname = "…", and
url.searchParams.set("k", "v") are all reflected in later reads
of .href. Protocols are lowercased when parsed or assigned.
As in browsers, assigning protocol cannot switch between a
web scheme such as https: and a custom scheme. Return a new
URL or string to make that change.
Shortened links such as bit.ly can't be expanded inside a
rewrite; see URL shorteners.
The ctx object
Your matcher, rewrite, and browser functions receive the current URL as
their first argument. To receive ctx as the second argument,
declare two parameters, such as (url, ctx) => …. A default
parameter such as (url, ctx = {}) => … does not request
context, because JavaScript reports that function's length as one.
This rule opens links from Mail in Safari:
{ match: (url, ctx) => ctx.opener?.bundleId === "com.apple.mail",
open: "com.apple.Safari" },
ctx contains:
{
url: "https://...", // the URL passed to resolve (the "originalUrl")
originalUrl: "https://...", // alias of ctx.url
opener: { // or null; see below
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,
capsLock: false, fn: false, function: false,
},
}
fn and function both represent the Fn/Globe key.
ctx.url is the URL Grinch received; it doesn't reflect
rewrites. The first argument, a URL instance, is the current URL and is
rebuilt for each function call.
Grinch identifies ctx.opener from the Apple Event's sender
when it can. Otherwise it falls back to the frontmost app, which may not be
the original sender; if that also fails, ctx.opener is
null. Guard with if (ctx.opener) { … } or optional
chaining (ctx.opener?.bundleId) in functions that read it. This
matches Finicky v4's options.opener.
opener.windowTitle is read only when a rule uses it. The first
read fetches the focused window title through the Accessibility API, which
takes about 5 ms. Configs that never read windowTitle pay
nothing. Grinch asks for Accessibility permission when it launches; until
you grant it in System Settings → Privacy & Security →
Accessibility, windowTitle is "".
Globals
Grinch installs the helpers domain(), from(),
running(), and strip(), a URL polyfill,
and the Finicky-compatible finicky.* namespace
(full list). For most decisions
you can use either form:
| Want | Grinch | Finicky style |
|---|---|---|
| Hostname or subdomain | domain("github.com", …) or "github.com" | Not available; use a bare string |
| Exact hostname only | (url) => url.hostname === "github.com" | finicky.matchHostnames("github.com") |
| Opener bundle ID | from("com.microsoft.Outlook") | (url, ctx) => ctx.opener?.bundleId === "…" |
| App is running | running("us.zoom.xos") | finicky.isAppRunning("Zoom") |
| Modifier keys | (url, ctx) => ctx.modifiers.shift | finicky.getModifierKeys() |
| Opener details | ctx.opener.{bundleId, name, path, windowTitle} | The same ctx.opener |
console.log, warn, error,
info, and debug write to stderr with a
grinch [level]: prefix. Objects are printed as JSON. Use them
to trace why a rule did or didn't fire.
Options
The options block accepts Finicky v4's keys plus Grinch's log
settings:
The Finicky options urlShorteners and keepRunning
are accepted but have no effect: shortened links need
expanding outside Grinch, and
Grinch always stays running. An unknown key produces a config warning,
which appears in the app, diagnostic log, and Grinch --validate.