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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ App logs use subsystem `com.thepublicgood.wrangurl`. In zsh, `log` is a shell bu

**URL flow.** macOS delivers URLs to `AppDelegate.application(_:open:)` → `URLRouter.handle`. The router queues URLs until `markReady()` in `applicationDidFinishLaunching`, because a cold launch can deliver them early. `URLRouter.preview(_:)` does the decision part:
1. `RuleMatcher` finds the first enabled rule matching the URL and its source app (`SourceApp`, read from the Apple Event's sender in `application(_:open:)`). It is compiled once per rules array and cached in the router.
2. `RoutePlanner` decides `.open(browserID)`, `.pick(browserIDs)` or `.noBrowserAvailable`. It applies the fallback browser (configured browser, then Safari, then the first installed one), the unmatched-URL behaviour, removal of uninstalled browsers, and "file URLs go to the fallback".
2. `RoutePlanner` decides `.open(browserID)`, `.pick(browserIDs)` or `.noBrowserAvailable`. It applies the fallback browser (configured browser, then Safari, then the first installed one), the unmatched-URL behaviour, removal of uninstalled browsers, and "file URLs go to the fallback". Holding exactly `AppSettings.pickerModifiers` (default ⌥⌘) forces `.pick` with every browser; `URLRouter.handle` reads `NSEvent.modifierFlags` on arrival, before any queueing.

The router then either opens the URL via `NSWorkspace` or shows `BrowserPickerController`. The URL testers in Settings call `preview(_:)`, so they can't diverge from real routing. `RoutePlanner`, `RuleMatcher`/`SimplePattern`, `PickerAction` and the `Config` import helpers are deliberately AppKit-free and pure; keep new routing logic there so it stays unit-testable.

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ WrangURL is a macOS menu bar app that opens each link in the right browser. You
- Limit a rule to links clicked in particular apps, such as sending links from Slack to Chrome.
- Send a match straight to one browser, or choose from a picker when a rule lists several.
- Choose what happens to links no rule matches: open them in a fallback browser, or show the picker.
- Hold ⌥⌘ while clicking any link to choose a browser, whatever the rules say.
- Keeps a searchable history of the links it has opened, grouped by day.
- Runs in the menu bar and can open at login.

Expand Down Expand Up @@ -66,6 +67,8 @@ To check your rules, use **Test a URL** at the bottom of the Rules tab. It shows

The picker appears next to the mouse pointer. The app you clicked the link in stays in front.

To choose a browser for a link that a rule would open directly, hold **⌥⌘** (Option-Command) while you click it. The picker then lists every browser, with the rule's browsers (or the fallback browser) first. To use other keys, click the **Always ask when holding** field in Settings → General, press the modifier keys you want, and let go. The ⓧ button next to it turns this off. Some apps give modifier-clicks their own meaning, so this only works where the app still hands the link to your default browser.

| Key | Action |
|---|---|
| `1`–`9` | Open in that browser |
Expand All @@ -84,7 +87,7 @@ History is kept only on this Mac, in `history.json` next to the configuration fi

### Settings and configuration

- **Settings → General** covers the fallback browser, what to do with unmatched links, launch at login, updates, whether URLs appear in the system log, whether to keep a history, and the default browser status.
- **Settings → General** covers the fallback browser, what to do with unmatched links, the keys that always show the picker, launch at login, updates, whether URLs appear in the system log, whether to keep a history, and the default browser status.
- WrangURL checks GitHub for new releases with [Sparkle](https://sparkle-project.org). Choose **Check for Updates…** in the menu to check now.
- If WrangURL stops being the default browser, the menu bar icon changes to ⚠︎.
- **Import… and Export…** save and load your rules and settings as JSON.
Expand Down
7 changes: 7 additions & 0 deletions WrangURL/Core/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ struct AppSettings: Codable, Equatable, Sendable {
var logsURLs = true
/// Whether opened links are recorded in the History window.
var keepsHistory = true
/// Holding exactly these keys when a link arrives shows the picker, whatever the rules say.
/// Empty turns this off.
var pickerModifiers: ModifierKeys = AppSettings.defaultPickerModifiers

static let defaultPickerModifiers: ModifierKeys = [.option, .command]
}

extension AppSettings {
Expand All @@ -26,6 +31,8 @@ extension AppSettings {
hasCompletedOnboarding = try container.decodeIfPresent(Bool.self, forKey: .hasCompletedOnboarding) ?? false
logsURLs = try container.decodeIfPresent(Bool.self, forKey: .logsURLs) ?? true
keepsHistory = try container.decodeIfPresent(Bool.self, forKey: .keepsHistory) ?? true
pickerModifiers = try container.decodeIfPresent(ModifierKeys.self, forKey: .pickerModifiers)
?? AppSettings.defaultPickerModifiers
}
}

Expand Down
12 changes: 12 additions & 0 deletions WrangURL/Core/ModifierKeys+AppKit.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import AppKit

extension ModifierKeys {
/// The ⌃⌥⇧⌘ keys in `flags`. Caps Lock, Fn and the rest are ignored.
init(_ flags: NSEvent.ModifierFlags) {
self = []
if flags.contains(.control) { insert(.control) }
if flags.contains(.option) { insert(.option) }
if flags.contains(.shift) { insert(.shift) }
if flags.contains(.command) { insert(.command) }
}
}
49 changes: 49 additions & 0 deletions WrangURL/Core/ModifierKeys.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Foundation

/// A combination of modifier keys. AppKit-free so it can live in `AppSettings`;
/// `URLRouter` converts `NSEvent.ModifierFlags` into it.
struct ModifierKeys: OptionSet, Hashable, Sendable {
let rawValue: Int

static let control = ModifierKeys(rawValue: 1 << 0)
static let option = ModifierKeys(rawValue: 1 << 1)
static let shift = ModifierKeys(rawValue: 1 << 2)
static let command = ModifierKeys(rawValue: 1 << 3)

private struct Key {
let key: ModifierKeys
let symbol: String
let name: String
}

/// Keys in the order macOS displays them, with their symbol and config name.
private static let keys = [
Key(key: .control, symbol: "⌃", name: "control"),
Key(key: .option, symbol: "⌥", name: "option"),
Key(key: .shift, symbol: "⇧", name: "shift"),
Key(key: .command, symbol: "⌘", name: "command"),
]

/// The keys as symbols, for example "⌥⌘". Empty when no key is set.
var symbols: String {
Self.keys.filter { contains($0.key) }.map(\.symbol).joined()
}
}

/// Encoded as a list of key names, such as `["option", "command"]`, so `config.json`
/// stays readable. Unknown names are ignored.
extension ModifierKeys: Codable {
init(from decoder: any Decoder) throws {
let names = try decoder.singleValueContainer().decode([String].self)
self = Self.keys.reduce(into: []) { result, entry in
if names.contains(entry.name) {
result.insert(entry.key)
}
}
}

func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(Self.keys.filter { contains($0.key) }.map(\.name))
}
}
22 changes: 18 additions & 4 deletions WrangURL/Core/RoutePlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,23 @@ struct RoutePlanner {
?? installedBrowserIDs.first(where: isAvailable)
}

func decide(for url: URL, matchedRule: Rule?) -> RouteDecision {
/// - Parameter heldModifiers: The modifier keys held when the URL arrived. If they are
/// exactly `settings.pickerModifiers`, the picker offers every browser.
func decide(for url: URL, matchedRule: Rule?, heldModifiers: ModifierKeys = []) -> RouteDecision {
// Local HTML files arrive via the document type; rules only apply to web URLs.
let ruleBrowserIDs = url.isFileURL ? nil : matchedRule.map { availableBrowserIDs($0.browserIDs) }

if !settings.pickerModifiers.isEmpty && heldModifiers == settings.pickerModifiers {
// The rule's browsers first, or else the fallback, then every other browser.
let preferred = ruleBrowserIDs.flatMap { $0.isEmpty ? nil : $0 } ?? [fallbackBrowserID].compactMap { $0 }
return openOrPick(availableBrowserIDs(preferred + installedBrowserIDs))
}

if url.isFileURL {
return openFallback()
}

if let rule = matchedRule {
var seen = Set<String>()
let browserIDs = rule.browserIDs.filter { isAvailable($0) && seen.insert($0).inserted }
if let browserIDs = ruleBrowserIDs {
return browserIDs.isEmpty ? openFallback() : openOrPick(browserIDs)
}

Expand All @@ -49,6 +57,12 @@ struct RoutePlanner {
}
}

/// The available browsers among `browserIDs`, without duplicates, in their original order.
private func availableBrowserIDs(_ browserIDs: [String]) -> [String] {
var seen = Set<String>()
return browserIDs.filter { isAvailable($0) && seen.insert($0).inserted }
}

private func openOrPick(_ browserIDs: [String]) -> RouteDecision {
switch browserIDs.count {
case 0: .noBrowserAvailable
Expand Down
31 changes: 21 additions & 10 deletions WrangURL/Core/URLRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@ final class URLRouter {
let ruleName: String?
}

/// A URL that arrived before launch finished, with what was known when it arrived.
private struct PendingURL {
let url: URL
let source: SourceApp?
let modifiers: ModifierKeys
}

private(set) var recent: [Entry] = []

@ObservationIgnored private let config: ConfigStore
@ObservationIgnored private let browsers: BrowserRegistry
@ObservationIgnored private let history: HistoryStore
@ObservationIgnored private let picker = BrowserPickerController()
@ObservationIgnored private var pending: [(url: URL, source: SourceApp?)] = []
@ObservationIgnored private var pending: [PendingURL] = []
@ObservationIgnored private var isReady = false
@ObservationIgnored private var matcherCache: (rules: [Rule], matcher: RuleMatcher)?

Expand All @@ -37,20 +44,22 @@ final class URLRouter {
isReady = true
let queued = pending
pending.removeAll()
for (url, source) in queued {
route(url, from: source)
for item in queued {
route(item.url, from: item.source, heldModifiers: item.modifiers)
}
}

/// Routes URLs clicked in `source`, the app they came from, if known.
func handle(_ urls: [URL], from source: SourceApp? = nil) {
// Read the keys now: by the time a queued URL is routed, the user may have let go.
let modifiers = ModifierKeys(NSEvent.modifierFlags)
guard isReady else {
pending.append(contentsOf: urls.map { ($0, source) })
pending.append(contentsOf: urls.map { PendingURL(url: $0, source: source, modifiers: modifiers) })
Self.logger.info("Queued \(urls.count) URL(s) until launch finishes")
return
}
for url in urls {
route(url, from: source)
route(url, from: source, heldModifiers: modifiers)
}
}

Expand All @@ -76,19 +85,21 @@ final class URLRouter {
}

/// What would happen to a URL clicked in `source`, without opening it. Used by the URL tester.
func preview(_ url: URL, from source: SourceApp? = nil) -> (rule: Rule?, decision: RouteDecision) {
func preview(
_ url: URL, from source: SourceApp? = nil, heldModifiers: ModifierKeys = []
) -> (rule: Rule?, decision: RouteDecision) {
let rule = matcher.match(url, from: source?.id)
return (rule, planner.decide(for: url, matchedRule: rule))
return (rule, planner.decide(for: url, matchedRule: rule, heldModifiers: heldModifiers))
}

/// The URL as it should appear in the log, following the "Include URLs in logs" setting.
private func loggable(_ url: URL) -> String {
config.config.settings.logsURLs ? url.absoluteString : "<URL hidden>"
}

private func route(_ url: URL, from source: SourceApp?) {
let (rule, decision) = preview(url, from: source)
Self.logger.info("Received \(self.loggable(url), privacy: .public) from \(source?.id ?? "unknown app", privacy: .public); rule: \(rule?.displayName ?? "none", privacy: .public); decision: \(String(describing: decision), privacy: .public)")
private func route(_ url: URL, from source: SourceApp?, heldModifiers: ModifierKeys) {
let (rule, decision) = preview(url, from: source, heldModifiers: heldModifiers)
Self.logger.info("Received \(self.loggable(url), privacy: .public) from \(source?.id ?? "unknown app", privacy: .public); rule: \(rule?.displayName ?? "none", privacy: .public); modifiers: \(heldModifiers.symbols, privacy: .public); decision: \(String(describing: decision), privacy: .public)")

switch decision {
case .open(let browserID):
Expand Down
37 changes: 36 additions & 1 deletion WrangURL/UI/Settings/GeneralSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,19 @@ struct GeneralSettingsView: View {
Text("Ask which browser to use").tag(UnmatchedBehavior.showPicker)
}
.pickerStyle(.radioGroup)

LabeledContent("Always ask when holding") {
ModifierKeysRecorder(keys: pickerModifiers)
}
if let warning = pickerModifiersWarning {
Label(warning, systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
} header: {
Text("Routing")
} footer: {
Text("The fallback browser also opens local HTML files and links whose rule has no installed browser.")
Text(routingFooter)
.font(.caption)
.foregroundStyle(.secondary)
}
Expand Down Expand Up @@ -133,6 +142,32 @@ struct GeneralSettingsView: View {
return planner.fallbackBrowserID.map(browsers.name(forID:)) ?? "none"
}

private var routingFooter: String {
var text = "The fallback browser also opens local HTML files and links whose rule has no installed browser."
let keys = config.config.settings.pickerModifiers
if !keys.isEmpty {
text += " Hold \(keys.symbols) while clicking a link to choose a browser, whatever the rules say."
}
return text
}

/// Single keys that other apps already give a meaning when clicking a link.
private var pickerModifiersWarning: String? {
switch config.config.settings.pickerModifiers {
case .control: "⌃-click is a right-click, so links clicked this way never reach WrangURL."
case .command: "Terminals such as iTerm2 open links with ⌘-click, so every terminal link will show the picker."
case .option: "Safari downloads links you ⌥-click instead of opening them."
default: nil
}
}

private var pickerModifiers: Binding<ModifierKeys> {
Binding(
get: { config.config.settings.pickerModifiers },
set: { keys in config.update { $0.settings.pickerModifiers = keys } }
)
}

private var fallbackBrowserID: Binding<String?> {
Binding(
get: { config.config.settings.fallbackBrowserID },
Expand Down
Loading