A link picker for macOS. Click a link anywhere, then choose the Zen space, Chrome or Firefox profile, or browser it opens in.
schowave.github.io/browserdoor
I use Zen with one space per client, each with its own container. Links from Slack, Mail or Teams always opened in whatever space happened to be active, so I kept ending up in the wrong account and moving tabs around by hand.
Browserosaurus and Finicky solve this for browsers, but not for spaces inside a browser. Browserdoor shows each Zen space as its own entry, next to Chrome and Firefox profiles and your other browsers. You pick one with a number key or a click.
What it does:
- reads your Zen spaces (names, icons, colors) and opens links with Zen's
--spaceflag (Zen 1.24 or later) - lists every profile of Chrome, Brave, Edge, Vivaldi and Chromium separately
- lists every Firefox profile separately, including the ones from Firefox's new profile manager
- only shows real browsers, so terminals, web-app wrappers and test builds stay out of the list
- collects links that arrive at the same moment into one picker, so one choice opens them all
- runs as a small menu bar app (Swift/SwiftUI). The only network request is the update check, which you can turn off.
- macOS 14 (Sonoma) or later
- No Xcode on macOS 15 or later (Apple Silicon and Intel): Homebrew installs a prebuilt bottle. On macOS 14 it builds from source and needs Xcode 16.
- For spaces: Zen 1.24 or later. Older versions show up as a single entry.
brew install schowave/tap/browserdoor
browserdoorbrowserdoor starts the app and always points at the installed version.
Without Homebrew: download Browserdoor.dmg (older versions are on the releases page), open it and drag Browserdoor to Applications. The DMG is signed and notarized by Apple, so it opens without a warning. This build updates itself: it checks once a day (switch it off under Settings), shows what's new and installs the update when you agree.
On first launch Browserdoor asks whether it should become your default browser (macOS asks you to confirm) and adds a login item. A Homebrew install also gets a link at ~/Applications/Browserdoor.app. That link keeps working after brew upgrade, and Browserdoor restarts itself once an upgrade is in. Upgrading from 0.6.1 or earlier, quit Browserdoor from the menu bar once and run browserdoor again.
Upgrading from Doorman (0.2.x) or Browserdoor 0.4.x
Browserdoor was called Doorman up to version 0.2.6. Homebrew only loads formulae you trust, and that trust is tied to the formula name, so trust the new name once before upgrading:
brew trust --formula schowave/tap/browserdoor
brew upgrade doorman && browserdoorbrew upgrade moves the doorman install over by itself. Your settings and ~/.config/doorman (now ~/.config/browserdoor) carry over, and the old command doorman keeps working.
Version 0.5.0 moved the app's bundle ID from io.github.schowave.doorman to io.github.schowave.browserdoor. The first start after updating from 0.4.x or earlier carries your settings, login item and menu bar position over. macOS ties the default browser to the bundle ID, so it asks you once to confirm Browserdoor again.
Click a link in any app and the picker opens at the mouse pointer:
| Key | Action |
|---|---|
1 … 9, 0, or a custom key |
Open the link in that target |
↑ ↓, then Enter |
Move the selection, then open |
⌘C |
Copy the URL(s) |
Esc, or a click outside the panel |
Cancel |
The menu bar icon has Settings… (⌘,) and Check for Updates…. If Browserdoor isn't your default browser, the menu tells you and offers to fix it. The app is in English or German, depending on your system language.
Open Settings… from the menu bar, or run browserdoor while it's already running. Under Targets you can drag entries into a different order, hide them, rename them, give them a key and star the one Enter should open. + adds any app, − removes it again. General has the default browser, login item and update check, Advanced the Zen profile and the config file.
Everything ends up in ~/.config/browserdoor/config.json, so you can also edit that by hand or keep it in your dotfiles:
{
"defaultTarget": "zen:{b4ac0402-9e47-4873-89b6-5dfeffe77a7e}",
"order": ["zen:{b4ac0402-9e47-4873-89b6-5dfeffe77a7e}", "app:com.apple.Safari"],
"hidden": ["app:com.google.Chrome.beta"],
"extraApps": ["com.googlecode.iterm2"],
"targets": {
"zen:{b4ac0402-9e47-4873-89b6-5dfeffe77a7e}": { "label": "Personal", "hotkey": "p" }
},
"zen": { "profilePath": null }
}| Key | Meaning |
|---|---|
defaultTarget |
Target that Enter opens. Without it, the first entry. |
order |
Targets listed first, in this order. All other targets follow in the default order: Zen workspaces, then Chromium profiles, then Firefox profiles, then other browsers A–Z. |
hidden |
Targets left out of the picker. |
extraApps |
Bundle IDs of apps that aren't known browsers but should appear anyway. |
targets.<id>.label |
Display name for the target. |
targets.<id>.hotkey |
A single key that opens this target. Targets without one get number keys. If two targets have the same key, the first one in the list keeps it. |
zen.profilePath |
Zen profile to use when it isn't the default profile from profiles.ini. |
Target IDs come in four forms:
| ID | Target |
|---|---|
zen:{uuid} |
A Zen workspace |
chromium:<bundleId>:<profileDir> |
A Chromium profile, e.g. chromium:com.google.Chrome:Profile 2 |
firefox:<bundleId>:<profilePath> |
A Firefox profile, e.g. firefox:org.mozilla.firefox:Profiles/x1y2.default-release |
app:<bundleId> |
Any other browser |
Browserdoor reads the file each time the picker opens, so changes apply right away. If the file is broken, the picker shows a warning and falls back to the defaults.
Zen keeps its spaces in zen-sessions.jsonlz4 in the profile folder (Mozilla's LZ4 format). Browserdoor reads that file and opens a link with zen --space "{uuid}" <url>. If Zen is already running, the command goes to that instance; if not, Browserdoor starts Zen through LaunchServices and it opens in the chosen space. The space icons come from Zen's omni.ja and are cached per Zen version.
Chromium profiles come from each browser's Local State file, and links open with open -na <App> --args --profile-directory=<dir> <url>.
Firefox profiles come from profiles.ini, and the ones made with Firefox's profile manager from its database in Profile Groups/. Links open with open -na Firefox --args --profile <dir> <url>: if that profile is already open, the link goes to its window, otherwise Firefox starts with it. With only one profile, Firefox stays a single entry. Firefox Developer Edition and Nightly share Firefox's profiles and show up as single entries.
The details are in SPEC.md.
A Zen workspace I just created is missing
Zen doesn't write its session file after every change. The space shows up once Zen has saved, which usually doesn't take long.
Zen shows up as one entry without workspaces
You need Zen 1.24 or later, since that's where --space came in. If you don't use Zen's default profile, pick yours under Settings → Advanced.
Firefox shows profiles I never use
Firefox creates a profile for every installation it has seen, and old ones stay in profiles.ini. Hide them under Settings → Targets, or delete them in about:profiles. Browserdoor already leaves out the empty default profile Firefox creates next to default-release.
A browser is missing from the list
Browserdoor only lists known browsers from /Applications, ~/Applications and /System. Add anything else with + under Settings → Targets.
"Start at login" is on, but Browserdoor doesn't start at login
The checkmark only means the LaunchAgent exists. macOS can still block it, so check System Settings → General → Login Items & Extensions and allow Browserdoor under Allow in the Background.
Links still open in my old browser
Open the menu bar menu. If it says Browserdoor isn't your default browser, choose Make Default Browser and confirm the macOS dialog.
Something else? Open an issue. The bug report form asks for the output of this command, which lists every target Browserdoor found and the command it opens a link with:
"$(brew --prefix)/opt/browserdoor/Browserdoor.app/Contents/MacOS/Browserdoor" --list-targetsBrowserdoor looks for a new release on GitHub once a day (you can switch that off in the settings). What changed is in CHANGELOG.md. If there is one, Update in Terminal runs brew upgrade browserdoor. Or do it yourself:
brew upgrade browserdoorA running Browserdoor notices the new version a few seconds later and restarts itself.
Quit Browserdoor from the menu bar, then remove the app: brew uninstall browserdoor, or for the download, move /Applications/Browserdoor.app to the Trash. Then remove what it left behind:
rm -f ~/Applications/Browserdoor.app ~/Library/LaunchAgents/io.github.schowave.browserdoor.plist
rm -rf ~/.config/browserdoor ~/Library/Caches/io.github.schowave.browserdoor
defaults delete io.github.schowave.browserdoorThen pick another default browser in System Settings → Desktop & Dock → Default web browser.
Tooling and tasks run through mise; Swift comes from Xcode. CONTRIBUTING.md has the checklist for pull requests, and SECURITY.md how to report a vulnerability. Everyone taking part follows the Code of Conduct.
mise install # actionlint, shellcheck, swiftformat (pinned in mise.toml)
mise tasks # list all tasks
mise run test # unit tests
mise run run # build build/Browserdoor.app and start it
mise run format # swiftformat
mise run targets # print the targets found on this Mac and the command each opens a link with
mise run ci # lint, unit tests, docs check, release-script tests, app bundle, formula check
mise run demo-gif # re-render docs/demo.gif from docs/demo/frames.py
mise run social-preview # re-render docs/social/preview.png (GitHub social preview)
mise run site # assemble the homepage (site/) in build/site; pages.yml publishes it
mise run check-dmg # the download build (Sparkle) as an ad-hoc signed DMG in build/The logic lives in Sources/BrowserdoorCore and is tested without the UI; Sources/Browserdoor is the app itself. GLOSSARY.md explains the terms used in the code. The tasks in .mise/tasks/ are plain shell scripts, and the Homebrew formula calls .mise/tasks/install directly, so installing doesn't need mise.
mise run release vX.Y.Z checks that main is pushed and clean, runs mise run ci, pushes the tag and puts the version's section from CHANGELOG.md on top of the release notes. The tag runs release.yml: tests, build, GitHub release, bottles for Apple Silicon and Intel (built on macOS 15) attached to the release, and an updated Formula/browserdoor.rb with a bottle do block in schowave/homebrew-tap. CI also builds the formula from a source tarball as a bottle (mise run bottle), pours that bottle again and runs brew test and brew audit --strict.
Next to the bottles, the release builds the download build: a DMG signed with Developer ID, notarized, and updating itself with Sparkle from the appcast.xml attached to the release. Until its secrets exist it skips itself; release.yml lists them, and setting them up takes one Apple Developer account:
- Developer ID certificate: in Xcode, Settings → Accounts → Manage Certificates → + → Developer ID Application, then export it from Keychain Access as
.p12with a password. SecretsDEVELOPER_ID_CERT_P12(base64 -i cert.p12) andDEVELOPER_ID_CERT_PASSWORD. - Notarization: an App Store Connect API key (Users and Access → Integrations → Team Keys, role Developer). Secrets
NOTARY_API_KEY(the.p8file's text),NOTARY_API_KEY_IDandNOTARY_API_ISSUER. - Sparkle keys: after
mise run check-dmg,.build/sparkle/artifacts/sparkle/Sparkle/bin/generate_keysputs a key pair into your login keychain and prints the public key: repository variableSPARKLE_PUBLIC_KEY.generate_keys -x sparkle.keyexports the private key: secretSPARKLE_PRIVATE_KEY, then delete the file. Keep the keychain item: losing the private key means the installed apps can't update any more.
Renovate opens a weekly pull request for GitHub Actions and one for the mise tools, waits seven days after a release before picking it up, and asks before major updates. It merges once CI is green. If a merge changes the app itself, auto-release.yml tags and releases the next patch version.
Thanks to Browserosaurus and Finicky for the idea, and to the Zen team for the --space flag that makes this possible.
MIT © David Schowalter