Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -78,8 +78,9 @@ Debug builds accept launch arguments for inspecting UI without clicking. For exa
- `-DebugInsertionIndex <n>` 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 <bundleIDs,…>` (plus optional `-DebugPickerURL <url>`) 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.
3 changes: 2 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ enum UnmatchedBehavior: String, Codable {
- `-DebugEditRule <index>` opens the rule editor.
- `-DebugInsertionIndex <n>` draws the browser drop line at that position.
- `-DebugOnboardingStep <0-3>` opens the setup assistant on that step.
- `-DebugAppearance <light|dark>` overrides the system appearance, for screenshots.
- `-DebugShowPicker <bundle IDs>` shows the picker with those browsers (comma-separated). `-DebugPickerURL <url>` 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.
Expand All @@ -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

Expand Down Expand Up @@ -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.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ WrangURL is a macOS menu bar app that opens each link in the right browser. You
</p>

- 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.
Expand Down Expand Up @@ -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

Expand All @@ -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**.

Expand Down
11 changes: 10 additions & 1 deletion WrangURL/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

guard !Self.isRunningTests else { return }

#if DEBUG
// Launch with `-DebugAppearance <light|dark>` 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)
Expand Down Expand Up @@ -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())
}
}
6 changes: 4 additions & 2 deletions WrangURL/Core/HistoryEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}
Expand Down
23 changes: 21 additions & 2 deletions WrangURL/Core/Rule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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) ?? []
}
}
23 changes: 17 additions & 6 deletions WrangURL/Core/RuleMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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? {
Expand Down
57 changes: 57 additions & 0 deletions WrangURL/Core/SourceApp+AppleEvent.swift
Original file line number Diff line number Diff line change
@@ -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<String>()
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: "")
}
}
24 changes: 24 additions & 0 deletions WrangURL/Core/SourceApp.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading