From 389796367fc526552547c87e531280873a0e4d12 Mon Sep 17 00:00:00 2001 From: simonasr Date: Mon, 9 Nov 2026 14:01:14 +0200 Subject: [PATCH] feat: select default catch-all from browser list --- Package.swift | 5 + README.md | 7 +- .../DefaultBrowserRadioGroupView.swift | 25 ++++ Sources/Browseroute/MenuView.swift | 129 +++++++++++++----- .../BrowserouteCoreTests.swift | 26 ++++ .../DefaultBrowserRadioGroupTests.swift | 119 ++++++++++++++++ 6 files changed, 276 insertions(+), 35 deletions(-) create mode 100644 Sources/Browseroute/DefaultBrowserRadioGroupView.swift create mode 100644 Tests/BrowserouteTests/DefaultBrowserRadioGroupTests.swift diff --git a/Package.swift b/Package.swift index 6f89c85..7bcec0f 100644 --- a/Package.swift +++ b/Package.swift @@ -33,5 +33,10 @@ let package = Package( dependencies: ["BrowserouteCore"], swiftSettings: swiftSettings, ), + .testTarget( + name: "BrowserouteTests", + dependencies: ["Browseroute"], + swiftSettings: swiftSettings, + ), ], ) diff --git a/README.md b/README.md index 4cd7543..f63e7cc 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ make install open /Applications/Browseroute.app ``` -Click the menu-bar icon (the branch arrow). Add your browsers, set one as the -Default catch-all, then Settings → Set as Default Browser… and accept the -system dialog. +Click the menu-bar icon (the branch arrow). Add your browsers, then click the +radio button beside one to make it the Default catch-all. Click its name or +chevron to edit its rules. Finally, Settings → Set as Default Browser… and accept +the system dialog. `make install` copies the app to `/Applications/Browseroute.app`. Default-browser registration only works from that stable path, not from a random `build/` copy. diff --git a/Sources/Browseroute/DefaultBrowserRadioGroupView.swift b/Sources/Browseroute/DefaultBrowserRadioGroupView.swift new file mode 100644 index 0000000..73460cd --- /dev/null +++ b/Sources/Browseroute/DefaultBrowserRadioGroupView.swift @@ -0,0 +1,25 @@ +import AppKit + +final class DefaultBrowserRadioGroupView: NSStackView { + override func keyDown(with event: NSEvent) { + let step: Int + switch event.keyCode { + case 123, 126: step = -1 + case 124, 125: step = 1 + default: + super.keyDown(with: event) + return + } + let buttons = arrangedSubviews.compactMap { $0 as? NSButton } + guard event.modifierFlags.isDisjoint(with: [.command, .control, .option]), + let focused = window?.firstResponder as? NSButton, + let index = buttons.firstIndex(of: focused) + else { + super.keyDown(with: event) + return + } + let next = buttons[(index + step + buttons.count) % buttons.count] + window?.makeFirstResponder(next) + next.performClick(nil) + } +} diff --git a/Sources/Browseroute/MenuView.swift b/Sources/Browseroute/MenuView.swift index bb43976..7d9ecd4 100644 --- a/Sources/Browseroute/MenuView.swift +++ b/Sources/Browseroute/MenuView.swift @@ -3,6 +3,8 @@ import BrowserouteCore import ServiceManagement import SwiftUI +private let browserRowHeight: CGFloat = 44 + struct MenuRootView: View { @Bindable var store: RoutingStore var onSizeChange: (CGSize) -> Void = { _ in } @@ -113,14 +115,26 @@ struct MenuRootView: View { private var browserList: some View { VStack(alignment: .leading, spacing: 2) { - ForEach(store.config.browsers) { browser in - Button { - selectedBundleId = browser.id - } label: { - browserRow(browser) + HStack(alignment: .top, spacing: 0) { + DefaultBrowserRadioGroup( + store: store, + bundleIds: store.config.browsers.map(\.id), + rowHeight: browserRowHeight, + ) + .frame(width: 30) + VStack(spacing: 2) { + ForEach(store.config.browsers) { browser in + Button { + selectedBundleId = browser.id + } label: { + browserRow(browser) + .frame(height: browserRowHeight) + } + .buttonStyle(.plain) + } } - .buttonStyle(.plain) } + .padding(.leading, 8) if !availableBrowsers.isEmpty { addBrowserMenu .padding(.horizontal, 10) @@ -137,24 +151,19 @@ struct MenuRootView: View { VStack(alignment: .leading, spacing: 1) { Text(BrowserLauncher.displayName(forBundleIdentifier: browser.id)) .font(.body) - Text(browser.patterns.count == 1 ? "1 rule" : "\(browser.patterns.count) rules") - .font(.caption) - .foregroundStyle(.secondary) + Text( + (browser.patterns.count == 1 ? "1 rule" : "\(browser.patterns.count) rules") + + (browser.id == store.config.defaultBrowserId ? " · Default" : ""), + ) + .font(.caption) + .foregroundStyle(.secondary) } Spacer(minLength: 8) - if browser.id == store.config.defaultBrowserId { - Text("Default") - .font(.caption2.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 7) - .padding(.vertical, 2) - .background(Color.accentColor, in: Capsule()) - } Image(systemName: "chevron.right") .font(.caption2.weight(.semibold)) .foregroundStyle(.tertiary) } - .padding(.horizontal, 14) + .padding(.trailing, 14) .padding(.vertical, 8) .contentShape(Rectangle()) } @@ -318,26 +327,13 @@ struct BrowserDetailView: View { Text(BrowserLauncher.displayName(forBundleIdentifier: bundleId)) .font(.headline) Spacer() - Toggle("Default catch-all", isOn: defaultBinding) - .toggleStyle(.switch) - .controlSize(.mini) + DefaultBrowserRadioGroup(store: store, bundleIds: [bundleId], title: "Default catch-all") .fixedSize() } .padding(.horizontal, 14) .padding(.vertical, 12) } - private var defaultBinding: Binding { - Binding( - get: { store.config.defaultBrowserId == bundleId }, - set: { - if $0 { - store.setDefault(bundleId: bundleId) - } - }, - ) - } - private var patternList: some View { let patterns = browser?.patterns ?? [] return Group { @@ -391,6 +387,75 @@ struct BrowserDetailView: View { } } +private struct DefaultBrowserRadioGroup: NSViewRepresentable { + @Bindable var store: RoutingStore + let bundleIds: [String] + var title = "" + var rowHeight: CGFloat? + + func makeNSView(context _: Context) -> DefaultBrowserRadioGroupView { + let stack = DefaultBrowserRadioGroupView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 2 + stack.setAccessibilityElement(true) + stack.setAccessibilityRole(.radioGroup) + stack.setAccessibilityLabel("Default catch-all browser") + return stack + } + + func updateNSView(_ stack: DefaultBrowserRadioGroupView, context: Context) { + if context.coordinator.bundleIds != bundleIds { + for view in stack.arrangedSubviews { + stack.removeArrangedSubview(view) + view.removeFromSuperview() + } + context.coordinator.bundleIds = bundleIds + for (index, bundleId) in bundleIds.enumerated() { + let button = NSButton( + radioButtonWithTitle: title, + target: context.coordinator, + action: #selector(Coordinator.selectDefault(_:)), + ) + button.tag = index + let name = BrowserLauncher.displayName(forBundleIdentifier: bundleId) + button.setAccessibilityLabel("Use \(name) as default catch-all") + button.toolTip = "Use \(name) for unmatched links and while routing is paused" + stack.addArrangedSubview(button) + button.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + if let rowHeight { + button.heightAnchor.constraint(equalToConstant: rowHeight).isActive = true + } + } + } + for (bundleId, view) in zip(bundleIds, stack.arrangedSubviews) { + (view as? NSButton)?.state = store.config.defaultBrowserId == bundleId ? .on : .off + } + } + + func sizeThatFits(_: ProposedViewSize, nsView: DefaultBrowserRadioGroupView, context _: Context) -> CGSize? { + nsView.fittingSize + } + + func makeCoordinator() -> Coordinator { + Coordinator(store: store) + } + + @MainActor + final class Coordinator: NSObject { + let store: RoutingStore + var bundleIds: [String] = [] + + init(store: RoutingStore) { + self.store = store + } + + @objc func selectDefault(_ sender: NSButton) { + store.setDefault(bundleId: bundleIds[sender.tag]) + } + } +} + @MainActor private func appIcon(for bundleId: String) -> some View { let image: NSImage = { diff --git a/Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift b/Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift index dc03316..55ddba0 100644 --- a/Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift +++ b/Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift @@ -167,6 +167,32 @@ private func dest(_ raw: String, _ config: RoutingConfig) -> String { #expect(store.config.browsers.map(\.id) == [island]) } +@MainActor +@Test func `changing default persists without changing browsers or matching rules`() throws { + let suiteName = UUID().uuidString + let suite = try #require(UserDefaults(suiteName: suiteName)) + defer { suite.removePersistentDomain(forName: suiteName) } + let store = RoutingStore(defaults: suite) + store.addBrowser(bundleId: island) + store.addBrowser(bundleId: chrome) + store.addPattern("work.example", to: island) + store.setDefault(bundleId: chrome) + let browsers = store.config.browsers + + store.setDefault(bundleId: island) + #expect(store.config.defaultBrowserId == island) + #expect(store.compiled.destination(for: url("https://unmatched.example")) == island) + #expect(RoutingStore(defaults: suite).config.defaultBrowserId == island) + + store.setDefault(bundleId: chrome) + store.setDefault(bundleId: chrome) + #expect(store.config.defaultBrowserId == chrome) + #expect(store.compiled.destination(for: url("https://unmatched.example")) == chrome) + #expect(store.compiled.destination(for: url("https://work.example")) == island) + #expect(store.config.browsers == browsers) + #expect(RoutingStore(defaults: suite).config == store.config) +} + @Test func `normalizePattern strips scheme query fragment and slash`() { #expect(RoutingStore.normalizePattern(" HTTPS://Example.COM/Path/?q=1#x ") == "example.com/path") #expect(RoutingStore.normalizePattern("https://github.com/org/repo/") == "github.com/org/repo") diff --git a/Tests/BrowserouteTests/DefaultBrowserRadioGroupTests.swift b/Tests/BrowserouteTests/DefaultBrowserRadioGroupTests.swift new file mode 100644 index 0000000..2a716b3 --- /dev/null +++ b/Tests/BrowserouteTests/DefaultBrowserRadioGroupTests.swift @@ -0,0 +1,119 @@ +@testable import Browseroute +import AppKit +import Testing + +@Suite(.serialized) +@MainActor +struct DefaultBrowserRadioGroupTests { + @Test(arguments: [UInt16(123), 124, 125, 126]) + func `arrows move selection and focus together`(_ keyCode: UInt16) throws { + let fixture = Fixture(count: 3, selected: 1) + defer { fixture.window.close() } + try fixture.press(keyCode) + let expected = keyCode == 123 || keyCode == 126 ? 0 : 2 + #expect(fixture.selection.index == expected) + #expect(fixture.window.firstResponder === fixture.buttons[expected]) + #expect(fixture.buttons.filter { $0.state == .on }.count == 1) + } + + @Test(arguments: [UInt16(123), 124, 125, 126]) + func `arrows wrap at group boundaries`(_ keyCode: UInt16) throws { + let backwards = keyCode == 123 || keyCode == 126 + let fixture = Fixture(count: 3, selected: backwards ? 0 : 2) + defer { fixture.window.close() } + try fixture.press(keyCode) + let expected = backwards ? 2 : 0 + #expect(fixture.selection.index == expected) + #expect(fixture.window.firstResponder === fixture.buttons[expected]) + } + + @Test func `single browser stays selected`() throws { + let fixture = Fixture(count: 1, selected: 0) + defer { fixture.window.close() } + for code: UInt16 in [123, 124, 125, 126] { + try fixture.press(code) + #expect(fixture.selection.index == 0) + #expect(fixture.buttons[0].state == .on) + #expect(fixture.window.firstResponder === fixture.buttons[0]) + } + } + + @Test func `modified arrows do not change default`() throws { + let fixture = Fixture(count: 3, selected: 1) + defer { fixture.window.close() } + for flags: NSEvent.ModifierFlags in [.command, .control, .option] { + try fixture.press(125, flags: flags) + #expect(fixture.selection.index == 1) + #expect(fixture.selection.activations == 0) + } + } + + @MainActor + private final class Selection: NSObject { + var index: Int + var activations = 0 + + init(index: Int) { + self.index = index + } + + @objc func select(_ sender: NSButton) { + index = sender.tag + activations += 1 + } + } + + @MainActor + private struct Fixture { + let window: NSWindow + let group: DefaultBrowserRadioGroupView + let buttons: [NSButton] + let selection: Selection + + init(count: Int, selected: Int) { + _ = NSApplication.shared + let selection = Selection(index: selected) + self.selection = selection + group = DefaultBrowserRadioGroupView() + group.orientation = .vertical + buttons = (0 ..< count).map { index in + let button = NSButton( + radioButtonWithTitle: "Browser \(index)", + target: selection, + action: #selector(Selection.select(_:)), + ) + button.tag = index + button.state = index == selected ? .on : .off + return button + } + for button in buttons { + group.addArrangedSubview(button) + } + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false, + ) + window.isReleasedWhenClosed = false + window.contentView = group + window.makeFirstResponder(buttons[selected]) + } + + func press(_ keyCode: UInt16, flags: NSEvent.ModifierFlags = []) throws { + let event = try #require(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: flags, + timestamp: 0, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "", + isARepeat: false, + keyCode: keyCode, + )) + group.keyDown(with: event) + } + } +}