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 : "