From 07c4a329033476c57b5bd5ee24c31f6a95f1a0b9 Mon Sep 17 00:00:00 2001 From: Warrick Bayman Date: Mon, 28 Sep 2026 11:47:55 +0200 Subject: [PATCH 1/5] Record the app each link was clicked in. Read the sender of the incoming GetURL Apple Event, map helper processes to their containing app, and keep it in the history entry. The History window shows its icon and searches by its name. Reopening a link from History keeps its original source app. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- WrangURL/App/AppDelegate.swift | 2 +- WrangURL/Core/HistoryEntry.swift | 6 ++- WrangURL/Core/SourceApp.swift | 61 +++++++++++++++++++++++++++ WrangURL/Core/URLRouter.swift | 36 ++++++++++------ WrangURL/UI/History/HistoryView.swift | 30 ++++++++++++- WrangURLTests/HistoryTests.swift | 32 ++++++++++++-- WrangURLTests/SourceAppTests.swift | 20 +++++++++ docs/index.html | 4 +- 9 files changed, 169 insertions(+), 26 deletions(-) create mode 100644 WrangURL/Core/SourceApp.swift create mode 100644 WrangURLTests/SourceAppTests.swift diff --git a/README.md b/README.md index 31d365c..9c95ffd 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,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..74dc415 100644 --- a/WrangURL/App/AppDelegate.swift +++ b/WrangURL/App/AppDelegate.swift @@ -112,6 +112,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/SourceApp.swift b/WrangURL/Core/SourceApp.swift new file mode 100644 index 0000000..64d23ca --- /dev/null +++ b/WrangURL/Core/SourceApp.swift @@ -0,0 +1,61 @@ +import AppKit + +/// The app a link was clicked in. +struct SourceApp: Codable, Hashable, Sendable { + /// Bundle identifier. + let id: String + let name: String +} + +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 + } + + /// 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 + } + + private static func displayName(of appURL: URL) -> String { + FileManager.default.displayName(atPath: appURL.path).replacing(/\.app$/, with: "") + } +} diff --git a/WrangURL/Core/URLRouter.swift b/WrangURL/Core/URLRouter.swift index 910d7fd..a9a920b 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 { @@ -81,25 +86,25 @@ final class URLRouter { config.config.settings.logsURLs ? url.absoluteString : "