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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,39 @@ export default {

See the [configuration](#configuration) for all the features Finicky supports.

### Asking which browser to use

Some urls cannot be told apart by a rule. A single sign-on link looks identical
whether it belongs to your work account or your personal one, so no matcher can
route it. For those, a browser can be `appType: "ask"`, which prompts you to
pick a destination instead of choosing one for you.

```js
// ~/.finicky.js
export default {
// Anything no handler matched asks, instead of guessing
defaultBrowser: {
name: "ask",
appType: "ask",
title: "Open link in", // optional heading for the prompt
options: [
{ name: "Google Chrome", profile: "Profile 2", title: "Work" },
{ name: "Google Chrome", profile: "Default", title: "Personal" },
"Firefox", // the usual shorthands work, including "Name:profile"
],
},
};
```

Each option is an ordinary browser value, so options can differ only by
`profile`. That is what lets the prompt offer two profiles of the same browser,
which a picker that only knows about applications cannot express. An option's
`title` labels its button; without one the button reads `Name - profile`.

`appType: "ask"` has to be written out, because the bare name `ask` is a
perfectly good application name and is treated as one. Dismissing the prompt
opens nothing.

## Configuration

Finicky has extensive support for matching, rewriting and starting browsers or other application that handle urls. See the wiki for the [full configuration documentation](<https://github.com/johnste/finicky/wiki/Configuration-(v4)>) explaining available, APIs and options as well as detail information on how to match on urls.
Expand Down
100 changes: 100 additions & 0 deletions apps/finicky/src/askpicker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package main

import (
"fmt"
"log/slog"
"strings"
"unicode/utf8"

"finicky/browser"
"finicky/picker"
)

// maxPickerURLLength caps the url shown in the picker. A url is one unbroken
// token, so AppKit cannot word-wrap it: shown whole, a long one widens the
// alert until its buttons are off screen. 60 keeps the dialog to about two
// lines; the untruncated url is still available as the tooltip.
const maxPickerURLLength = 60

// abbreviateURL shortens a url for display by eliding its middle, keeping the
// scheme and host whole because that is what the choice is actually made on,
// plus the tail, which is where two otherwise identical urls differ.
func abbreviateURL(rawURL string, maxLength int) string {
runes := []rune(rawURL)
if maxLength <= 1 || len(runes) <= maxLength {
return rawURL
}

hostEnd := len(runes)
if scheme := strings.Index(rawURL, "://"); scheme >= 0 {
afterScheme := scheme + len("://")
if slash := strings.IndexByte(rawURL[afterScheme:], '/'); slash >= 0 {
hostEnd = utf8.RuneCountInString(rawURL[:afterScheme+slash])
}
}

// A host that fills the budget on its own leaves no room to keep it whole,
// so fall back to truncating the tail.
if hostEnd+1 >= maxLength {
return string(runes[:maxLength-1]) + "…"
}

tail := maxLength - hostEnd - 1
return string(runes[:hostEnd]) + "…" + string(runes[len(runes)-tail:])
}

// resolveAskConfig presents a picker for a browser config with appType "ask"
// and returns the destination the user chose. The second return value is false
// when nothing should be opened: the user cancelled, the config offered no
// options to choose from, or this is a dry run.
func resolveAskConfig(config browser.BrowserConfig, dryRun bool) (browser.BrowserConfig, bool) {
if len(config.Options) == 0 {
slog.Error("Browser appType is \"ask\" but no options were configured", "url", config.URL)
return config, false
}

labels := make([]string, 0, len(config.Options))
for _, option := range config.Options {
labels = append(labels, askOptionLabel(option))
}

// A dry run simulates without opening anything, and a modal waiting on a
// click is the one thing a simulation must not do.
if dryRun {
slog.Info("Dry run, not showing the picker", "url", config.URL, "options", strings.Join(labels, ", "))
return config, false
}

title := config.Title
if title == "" {
title = "Open link in"
}

// The url is abbreviated for display and passed whole as the tooltip.
choice := picker.Choose(title, abbreviateURL(config.URL, maxPickerURLLength), config.URL, labels)
if choice == picker.Cancelled {
slog.Info("Picker cancelled, not opening url", "url", config.URL)
return config, false
}

chosen := config.Options[choice]
chosen.URL = config.URL
if chosen.OpenInBackground == nil {
chosen.OpenInBackground = config.OpenInBackground
}

slog.Info("Picker selected browser", "name", chosen.Name, "profile", chosen.Profile, "url", chosen.URL)
return chosen, true
}

// askOptionLabel is the text shown on an option's button: an explicit title if
// the config gave one, otherwise the browser name qualified by its profile.
func askOptionLabel(option browser.BrowserConfig) string {
if option.Title != "" {
return option.Title
}
if option.Profile != "" {
return fmt.Sprintf("%s - %s", option.Name, option.Profile)
}
return option.Name
}
140 changes: 140 additions & 0 deletions apps/finicky/src/askpicker_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package main

import (
"testing"

"finicky/browser"
)

func TestAskOptionLabel(t *testing.T) {
tests := []struct {
name string
option browser.BrowserConfig
want string
}{
{
name: "explicit title wins",
option: browser.BrowserConfig{Name: "Google Chrome", Profile: "Profile 2", Title: "Work"},
want: "Work",
},
{
name: "profile qualifies the name",
option: browser.BrowserConfig{Name: "Google Chrome", Profile: "Profile 2"},
want: "Google Chrome - Profile 2",
},
{
name: "bare name when there is no profile",
option: browser.BrowserConfig{Name: "Safari"},
want: "Safari",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if got := askOptionLabel(test.option); got != test.want {
t.Errorf("askOptionLabel() = %q, want %q", got, test.want)
}
})
}
}

// An "ask" config with nothing to choose from must not open anything, and must
// not reach the picker, which would block on a modal nobody asked for.
func TestResolveAskConfigWithoutOptions(t *testing.T) {
config := browser.BrowserConfig{
Name: "ask",
AppType: "ask",
URL: "https://example.com",
}

got, shouldLaunch := resolveAskConfig(config, false)

if shouldLaunch {
t.Error("expected shouldLaunch to be false when no options are configured")
}
if got.URL != config.URL {
t.Errorf("expected the url to be preserved, got %q", got.URL)
}
}

func TestAbbreviateURL(t *testing.T) {
tests := []struct {
name string
url string
maxLength int
want string
}{
{
name: "short urls are untouched",
url: "https://example.com/docs",
maxLength: 90,
want: "https://example.com/docs",
},
{
name: "a url exactly at the cap is untouched",
url: "https://example.com/12345",
maxLength: 25,
want: "https://example.com/12345",
},
{
name: "the middle goes, scheme and host survive",
url: "https://mail.google.com/mail/u/0/some/very/long/path/that/keeps/going#inbox/FMfcgzQbfWKpXsMb",
maxLength: 40,
want: "https://mail.google.com…FMfcgzQbfWKpXsMb",
},
{
name: "a host longer than the cap falls back to the tail",
url: "https://a-really-long-hostname-beyond-the-budget.example.com/path",
maxLength: 20,
want: "https://a-really-lo…",
},
{
name: "a url with no path keeps the whole host",
url: "https://averyveryverylonghostnamethatfitsthebudget.example.com",
maxLength: 40,
want: "https://averyveryverylonghostnamethatfi…",
},
{
name: "a schemeless string still truncates",
url: "not-really-a-url-but-still-far-too-long-to-show",
maxLength: 20,
want: "not-really-a-url-bu…",
},
{
name: "multibyte characters are not split",
url: "https://example.com/äöüäöüäöüäöüäöüäöü",
maxLength: 26,
want: "https://example.com…äöüäöü",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := abbreviateURL(test.url, test.maxLength)
if got != test.want {
t.Errorf("abbreviateURL() = %q, want %q", got, test.want)
}
if length := len([]rune(got)); length > test.maxLength {
t.Errorf("result is %d runes, over the %d cap", length, test.maxLength)
}
})
}
}

// A dry run must not reach the picker. If this regresses the test blocks on a
// modal rather than failing an assertion, which is itself the signal.
func TestResolveAskConfigDryRunDoesNotPrompt(t *testing.T) {
config := browser.BrowserConfig{
Name: "ask",
AppType: "ask",
URL: "https://example.com",
Options: []browser.BrowserConfig{
{Name: "Google Chrome", Profile: "Profile 2", Title: "Work"},
{Name: "Google Chrome", Profile: "Default", Title: "Personal"},
},
}

if _, shouldLaunch := resolveAskConfig(config, true); shouldLaunch {
t.Error("a dry run must not resolve to something launchable")
}
}
5 changes: 5 additions & 0 deletions apps/finicky/src/browser/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ type BrowserConfig struct {
Profile string `json:"profile"`
Args []string `json:"args"`
URL string `json:"url"`
// Title labels this destination in an "ask" picker. When AppType is "ask"
// it is the picker's heading instead.
Title string `json:"title"`
// Options are the destinations offered when AppType is "ask".
Options []BrowserConfig `json:"options"`
}

type browserInfo struct {
Expand Down
31 changes: 27 additions & 4 deletions apps/finicky/src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ type ConfigInfo struct {
ConfigPath string
}

var urlListener chan URLInfo = make(chan URLInfo)
// Buffered so handing a url over never blocks the caller. HandleURL runs on the
// main thread, and a handler that occupies the main thread while it works (the
// "ask" picker runs a modal there) cannot drain the channel it would be blocking
// on: the url event deadlocks the app, beachball and all.
var urlListener chan URLInfo = make(chan URLInfo, 64)
var windowClosed chan struct{} = make(chan struct{})
var vm *config.VM

Expand Down Expand Up @@ -196,8 +200,15 @@ func main() {
} else {
lastError = nil
}
if launchErr := browser.LaunchBrowser(*config, dryRun, urlInfo.OpenInBackground); launchErr != nil {
slog.Error("Failed to start browser", "error", launchErr)
launchConfig := *config
shouldLaunch := true
if launchConfig.AppType == "ask" {
launchConfig, shouldLaunch = resolveAskConfig(launchConfig, dryRun)
}
if shouldLaunch {
if launchErr := browser.LaunchBrowser(launchConfig, dryRun, urlInfo.OpenInBackground); launchErr != nil {
slog.Error("Failed to start browser", "error", launchErr)
}
}

slog.Debug("Time taken evaluating URL and opening browser", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000))
Expand Down Expand Up @@ -292,11 +303,23 @@ func HandleURL(url *C.char, name *C.char, bundleId *C.char, path *C.char, window
}
}

urlListener <- URLInfo{
info := URLInfo{
URL: urlString,
Opener: &opener,
OpenInBackground: bool(openInBackground),
}

// This runs on the main thread, which must never block here: see urlListener.
select {
case urlListener <- info:
default:
// The queue only fills when urls arrive faster than they are answered,
// which means dozens of unanswered pickers are already stacked up.
// Dropping keeps the hand-off ordered and bounded; deferring the send to
// a goroutine instead would let it overtake urls that arrive later, and
// let goroutines accumulate for as long as the stream lasts.
slog.Warn("Url queue is full, dropping url", "url", urlString, "queued", len(urlListener))
}
}

//export TestURL
Expand Down
48 changes: 48 additions & 0 deletions apps/finicky/src/picker/picker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Package picker presents a modal list of destinations and reports which one
// the user chose. It backs the "ask" app type.
package picker

/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Cocoa
#include <stdlib.h>
#include "picker.h"
*/
import "C"

import "unsafe"

// Cancelled is returned by Choose when the user dismissed the picker.
const Cancelled = -1

// Choose shows a modal picker listing labels and returns the index of the
// chosen label, or Cancelled if the user dismissed it. tooltip is shown on
// hover and carries the untruncated form of message.
func Choose(title string, message string, tooltip string, labels []string) int {
if len(labels) == 0 {
return Cancelled
}

cTitle := C.CString(title)
defer C.free(unsafe.Pointer(cTitle))
cMessage := C.CString(message)
defer C.free(unsafe.Pointer(cMessage))
cTooltip := C.CString(tooltip)
defer C.free(unsafe.Pointer(cTooltip))

pointerSize := C.size_t(unsafe.Sizeof(uintptr(0)))
cLabels := C.malloc(C.size_t(len(labels)) * pointerSize)
defer C.free(cLabels)

labelSlice := unsafe.Slice((**C.char)(cLabels), len(labels))
for i, label := range labels {
labelSlice[i] = C.CString(label)
}
defer func() {
for i := range labels {
C.free(unsafe.Pointer(labelSlice[i]))
}
}()

return int(C.showBrowserPicker(cTitle, cMessage, cTooltip, (**C.char)(cLabels), C.int(len(labels))))
}
Loading