diff --git a/CLAUDE.md b/CLAUDE.md
index 74bf1ae..7b48eea 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/README.md b/README.md
index c8834cb..3e9f4be 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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 |
@@ -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.
diff --git a/WrangURL/Core/Config.swift b/WrangURL/Core/Config.swift
index 17d42e8..c9f96ec 100644
--- a/WrangURL/Core/Config.swift
+++ b/WrangURL/Core/Config.swift
@@ -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 {
@@ -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
}
}
diff --git a/WrangURL/Core/ModifierKeys+AppKit.swift b/WrangURL/Core/ModifierKeys+AppKit.swift
new file mode 100644
index 0000000..c76bd78
--- /dev/null
+++ b/WrangURL/Core/ModifierKeys+AppKit.swift
@@ -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) }
+ }
+}
diff --git a/WrangURL/Core/ModifierKeys.swift b/WrangURL/Core/ModifierKeys.swift
new file mode 100644
index 0000000..38355e1
--- /dev/null
+++ b/WrangURL/Core/ModifierKeys.swift
@@ -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))
+ }
+}
diff --git a/WrangURL/Core/RoutePlanner.swift b/WrangURL/Core/RoutePlanner.swift
index 3795d0c..fbe1de6 100644
--- a/WrangURL/Core/RoutePlanner.swift
+++ b/WrangURL/Core/RoutePlanner.swift
@@ -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()
- let browserIDs = rule.browserIDs.filter { isAvailable($0) && seen.insert($0).inserted }
+ if let browserIDs = ruleBrowserIDs {
return browserIDs.isEmpty ? openFallback() : openOrPick(browserIDs)
}
@@ -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()
+ return browserIDs.filter { isAvailable($0) && seen.insert($0).inserted }
+ }
+
private func openOrPick(_ browserIDs: [String]) -> RouteDecision {
switch browserIDs.count {
case 0: .noBrowserAvailable
diff --git a/WrangURL/Core/URLRouter.swift b/WrangURL/Core/URLRouter.swift
index 4cfbd5c..adac3b7 100644
--- a/WrangURL/Core/URLRouter.swift
+++ b/WrangURL/Core/URLRouter.swift
@@ -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)?
@@ -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)
}
}
@@ -76,9 +85,11 @@ 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.
@@ -86,9 +97,9 @@ final class URLRouter {
config.config.settings.logsURLs ? url.absoluteString : ""
}
- 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):
diff --git a/WrangURL/UI/Settings/GeneralSettingsView.swift b/WrangURL/UI/Settings/GeneralSettingsView.swift
index d3a2be0..2d2570a 100644
--- a/WrangURL/UI/Settings/GeneralSettingsView.swift
+++ b/WrangURL/UI/Settings/GeneralSettingsView.swift
@@ -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)
}
@@ -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 {
+ Binding(
+ get: { config.config.settings.pickerModifiers },
+ set: { keys in config.update { $0.settings.pickerModifiers = keys } }
+ )
+ }
+
private var fallbackBrowserID: Binding {
Binding(
get: { config.config.settings.fallbackBrowserID },
diff --git a/WrangURL/UI/Settings/ModifierKeysRecorder.swift b/WrangURL/UI/Settings/ModifierKeysRecorder.swift
new file mode 100644
index 0000000..b4d7e70
--- /dev/null
+++ b/WrangURL/UI/Settings/ModifierKeysRecorder.swift
@@ -0,0 +1,124 @@
+import AppKit
+import SwiftUI
+
+/// A field that records a combination of modifier keys. Click it, press the keys, and the
+/// combination is saved when they're all released. Esc, clicking the field again, or
+/// switching to another app cancels.
+struct ModifierKeysRecorder: View {
+ @Binding var keys: ModifierKeys
+ @State private var recording = ModifierKeysRecording()
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Button {
+ if recording.isRecording {
+ recording.stop()
+ } else {
+ recording.start { keys = $0 }
+ }
+ } label: {
+ Text(title)
+ .frame(minWidth: 120)
+ }
+ .help(recording.isRecording ? "Press the modifier keys, or Esc to cancel" : "Click, then press the modifier keys")
+
+ if !keys.isEmpty && !recording.isRecording {
+ Button {
+ keys = []
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.borderless)
+ .help("Turn off")
+ .accessibilityLabel("Turn off")
+ }
+ }
+ .onDisappear { recording.stop() }
+ }
+
+ private var title: String {
+ if recording.isRecording {
+ return recording.held.isEmpty ? "Press keys…" : recording.held.symbols
+ }
+ return keys.isEmpty ? "Record Keys" : keys.symbols
+ }
+}
+
+/// Watches key events in WrangURL's own windows while recording. A local monitor sees them
+/// without Accessibility permission, because the Settings window is key.
+@MainActor
+@Observable
+final class ModifierKeysRecording {
+ private(set) var isRecording = false
+ /// The keys held right now, shown while recording.
+ private(set) var held: ModifierKeys = []
+
+ /// Every key held since the first one was pressed, so releasing keys one at a time
+ /// still records the whole combination.
+ @ObservationIgnored private var combination: ModifierKeys = []
+ @ObservationIgnored private var monitor: Any?
+ @ObservationIgnored private var resignObserver: (any NSObjectProtocol)?
+ @ObservationIgnored private var onFinish: (@MainActor (ModifierKeys) -> Void)?
+
+ private static let escapeKeyCode: UInt16 = 53
+
+ func start(onFinish: @escaping @MainActor (ModifierKeys) -> Void) {
+ stop()
+ self.onFinish = onFinish
+ isRecording = true
+ monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged, .keyDown]) { [weak self] event in
+ let type = event.type
+ let flags = event.modifierFlags
+ let keyCode = event.keyCode
+ let consumed = MainActor.assumeIsolated {
+ self?.handle(type: type, flags: flags, keyCode: keyCode) ?? false
+ }
+ return consumed ? nil : event
+ }
+ // Key events stop arriving once another app is active, so don't wait for them.
+ resignObserver = NotificationCenter.default.addObserver(
+ forName: NSApplication.didResignActiveNotification, object: nil, queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated { self?.stop() }
+ }
+ }
+
+ func stop() {
+ if let monitor {
+ NSEvent.removeMonitor(monitor)
+ }
+ monitor = nil
+ if let resignObserver {
+ NotificationCenter.default.removeObserver(resignObserver)
+ }
+ resignObserver = nil
+ onFinish = nil
+ isRecording = false
+ held = []
+ combination = []
+ }
+
+ /// Returns whether the event is used up, so recording keys doesn't trigger shortcuts.
+ private func handle(type: NSEvent.EventType, flags: NSEvent.ModifierFlags, keyCode: UInt16) -> Bool {
+ switch type {
+ case .flagsChanged:
+ held = ModifierKeys(flags)
+ combination.formUnion(held)
+ if held.isEmpty && !combination.isEmpty {
+ let finished = combination
+ let onFinish = onFinish
+ stop()
+ onFinish?(finished)
+ }
+ return false
+ case .keyDown:
+ if keyCode == Self.escapeKeyCode {
+ stop()
+ }
+ return true
+ default:
+ return false
+ }
+ }
+}
diff --git a/WrangURLTests/RoutePlannerTests.swift b/WrangURLTests/RoutePlannerTests.swift
index 69d7f7c..84c1a8c 100644
--- a/WrangURLTests/RoutePlannerTests.swift
+++ b/WrangURLTests/RoutePlannerTests.swift
@@ -74,4 +74,57 @@ struct RoutePlannerTests {
let file = URL(filePath: "/tmp/page.html")
#expect(planner(unmatched: .showPicker).decide(for: file, matchedRule: rule([firefox])) == .open(browserID: chrome))
}
+
+ // MARK: Picker modifiers (default ⌥⌘)
+
+ private let forced: ModifierKeys = [.option, .command]
+
+ @Test func modifiersShowPickerWithRuleBrowsersFirst() {
+ let decision = planner().decide(for: web, matchedRule: rule([safari, missing]), heldModifiers: forced)
+ #expect(decision == .pick(browserIDs: [safari, chrome, firefox]))
+ }
+
+ @Test func modifiersShowPickerWithFallbackFirstWhenUnmatched() {
+ let decision = planner(fallback: firefox).decide(for: web, matchedRule: nil, heldModifiers: forced)
+ #expect(decision == .pick(browserIDs: [firefox, chrome, safari]))
+ }
+
+ @Test func modifiersShowPickerForFileURLs() {
+ let file = URL(filePath: "/tmp/page.html")
+ let decision = planner().decide(for: file, matchedRule: rule([firefox]), heldModifiers: forced)
+ #expect(decision == .pick(browserIDs: [chrome, firefox, safari]))
+ }
+
+ @Test func modifiersMustMatchExactly() {
+ let single = rule([firefox])
+ #expect(planner().decide(for: web, matchedRule: single, heldModifiers: [.option]) == .open(browserID: firefox))
+ #expect(planner().decide(for: web, matchedRule: single, heldModifiers: [.option, .command, .shift]) == .open(browserID: firefox))
+ }
+
+ @Test func emptyPickerModifiersTurnTheShortcutOff() {
+ var planner = planner()
+ planner.settings.pickerModifiers = []
+ #expect(planner.decide(for: web, matchedRule: rule([firefox]), heldModifiers: []) == .open(browserID: firefox))
+ }
+
+ @Test func modifiersWithOneBrowserOpenIt() {
+ let decision = planner(fallback: nil, installed: [safari]).decide(for: web, matchedRule: nil, heldModifiers: forced)
+ #expect(decision == .open(browserID: safari))
+ }
+
+ @Test func pickerModifiersRoundTripAsKeyNames() throws {
+ var settings = AppSettings()
+ settings.pickerModifiers = [.command, .shift]
+ let data = try JSONEncoder().encode(settings)
+ let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
+ #expect(json["pickerModifiers"] as? [String] == ["shift", "command"])
+ #expect(try JSONDecoder().decode(AppSettings.self, from: data).pickerModifiers == [.shift, .command])
+ }
+
+ @Test func pickerModifiersDefaultToOptionCommandAndIgnoreUnknownKeys() throws {
+ let missing = try JSONDecoder().decode(AppSettings.self, from: Data("{}".utf8))
+ #expect(missing.pickerModifiers == [.option, .command])
+ let unknown = try JSONDecoder().decode(AppSettings.self, from: Data(#"{"pickerModifiers": ["option", "hyper"]}"#.utf8))
+ #expect(unknown.pickerModifiers == [.option])
+ }
}
diff --git a/docs/images/settings-general-dark.png b/docs/images/settings-general-dark.png
index 3dd0471..f707397 100644
Binary files a/docs/images/settings-general-dark.png and b/docs/images/settings-general-dark.png differ
diff --git a/docs/images/settings-general-light.png b/docs/images/settings-general-light.png
index bd611a1..30257fe 100644
Binary files a/docs/images/settings-general-light.png and b/docs/images/settings-general-light.png differ
diff --git a/docs/index.html b/docs/index.html
index 9d29b15..0298b0e 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -312,6 +312,10 @@
Choose in the picker
When a rule lists more than one browser, the picker appears next to the mouse pointer.
The app you clicked the link in stays in front, so you can keep working after you choose.
+
+ To choose for any link, hold ⌥⌘ 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 Always ask when holding in Settings → General and press them.
+
1–9
Open in that browser
@@ -362,13 +366,13 @@
Menu bar and settings
+ alt="The General tab: default browser status, fallback browser, unmatched links, the keys that always show the picker (Option-Command), startup and updates.">
Settings → General.
- Settings → General covers the fallback browser and what happens when no rule matches, plus launch at login, automatic updates, whether URLs are logged, whether to keep a history, and your configuration.
+ Settings → General covers the fallback browser, what happens when no rule matches and the keys that always show the picker, plus launch at login, automatic updates, whether URLs are logged, whether to keep a history, and your configuration.
Use Import… and Export… to move your rules and settings between Macs as a JSON file.