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.

Browseroute popover @@ -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