A tiny native macOS app that registers as the system handler for http/https
URLs, shows a lightweight picker of your installed browsers when you open a link,
and forwards the URL to whichever browser you choose.
Useful when you want per-link control over which browser opens — work vs. personal, Chromium vs. WebKit, a profile-specific browser, etc.
- macOS 12 (Monterey) or later
- Apple Silicon or Intel —
make bundleproduces a binary for your host architecture. To produce a universal (arm64 + x86_64) bundle that runs natively on both, usemake bundle-universal(requires both platform SDKs installed; the cross build is slower). - Xcode command line tools (
swift,codesign,plutil,make)
# Run the headlessly-testable core logic tests
swift test
# Build the host-arch release binary and assemble build/BrowserSelect.app (ad-hoc signed)
make bundle
# Build, install to /Applications, and register with Launch Services in one step.
# This is what makes the app eligible to be the default browser (see below).
make install
# Optional: build a universal (arm64 + x86_64) bundle instead.
# Requires both platform SDKs installed; the cross build is slower.
make bundle-universal
# Remove the installed app and its Launch Services registration.
make uninstallmake bundle produces build/BrowserSelect.app containing a binary for your host
architecture (the machine you build on). For a fat binary that runs natively on both
Apple Silicon and Intel, run make bundle-universal instead — it requires the x86_64
platform SDK alongside the arm64 toolchain and takes noticeably longer.
Note that swift run will not
work for this app: it is an LSUIElement accessory app that must be launched from the
assembled bundle so macOS reads its Info.plist and registers it as a URL handler.
- Run
make install. This builds the bundle, copies it to/Applications/BrowserSelect.app, and registers it with Launch Services. This step matters: macOS only lists an app in the Default-web-browser picker when it lives in/Applications(or~/Applications) and is registered there — an app left inbuild/will not appear. - Open System Settings → Desktop & Dock → Default web browser and choose BrowserSelect from the dropdown. (On macOS 12 the setting lives in System Preferences → General → Default web browser.) If System Settings was already open, quit and reopen it so its app list refreshes.
From then on, clicking a link in any app routes through BrowserSelect's picker.
After every rebuild, re-run
make install.make bundleonly refreshesbuild/, which macOS does not surface in the Default-browser picker;make installre-syncs the/Applicationscopy and its registration.
The bundle is ad-hoc signed (codesign -s -), not signed with a Developer ID and
not notarized.
If you built it locally (make bundle), there is nothing to do — locally built
apps are not quarantined, so open build/BrowserSelect.app just works. (Running
xattr -d com.apple.quarantine ... on a local build prints
No such xattr: com.apple.quarantine, which is harmless — there was no flag to remove.)
If you downloaded a prebuilt BrowserSelect.app (e.g. a GitHub release zip), macOS
quarantines it and Gatekeeper blocks the first launch ("cannot be opened because the
developer cannot be verified"). Remove the quarantine attribute the OS added:
xattr -dr com.apple.quarantine /path/to/BrowserSelect.appWhy: ad-hoc signatures carry no notarization ticket, so Gatekeeper quarantines downloaded copies on first launch. Stripping the quarantine flag tells the OS you trust this binary. (Alternatively, right-click the app → Open → confirm the dialog once.)
- Resident accessory process.
LSUIElement = YESmeans no Dock icon and no menu bar. The process stays alive in the background after launch, so only the first click after a reboot pays a cold-start cost; every link after that hits a warm process. - URL reception. The app registers an
NSAppleEventManagerhandler forkInternetEventClass/kAEGetURLinapplicationDidFinishLaunching. TheCFBundleURLTypesentry inInfo.plist(claiminghttpandhttps) is what makes Launch Services deliver those URL-open events to this app. - ~300 ms picker budget. The browser list is enumerated and cached at launch (before the URL handler can fire), and the picker window is created hidden at launch (pre-warmed). When a URL arrives, the hot path only validates the URL, sets the pending URL, and reveals the already-built window — no enumeration or view construction on the critical path. A background timer re-enumerates every 30 s to catch browser installs/uninstalls.
- Browser enumeration. Uses
NSWorkspace.urlsForApplications(toOpen:)(macOS 12+) against a representativehttp://example.com— deliberately not the deprecatedLSCopyApplicationURLsForURL. - Injected self-exclusion ID. The core
BrowserEnumeratorexcludes this app from its own results so it never offers to open URLs in itself (an infinite loop). The excluded bundle ID is injected viaBrowserEnumerator(excludingBundleID:)rather than read fromBundle.main, which keeps the exclusion logic unit-testable headlessly. The app's call site passesBundle.main.bundleIdentifier. - Launching. The chosen URL is opened via
NSWorkspace.open(_:withApplicationAt:configuration:completionHandler:)so launch failures can be reported asynchronously.
Core logic (URL sanitization, self-exclusion, de-dup, ordering) is covered by swift test.
The live UI and system-launch paths require a GUI session:
make bundle(a locally built bundle is not quarantined — noxattrstep needed).open build/BrowserSelect.app— confirm no Dock icon appears and no window shows.- Trigger a URL event without changing your default browser:
The picker should appear within a fraction of a second listing your installed browsers (BrowserSelect itself absent).
osascript -e 'tell application id "com.bdemers.browserselect" to open location "https://example.com"' - Click a browser, or use Left/Right arrows + Return to choose; the URL should open there. Press Escape (or click away) to dismiss without opening.
- To exercise it as the real default browser, follow Set as Default Browser above and click a link in any app.
The <300 ms target is a design goal and must be confirmed on your own machine — it cannot be asserted in a headless test. The app instruments the receive→present hot path and emits the elapsed time to the unified log. With the app running, trigger an event and read the measurement back:
# In one terminal, stream the app's routing log:
log stream --predicate 'subsystem == "com.bdemers.browserselect" && category == "routing"' --info
# In another, fire a URL event:
osascript -e 'tell application id "com.bdemers.browserselect" to open location "https://example.com"'The stream prints a line like Picker presented in 14.2 ms for scheme https. Note this
measures the warm path (process already resident, which is the steady state for an
LSUIElement app). The first event after a reboot pays a one-time cold start; trigger a
throwaway URL once after login if you want every subsequent click warm.
If you prefer not to use the log, you can eyeball it: time osascript -e '...' brackets the
round trip including AppleScript overhead, which is a loose upper bound.
The project is two SPM targets:
BrowserSelectKit— pure Swift core (no AppKit):Browser,BrowserEnumerator,URLRouter. This is an internal test seam, not a stable public API — it exists so the routing and selection logic can be tested without a GUI session. Treat its surface as subject to change.BrowserSelectApp— the AppKit/SwiftUI app: AppDelegate, picker window/view, and the Launch Services discovery glue.
Run swift test before sending changes. UI changes need the manual verification recipe
above since they can't be exercised headlessly.
make lint— runs Apple's swift-format (style, config in.swift-format) and SwiftLint (idiom/correctness, config in.swiftlint.yml). Both must pass clean.make format— auto-formats sources in place with swift-format.- SwiftLint is the only extra dev tool:
brew install swiftlint. swift-format ships with the Swift 6 toolchain. The app itself has no third-party dependencies. - CI (
.github/workflows/ci.yml) runs build, tests, a strict-concurrency build, and both linters on every push/PR. CodeQL security scanning runs via.github/workflows/codeql.yml(results in the repo's Security tab).
