From cd8f8fe792dc86171f1374ea99b12666a1a3033d Mon Sep 17 00:00:00 2001 From: Warrick Bayman Date: Mon, 28 Sep 2026 16:23:24 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Hold=20=E2=8C=A5=E2=8C=98=20while=20clickin?= =?UTF-8?q?g=20a=20link=20to=20always=20show=20the=20browser=20picker.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URLRouter reads the held modifier keys when a URL arrives, before it can be queued. If they match the new pickerModifiers setting exactly, RoutePlanner shows the picker with every browser, listing the matched rule's browsers (or the fallback browser) first. The keys default to ⌥⌘ and can be changed or turned off in Settings → General. They are stored in config.json as key names, and older config files load with the default. Documents the shortcut in the README and on the website, and recaptures the Settings → General screenshots with the new row. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- README.md | 5 +- WrangURL/Core/Config.swift | 7 +++ WrangURL/Core/ModifierKeys.swift | 49 ++++++++++++++++ WrangURL/Core/RoutePlanner.swift | 22 ++++++-- WrangURL/Core/URLRouter.swift | 41 ++++++++++---- .../UI/Settings/GeneralSettingsView.swift | 34 ++++++++++- WrangURLTests/RoutePlannerTests.swift | 53 ++++++++++++++++++ docs/images/settings-general-dark.png | Bin 115670 -> 116651 bytes docs/images/settings-general-light.png | Bin 111740 -> 111188 bytes docs/index.html | 8 ++- 11 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 WrangURL/Core/ModifierKeys.swift 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..531f8e8 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. Change the keys, or turn this off, with **Always ask when holding** in Settings → General. 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.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..534b534 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 : "