diff --git a/CLAUDE.md b/CLAUDE.md
index a1f325c..74bf1ae 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -27,7 +27,7 @@ App logs use subsystem `com.thepublicgood.wrangurl`. In zsh, `log` is a shell bu
## Architecture
**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 matching rule. It is compiled once per rules array and cached in the router.
+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".
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.
@@ -78,8 +78,9 @@ Debug builds accept launch arguments for inspecting UI without clicking. For exa
- `-DebugInsertionIndex ` draws the rule editor's drop line at that position.
- `-DebugOnboardingStep 0-3` opens the setup assistant on that step.
- `-DebugShowHistory YES` opens the History window.
+- `-DebugAppearance light|dark` overrides the system appearance.
- `-DebugShowPicker ` (plus optional `-DebugPickerURL `) shows the picker. `docs/picker.png` was captured this way.
-`docs/` is also the GitHub Pages site (`index.html`, `style.css`, `site.js`, no build step). Its screenshots in `docs/images/` were captured this way in light (`-NSRequiresAquaSystemAppearance YES`) and dark variants, using a demo `config.json`.
+`docs/` is also the GitHub Pages site (`index.html`, `style.css`, `site.js`, no build step). Its screenshots in `docs/images/` were captured this way in light and dark variants (`-DebugAppearance`; `-NSRequiresAquaSystemAppearance` is ignored), using the demo `config.json` and `history.json` in `docs/demo/`. Its README has the steps.
The debug build shares its sandbox container, and so its `config.json`, with any installed copy of WrangURL: `~/Library/Containers/com.thepublicgood.wrangurl/Data/Library/Application Support/WrangURL/`. Back that file up before experimenting with rules, and restore it afterwards.
diff --git a/PLAN.md b/PLAN.md
index 61ad419..554686e 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -179,6 +179,7 @@ enum UnmatchedBehavior: String, Codable {
- `-DebugEditRule ` opens the rule editor.
- `-DebugInsertionIndex ` draws the browser drop line at that position.
- `-DebugOnboardingStep <0-3>` opens the setup assistant on that step.
+ - `-DebugAppearance ` overrides the system appearance, for screenshots.
- `-DebugShowPicker ` shows the picker with those browsers (comma-separated). `-DebugPickerURL ` sets the link it displays. Choosing a browser does nothing.
- When the app is hosting the unit tests (`XCTestConfigurationFilePath` is set), it skips startup side effects: it doesn't record the fallback browser or open the setup assistant.
- Setup is marked complete when its window closes by any means, so it doesn't reappear on every launch. Settings → General → Run Setup Again… reopens it.
@@ -191,6 +192,7 @@ enum UnmatchedBehavior: String, Codable {
- **Import:** only accepts JSON with a `rules` or `settings` key, so an arbitrary file can't wipe the configuration. It offers Add Rules, which keeps your settings and gives duplicate IDs new ones, or Replace All, which keeps the "setup done" flag.
- In zsh, `log` is a shell builtin, so use `/usr/bin/log stream --predicate 'subsystem == "com.thepublicgood.wrangurl"'` to view the app's logs.
- Sparkle handles auto-updates (see the release workflow and `UpdateManager`).
+- **Source app:** read from the `keySenderPIDAttr` of the `GetURL` Apple Event inside `application(_:open:)`, the only time that event is available. Helper processes (Electron, WebKit) are mapped to their outermost `.app`. When the sender isn't an app (e.g. `open` in Terminal) the source is unknown. Falling back to the frontmost app was tried and dropped: it's often the browser the previous link went to. Rules limited to apps never match an unknown source.
## 11. Milestones
@@ -218,7 +220,6 @@ enum UnmatchedBehavior: String, Codable {
## 12. Future ideas
- Hold ⌥ while clicking a link to force the picker.
-- Rules based on the source app (e.g. "links from Slack → Chrome"). The sender's process ID is available from the incoming Apple Event.
- Browser profiles, such as Chrome or Edge profiles, launched with command-line arguments.
- A "Remember this choice" option in the picker that creates a rule automatically.
- Stripping tracking parameters (`utm_*`) before opening.
diff --git a/README.md b/README.md
index 31d365c..c8834cb 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ WrangURL is a macOS menu bar app that opens each link in the right browser. You
- Match URLs with simple patterns like `localhost`, `*.example.com` or `github.com/myorg/*`, or with regular expressions.
+- 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.
- Keeps a searchable history of the links it has opened, grouped by day.
@@ -57,7 +58,9 @@ Simple patterns ignore case. A path matches as a prefix, and `*` is a wildcard.
**Regular expressions** can match anywhere in the full URL. They're case-sensitive; start the pattern with `(?i)` to ignore case.
-To check your rules, use **Test a URL** at the bottom of the Rules tab. It shows which rule matches and which browser would open. The rule editor has its own test field for the rule you're editing.
+**Clicked In** limits a rule to links clicked in particular apps. Add apps from the list of running apps, or choose one from disk. With apps listed, the pattern is optional: leave it empty to send every link from those apps to the rule's browsers. A rule limited to apps never matches a link whose source app is unknown, such as one opened with `open` in Terminal.
+
+To check your rules, use **Test a URL** at the bottom of the Rules tab. It shows which rule matches and which browser would open. When a rule is limited to apps, choose the app the link is clicked in next to the field. The rule editor has its own test field for the rule you're editing.
### The browser picker
@@ -73,7 +76,9 @@ The picker appears next to the mouse pointer. The app you clicked the link in st
### History
-Choose **History…** in the menu to see every link WrangURL has opened, grouped by day, with the time, the rule that matched and the browser it went to. Search filters by URL, browser or rule. The buttons at the end of each row copy the link or open it again through your rules; double-clicking also opens it again. Right-click to copy, open or delete selected links. The trash button in the toolbar clears everything.
+Choose **History…** in the menu to see every link WrangURL has opened, grouped by day, with the time, the app the link was clicked in, the rule that matched and the browser it went to. Search filters by URL, browser, rule or source app. The buttons at the end of each row copy the link or open it again through your rules; double-clicking also opens it again. Right-click to copy, open or delete selected links. The trash button in the toolbar clears everything.
+
+WrangURL reads the source app from the request macOS sends when a link is clicked. Links opened with `open` in Terminal, or sent by an app that has already quit, show no source app.
History is kept only on this Mac, in `history.json` next to the configuration file, and holds the most recent 5,000 links. Turn it off in **Settings → General**.
diff --git a/WrangURL/App/AppDelegate.swift b/WrangURL/App/AppDelegate.swift
index c87e5f2..82ccedf 100644
--- a/WrangURL/App/AppDelegate.swift
+++ b/WrangURL/App/AppDelegate.swift
@@ -59,6 +59,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
guard !Self.isRunningTests else { return }
+ #if DEBUG
+ // Launch with `-DebugAppearance ` to override the system appearance, e.g. for screenshots.
+ switch UserDefaults.standard.string(forKey: "DebugAppearance") {
+ case "light": NSApp.appearance = NSAppearance(named: .aqua)
+ case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
+ default: break
+ }
+ #endif
+
defaultBrowser.captureFallbackIfNeeded()
if !config.config.settings.hasCompletedOnboarding {
onboardingWindow.show(step: Self.debugOnboardingStep ?? .fallback)
@@ -112,6 +121,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
func application(_ application: NSApplication, open urls: [URL]) {
- router.handle(urls)
+ router.handle(urls, from: SourceApp.forCurrentAppleEvent())
}
}
diff --git a/WrangURL/Core/HistoryEntry.swift b/WrangURL/Core/HistoryEntry.swift
index 5d4000d..3e7f6d1 100644
--- a/WrangURL/Core/HistoryEntry.swift
+++ b/WrangURL/Core/HistoryEntry.swift
@@ -8,16 +8,18 @@ struct HistoryEntry: Identifiable, Codable, Equatable {
let browserID: String
let browserName: String
let ruleName: String?
+ /// The app the link was clicked in. Nil for older entries and when it couldn't be told.
+ var sourceApp: SourceApp?
}
extension [HistoryEntry] {
- /// Entries whose URL, browser or rule contains `query`, ignoring case and diacritics.
+ /// Entries whose URL, browser, rule or source app contains `query`, ignoring case and diacritics.
/// A blank query matches everything.
func matching(_ query: String) -> [HistoryEntry] {
let query = query.trimmingCharacters(in: .whitespaces)
guard !query.isEmpty else { return self }
return filter { entry in
- [entry.url.absoluteString, entry.browserName, entry.ruleName ?? ""]
+ [entry.url.absoluteString, entry.browserName, entry.ruleName ?? "", entry.sourceApp?.name ?? ""]
.contains { $0.localizedStandardContains(query) }
}
}
diff --git a/WrangURL/Core/Rule.swift b/WrangURL/Core/Rule.swift
index a8923b4..becb8e9 100644
--- a/WrangURL/Core/Rule.swift
+++ b/WrangURL/Core/Rule.swift
@@ -15,12 +15,30 @@ struct Rule: Codable, Identifiable, Hashable, Sendable {
/// Bundle identifiers, in the order they appear in the picker.
var browserIDs: [String]
var isEnabled = true
+ /// Apps the link must be clicked in. Empty matches links from any app, including unknown ones.
+ var sourceApps: [SourceApp] = []
}
extension Rule {
- /// The name, or the pattern when no name was given.
+ /// The name, or the pattern when no name was given, or the source apps when there's no pattern either.
var displayName: String {
- name.trimmingCharacters(in: .whitespaces).isEmpty ? pattern : name
+ if !name.trimmingCharacters(in: .whitespaces).isEmpty {
+ return name
+ }
+ if matchesAnyURL {
+ return "Links from \(sourceAppNames)"
+ }
+ return pattern
+ }
+
+ /// A rule limited to source apps may leave the pattern empty to match every URL from them.
+ var matchesAnyURL: Bool {
+ !sourceApps.isEmpty && pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ /// The source apps' names, e.g. "Slack or Mail".
+ var sourceAppNames: String {
+ sourceApps.map(\.name).formatted(.list(type: .or))
}
// Tolerate missing keys so older or hand-edited config files still load.
@@ -32,5 +50,6 @@ extension Rule {
kind = try container.decodeIfPresent(Kind.self, forKey: .kind) ?? .simple
browserIDs = try container.decodeIfPresent([String].self, forKey: .browserIDs) ?? []
isEnabled = try container.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
+ sourceApps = try container.decodeIfPresent([SourceApp].self, forKey: .sourceApps) ?? []
}
}
diff --git a/WrangURL/Core/RuleMatcher.swift b/WrangURL/Core/RuleMatcher.swift
index 3aaaa4c..27d4c1d 100644
--- a/WrangURL/Core/RuleMatcher.swift
+++ b/WrangURL/Core/RuleMatcher.swift
@@ -14,18 +14,19 @@ enum PatternError: LocalizedError, Equatable {
}
}
-/// Finds the first enabled rule matching a URL. Rules are compiled once at init.
+/// Finds the first enabled rule matching a URL and the app it was clicked in. Rules are compiled once at init.
struct RuleMatcher {
- private let compiled: [(rule: Rule, regex: NSRegularExpression)]
+ /// A nil regex matches every URL (see `Rule.matchesAnyURL`).
+ private let compiled: [(rule: Rule, regex: NSRegularExpression?)]
/// Enabled rules whose pattern failed to compile; these never match.
let invalidRules: [UUID: PatternError]
init(rules: [Rule]) {
- var compiled: [(Rule, NSRegularExpression)] = []
+ var compiled: [(Rule, NSRegularExpression?)] = []
var invalid: [UUID: PatternError] = [:]
for rule in rules where rule.isEnabled {
do {
- compiled.append((rule, try Self.compile(rule.pattern, kind: rule.kind)))
+ compiled.append((rule, rule.matchesAnyURL ? nil : try Self.compile(rule.pattern, kind: rule.kind)))
} catch {
invalid[rule.id] = error
}
@@ -34,10 +35,20 @@ struct RuleMatcher {
self.invalidRules = invalid
}
- func match(_ url: URL) -> Rule? {
+ /// `sourceID` is the bundle ID of the app the link was clicked in, if known. Rules limited
+ /// to source apps never match links from an unknown app.
+ func match(_ url: URL, from sourceID: String? = nil) -> Rule? {
let string = url.absoluteString
let range = NSRange(string.startIndex..., in: string)
- return compiled.first { $0.regex.firstMatch(in: string, range: range) != nil }?.rule
+ return compiled.first { rule, regex in
+ guard rule.sourceApps.isEmpty || rule.sourceApps.contains(where: { $0.id == sourceID }) else { return false }
+ return regex.map { $0.firstMatch(in: string, range: range) != nil } ?? true
+ }?.rule
+ }
+
+ /// Like `validate(_:kind:)`, but allows an empty pattern when the rule has source apps.
+ static func validate(_ rule: Rule) -> PatternError? {
+ rule.matchesAnyURL ? nil : validate(rule.pattern, kind: rule.kind)
}
static func validate(_ pattern: String, kind: Rule.Kind) -> PatternError? {
diff --git a/WrangURL/Core/SourceApp+AppleEvent.swift b/WrangURL/Core/SourceApp+AppleEvent.swift
new file mode 100644
index 0000000..b9c4715
--- /dev/null
+++ b/WrangURL/Core/SourceApp+AppleEvent.swift
@@ -0,0 +1,57 @@
+import AppKit
+
+extension SourceApp {
+ /// The app that sent the Apple Event being handled, e.g. the one in `application(_:open:)`.
+ /// Only valid while that event is being handled.
+ ///
+ /// Helper processes (Electron, WebKit, XPC services) are mapped to the app that contains them.
+ /// Nil when the sender isn't an app, such as `open` in Terminal, or has already quit. The
+ /// frontmost app isn't used as a fallback: it's often the browser the previous link went to.
+ @MainActor
+ static func forCurrentAppleEvent() -> SourceApp? {
+ let event = NSAppleEventManager.shared().currentAppleEvent
+ let pid = event?.attributeDescriptor(forKeyword: keySenderPIDAttr)?.int32Value
+ let sender = pid.flatMap { NSRunningApplication(processIdentifier: $0) }
+ return sender.flatMap(from)
+ }
+
+ /// The outermost app containing `app`, or nil if `app` isn't inside an app bundle or is WrangURL.
+ @MainActor
+ private static func from(_ app: NSRunningApplication) -> SourceApp? {
+ guard let bundleURL = app.bundleURL,
+ let appURL = containingAppURL(of: bundleURL) else { return nil }
+
+ let source: SourceApp?
+ if appURL.standardizedFileURL == bundleURL.standardizedFileURL {
+ source = app.bundleIdentifier.map { SourceApp(id: $0, name: app.localizedName ?? displayName(of: appURL)) }
+ } else if let parent = NSWorkspace.shared.runningApplications.first(where: {
+ $0.bundleURL?.standardizedFileURL == appURL.standardizedFileURL
+ }), let id = parent.bundleIdentifier {
+ source = SourceApp(id: id, name: parent.localizedName ?? displayName(of: appURL))
+ } else {
+ source = Bundle(url: appURL)?.bundleIdentifier.map { SourceApp(id: $0, name: displayName(of: appURL)) }
+ }
+ return source?.id == Bundle.main.bundleIdentifier ? nil : source
+ }
+
+ /// Regular apps that are running now, by name, excluding WrangURL.
+ @MainActor
+ static var running: [SourceApp] {
+ var seen = Set()
+ return NSWorkspace.shared.runningApplications
+ .filter { $0.activationPolicy == .regular }
+ .compactMap(from)
+ .filter { seen.insert($0.id).inserted }
+ .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
+ }
+
+ /// The app at `appURL`, e.g. one the user chose in an open panel.
+ static func app(at appURL: URL) -> SourceApp? {
+ guard let id = Bundle(url: appURL)?.bundleIdentifier, id != Bundle.main.bundleIdentifier else { return nil }
+ return SourceApp(id: id, name: displayName(of: appURL))
+ }
+
+ private static func displayName(of appURL: URL) -> String {
+ FileManager.default.displayName(atPath: appURL.path).replacing(/\.app$/, with: "")
+ }
+}
diff --git a/WrangURL/Core/SourceApp.swift b/WrangURL/Core/SourceApp.swift
new file mode 100644
index 0000000..ac6a819
--- /dev/null
+++ b/WrangURL/Core/SourceApp.swift
@@ -0,0 +1,24 @@
+import Foundation
+
+/// The app a link was clicked in.
+struct SourceApp: Codable, Hashable, Sendable {
+ /// Bundle identifier.
+ let id: String
+ let name: String
+}
+
+extension SourceApp {
+ /// The outermost `.app` bundle in `url`'s path, e.g. `/Applications/Slack.app` for
+ /// `/Applications/Slack.app/Contents/Frameworks/Slack Helper.app`.
+ nonisolated static func containingAppURL(of url: URL) -> URL? {
+ var appURL: URL?
+ var current = url.standardizedFileURL
+ while current.pathComponents.count > 1 {
+ if current.pathExtension.caseInsensitiveCompare("app") == .orderedSame {
+ appURL = current
+ }
+ current = current.deletingLastPathComponent()
+ }
+ return appURL
+ }
+}
diff --git a/WrangURL/Core/URLRouter.swift b/WrangURL/Core/URLRouter.swift
index 910d7fd..4cfbd5c 100644
--- a/WrangURL/Core/URLRouter.swift
+++ b/WrangURL/Core/URLRouter.swift
@@ -20,7 +20,7 @@ final class URLRouter {
@ObservationIgnored private let browsers: BrowserRegistry
@ObservationIgnored private let history: HistoryStore
@ObservationIgnored private let picker = BrowserPickerController()
- @ObservationIgnored private var pending: [URL] = []
+ @ObservationIgnored private var pending: [(url: URL, source: SourceApp?)] = []
@ObservationIgnored private var isReady = false
@ObservationIgnored private var matcherCache: (rules: [Rule], matcher: RuleMatcher)?
@@ -37,16 +37,21 @@ final class URLRouter {
isReady = true
let queued = pending
pending.removeAll()
- handle(queued)
+ for (url, source) in queued {
+ route(url, from: source)
+ }
}
- func handle(_ urls: [URL]) {
+ /// Routes URLs clicked in `source`, the app they came from, if known.
+ func handle(_ urls: [URL], from source: SourceApp? = nil) {
guard isReady else {
- pending.append(contentsOf: urls)
+ pending.append(contentsOf: urls.map { ($0, source) })
Self.logger.info("Queued \(urls.count) URL(s) until launch finishes")
return
}
- urls.forEach(route)
+ for url in urls {
+ route(url, from: source)
+ }
}
var planner: RoutePlanner {
@@ -70,9 +75,9 @@ final class URLRouter {
return matcher
}
- /// What would happen to a URL, without opening it. Used by the URL tester.
- func preview(_ url: URL) -> (rule: Rule?, decision: RouteDecision) {
- let rule = matcher.match(url)
+ /// 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) {
+ let rule = matcher.match(url, from: source?.id)
return (rule, planner.decide(for: url, matchedRule: rule))
}
@@ -81,25 +86,25 @@ final class URLRouter {
config.config.settings.logsURLs ? url.absoluteString : ""
}
- private func route(_ url: URL) {
- let (rule, decision) = preview(url)
- Self.logger.info("Received \(self.loggable(url), privacy: .public); rule: \(rule?.displayName ?? "none", privacy: .public); decision: \(String(describing: decision), privacy: .public)")
+ 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)")
switch decision {
case .open(let browserID):
- open(url, inBrowserWithID: browserID, rule: rule)
+ open(url, inBrowserWithID: browserID, rule: rule, source: source)
case .pick(let browserIDs):
- presentPicker(for: url, browserIDs: browserIDs, rule: rule)
+ presentPicker(for: url, browserIDs: browserIDs, rule: rule, source: source)
case .noBrowserAvailable:
Self.logger.error("No browser available for \(self.loggable(url), privacy: .public)")
}
}
- private func presentPicker(for url: URL, browserIDs: [String], rule: Rule?) {
+ private func presentPicker(for url: URL, browserIDs: [String], rule: Rule?, source: SourceApp?) {
let choices = browserIDs.compactMap(browsers.resolve)
guard choices.count > 1 else {
if let only = choices.first {
- open(url, inBrowserWithID: only.id, rule: rule)
+ open(url, inBrowserWithID: only.id, rule: rule, source: source)
}
return
}
@@ -109,11 +114,11 @@ final class URLRouter {
Self.logger.info("Picker cancelled for \(self.loggable(url), privacy: .public)")
return
}
- self.open(url, inBrowserWithID: browser.id, rule: rule)
+ self.open(url, inBrowserWithID: browser.id, rule: rule, source: source)
}
}
- private func open(_ url: URL, inBrowserWithID browserID: String, rule: Rule?) {
+ private func open(_ url: URL, inBrowserWithID browserID: String, rule: Rule?, source: SourceApp?) {
guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: browserID) else {
Self.logger.error("Browser \(browserID, privacy: .public) is not installed")
return
@@ -138,7 +143,10 @@ final class URLRouter {
recent.removeLast(recent.count - Self.recentLimit)
}
if config.config.settings.keepsHistory {
- history.record(HistoryEntry(url: url, date: .now, browserID: browserID, browserName: name, ruleName: rule?.displayName))
+ history.record(HistoryEntry(
+ url: url, date: .now, browserID: browserID, browserName: name,
+ ruleName: rule?.displayName, sourceApp: source
+ ))
}
}
}
diff --git a/WrangURL/UI/History/HistoryView.swift b/WrangURL/UI/History/HistoryView.swift
index 4b5f259..e8cac08 100644
--- a/WrangURL/UI/History/HistoryView.swift
+++ b/WrangURL/UI/History/HistoryView.swift
@@ -100,7 +100,9 @@ struct HistoryView: View {
/// Opens the links again through the rules, as if they had just been clicked.
private func open(_ ids: Set) {
- router.handle(entries(ids).map(\.url))
+ for entry in entries(ids) {
+ router.handle([entry.url], from: entry.sourceApp)
+ }
}
private func copy(_ ids: Set) {
@@ -115,7 +117,7 @@ struct HistoryView: View {
}
}
-/// One opened link: time, URL and the browser it went to, with buttons to open or copy it.
+/// One opened link: time, the app it came from, URL and the browser it went to, with buttons to open or copy it.
private struct HistoryRow: View {
let entry: HistoryEntry
let onOpen: () -> Void
@@ -126,6 +128,7 @@ private struct HistoryRow: View {
Text(entry.date, format: .dateTime.hour().minute())
.monospacedDigit()
.foregroundStyle(.secondary)
+ SourceAppIcon(source: entry.sourceApp)
Text(entry.url.absoluteString)
.lineLimit(1)
.truncationMode(.middle)
@@ -150,3 +153,26 @@ private struct HistoryRow: View {
.padding(4)
}
}
+
+/// The icon of the app a link came from, or a placeholder when that isn't known.
+private struct SourceAppIcon: View {
+ @Environment(BrowserRegistry.self) private var browsers
+
+ let source: SourceApp?
+
+ var body: some View {
+ Group {
+ if let source, let icon = browsers.icon(forID: source.id) {
+ Image(nsImage: icon)
+ .resizable()
+ } else {
+ Image(systemName: "questionmark.app.dashed")
+ .resizable()
+ .foregroundStyle(.tertiary)
+ .padding(2)
+ }
+ }
+ .frame(width: 16, height: 16)
+ .help(source.map { "From \($0.name)" } ?? "Source app unknown")
+ }
+}
diff --git a/WrangURL/UI/Settings/RuleEditorView.swift b/WrangURL/UI/Settings/RuleEditorView.swift
index bc17761..99c18c9 100644
--- a/WrangURL/UI/Settings/RuleEditorView.swift
+++ b/WrangURL/UI/Settings/RuleEditorView.swift
@@ -43,6 +43,8 @@ struct RuleEditorView: View {
patternHint
}
+ SourceAppsSection(apps: $draft.sourceApps)
+
Section {
if draft.browserIDs.isEmpty {
Text("No browsers yet")
@@ -88,7 +90,7 @@ struct RuleEditorView: View {
}
.padding(12)
}
- .frame(width: 520, height: 500)
+ .frame(width: 520, height: 560)
#if DEBUG
// Launch with `-DebugInsertionIndex ` to preview the drop indicator (for UI work).
.onAppear {
@@ -100,7 +102,7 @@ struct RuleEditorView: View {
// MARK: - Pattern
private var patternError: PatternError? {
- RuleMatcher.validate(draft.pattern, kind: draft.kind)
+ RuleMatcher.validate(draft)
}
private var patternPrompt: String {
@@ -240,8 +242,10 @@ struct RuleEditorView: View {
guard patternError == nil else { return (false, "Fix the pattern first") }
var rule = draft
rule.isEnabled = true
- let matches = RuleMatcher(rules: [rule]).match(url) != nil
- return (matches, matches ? "This rule matches" : "This rule doesn't match")
+ guard RuleMatcher(rules: [rule]).match(url, from: rule.sourceApps.first?.id) != nil else {
+ return (false, "This rule doesn't match")
+ }
+ return (true, rule.sourceApps.isEmpty ? "This rule matches" : "This rule matches when clicked in \(rule.sourceAppNames)")
}
private var canSave: Bool {
@@ -249,6 +253,81 @@ struct RuleEditorView: View {
}
}
+/// The apps a rule is limited to, with a menu to add running apps or choose one from disk.
+private struct SourceAppsSection: View {
+ @Environment(BrowserRegistry.self) private var browsers
+ @Binding var apps: [SourceApp]
+ @State private var isChoosingApp = false
+
+ var body: some View {
+ Section {
+ if apps.isEmpty {
+ Text("Any app")
+ .foregroundStyle(.secondary)
+ }
+ ForEach(apps, id: \.id) { app in
+ HStack {
+ BrowserIcon(id: app.id, size: 20)
+ Text(app.name)
+ Spacer()
+ Button { apps.removeAll { $0.id == app.id } } label: { Image(systemName: "minus.circle.fill") }
+ .buttonStyle(.borderless)
+ .foregroundStyle(.secondary)
+ .help("Remove")
+ }
+ }
+ addMenu
+ } header: {
+ Text("Clicked In")
+ } footer: {
+ Text("Limit the rule to links clicked in these apps. With apps listed, the pattern is optional; "
+ + "leave it empty to match every link from them.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var addMenu: some View {
+ let running = SourceApp.running.filter { app in !apps.contains { $0.id == app.id } }
+ return Menu("Add App") {
+ Section("Running Apps") {
+ ForEach(running, id: \.id) { app in
+ Button {
+ add(app)
+ } label: {
+ Label {
+ Text(app.name)
+ } icon: {
+ if let icon = browsers.menuIcon(forID: app.id) {
+ Image(nsImage: icon)
+ }
+ }
+ }
+ }
+ }
+ Divider()
+ Button("Choose App…") { isChoosingApp = true }
+ }
+ .fixedSize()
+ .fileImporter(isPresented: $isChoosingApp, allowedContentTypes: [.application], allowsMultipleSelection: true) { result in
+ guard case .success(let urls) = result else { return }
+ for url in urls {
+ let didAccess = url.startAccessingSecurityScopedResource()
+ defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
+ if let app = SourceApp.app(at: url) {
+ add(app)
+ }
+ }
+ }
+ .fileDialogDefaultDirectory(URL(filePath: "/Applications", directoryHint: .isDirectory))
+ }
+
+ private func add(_ app: SourceApp) {
+ guard !apps.contains(where: { $0.id == app.id }) else { return }
+ apps.append(app)
+ }
+}
+
/// Tracks whether the pointer is over the top or bottom half of a browser row, so the
/// insertion line can be drawn on the divider above or below it.
private struct BrowserRowDropDelegate: DropDelegate {
diff --git a/WrangURL/UI/Settings/RulesSettingsView.swift b/WrangURL/UI/Settings/RulesSettingsView.swift
index 213ccbb..6d4438b 100644
--- a/WrangURL/UI/Settings/RulesSettingsView.swift
+++ b/WrangURL/UI/Settings/RulesSettingsView.swift
@@ -156,12 +156,25 @@ private struct RuleRow: View {
.fontWeight(.medium)
}
HStack(spacing: 6) {
- Text(rule.pattern)
- .font(hasName ? .callout.monospaced() : .body.monospaced())
- .foregroundStyle(hasName ? .secondary : .primary)
- .lineLimit(1)
- .truncationMode(.middle)
- if rule.kind == .regex {
+ if rule.matchesAnyURL {
+ Text("Any link from \(rule.sourceAppNames)")
+ .font(hasName ? .callout : .body)
+ .foregroundStyle(hasName ? .secondary : .primary)
+ .lineLimit(1)
+ } else {
+ Text(rule.pattern)
+ .font(hasName ? .callout.monospaced() : .body.monospaced())
+ .foregroundStyle(hasName ? .secondary : .primary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ if !rule.sourceApps.isEmpty {
+ Text("from \(rule.sourceAppNames)")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ if rule.kind == .regex && !rule.matchesAnyURL {
Text("REGEX")
.font(.caption2.weight(.semibold))
.foregroundStyle(.secondary)
@@ -173,7 +186,7 @@ private struct RuleRow: View {
Spacer()
- if let error = RuleMatcher.validate(rule.pattern, kind: rule.kind) {
+ if let error = RuleMatcher.validate(rule) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.yellow)
.help(error.localizedDescription)
@@ -197,12 +210,26 @@ private struct URLTesterView: View {
@Environment(ConfigStore.self) private var config
@State private var input = ""
+ @State private var source: SourceApp?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
- TextField("Test a URL", text: $input, prompt: Text("Test a URL, e.g. http://localhost:3000"))
- .textFieldStyle(.roundedBorder)
- .font(.body.monospaced())
+ HStack {
+ TextField("Test a URL", text: $input, prompt: Text("Test a URL, e.g. http://localhost:3000"))
+ .textFieldStyle(.roundedBorder)
+ .font(.body.monospaced())
+ if !ruleSourceApps.isEmpty {
+ Picker("Clicked In", selection: $source) {
+ Text("Any Other App").tag(SourceApp?.none)
+ Divider()
+ ForEach(ruleSourceApps, id: \.id) { app in
+ Text(app.name).tag(Optional(app))
+ }
+ }
+ .fixedSize()
+ .help("The app the link is clicked in")
+ }
+ }
Text(result)
.font(.callout)
.foregroundStyle(.secondary)
@@ -214,8 +241,14 @@ private struct URLTesterView: View {
_ = config.config // Re-evaluate when rules or settings change.
guard !input.isEmpty else { return " " }
guard let url = URL(userInput: input) else { return "Not a valid URL" }
- let (rule, decision) = router.preview(url)
+ let (rule, decision) = router.preview(url, from: source)
let match = rule.map { "Matches “\($0.displayName)”." } ?? "No rule matches."
return "\(match) \(browsers.describe(decision))."
}
+
+ /// Every app a rule is limited to, in rule order.
+ private var ruleSourceApps: [SourceApp] {
+ var seen = Set()
+ return config.config.rules.flatMap(\.sourceApps).filter { seen.insert($0.id).inserted }
+ }
}
diff --git a/WrangURLTests/HistoryTests.swift b/WrangURLTests/HistoryTests.swift
index 7b5b2ee..88b80b1 100644
--- a/WrangURLTests/HistoryTests.swift
+++ b/WrangURLTests/HistoryTests.swift
@@ -33,6 +33,27 @@ struct HistoryStoreTests {
#expect(HistoryStore(fileURL: fileURL).entries.map(\.ruleName) == [nil, "Work"])
}
+ @Test func keepsSourceApp() {
+ let store = HistoryStore(fileURL: fileURL)
+ let slack = SourceApp(id: "com.tinyspeck.slackmacgap", name: "Slack")
+ store.record(entry("https://a.example", sourceApp: slack))
+
+ #expect(HistoryStore(fileURL: fileURL).entries.map(\.sourceApp) == [slack])
+ }
+
+ @Test func loadsEntriesSavedBeforeSourceApps() throws {
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let json = """
+ [{"id": "\(UUID().uuidString)", "url": "https://a.example", "date": "2026-09-21T10:00:00Z",
+ "browserID": "com.apple.Safari", "browserName": "Safari"}]
+ """
+ try Data(json.utf8).write(to: fileURL)
+
+ let entries = HistoryStore.load(from: fileURL)
+ #expect(entries.count == 1)
+ #expect(entries.first?.sourceApp == nil)
+ }
+
@Test func trimsToLimit() {
let store = HistoryStore(fileURL: fileURL, limit: 3)
for index in 1...5 {
@@ -73,13 +94,14 @@ struct HistoryStoreTests {
/// Dates are whole seconds, because ISO 8601 in JSON drops fractions and the
/// reloaded entries wouldn't compare equal otherwise.
- private func entry(_ url: String, ruleName: String? = nil) -> HistoryEntry {
+ private func entry(_ url: String, ruleName: String? = nil, sourceApp: SourceApp? = nil) -> HistoryEntry {
HistoryEntry(
url: URL(string: url)!,
date: Date(timeIntervalSince1970: 1_790_000_000),
browserID: "com.apple.Safari",
browserName: "Safari",
- ruleName: ruleName
+ ruleName: ruleName,
+ sourceApp: sourceApp
)
}
}
@@ -129,7 +151,8 @@ struct HistoryGroupingTests {
struct HistorySearchTests {
private let entries = [
- HistoryEntry(url: URL(string: "https://github.com/org/repo")!, date: .now, browserID: "com.google.Chrome", browserName: "Google Chrome", ruleName: "Work"),
+ HistoryEntry(url: URL(string: "https://github.com/org/repo")!, date: .now, browserID: "com.google.Chrome", browserName: "Google Chrome", ruleName: "Work",
+ sourceApp: SourceApp(id: "com.tinyspeck.slackmacgap", name: "Slack")),
HistoryEntry(url: URL(string: "http://localhost:3000")!, date: .now, browserID: "org.mozilla.firefox", browserName: "Firefox", ruleName: nil),
]
@@ -144,8 +167,9 @@ struct HistorySearchTests {
("work", "github.com"),
("3000", "localhost"),
("firefox", "localhost"),
+ ("SLACK", "github.com"),
])
- func matchesURLBrowserOrRuleIgnoringCase(query: String, host: String) {
+ func matchesURLBrowserRuleOrSourceIgnoringCase(query: String, host: String) {
#expect(entries.matching(query).map(\.url.host) == [host])
}
diff --git a/WrangURLTests/RuleMatcherTests.swift b/WrangURLTests/RuleMatcherTests.swift
index d596b23..0ed540e 100644
--- a/WrangURLTests/RuleMatcherTests.swift
+++ b/WrangURLTests/RuleMatcherTests.swift
@@ -127,3 +127,74 @@ struct RuleMatcherTests {
#expect(matcher.match(try #require(URL(string: "https://example.com"))) == nil)
}
}
+
+struct SourceAppRuleTests {
+ private let chrome = "com.google.Chrome"
+ private let slack = SourceApp(id: "com.tinyspeck.slackmacgap", name: "Slack")
+ private let mail = SourceApp(id: "com.apple.mail", name: "Mail")
+
+ @Test func ruleLimitedToAppsMatchesOnlyThoseApps() throws {
+ let rule = Rule(name: "Work", pattern: "github.com", kind: .simple, browserIDs: [chrome], sourceApps: [slack, mail])
+ let matcher = RuleMatcher(rules: [rule])
+ let url = try #require(URL(string: "https://github.com/org"))
+
+ #expect(matcher.match(url, from: slack.id) != nil)
+ #expect(matcher.match(url, from: mail.id) != nil)
+ #expect(matcher.match(url, from: "com.apple.finder") == nil)
+ #expect(matcher.match(url, from: nil) == nil)
+ #expect(matcher.match(try #require(URL(string: "https://example.com")), from: slack.id) == nil)
+ }
+
+ @Test func ruleWithoutAppsMatchesAnySource() throws {
+ let rule = Rule(name: "Any", pattern: "github.com", kind: .simple, browserIDs: [chrome])
+ let url = try #require(URL(string: "https://github.com"))
+
+ #expect(RuleMatcher(rules: [rule]).match(url, from: slack.id) != nil)
+ #expect(RuleMatcher(rules: [rule]).match(url, from: nil) != nil)
+ }
+
+ @Test func emptyPatternWithAppsMatchesEveryURLFromThem() throws {
+ let rule = Rule(name: "", pattern: " ", kind: .simple, browserIDs: [chrome], sourceApps: [slack])
+ let matcher = RuleMatcher(rules: [rule])
+
+ #expect(RuleMatcher.validate(rule) == nil)
+ #expect(matcher.invalidRules.isEmpty)
+ #expect(matcher.match(try #require(URL(string: "https://example.com/a?b")), from: slack.id) != nil)
+ #expect(matcher.match(try #require(URL(string: "https://example.com")), from: mail.id) == nil)
+ }
+
+ @Test func emptyPatternWithoutAppsIsInvalid() {
+ let rule = Rule(name: "", pattern: "", kind: .simple, browserIDs: [chrome])
+ #expect(RuleMatcher.validate(rule) == .empty)
+ }
+
+ @Test func appRuleOrderStillDecides() throws {
+ let rules = [
+ Rule(name: "Slack", pattern: "", kind: .simple, browserIDs: [chrome], sourceApps: [slack]),
+ Rule(name: "GitHub", pattern: "github.com", kind: .simple, browserIDs: ["com.apple.Safari"]),
+ ]
+ let matcher = RuleMatcher(rules: rules)
+ let url = try #require(URL(string: "https://github.com"))
+
+ #expect(matcher.match(url, from: slack.id)?.name == "Slack")
+ #expect(matcher.match(url, from: mail.id)?.name == "GitHub")
+ }
+
+ @Test func displayNameFallsBackToSourceApps() {
+ let rule = Rule(name: "", pattern: "", kind: .simple, browserIDs: [], sourceApps: [slack, mail])
+ #expect(rule.displayName == "Links from Slack or Mail")
+ #expect(Rule(name: "", pattern: "localhost", kind: .simple, browserIDs: [], sourceApps: [slack]).displayName == "localhost")
+ }
+
+ @Test func rulesSavedBeforeSourceAppsStillLoad() throws {
+ let json = #"{"name": "Old", "pattern": "localhost", "kind": "simple", "browserIDs": ["com.apple.Safari"]}"#
+ let rule = try JSONDecoder().decode(Rule.self, from: Data(json.utf8))
+ #expect(rule.sourceApps.isEmpty)
+ }
+
+ @Test func sourceAppsRoundTrip() throws {
+ let rule = Rule(name: "Work", pattern: "", kind: .simple, browserIDs: [chrome], sourceApps: [slack])
+ let decoded = try JSONDecoder().decode(Rule.self, from: JSONEncoder().encode(rule))
+ #expect(decoded == rule)
+ }
+}
diff --git a/WrangURLTests/SourceAppTests.swift b/WrangURLTests/SourceAppTests.swift
new file mode 100644
index 0000000..84dbfb9
--- /dev/null
+++ b/WrangURLTests/SourceAppTests.swift
@@ -0,0 +1,20 @@
+import Foundation
+import Testing
+@testable import WrangURL
+
+struct SourceAppTests {
+ @Test(arguments: [
+ ("/Applications/Slack.app", "/Applications/Slack.app"),
+ ("/Applications/Slack.app/Contents/Frameworks/Slack Helper.app", "/Applications/Slack.app"),
+ (
+ "/System/Library/Frameworks/WebKit.framework/Versions/A/XPCServices/com.apple.WebKit.WebContent.xpc",
+ nil
+ ),
+ ("/Users/me/Applications/Tools/Mail Helper.APP/Contents/MacOS/x", "/Users/me/Applications/Tools/Mail Helper.APP"),
+ ("/usr/bin/open", nil),
+ ] as [(String, String?)])
+ func containingAppIsOutermostAppBundle(path: String, expected: String?) {
+ let url = URL(filePath: path)
+ #expect(SourceApp.containingAppURL(of: url)?.path == expected)
+ }
+}
diff --git a/docs/demo/README.md b/docs/demo/README.md
new file mode 100644
index 0000000..4684891
--- /dev/null
+++ b/docs/demo/README.md
@@ -0,0 +1,36 @@
+# Screenshot demo data
+
+`config.json` and `history.json` are the demo rules and history used for the screenshots in `docs/images/`. The rules use Google Chrome, Brave Browser, Safari and Zen, and the source apps Slack, Mail, Messages, Notes and Calendar. Install those apps first, or their icons show as placeholders.
+
+The debug build shares its sandbox container with any installed copy of WrangURL, so back up your own files first. Quit WrangURL, then:
+
+```sh
+DATA=~/Library/Containers/com.thepublicgood.wrangurl/Data/Library/Application\ Support/WrangURL
+mkdir -p /tmp/wrangurl-backup && cp "$DATA"/{config,history}.json /tmp/wrangurl-backup/
+cp docs/demo/config.json "$DATA/config.json"
+
+# Shift the history's dates so the newest entry is today, so the day headers read "Today" and "Yesterday".
+python3 - "$DATA/history.json" <<'EOF'
+import datetime as dt, json, sys
+entries = json.load(open("docs/demo/history.json"))
+parse = lambda s: dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
+shift = dt.date.today() - parse(entries[0]["date"]).astimezone().date()
+for entry in entries:
+ entry["date"] = (parse(entry["date"]) + shift).strftime("%Y-%m-%dT%H:%M:%SZ")
+json.dump(entries, open(sys.argv[1], "w"), indent=2)
+EOF
+```
+
+Capture each screen from the debug build with the launch arguments in `CLAUDE.md`, once with `-DebugAppearance light` and once with `-DebugAppearance dark`. For example:
+
+```sh
+open build/DerivedData/Build/Products/Debug/WrangURL.app --args -DebugSettingsTab rules -DebugEditRule 1 -DebugAppearance light
+```
+
+The website's screenshots are 1x window captures that include the window shadow (`screencapture -l `).
+
+Afterwards, quit the debug build and restore your files:
+
+```sh
+cp /tmp/wrangurl-backup/{config,history}.json "$DATA/"
+```
diff --git a/docs/demo/config.json b/docs/demo/config.json
new file mode 100644
index 0000000..f2a9e06
--- /dev/null
+++ b/docs/demo/config.json
@@ -0,0 +1,92 @@
+{
+ "rules": [
+ {
+ "id": "E4C477ED-9CA2-4C31-8A84-A8FEFC95F869",
+ "name": "Local development",
+ "pattern": "localhost",
+ "kind": "simple",
+ "browserIDs": [
+ "com.google.Chrome",
+ "com.brave.Browser"
+ ],
+ "isEnabled": true,
+ "sourceApps": []
+ },
+ {
+ "id": "25253F0D-0966-4DBB-AFE8-1616E2B925CA",
+ "name": "Work docs",
+ "pattern": "*.atlassian.net",
+ "kind": "simple",
+ "browserIDs": [
+ "com.google.Chrome"
+ ],
+ "isEnabled": true,
+ "sourceApps": [
+ {
+ "id": "com.tinyspeck.slackmacgap",
+ "name": "Slack"
+ },
+ {
+ "id": "com.apple.mail",
+ "name": "Mail"
+ }
+ ]
+ },
+ {
+ "id": "04B808F8-282D-495C-9537-25689C189D2F",
+ "name": "Company GitHub",
+ "pattern": "github.com/myorg/*",
+ "kind": "simple",
+ "browserIDs": [
+ "com.brave.Browser"
+ ],
+ "isEnabled": true,
+ "sourceApps": []
+ },
+ {
+ "id": "B7C2C868-26B6-408A-9089-E957250C5263",
+ "name": "Video calls",
+ "pattern": "(?i)^https://(meet\\.google|[a-z0-9-]+\\.zoom)\\.(com|us)/",
+ "kind": "regex",
+ "browserIDs": [
+ "com.apple.Safari",
+ "app.zen-browser.zen"
+ ],
+ "isEnabled": true,
+ "sourceApps": []
+ },
+ {
+ "id": "4B3998CE-9373-42D2-9C27-95371DAA87B2",
+ "name": "",
+ "pattern": "",
+ "kind": "simple",
+ "browserIDs": [
+ "com.apple.Safari"
+ ],
+ "isEnabled": true,
+ "sourceApps": [
+ {
+ "id": "com.apple.MobileSMS",
+ "name": "Messages"
+ }
+ ]
+ },
+ {
+ "id": "18AD8D67-5CD0-4F3A-8BC5-0B470F3EB0FE",
+ "name": "",
+ "pattern": "*.apple.com",
+ "kind": "simple",
+ "browserIDs": [
+ "com.apple.Safari"
+ ],
+ "isEnabled": false,
+ "sourceApps": []
+ }
+ ],
+ "settings": {
+ "fallbackBrowserID": "app.zen-browser.zen",
+ "hasCompletedOnboarding": true,
+ "unmatchedBehavior": "openFallback"
+ },
+ "version": 1
+}
\ No newline at end of file
diff --git a/docs/demo/history.json b/docs/demo/history.json
new file mode 100644
index 0000000..b73981a
--- /dev/null
+++ b/docs/demo/history.json
@@ -0,0 +1,126 @@
+[
+ {
+ "id": "84F33950-62DC-4AB9-992B-A092FCAB56D3",
+ "url": "http://localhost:3000/dashboard",
+ "date": "2026-09-28T08:42:00Z",
+ "browserID": "com.google.Chrome",
+ "browserName": "Google Chrome",
+ "ruleName": "Local development",
+ "sourceApp": {
+ "id": "com.apple.Notes",
+ "name": "Notes"
+ }
+ },
+ {
+ "id": "9A758AA9-DC0A-4D0E-A0E2-260F33B408E0",
+ "url": "https://myteam.atlassian.net/browse/WEB-214",
+ "date": "2026-09-28T08:15:00Z",
+ "browserID": "com.google.Chrome",
+ "browserName": "Google Chrome",
+ "ruleName": "Work docs",
+ "sourceApp": {
+ "id": "com.tinyspeck.slackmacgap",
+ "name": "Slack"
+ }
+ },
+ {
+ "id": "DC3F1DE0-CB78-40FE-993C-AD46A983D868",
+ "url": "https://meet.google.com/abc-defg-hij",
+ "date": "2026-09-28T07:58:00Z",
+ "browserID": "com.apple.Safari",
+ "browserName": "Safari",
+ "ruleName": "Video calls",
+ "sourceApp": {
+ "id": "com.apple.iCal",
+ "name": "Calendar"
+ }
+ },
+ {
+ "id": "788260F0-65F6-4DB9-999C-AC01D93EFBD3",
+ "url": "https://developer.apple.com/documentation/swiftui/list",
+ "date": "2026-09-28T07:31:00Z",
+ "browserID": "app.zen-browser.zen",
+ "browserName": "Zen",
+ "sourceApp": {
+ "id": "com.apple.mail",
+ "name": "Mail"
+ }
+ },
+ {
+ "id": "F826DC41-0328-4A27-9A1B-64BAC6398191",
+ "url": "https://www.bbc.com/news/technology",
+ "date": "2026-09-28T06:47:00Z",
+ "browserID": "com.apple.Safari",
+ "browserName": "Safari",
+ "ruleName": "Links from Messages",
+ "sourceApp": {
+ "id": "com.apple.MobileSMS",
+ "name": "Messages"
+ }
+ },
+ {
+ "id": "BA9D19CD-D26E-4C2A-8E38-0B68A139E74C",
+ "url": "https://github.com/myorg/website/pull/42",
+ "date": "2026-09-27T15:12:00Z",
+ "browserID": "com.brave.Browser",
+ "browserName": "Brave Browser",
+ "ruleName": "Company GitHub",
+ "sourceApp": {
+ "id": "com.tinyspeck.slackmacgap",
+ "name": "Slack"
+ }
+ },
+ {
+ "id": "30030BEF-0342-41F6-9880-2EB02A32B455",
+ "url": "http://localhost:5173/settings",
+ "date": "2026-09-27T13:03:00Z",
+ "browserID": "com.brave.Browser",
+ "browserName": "Brave Browser",
+ "ruleName": "Local development"
+ },
+ {
+ "id": "91B82C66-4393-4AC3-92B6-FCFCD913C416",
+ "url": "https://www.swift.org/documentation/",
+ "date": "2026-09-27T09:26:00Z",
+ "browserID": "app.zen-browser.zen",
+ "browserName": "Zen",
+ "sourceApp": {
+ "id": "com.apple.Notes",
+ "name": "Notes"
+ }
+ },
+ {
+ "id": "FE7CBF27-85D2-4200-A00B-7DE0C0D1A16D",
+ "url": "https://myteam.atlassian.net/wiki/spaces/ENG",
+ "date": "2026-09-27T07:05:00Z",
+ "browserID": "com.google.Chrome",
+ "browserName": "Google Chrome",
+ "ruleName": "Work docs",
+ "sourceApp": {
+ "id": "com.apple.mail",
+ "name": "Mail"
+ }
+ },
+ {
+ "id": "A4A6F657-B100-4154-8A18-EDD5977C092E",
+ "url": "https://en.wikipedia.org/wiki/Lasso",
+ "date": "2026-09-23T12:40:00Z",
+ "browserID": "app.zen-browser.zen",
+ "browserName": "Zen",
+ "sourceApp": {
+ "id": "com.apple.MobileSMS",
+ "name": "Messages"
+ }
+ },
+ {
+ "id": "753DAD56-76B3-472E-871B-44FE72B8C555",
+ "url": "https://www.apple.com/macos/",
+ "date": "2026-09-23T11:18:00Z",
+ "browserID": "app.zen-browser.zen",
+ "browserName": "Zen",
+ "sourceApp": {
+ "id": "com.apple.mail",
+ "name": "Mail"
+ }
+ }
+]
\ No newline at end of file
diff --git a/docs/images/history-dark.png b/docs/images/history-dark.png
index 3a788fc..0cb6e00 100644
Binary files a/docs/images/history-dark.png and b/docs/images/history-dark.png differ
diff --git a/docs/images/history-light.png b/docs/images/history-light.png
index 03cadd7..65da80b 100644
Binary files a/docs/images/history-light.png and b/docs/images/history-light.png differ
diff --git a/docs/images/rule-editor-dark.png b/docs/images/rule-editor-dark.png
index 93d8def..6690e94 100644
Binary files a/docs/images/rule-editor-dark.png and b/docs/images/rule-editor-dark.png differ
diff --git a/docs/images/rule-editor-light.png b/docs/images/rule-editor-light.png
index 8a2e78b..c86a071 100644
Binary files a/docs/images/rule-editor-light.png and b/docs/images/rule-editor-light.png differ
diff --git a/docs/images/settings-rules-dark.png b/docs/images/settings-rules-dark.png
index 6cd68f9..e229d1d 100644
Binary files a/docs/images/settings-rules-dark.png and b/docs/images/settings-rules-dark.png differ
diff --git a/docs/images/settings-rules-light.png b/docs/images/settings-rules-light.png
index ec21820..849cc05 100644
Binary files a/docs/images/settings-rules-light.png and b/docs/images/settings-rules-light.png differ
diff --git a/docs/index.html b/docs/index.html
index 1db9834..faf3d24 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -250,7 +250,7 @@
Write rules
+ alt="The Rules tab listing six rules, each with its pattern, the apps it is limited to, and the icons of the browsers it opens.">
The Rules tab. The icons show which browsers each rule opens.
@@ -258,9 +258,9 @@
Write rules
+ alt="The rule editor for Work docs: a simple pattern of *.atlassian.net, clicked in Slack or Mail, that opens Google Chrome.">
- Editing a rule. With two browsers, this rule shows the picker.
+ Editing a rule. It only applies to links clicked in Slack or Mail.
@@ -286,10 +286,18 @@
Regular expressions
(?i)^https://meet\.google\.com/.
+
Limit a rule to apps
+
+ Add apps under Clicked In to make a rule apply only to links clicked in those apps, for example to send
+ links from Slack to Chrome. With apps listed, the pattern is optional: leave it empty to match every link from
+ them. Links whose source app can’t be told, such as ones opened with open in Terminal, never match
+ these rules.
+
+
Test before you rely on it
Paste a link into Test a URL at the bottom of the Rules tab to see which rule matches and which browser
- would open. The rule editor has its own test field for the rule you’re working on.
+ would open. When a rule is limited to apps, choose the app the link is clicked in next to the field. The rule editor has its own test field for the rule you’re working on.
Choose in the picker
@@ -319,20 +327,20 @@
Choose in the picker
Look back at your history
Choose History… from the menu bar icon to see every link WrangURL has opened, grouped by day.
- Each row shows the time, the link, the rule that matched it and the browser it went to.
+ Each row shows the time, the app you clicked the link in, the link, the rule that matched it and the browser it went to.
+ alt="The History window: links grouped under Today, Yesterday and an earlier date, each with its time, the icon of the app it was clicked in, matching rule, browser, and buttons to copy or open it.">
- The History window. Rule names appear next to the browser when a rule matched.
+ The History window. The icon before each link shows the app it was clicked in. Rule names appear next to the browser when a rule matched.
-
Type in Search History to filter by link, browser or rule.
+
Type in Search History to filter by link, browser, rule or the app the link came from.
Use the buttons at the end of a row to copy the link, or to open it again through your rules. Double-clicking a row also opens it again.
Right-click to open, copy or delete the selected links. Hold ⌘ or Shift to select several.
Click the trash button in the toolbar to clear the whole history.