Skip to content
Open
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: 5 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,10 @@ let package = Package(
dependencies: ["BrowserouteCore"],
swiftSettings: swiftSettings,
),
.testTarget(
name: "BrowserouteTests",
dependencies: ["Browseroute"],
swiftSettings: swiftSettings,
),
],
)
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions Sources/Browseroute/DefaultBrowserRadioGroupView.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
129 changes: 97 additions & 32 deletions Sources/Browseroute/MenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
Expand All @@ -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())
}
Expand Down Expand Up @@ -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<Bool> {
Binding(
get: { store.config.defaultBrowserId == bundleId },
set: {
if $0 {
store.setDefault(bundleId: bundleId)
}
},
)
}

private var patternList: some View {
let patterns = browser?.patterns ?? []
return Group {
Expand Down Expand Up @@ -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 = {
Expand Down
26 changes: 26 additions & 0 deletions Tests/BrowserouteCoreTests/BrowserouteCoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
119 changes: 119 additions & 0 deletions Tests/BrowserouteTests/DefaultBrowserRadioGroupTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}