diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55ee41a..138b622 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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`.
diff --git a/README.md b/README.md
index e849344..4cd7543 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 0cf58fe..57da364 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -58,6 +58,138 @@
public.xhtml
+
+ CFBundleTypeName
+ SVG image
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.svg-image
+
+
+
+ CFBundleTypeName
+ Plain text document
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.plain-text
+
+
+
+ CFBundleTypeName
+ JavaScript source
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ com.netscape.javascript-source
+
+
+
+ CFBundleTypeName
+ CSS style sheet
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.css
+
+
+
+ CFBundleTypeName
+ XML document
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.xml
+
+
+
+ CFBundleTypeName
+ GIF image
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ com.compuserve.gif
+
+
+
+ CFBundleTypeName
+ JPEG image
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.jpeg
+
+
+
+ CFBundleTypeName
+ PNG image
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.png
+
+
+
+ CFBundleTypeName
+ WebP image
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ org.webmproject.webp
+
+
+
+ CFBundleTypeName
+ AVIF image
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ public.avif
+
+
+
+ CFBundleTypeName
+ PDF document
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ com.adobe.pdf
+
+
diff --git a/Sources/Browseroute/AppDelegate.swift b/Sources/Browseroute/AppDelegate.swift
index 3d43e2b..49fa9a6 100644
--- a/Sources/Browseroute/AppDelegate.swift
+++ b/Sources/Browseroute/AppDelegate.swift
@@ -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) {
diff --git a/Sources/Browseroute/MenuView.swift b/Sources/Browseroute/MenuView.swift
index 5f7812a..bb43976 100644
--- a/Sources/Browseroute/MenuView.swift
+++ b/Sources/Browseroute/MenuView.swift
@@ -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 {
diff --git a/Sources/Browseroute/Router.swift b/Sources/Browseroute/Router.swift
index 4a5d6d7..1cc8383 100644
--- a/Sources/Browseroute/Router.swift
+++ b/Sources/Browseroute/Router.swift
@@ -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)
diff --git a/Sources/BrowserouteCore/BrowserLauncher.swift b/Sources/BrowserouteCore/BrowserLauncher.swift
index a5db09e..b643d0c 100644
--- a/Sources/BrowserouteCore/BrowserLauncher.swift
+++ b/Sources/BrowserouteCore/BrowserLauncher.swift
@@ -29,6 +29,29 @@ public struct LaunchOutcome: Sendable, Equatable {
}
}
+public enum URLLabel {
+ /// "index.html", "example.com", "mailto:x@y.z", or "5 items". One line;
+ /// callers truncate.
+ public static func label(for urls: [URL]) -> String {
+ if urls.count > 1 {
+ return "\(urls.count) items"
+ }
+ guard let url = urls.first else {
+ return ""
+ }
+ if url.isFileURL {
+ let name = url.lastPathComponent
+ return name.isEmpty ? url.path : name
+ }
+ switch url.scheme?.lowercased() {
+ case "http", "https":
+ return url.host ?? url.absoluteString
+ default:
+ return url.absoluteString
+ }
+ }
+}
+
public struct InstalledBrowser: Identifiable, Sendable {
public let id: String
public let name: String
@@ -84,13 +107,13 @@ public enum BrowserLauncher {
return result.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
- public static func open(_ url: URL, withBundleIdentifier bundleId: String) async throws {
+ public static func open(_ urls: [URL], withBundleIdentifier bundleId: String) async throws {
guard let appURL = applicationURL(forBundleIdentifier: bundleId) else {
throw LaunchError.browserNotFound(bundleId)
}
do {
try await NSWorkspace.shared.open(
- [url],
+ urls,
withApplicationAt: appURL,
configuration: NSWorkspace.OpenConfiguration(),
)
@@ -99,46 +122,48 @@ public enum BrowserLauncher {
}
}
- /// Open in `destination`, falling back to `fallback` if that browser is missing.
+ /// Open in `destination`, falling back to `fallback` when that browser is
+ /// missing or cannot open the URLs.
public static func open(
- _ url: URL,
+ _ urls: [URL],
destination: String,
fallback: String,
) async -> LaunchOutcome {
do {
- try await open(url, withBundleIdentifier: destination)
+ try await open(urls, withBundleIdentifier: destination)
return LaunchOutcome(opened: true, destination: destination)
- } catch LaunchError.browserNotFound {
- log.error("Browser \(destination, privacy: .public) not found")
+ } catch {
+ log.error("\(error.localizedDescription, privacy: .public)")
+ let (missing, detail): (Bool, String) = switch error {
+ case LaunchError.browserNotFound: (true, error.localizedDescription)
+ case let LaunchError.openFailed(_, underlying): (false, underlying.localizedDescription)
+ default: (false, error.localizedDescription)
+ }
+ let wanted = displayName(forBundleIdentifier: destination)
+ let fallbackName = displayName(forBundleIdentifier: fallback)
+ let label = URLLabel.label(for: urls)
if destination != fallback {
do {
- try await open(url, withBundleIdentifier: fallback)
- let fallbackName = displayName(forBundleIdentifier: fallback)
- let wanted = displayName(forBundleIdentifier: destination)
+ try await open(urls, withBundleIdentifier: fallback)
return LaunchOutcome(
opened: true,
destination: fallback,
- notification: "Browser \(wanted) not found — opened in \(fallbackName)",
+ notification: missing
+ ? "Browser \(wanted) not found — opened in \(fallbackName)"
+ : "\(wanted) couldn't open \(label) — opened in \(fallbackName)",
)
} catch {
- log
- .error(
- "Fallback \(fallback, privacy: .public) failed: \(error.localizedDescription, privacy: .public)",
- )
+ log.error(
+ "Fallback \(fallback, privacy: .public) failed: \(error.localizedDescription, privacy: .public)",
+ )
}
}
- let fallbackName = displayName(forBundleIdentifier: fallback)
- return LaunchOutcome(
- opened: false,
- destination: destination,
- notification: "Browser \(fallbackName) not found — URL not opened",
- )
- } catch {
- log.error("Open failed: \(error.localizedDescription, privacy: .public)")
return LaunchOutcome(
opened: false,
destination: destination,
- notification: error.localizedDescription,
+ notification: missing
+ ? "Browser \(fallbackName) not found — URL not opened"
+ : "\(wanted) couldn't open \(label): \(detail)",
)
}
}
diff --git a/Sources/BrowserouteCore/RuleEngine.swift b/Sources/BrowserouteCore/RuleEngine.swift
index 4f61ef4..234437d 100644
--- a/Sources/BrowserouteCore/RuleEngine.swift
+++ b/Sources/BrowserouteCore/RuleEngine.swift
@@ -8,6 +8,12 @@ public struct RoutingConfig: Codable, Equatable, Sendable {
self.browsers = browsers
self.defaultBrowserId = defaultBrowserId
}
+
+ /// Browser for anything the rules do not decide: unmatched hosts, local
+ /// files, non-http schemes, paused routing.
+ public var catchAllBrowserId: String {
+ defaultBrowserId ?? browsers.first?.id ?? "com.apple.Safari"
+ }
}
public struct BrowserRule: Codable, Equatable, Sendable, Identifiable {
@@ -58,7 +64,7 @@ public struct CompiledRules: @unchecked Sendable {
}
}
entries = compiled
- fallback = config.defaultBrowserId ?? config.browsers.first?.id ?? "com.apple.Safari"
+ fallback = config.catchAllBrowserId
}
/// Peel one Outlook SafeLinks wrapper so matching uses the inner host.
diff --git a/Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift b/Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift
index d14b280..dc03316 100644
--- a/Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift
+++ b/Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift
@@ -225,3 +225,28 @@ private func dest(_ raw: String, _ config: RoutingConfig) -> String {
let reloaded = RoutingStore(defaults: suite)
#expect(reloaded.routingEnabled == false)
}
+
+@Test func `file URL goes to the catch-all, path is not a host`() {
+ let config = RoutingConfig(
+ browsers: [BrowserRule(id: island, patterns: ["example.com"])],
+ defaultBrowserId: chrome,
+ )
+ #expect(dest("file:///Users/me/example.com/index.html", config) == chrome)
+}
+
+@Test func `catchAllBrowserId prefers default, then first browser, then Safari`() {
+ #expect(RoutingConfig(
+ browsers: [BrowserRule(id: island), BrowserRule(id: chrome)],
+ defaultBrowserId: chrome,
+ ).catchAllBrowserId == chrome)
+ #expect(RoutingConfig(browsers: [BrowserRule(id: island), BrowserRule(id: chrome)]).catchAllBrowserId == island)
+ #expect(RoutingConfig().catchAllBrowserId == safari)
+}
+
+@Test func `URLLabel names files, hosts, other schemes and batches`() {
+ #expect(URLLabel.label(for: [url("file:///a/b/index.html")]) == "index.html")
+ #expect(URLLabel.label(for: [url("https://a.example.com/x")]) == "a.example.com")
+ #expect(URLLabel.label(for: [url("mailto:x@y.z")]) == "mailto:x@y.z")
+ #expect(URLLabel.label(for: [url("file:///a/b.html"), url("https://example.com")]) == "2 items")
+ #expect(URLLabel.label(for: []) == "")
+}
diff --git a/version.env b/version.env
index ffda9db..62f3287 100644
--- a/version.env
+++ b/version.env
@@ -1,3 +1,3 @@
# Single source of truth for versioning. Sourced by every packaging/release script.
-MARKETING_VERSION=0.1.0
+MARKETING_VERSION=0.2.0
BUILD_NUMBER=1