Skip to the content.

Thanks for taking a look. This is a small, deliberately dependency-free macOS utility — contributions are welcome, and the project is easy to build.

Build and run

git clone https://github.com/hexember/active-browser.git
cd active-browser
make install     # builds, installs to /Applications, registers, launches

Requirements: macOS 13+ and a Swift 6 toolchain (swift --version). Install the Xcode Command Line Tools with xcode-select --install if you don’t have them.

Zero third-party dependencies: Swift plus Apple frameworks only (AppKit, Foundation, ServiceManagement).

Command What it does
make build swift build -c release
make bundle assemble and ad-hoc sign build/ActiveBrowser.app
make run bundle, then launch the build/ copy
make install build, install to /Applications, register, launch
make release produce build/ActiveBrowser.app.zip + SHA256SUMS
make clean unregister the build copy, remove .build/ and build/

Three things that will cost you an hour if nobody tells you

1. The app must run from the .app bundle. A bare swift build binary has no Info.plist, so LSUIElement (no Dock icon), the http/https claim, and the self-filter are all inert — Bundle.main.bundleIdentifier is nil outside a bundle. Use make run or make install, never .build/release/ActiveBrowser directly.

2. NSLog output does not reach Console.app or log show. To read the app’s own logging you have to run the binary directly and capture stderr:

pkill -x ActiveBrowser
/Applications/ActiveBrowser.app/Contents/MacOS/ActiveBrowser > /tmp/ab.log 2>&1 &
sleep 4; kill %1; cat /tmp/ab.log
open -a ActiveBrowser          # relaunch through Launch Services

3. Don’t run make run while a copy is installed. It launches a second bundle with the same bundle id claiming the same URL schemes, so macOS registers both and you get two ActiveBrowser rows in the default-browser dropdown. make clean unregisters and removes the build copy. make install and make release both delete build/ActiveBrowser.app themselves for exactly this reason.

Architecture

Sources/ActiveBrowser/
├── App/
│   ├── AppDelegate.swift      # entry point, owns all state
│   └── FocusObserver.swift    # the one NSWorkspace focus subscription
├── Core/
│   ├── BrowserRegistry.swift  # installed https handlers, minus ourselves
│   ├── BrowserStack.swift     # LRU order + target resolution
│   ├── Settings.swift         # UserDefaults-backed preferences
│   └── URLDispatcher.swift    # the single place a URL is handed off
└── UI/
    └── MenuBarManager.swift   # status item and menu

Repository layout

Sources/            the app
Support/            Info.plist (the bundle manifest)
assets/             icon artwork (see assets/README.md)
install.sh          the curl installer
Makefile            build, bundle, install, release
docs/               background notes
.github/workflows/  CI, release, and the GitHub Pages site
_config.yml         GitHub Pages (Jekyll): which docs are published

README.md is also the website at https://hexember.github.io/active-browser/, rebuilt on every push to main. A link in README (or in CONTRIBUTING, SECURITY, CODE_OF_CONDUCT or CHANGELOG) must be absolute or point to one of those five .md files, because anything else 404s on the site. A new file meant for the site must also be added to the allowlist check and paths: in .github/workflows/pages.yml.

Project history

Three directories are history, not instructions: tasks/, Project.md, and .claude/. This project was built by AI agents working through a task-per-PR workflow, and those files are that workflow’s records — the specs, the review notes, and the agent definitions. They’re kept because the reasoning in them is often useful (several non-obvious macOS behaviours are documented there and nowhere else), but you do not need to read or follow any of it to contribute. CONTRIBUTING.md is the only process document that applies to you. tasks/TEST-PLAN.md is the manual checklist that workflow accumulated.

One caveat if you do read them: Project.md is the original specification, and the shipped code departs from it in several places, notably three where the spec turned out to be wrong — the @main entry point, the install: target ordering, and the SMAppService status gate. Where the two disagree, the code is correct. Don’t “restore” it to match the document.

Testing

Testing is manual — there is no XCTest target, and please don’t add one as part of an unrelated change. Almost everything that can break here is macOS integration: Launch Services registration, the default-browser binding, focus notifications, login items. None of that is meaningfully unit-testable, and a test suite that mocked it would only assert that the mocks agree with each other.

What CI does check on every push and pull request:

Before opening a PR, please also check by hand whatever your change touches. If it affects routing, verify with open -a ActiveBrowser https://example.com/test — that reaches the same entry point as a real link click, so you don’t have to make ActiveBrowser your default browser to test it.

Two gotchas when testing routing manually:

Design constraints

These are enforced in review; a PR that breaks one will be asked to change:

Pull requests

Releasing (maintainers)

Push a v* tag from main. The release workflow builds on macos-latest, verifies the artefacts, and only then publishes them to the release.

git tag v0.1.1 && git push origin v0.1.1

The published asset names ActiveBrowser.app.zip and SHA256SUMS are a contract that install.sh depends on — renaming either breaks the installer for every user. CI guards this.

Testing a release locally

Run the installer against a locally built zip before tagging:

make release
ACTIVEBROWSER_ZIP=build/ActiveBrowser.app.zip sh install.sh

With ACTIVEBROWSER_ZIP set, install.sh makes no network access at all. ACTIVEBROWSER_SUMS names the checksum file and defaults to the SHA256SUMS next to the zip, which is exactly where make release writes it. ACTIVEBROWSER_VERSION pins a release tag instead of resolving the latest one.