Turn the websites you live in into standalone, tabbed macOS apps, and make links clicked anywhere on your Mac open in the right app instead of a browser tab. Ships with Gmail, Google Calendar, and GitHub; adding a site is one JSON entry.
Two pieces:
- The shell. One Electron app, built once per site from
sites.json. Real tabs (oneWebContentsViewper tab), restored on relaunch. Links to other sites leave the app; links to a site that has its own app land there. - LinkRouter. A ~200-line Swift app that becomes your default browser. It
matches each URL against
~/.config/webapps/routes.jsonand hands it to the matching app, or to the browser that was your default before.
- macOS 13 or newer, Apple Silicon or Intel.
- Xcode Command Line Tools (
xcode-select --install) forswiftc. - Node 24 and pnpm 10.
mise.tomlpins both if you use mise.
git clone https://github.com/npendery/webapps && cd webapps
scripts/install.sh
This builds the apps and the router, copies them into /Applications, writes
~/.config/webapps/routes.json with your current default browser as the
fallback, and asks macOS to make LinkRouter the default browser (confirm the
dialog). Sign in to each app once; each has its own cookie store.
The apps are ad-hoc signed. They run fine locally; they are not meant to be distributed as built binaries.
| Keys | Action |
|---|---|
| Cmd+T / Cmd+W | New tab (home page) / close tab |
| Cmd+1..9, Cmd+Shift+[ / ] | Select tab, previous / next tab |
| Cmd+[ / ] | Back / forward |
| Cmd+R, Cmd+Shift+H | Reload, home |
| Cmd+Shift+O | Open current page in your regular browser |
| Cmd+Shift+C | Copy current URL |
| Cmd+Plus / Cmd+- / Cmd+0 | Zoom |
A two-finger swipe right goes back, left goes forward. The page gets the gesture first: it only navigates once the thing under the cursor has run out of room to scroll sideways, the way a browser does.
Right-clicking a page gives back / forward / reload, or link and editing actions when the click lands on a link or in a text field. Pages that ship their own context menu keep it.
Quitting and relaunching restores your tabs. Web notifications work as native notifications; clicking one opens the item in a tab.
-
Add an entry to
sites.json:{ "id": "linear", "name": "Linear", "home": "https://linear.app/", "match": ["linear.app", "*.linear.app"], "allow": ["accounts.google.com"], "icon": "https://linear.app/static/apple-touch-icon.png" }match: hosts the router sends to this app, and that stay inside it.allow: hosts that stay inside the app but are not routed to it. Put your identity provider and sign-in hosts here. A pattern may carry a path prefix (www.google.com/a/).*.example.commatches subdomains only; list the apex separately.- Exactly one site may
matcha given host; the tests enforce it.
-
scripts/fetch-icons.shthenscripts/install.sh linear(no argument rebuilds every site).
Bundle ids are <bundleIdPrefix>.<id>; change bundleIdPrefix in
sites.json if you fork.
Single sign-on bounces through several hosts. Anything not in match or
allow is handed to your regular browser mid-flow and the sign-in breaks.
When that happens, the last line of ~/Library/Logs/LinkRouter.log names the
host to add to allow. The shipped Google entries already cover Google
accounts, Okta (*.okta.com), and the Google Workspace SAML endpoint
(www.google.com/a/).
- Routing. macOS has one default browser slot. LinkRouter occupies it,
reads
routes.json, unwraps Gmail'sgoogle.com/url?q=redirector, and opens the URL with the matching app's bundle id via Launch Services. Every decision is logged (scheme, host and path only) to~/Library/Logs/LinkRouter.log. - Inside an app. Top-level navigations to out-of-scope hosts are cancelled
and sent to the system default browser, which is the router, so the link
still ends up in the right place.
window.opento an in-scope URL becomes a tab (the page gets a real window proxy back, so pop-up detection stays quiet); non-http popups such as Gmail's compose pop-out stay real windows. - Google sign-in. Electron's
window.chromeis empty because it ships Chromium without thechrome/layer, and Google's sign-in rejects that as an embedded browser. A sandboxed preload restores the members plain Chromium exposes before any page script runs. The user agent is plain Chrome for the bundled Chromium version.
pnpm install
pnpm test # matcher, policy, state, preload tests
router/build.sh && router/test.sh # same URL fixtures through the Swift router
SITE=github pnpm dev # run one site unpackaged
WEBAPPS_SITE=gmail WEBAPPS_PROFILE=dev pnpm exec electron . --remote-debugging-port=9333
# run beside the installed app, with CDP
pnpm build [siteId] # dist/<Name>.app
Design notes and the implementation plan live under docs/superpowers/.
System Settings → Desktop & Dock → Default web browser → pick your browser.
Then delete the apps from /Applications, ~/.config/webapps, and
~/Library/Application Support/webapps/.
MIT