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:

~/.config/grinch.js
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.

SyntaxMatches
"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) => boolYour 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.

FormEffect
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.

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 FedRAMP urldefense.us tenant

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:

ReturnEffect
stringUse as the new URL.
URL instance, including the mutated inputUse its .href.
{protocol, host, pathname, search, hash, …}Join the fields into a URL.
nullDrop 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:

WantGrinchFinicky style
Hostname or subdomaindomain("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 IDfrom("com.microsoft.Outlook")(url, ctx) => ctx.opener?.bundleId === "…"
App is runningrunning("us.zoom.xos")finicky.isAppRunning("Zoom")
Modifier keys(url, ctx) => ctx.modifiers.shiftfinicky.getModifierKeys()
Opener detailsctx.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:

hideIcon: true
Don't show the menu-bar icon. Restart Grinch to apply; reloads don't change it. Automatic update checks still run. Reopen the running app from Spotlight or Finder for a window with config, log, and update actions.
checkForUpdates: false
Turn off the daily update check (true turns it on). While the key is set, it overrides the Check for Updates Automatically menu item, which is greyed out. Without the key, the menu item decides. Grinch saves the value as its own preference, so after you remove the key the menu item starts from the last value the config set.
logRequests: true
Add a resolve event for every routed URL to the diagnostic log.
logRotateBytes: <n>, logRotateDays: <n>
Cap the diagnostic log's growth. Rotation renames the current file to <original-name>.log.<iso-timestamp> and starts an empty one; same-second collisions add .1, .2, and so on. Bytes must be a positive whole number; days may be fractional, with a minimum of one second. Combine both and whichever fires first wins. By default the file grows until you delete it.
logFormat: "text"
Write readable diagnostic events. The default, "json", writes one JSON object per line. An omitted, null, or undefined value selects JSON. An invalid value keeps the current format, records a config error, and prints a warning; routing continues, and Grinch --validate still exits 0.

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.