Documentation
Junction is a rule-based link router for macOS. It sits as your default browser and sends every link you click to the right browser, browser profile, or native app — by rules you write once.
macOS allows exactly one default browser. If you juggle work and personal Chrome profiles, want Zoom links in the Zoom app instead of a tab, or want links from Terminal to open somewhere other than links from Mail, you need a router in between. Junction takes the default-browser slot: it receives the link, checks it against your ordered rule list, and hands it off to wherever it belongs. First match wins; anything unmatched opens in your fallback browser.
It's a menu-bar app — no windows, no Dock icon. Open source (MIT), with a GUI and a human-readable config file you can check into your dotfiles, plus a CLI. Zero telemetry.
Installation
Requirements: macOS 13 Ventura or later. Builds are universal — Apple silicon and Intel.
Homebrew (recommended)
The repo is its own tap, so point Homebrew straight at it. The cask lives outside Homebrew's official taps, so trust it before installing:
brew tap jnahian/junction https://github.com/jnahian/junctionbrew trust --cask jnahian/junction/junctionbrew install --cask junctionxattr -dr com.apple.quarantine /Applications/Junction.appHomebrew also symlinks the bundled junction CLI onto your PATH.
Manual DMG
Download Junction.dmg from the latest release, open it, and drag Junction into Applications. Then run the xattr command above. With a manual install, link the CLI yourself if you want it:
ln -sf "/Applications/Junction.app/Contents/Helpers/junction" /usr/local/bin/junctionUninstall
brew uninstall --zap --cask junction # Homebrew: also removes config + prefsrm -rf /Applications/Junction.app # manual installYour rules live at ~/.config/junction/config.json — delete that too on a manual uninstall if you want a clean slate.
First launch
Junction opens a short setup the first time you run it.
1 · Pick a fallback browser
Where anything your rules don't match will open. This is the safety net that makes routing non-destructive — you can add rules gradually and nothing breaks in the meantime.
2 · Make Junction your default browser
The one step it can't work without: being the default browser is how Junction sees every clicked link. Setup offers to do it, or set it yourself under System Settings → Desktop & Dock → Default web browser. Junction hands each link off to a real browser afterward — you never browse "inside" it.
3 · Enable a few starter rules
Setup only offers rules for apps and browsers you actually have installed. After that Junction lives in the menu bar; click the icon for recent links, settings, and updates.
The config file
Rules live in ~/.config/junction/config.json (honors $XDG_CONFIG_HOME). Edit them in the GUI or in your editor — it's plain text you own, so commit it to your dotfiles, sync it, or share it with your team.
{
"version": 1,
"fallback": { "app": "com.apple.Safari" },
"stripTrackingParams": true,
"rules": [
{
"name": "Work links → Chrome work profile",
"match": { "patterns": ["*.atlassian.net/*", "app.clickup.com/*"] },
"action": { "app": "com.google.Chrome", "profile": "Profile 1" }
},
{
"name": "Zoom → native app",
"match": { "patterns": ["*.zoom.us/j/*", "*.zoom.us/w/*"] },
"action": { "deepLink": "zoom" }
},
{
"name": "Links from Terminal → Chrome",
"match": { "sourceApps": ["com.googlecode.iterm2", "com.apple.Terminal"] },
"action": { "app": "com.google.Chrome" }
}
]
}Top-level keys:
rules— the ordered rule list, checked top to bottom.fallback— where unmatched links go, named by bundle ID.stripTrackingParams— drop tracking parameters before routing.enabledRewriters,customRewriters,slackTeams— see Deep links.
Rule syntax
A rule is a name, a match, and an action:
{
"name": "Personal → Firefox profile",
"match": { "patterns": ["reddit.com/*", "news.ycombinator.com/*"] },
"action": { "app": "org.mozilla.firefox", "profile": "personal" }
}Match patterns
Patterns are matched against the link's host/path. The scheme is ignored unless you write it, the host is case-insensitive, the path is case-sensitive, and the query string is ignored — use regex when the query matters.
| Pattern | Matches |
|---|---|
*.atlassian.net/* | Any subdomain of atlassian.net — and the apex domain itself — at any path depth |
app.clickup.com/* | That exact host, any path below it |
reddit.com | A bare host: matches all of its subpaths |
*.zoom.us/j/* | * stays inside one path segment; ** or a trailing * crosses segments |
* | Everything — but you don't need it; unmatched links already go to your fallback |
Match on the source app
A rule can also match where the click came from — the bundle ID of the app that opened the link. Combine it with patterns to narrow a rule to one app's links:
{
"name": "Meeting links from Calendar → picker",
"match": {
"patterns": ["meet.google.com/*"],
"sourceApps": ["com.apple.iCal"]
},
"action": { "prompt": true }
}Precedence
Order is the logic. Junction walks the list from the top and stops at the first rule that matches, so put specific patterns above broad ones. If two rules could match the same link, the one higher in the file wins.
Fallback
If no rule matches, the link opens in your fallback browser. So does any rule that can't be carried out — a browser that isn't installed, a profile that was deleted. Junction never drops a click.
junction test <url> prints the matching rule and the decision without opening anything. See Command line.Actions
The action decides what happens to a matched link:
| Action | What it does |
|---|---|
{ "app": "com.google.Chrome" } | Open in a browser or app, named by its bundle ID. |
{ "app": "com.google.Chrome", "profile": "Profile 1" } | Open in a specific browser profile — a Chromium profile directory (Default, Profile 1) or a Firefox profile name (what firefox -P takes, listed in Settings → Browsers). |
{ "deepLink": "zoom" } | Rewrite the link to a native app deep link. Works whether or not the rewriter is enabled in Settings → Deep Links. |
{ "prompt": true } | Show the picker and let you choose at click time. |
{ "clipboard": true } | Copy the link instead of opening it. |
profile is a Chromium profile directory or a Firefox profile name. If a rule names a profile that no longer exists, the link degrades to your fallback browser rather than opening in the wrong one.Not supported:
- Firefox containers are an extension feature with no launch-flag equivalent — Firefox profiles are supported.
- Arc spaces have no public API.
Deep links
A deep-link rewriter turns a web URL into a native app URL, so the link skips the browser entirely. Junction ships 18 of them, covering these apps:
Zoom
Spotify
Slack
Figma
Notion
Microsoft Teams
Discord
Linear
VS Code
ClickUp
GitHub Desktop
Telegram
WhatsApp
Apple Music
Asana
Todoist
The automatic rewriters are off until you enable them in Settings → Deep Links (enabledRewriters in the config). A rule with an explicit deepLink action always works regardless. A rewriter only fires if an app claiming its URL scheme is actually installed.
Add your own
Deep Links → Add App… takes a name, the app's URL scheme, a regex for the web URL, and a template for the app URL, with a test field to check it before saving. It lands in customRewriters and behaves exactly like a built-in — including as a deepLink rule action. Naming yours after a built-in replaces that built-in.
{
"customRewriters": [
{
"id": "linear",
"name": "Linear",
"patterns": ["^https?://linear\\.app/([^?#]*)"],
"template": "linear://$1",
"scheme": "linear"
}
],
"enabledRewriters": ["zoom", "spotify", "linear"],
"slackTeams": { "acme": "T01ABCDEF" }
}Capture only the part of the link the app needs: [^?#]* keeps a link's query and fragment out of the app URL, which a catch-all (.*) would hand straight to the app.
slackTeams (find it in app.slack.com/client/TEAM_ID/…). An unmapped workspace opens in the browser rather than on the wrong screen.Rewriters are data, not code — rewriters.json. Contributing one for everyone is a JSON-only pull request; see CONTRIBUTING.md.
Command line
The app bundle ships a companion CLI at Contents/Helpers/junction (Homebrew links it onto your PATH automatically).
junction test "https://mycorp.atlassian.net/browse/X-1" # which rule wins, and why
junction open "https://example.com" # route a link now
junction config path # print the config path
junction config validate # check the file before savingjunction test is the fastest way to reason about precedence — it prints which rule matched and what Junction would do, without opening anything. Add --source <bundle-id> to test a source-app rule:
junction test "https://mycorp.atlassian.net/browse/X-1" --source com.tinyspeck.slackmacgapjunction config validate runs the same validation the GUI and the file watcher use, so a config that passes here is a config the app will load.
Troubleshooting
macOS refuses to open the app
Junction isn't notarized yet, so macOS quarantines it. Clear the flag with xattr -dr com.apple.quarantine /Applications/Junction.app, or right-click Junction.app → Open → Open.
Nothing routes at all
Junction only sees links when it is the default browser — that's the one step it can't work without. Re-select it under System Settings → Desktop & Dock → Default web browser.
Links open in the wrong place
Almost always precedence: a broader rule above is catching the link first. Run junction test <url> — it prints the matching rule and the decision without opening anything.
A rule opens the fallback instead of its app
Junction degrades to the fallback rather than opening the wrong thing — a deleted browser profile, an app that isn't installed, or an unknown rewriter all land there. The menu-bar icon flags an invalid config, and the last-good rules stay live until you fix it.
Slack deep links open the browser
Slack permalinks don't carry the team ID the app needs. Map each workspace subdomain to its team ID in Settings → Deep Links (or slackTeams in the config); unmapped workspaces open in the browser rather than on the wrong screen.
Privacy
Zero telemetry, zero analytics. Junction makes exactly one kind of network request: the Sparkle update check against this repository's release feed. The links you route are never sent anywhere and never logged to disk — the recent-links list is in memory, capped at 10, and cleared on quit.
Being the default browser is a high-trust position. That's the argument for open source, for CI that builds and tests every commit, and for updates that only install if they carry a valid EdDSA signature from the maintainer's key.
