Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@

All notable changes to Browseroute are documented here (Keep a Changelog style).

## 0.1.0 — Unreleased
## 0.2.0 — 2026-09-07
- Forward every URL handed to Browseroute: local files (`.html` from Finder), other schemes, and unmatched links all open in the Default catch-all instead of being dropped.
- Open several links or files at once: they are grouped by destination and handed to each browser in arrival order.
- Fall back to the Default catch-all (with a notification) when the chosen browser cannot open an item, not just when it is missing.
- Listed in Finder's Open With for html, xhtml, svg, txt, js, css, xml, png, jpeg, gif, webp, avif, and pdf.

## 0.1.0 — 2026-08-27
- Route `http`/`https` links as the macOS default browser from rules edited in the menu-bar popover.
- Host suffix, host glob, and host+path glob matching, with a Default catch-all.
- Launch at login via `SMAppService`.
Expand Down
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ Route each `http`/`https` link to the browser you choose.

Browseroute is a macOS menu-bar app. It registers as the default web browser,
then opens every link in the matching browser: host suffix, host glob, or
host+path glob. Unmatched links go to the catch-all you mark as Default.
host+path glob. Unmatched links, local files, and other schemes go to the
catch-all you mark as Default — nothing it is handed is dropped.

<p align="center">
<img width="340" alt="Browseroute popover" src="docs/popover.png">
Expand Down Expand Up @@ -61,13 +62,22 @@ the first match wins. Matching is case-insensitive.
| `*.corp.com` | host glob (`a.corp.com`). Not the apex `corp.com`. |
| `github.com/work-org` or `github.com/work-org/*` | host + path. A pattern with `/` gets an implicit trailing `*` if it does not already end in `*`. |

If nothing matches, it uses the Default catch-all. If none is set, the first
browser in the list. If the list is empty, Safari.
Rules apply to `http` and `https`. If nothing matches — and for local files,
other schemes, and while routing is paused — it uses the Default catch-all. If
none is set, the first browser in the list. If the list is empty, Safari.

Outlook SafeLinks are unwrapped first, so matching uses the inner host.

If the chosen browser is missing, a notification says so. The link then opens
in the Default catch-all when that one is installed; otherwise it is not opened.
Opening several links or files at once groups them by destination: each browser
gets its share in one go, in the order the items arrived.

If the chosen browser is missing, or it cannot open what it was handed, a
notification says so and the items open in the Default catch-all instead. If
that one fails too, nothing is opened.

Browseroute is also an Open With option in Finder for html, xhtml, svg, txt,
js, css, xml, png, jpeg, gif, webp, avif, and pdf files; choosing it opens the
file in the Default catch-all.

## Develop

Expand Down
132 changes: 132 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,138 @@
<string>public.xhtml</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>SVG image</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.svg-image</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Plain text document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>JavaScript source</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.netscape.javascript-source</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>CSS style sheet</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.css</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>XML document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.xml</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>GIF image</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.compuserve.gif</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>JPEG image</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.jpeg</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>PNG image</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.png</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>WebP image</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>org.webmproject.webp</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>AVIF image</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.avif</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>PDF document</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>com.adobe.pdf</string>
</array>
</dict>
</array>
</dict>
</plist>
4 changes: 1 addition & 3 deletions Sources/Browseroute/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}

func application(_: NSApplication, open urls: [URL]) {
for url in urls {
Router.shared.route(url)
}
Router.shared.route(urls)
}

func applicationDidResignActive(_: Notification) {
Expand Down
4 changes: 2 additions & 2 deletions Sources/Browseroute/MenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,9 @@ struct MenuRootView: View {
guard let last = Router.shared.lastRouted else {
return "No links routed yet"
}
let host = last.url.host ?? last.url.absoluteString
let label = URLLabel.label(for: last.urls)
let name = BrowserLauncher.displayName(forBundleIdentifier: last.destination)
return "\(host) → \(name)"
return "\(label) → \(name)"
}

private var emptyState: some View {
Expand Down
61 changes: 38 additions & 23 deletions Sources/Browseroute/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,37 +12,52 @@ final class Router {
static let shared = Router()

var store: RoutingStore = .shared
private(set) var lastRouted: (url: URL, destination: String)?
private(set) var lastRouted: (urls: [URL], destination: String)?

func route(_ url: URL) {
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "http" || scheme == "https" else {
log.info("Dropped non-http scheme \(scheme, privacy: .public)")
return
/// Every URL is forwarded: rules decide http(s), everything else goes to
/// the catch-all. URLs sharing a destination open together so tab order
/// matches the order they arrived in.
func route(_ urls: [URL]) {
var groups: [(destination: String, urls: [URL])] = []
for url in urls {
let dest = destination(for: url)
if let index = groups.firstIndex(where: { $0.destination == dest }) {
groups[index].urls.append(url)
} else {
groups.append((dest, [url]))
}
}
let matched = CompiledRules.unwrap(url)
let dest: String = if store.routingEnabled {
store.compiled.destination(for: url)
} else {
store.config.defaultBrowserId ?? store.config.browsers.first?.id ?? "com.apple.Safari"
let fallback = store.config.catchAllBrowserId
for group in groups {
let label = URLLabel.label(for: group.urls)
if store.routingEnabled {
log.info("Routing \(label, privacy: .public) -> \(group.destination, privacy: .public)")
} else {
log.info("Paused \(label, privacy: .public) -> \(group.destination, privacy: .public)")
}
Task { await open(group.urls, destination: group.destination, fallback: fallback) }
}
let fallback = store.config.defaultBrowserId ?? dest
let host = matched.host ?? "(none)"
if matched.absoluteString != url.absoluteString {
log.info("Unwrapped \(url.host ?? "", privacy: .public) -> \(host, privacy: .public)")
}

private func destination(for url: URL) -> String {
let scheme = url.scheme?.lowercased() ?? ""
let isWeb = scheme == "http" || scheme == "https"
guard store.routingEnabled, isWeb else {
return store.config.catchAllBrowserId
}
if store.routingEnabled {
log.info("Routing \(host, privacy: .public) -> \(dest, privacy: .public)")
} else {
log.info("Paused \(host, privacy: .public) -> \(dest, privacy: .public)")
let unwrapped = CompiledRules.unwrap(url)
if unwrapped.absoluteString != url.absoluteString {
log.info(
"Unwrapped \(url.host ?? "", privacy: .public) -> \(unwrapped.host ?? "(none)", privacy: .public)",
)
}
Task { await open(url, destination: dest, fallback: fallback) }
return store.compiled.destination(for: url)
}

private func open(_ url: URL, destination: String, fallback: String) async {
let outcome = await BrowserLauncher.open(url, destination: destination, fallback: fallback)
private func open(_ urls: [URL], destination: String, fallback: String) async {
let outcome = await BrowserLauncher.open(urls, destination: destination, fallback: fallback)
if outcome.opened {
lastRouted = (url, outcome.destination)
lastRouted = (urls, outcome.destination)
}
if let message = outcome.notification {
AppNotify.post(body: message)
Expand Down
Loading
Loading