From d83598d5bdb594508eefa2e1e4b180def37c576d Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:35:47 +0300 Subject: [PATCH 1/6] Add an "ask" app type that prompts for the destination Handlers and defaultBrowser can now resolve to a picker instead of a fixed browser: defaultBrowser: { name: "ask", appType: "ask", options: [ { name: "Google Chrome", profile: "Profile 2", title: "Work" }, { name: "Google Chrome", profile: "Default", title: "Personal" }, ], } Options carry a profile, so the prompt can offer two profiles of the same browser, which a browser-level picker cannot express. Options also accept the existing "name:profile" string shorthand. Requiring an explicit appType of "ask" means an app genuinely named "ask" is never hijacked, since autodetectAppStringType would read the bare string as an app name. The picker is an NSAlert shown between resolving a url and launching it, so every existing handler, rewrite and option path is untouched. Cancelling the prompt opens nothing, and an "ask" config with no options logs an error rather than showing an empty dialog. --- apps/finicky/src/askpicker.go | 57 ++++++++++++++++ apps/finicky/src/askpicker_test.go | 58 ++++++++++++++++ apps/finicky/src/browser/launcher.go | 5 ++ apps/finicky/src/main.go | 11 ++- apps/finicky/src/picker/picker.go | 45 +++++++++++++ apps/finicky/src/picker/picker.h | 8 +++ apps/finicky/src/picker/picker.m | 58 ++++++++++++++++ packages/config-api/src/askPicker.test.ts | 81 +++++++++++++++++++++++ packages/config-api/src/configSchema.ts | 29 +++++++- packages/config-api/src/index.ts | 20 +++++- 10 files changed, 367 insertions(+), 5 deletions(-) create mode 100644 apps/finicky/src/askpicker.go create mode 100644 apps/finicky/src/askpicker_test.go create mode 100644 apps/finicky/src/picker/picker.go create mode 100644 apps/finicky/src/picker/picker.h create mode 100644 apps/finicky/src/picker/picker.m create mode 100644 packages/config-api/src/askPicker.test.ts diff --git a/apps/finicky/src/askpicker.go b/apps/finicky/src/askpicker.go new file mode 100644 index 0000000..5679959 --- /dev/null +++ b/apps/finicky/src/askpicker.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "log/slog" + + "finicky/browser" + "finicky/picker" +) + +// 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, either because the user cancelled or because +// the config offered no options to choose from. +func resolveAskConfig(config browser.BrowserConfig) (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)) + } + + title := config.Title + if title == "" { + title = "Open link in" + } + + choice := picker.Choose(title, 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 +} diff --git a/apps/finicky/src/askpicker_test.go b/apps/finicky/src/askpicker_test.go new file mode 100644 index 0000000..3643ac9 --- /dev/null +++ b/apps/finicky/src/askpicker_test.go @@ -0,0 +1,58 @@ +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) + + 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) + } +} diff --git a/apps/finicky/src/browser/launcher.go b/apps/finicky/src/browser/launcher.go index a3a7120..4e17ea7 100644 --- a/apps/finicky/src/browser/launcher.go +++ b/apps/finicky/src/browser/launcher.go @@ -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 { diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 829a8f2..d8b7622 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -196,8 +196,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) + } + 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)) diff --git a/apps/finicky/src/picker/picker.go b/apps/finicky/src/picker/picker.go new file mode 100644 index 0000000..3a9638e --- /dev/null +++ b/apps/finicky/src/picker/picker.go @@ -0,0 +1,45 @@ +// 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 +#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. +func Choose(title string, message 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)) + + 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, (**C.char)(cLabels), C.int(len(labels)))) +} diff --git a/apps/finicky/src/picker/picker.h b/apps/finicky/src/picker/picker.h new file mode 100644 index 0000000..4e58f0a --- /dev/null +++ b/apps/finicky/src/picker/picker.h @@ -0,0 +1,8 @@ +#ifndef PICKER_H +#define PICKER_H + +// Shows a modal picker listing the given labels and returns the index of the +// chosen label, or -1 if the user cancelled. +int showBrowserPicker(const char *title, const char *message, const char **labels, int count); + +#endif diff --git a/apps/finicky/src/picker/picker.m b/apps/finicky/src/picker/picker.m new file mode 100644 index 0000000..7559319 --- /dev/null +++ b/apps/finicky/src/picker/picker.m @@ -0,0 +1,58 @@ +#import +#import "picker.h" + +// Builds and runs the alert. Must be called on the main thread. +static int runPickerOnMainThread(NSString *title, NSString *message, NSArray *labels) { + // Finicky always has an NSApplication by this point, but guarding here lets + // the picker be exercised from a plain binary with no app bundle. + if (NSApp == nil) { + [NSApplication sharedApplication]; + [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; + } + + NSAlert *alert = [[NSAlert alloc] init]; + [alert setMessageText:title]; + [alert setInformativeText:message]; + [alert setAlertStyle:NSAlertStyleInformational]; + + for (NSString *label in labels) { + [alert addButtonWithTitle:label]; + } + // Titling the last button "Cancel" gives it Escape automatically. + [alert addButtonWithTitle:@"Cancel"]; + + // Finicky normally runs without a dock icon, so the alert needs an explicit + // activation to come to the front of whichever app the link was clicked in. + [NSApp activateIgnoringOtherApps:YES]; + + NSModalResponse response = [alert runModal]; + NSInteger index = response - NSAlertFirstButtonReturn; + if (index < 0 || index >= (NSInteger)[labels count]) { + return -1; + } + return (int)index; +} + +int showBrowserPicker(const char *title, const char *message, const char **labels, int count) { + @autoreleasepool { + NSMutableArray *items = [NSMutableArray arrayWithCapacity:count]; + for (int i = 0; i < count; i++) { + NSString *label = labels[i] ? [NSString stringWithUTF8String:labels[i]] : @""; + [items addObject:label]; + } + + NSString *titleStr = title ? [NSString stringWithUTF8String:title] : @"Open link"; + NSString *messageStr = message ? [NSString stringWithUTF8String:message] : @""; + + __block int result = -1; + if ([NSThread isMainThread]) { + result = runPickerOnMainThread(titleStr, messageStr, items); + } else { + // URLs are handled on a background goroutine, but AppKit is main-thread only. + dispatch_sync(dispatch_get_main_queue(), ^{ + result = runPickerOnMainThread(titleStr, messageStr, items); + }); + } + return result; + } +} diff --git a/packages/config-api/src/askPicker.test.ts b/packages/config-api/src/askPicker.test.ts new file mode 100644 index 0000000..ee6c344 --- /dev/null +++ b/packages/config-api/src/askPicker.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; + +import { createBrowserConfig } from "./index"; + +describe("createBrowserConfig with picker options", () => { + it("defaults options to an empty list", () => { + expect(createBrowserConfig("Google Chrome").options).toEqual([]); + expect(createBrowserConfig(null).options).toEqual([]); + }); + + it("keeps the ask app type and its own title", () => { + const config = createBrowserConfig({ + name: "ask", + appType: "ask", + title: "Where should this go?", + options: ["Safari"], + }); + + expect(config.appType).toBe("ask"); + expect(config.title).toBe("Where should this go?"); + }); + + it("normalizes object options, autodetecting the app type", () => { + const config = createBrowserConfig({ + name: "ask", + appType: "ask", + options: [ + { name: "Google Chrome", profile: "Profile 2", title: "Work" }, + { name: "com.google.Chrome", profile: "Default" }, + ], + }); + + expect(config.options).toEqual([ + { + name: "Google Chrome", + appType: "appName", + openInBackground: undefined, + profile: "Profile 2", + args: [], + title: "Work", + }, + { + name: "com.google.Chrome", + appType: "bundleId", + openInBackground: undefined, + profile: "Default", + args: [], + title: "", + }, + ]); + }); + + it("accepts the name:profile string shorthand for an option", () => { + const config = createBrowserConfig({ + name: "ask", + appType: "ask", + options: ["Google Chrome:Profile 2"], + }); + + expect(config.options).toEqual([ + { + name: "Google Chrome", + appType: "appName", + openInBackground: undefined, + profile: "Profile 2", + args: [], + title: "", + }, + ]); + }); + + it("leaves an explicit option app type alone", () => { + const config = createBrowserConfig({ + name: "ask", + appType: "ask", + options: [{ name: "Google Chrome", appType: "path" }], + }); + + expect(config.options[0].appType).toBe("path"); + }); +}); diff --git a/packages/config-api/src/configSchema.ts b/packages/config-api/src/configSchema.ts index cea27ae..62c8c4c 100644 --- a/packages/config-api/src/configSchema.ts +++ b/packages/config-api/src/configSchema.ts @@ -74,10 +74,24 @@ const UrlMatcherPatternSchema = z.union([ // ===== Browser Schemas ===== -const appTypes = ["appName", "bundleId", "path", "none"] as const; +const appTypes = ["appName", "bundleId", "path", "none", "ask"] as const; export type AppType = (typeof appTypes)[number]; +const PickerOptionSchema = z + .union([ + z.string(), + z.object({ + name: z.string(), + appType: z.enum(appTypes).optional(), + profile: z.string().optional(), + args: z.array(z.string()).optional(), + title: z.string().optional(), + }), + ]) + .identifier("PickerOption") + .describe('A destination offered when appType is "ask"'); + const BrowserConfigSchema = z .object({ name: z.string(), @@ -85,10 +99,21 @@ const BrowserConfigSchema = z openInBackground: z.boolean().optional(), profile: z.string().optional(), args: z.array(z.string()).optional(), + title: z.string().optional(), + options: z.array(PickerOptionSchema).optional(), }) .identifier("BrowserConfig") .describe("A browser or app to open for urls"); +const PickerOptionStrictSchema = z.object({ + name: z.string(), + appType: z.enum(appTypes), + openInBackground: z.boolean().optional(), + profile: z.string(), + args: z.array(z.string()), + title: z.string(), +}); + export const BrowserConfigStrictSchema = z.object({ name: z.string(), appType: z.enum(appTypes), @@ -96,6 +121,8 @@ export const BrowserConfigStrictSchema = z.object({ profile: z.string(), args: z.array(z.string()), url: z.string(), + title: z.string(), + options: z.array(PickerOptionStrictSchema), }); const BrowserResolverSchema = z diff --git a/packages/config-api/src/index.ts b/packages/config-api/src/index.ts index 4723550..a84f37f 100644 --- a/packages/config-api/src/index.ts +++ b/packages/config-api/src/index.ts @@ -148,12 +148,14 @@ export function openUrl( export function createBrowserConfig( browser: string | BrowserConfig | null ): Omit { - const defaults = { + const optionDefaults = { appType: "appName" as const, openInBackground: undefined, profile: "", args: [], + title: "", }; + const defaults = { ...optionDefaults, options: [] }; if (browser === null) { return { @@ -172,7 +174,21 @@ export function createBrowserConfig( }; } - return { ...defaults, ...browser }; + // Picker options accept the same shorthands as any other browser value, so + // "Google Chrome:Profile 2" and { name, profile } both work. + return { + ...defaults, + ...browser, + options: (browser.options ?? []).map((option) => + typeof option === "string" + ? { ...optionDefaults, ...resolveBrowserInfo(option) } + : { + ...optionDefaults, + ...option, + appType: option.appType ?? autodetectAppStringType(option.name), + } + ), + }; } export function resolveBrowserInfo( From 7783f6a01b73dee5be230cc272bc28d0e5a71952 Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:41:39 +0300 Subject: [PATCH 2/6] Document the "ask" app type and cover its config path Adds README guidance for the picker, including why an option list of profiles is the case a browser-level picker cannot serve, and why the app type has to be spelled out rather than inferred from the name. The resolver tests assert that options survive the JS config API into BrowserConfig with profiles, titles and the "name:profile" shorthand intact, and that a url no handler matches is unaffected by an ask handler being present. --- README.md | 33 ++++++++ .../finicky/src/resolver/resolver_ask_test.go | 83 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 apps/finicky/src/resolver/resolver_ask_test.go diff --git a/README.md b/README.md index 65ad163..95987c5 100644 --- a/README.md +++ b/README.md @@ -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]() explaining available, APIs and options as well as detail information on how to match on urls. diff --git a/apps/finicky/src/resolver/resolver_ask_test.go b/apps/finicky/src/resolver/resolver_ask_test.go new file mode 100644 index 0000000..21b6dca --- /dev/null +++ b/apps/finicky/src/resolver/resolver_ask_test.go @@ -0,0 +1,83 @@ +package resolver_test + +import ( + "testing" + + . "finicky/resolver" +) + +// Proves the whole config path for appType "ask": the JS config API must carry +// the picker's options through to the Go BrowserConfig with their profiles and +// labels intact, since the picker has nothing to show otherwise. +func TestResolveURL_AskDefaultBrowser(t *testing.T) { + vm := jsVM(t, `({ + defaultBrowser: { + name: "ask", + appType: "ask", + title: "Open where?", + options: [ + { name: "Google Chrome", profile: "Profile 2", title: "Work" }, + "Google Chrome:Default" + ] + } + })`) + + result, err := ResolveURL(vm, "https://example.com", nil, false) + if err != nil { + t.Fatal(err) + } + + if result.AppType != "ask" { + t.Errorf("appType = %q, want %q", result.AppType, "ask") + } + if result.Title != "Open where?" { + t.Errorf("title = %q, want %q", result.Title, "Open where?") + } + if len(result.Options) != 2 { + t.Fatalf("got %d options, want 2", len(result.Options)) + } + + if got := result.Options[0]; got.Name != "Google Chrome" || got.Profile != "Profile 2" || got.Title != "Work" { + t.Errorf("option 0 = %+v, want Google Chrome / Profile 2 / Work", got) + } + // The "name:profile" shorthand must expand the same way it does elsewhere. + if got := result.Options[1]; got.Name != "Google Chrome" || got.Profile != "Default" || got.AppType != "appName" { + t.Errorf("option 1 = %+v, want Google Chrome / Default / appName", got) + } +} + +// A handler resolving to "ask" must behave the same as a default browser does. +func TestResolveURL_AskHandler(t *testing.T) { + vm := jsVM(t, `({ + defaultBrowser: "Safari", + handlers: [ + { + match: "*example.com/*", + browser: { name: "ask", appType: "ask", options: ["Safari", "Firefox"] } + } + ] + })`) + + result, err := ResolveURL(vm, "https://example.com/page", nil, false) + if err != nil { + t.Fatal(err) + } + if result.AppType != "ask" { + t.Fatalf("appType = %q, want %q", result.AppType, "ask") + } + if len(result.Options) != 2 || result.Options[0].Name != "Safari" || result.Options[1].Name != "Firefox" { + t.Errorf("options = %+v, want Safari then Firefox", result.Options) + } + + // A url the handler does not match must be untouched by any of this. + other, err := ResolveURL(vm, "https://other.test/page", nil, false) + if err != nil { + t.Fatal(err) + } + if other.AppType == "ask" { + t.Error("unmatched url resolved to the picker") + } + if other.Name != "Safari" { + t.Errorf("unmatched url = %q, want Safari", other.Name) + } +} From 0046f0d0b3e2d4e03c07630dbf29b0e28f028888 Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Mon, 12 Oct 2026 11:44:50 +0300 Subject: [PATCH 3/6] Do not show the picker during a dry run -dry-run means "simulate without actually opening browsers", but the ask path ran before LaunchBrowser, so a dry run would block on a modal waiting to be clicked. It now logs the options it would have offered and resolves to nothing launchable. dryRun is passed in rather than read from the package variable, so the behaviour is visible at the call site and testable. --- apps/finicky/src/askpicker.go | 14 +++++++++++--- apps/finicky/src/askpicker_test.go | 20 +++++++++++++++++++- apps/finicky/src/main.go | 2 +- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/finicky/src/askpicker.go b/apps/finicky/src/askpicker.go index 5679959..b5f55e8 100644 --- a/apps/finicky/src/askpicker.go +++ b/apps/finicky/src/askpicker.go @@ -3,6 +3,7 @@ package main import ( "fmt" "log/slog" + "strings" "finicky/browser" "finicky/picker" @@ -10,9 +11,9 @@ import ( // 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, either because the user cancelled or because -// the config offered no options to choose from. -func resolveAskConfig(config browser.BrowserConfig) (browser.BrowserConfig, bool) { +// 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 @@ -23,6 +24,13 @@ func resolveAskConfig(config browser.BrowserConfig) (browser.BrowserConfig, bool 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" diff --git a/apps/finicky/src/askpicker_test.go b/apps/finicky/src/askpicker_test.go index 3643ac9..f035215 100644 --- a/apps/finicky/src/askpicker_test.go +++ b/apps/finicky/src/askpicker_test.go @@ -47,7 +47,7 @@ func TestResolveAskConfigWithoutOptions(t *testing.T) { URL: "https://example.com", } - got, shouldLaunch := resolveAskConfig(config) + got, shouldLaunch := resolveAskConfig(config, false) if shouldLaunch { t.Error("expected shouldLaunch to be false when no options are configured") @@ -56,3 +56,21 @@ func TestResolveAskConfigWithoutOptions(t *testing.T) { t.Errorf("expected the url to be preserved, got %q", got.URL) } } + +// 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") + } +} diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index d8b7622..ea7b7b8 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -199,7 +199,7 @@ func main() { launchConfig := *config shouldLaunch := true if launchConfig.AppType == "ask" { - launchConfig, shouldLaunch = resolveAskConfig(launchConfig) + launchConfig, shouldLaunch = resolveAskConfig(launchConfig, dryRun) } if shouldLaunch { if launchErr := browser.LaunchBrowser(launchConfig, dryRun, urlInfo.OpenInBackground); launchErr != nil { From cbf28bc4c68ec68af15b9adf639dc6f03be68b4e Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:11:12 +0300 Subject: [PATCH 4/6] Fix a deadlock when a url arrives while the picker is open HandleURL runs on the main thread and sent into an unbuffered urlListener, so it blocked until the consumer goroutine received. That consumer is the same goroutine that shows the "ask" picker, and it shows it by dispatching to the main thread. A second url arriving while a picker was open therefore blocked the main thread on a channel only the main thread could drain: the app beachballed, the open picker stopped responding, and every later url was ignored until Finicky was killed. The channel is now buffered and the main-thread send is non-blocking, with an asynchronous hand-off if the buffer is ever full, so delivering a url can never block the thread that has to service the picker. Verified by hand on macOS 27: two urls in quick succession now queue, both pickers respond, the second is presented once the first is answered, and choosing an option still opens the chosen profile. --- apps/finicky/src/main.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index ea7b7b8..13037ad 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -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 @@ -299,11 +303,19 @@ 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: + slog.Warn("Url queue is full, handing off asynchronously", "url", urlString) + go func() { urlListener <- info }() + } } //export TestURL From c82c4a4e2fbd8e139c9a45e6583a52077e06cf63 Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:18:52 +0300 Subject: [PATCH 5/6] Bound the pending url queue instead of deferring sends The full-buffer fallback spawned a goroutine per url to complete the send later. That is unbounded while a picker stays open, and a deferred send can overtake a url that arrives after it, so delivery was neither bounded nor ordered. The queue only fills when urls arrive faster than they are answered, which already means dozens of unanswered pickers are stacked up, so an overflowing url is now dropped with a warning. The main thread still never blocks, and the hand-off stays in arrival order. --- apps/finicky/src/main.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 13037ad..426d542 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -313,8 +313,12 @@ func HandleURL(url *C.char, name *C.char, bundleId *C.char, path *C.char, window select { case urlListener <- info: default: - slog.Warn("Url queue is full, handing off asynchronously", "url", urlString) - go func() { urlListener <- info }() + // 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)) } } From 80be9748f030a80034421f09ac419066887fa026 Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:18:43 +0200 Subject: [PATCH 6/6] Keep the picker on screen when the url is long A url is one unbroken token, so AppKit cannot word-wrap it. Set whole as the alert's informative text, a long url widened the dialog until its buttons were off screen, leaving no way to answer the prompt. The displayed url is now abbreviated to 60 characters with its middle elided, keeping the scheme and host, which is what the choice is actually made on, and the tail, which is where two urls on the same host differ. A host too long to fit falls back to truncating the tail. Nothing is lost: the whole url is attached as a tooltip. NSAlert does not expose its own text fields, so the hover target is the dialog rather than the url text itself. Verified by hand on macOS 27 with a 249 character url: the dialog fits, all three buttons are reachable, and hovering shows the full url. --- apps/finicky/src/askpicker.go | 37 ++++++++++++++++- apps/finicky/src/askpicker_test.go | 64 ++++++++++++++++++++++++++++++ apps/finicky/src/picker/picker.go | 9 +++-- apps/finicky/src/picker/picker.h | 5 ++- apps/finicky/src/picker/picker.m | 16 ++++++-- 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/apps/finicky/src/askpicker.go b/apps/finicky/src/askpicker.go index b5f55e8..3357c42 100644 --- a/apps/finicky/src/askpicker.go +++ b/apps/finicky/src/askpicker.go @@ -4,11 +4,45 @@ 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 @@ -36,7 +70,8 @@ func resolveAskConfig(config browser.BrowserConfig, dryRun bool) (browser.Browse title = "Open link in" } - choice := picker.Choose(title, config.URL, labels) + // 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 diff --git a/apps/finicky/src/askpicker_test.go b/apps/finicky/src/askpicker_test.go index f035215..d5cdfa2 100644 --- a/apps/finicky/src/askpicker_test.go +++ b/apps/finicky/src/askpicker_test.go @@ -57,6 +57,70 @@ func TestResolveAskConfigWithoutOptions(t *testing.T) { } } +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) { diff --git a/apps/finicky/src/picker/picker.go b/apps/finicky/src/picker/picker.go index 3a9638e..76b982c 100644 --- a/apps/finicky/src/picker/picker.go +++ b/apps/finicky/src/picker/picker.go @@ -16,8 +16,9 @@ import "unsafe" 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. -func Choose(title string, message string, labels []string) int { +// 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 } @@ -26,6 +27,8 @@ func Choose(title string, message string, labels []string) int { 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) @@ -41,5 +44,5 @@ func Choose(title string, message string, labels []string) int { } }() - return int(C.showBrowserPicker(cTitle, cMessage, (**C.char)(cLabels), C.int(len(labels)))) + return int(C.showBrowserPicker(cTitle, cMessage, cTooltip, (**C.char)(cLabels), C.int(len(labels)))) } diff --git a/apps/finicky/src/picker/picker.h b/apps/finicky/src/picker/picker.h index 4e58f0a..36899e0 100644 --- a/apps/finicky/src/picker/picker.h +++ b/apps/finicky/src/picker/picker.h @@ -2,7 +2,8 @@ #define PICKER_H // Shows a modal picker listing the given labels and returns the index of the -// chosen label, or -1 if the user cancelled. -int showBrowserPicker(const char *title, const char *message, const char **labels, int count); +// chosen label, or -1 if the user cancelled. tooltip, when not NULL, is shown +// on hover and carries the untruncated form of message. +int showBrowserPicker(const char *title, const char *message, const char *tooltip, const char **labels, int count); #endif diff --git a/apps/finicky/src/picker/picker.m b/apps/finicky/src/picker/picker.m index 7559319..740cce7 100644 --- a/apps/finicky/src/picker/picker.m +++ b/apps/finicky/src/picker/picker.m @@ -2,7 +2,7 @@ #import "picker.h" // Builds and runs the alert. Must be called on the main thread. -static int runPickerOnMainThread(NSString *title, NSString *message, NSArray *labels) { +static int runPickerOnMainThread(NSString *title, NSString *message, NSString *tooltip, NSArray *labels) { // Finicky always has an NSApplication by this point, but guarding here lets // the picker be exercised from a plain binary with no app bundle. if (NSApp == nil) { @@ -21,6 +21,13 @@ static int runPickerOnMainThread(NSString *title, NSString *message, NSArray 0) { + [[[alert window] contentView] setToolTip:tooltip]; + } + // Finicky normally runs without a dock icon, so the alert needs an explicit // activation to come to the front of whichever app the link was clicked in. [NSApp activateIgnoringOtherApps:YES]; @@ -33,7 +40,7 @@ static int runPickerOnMainThread(NSString *title, NSString *message, NSArray *items = [NSMutableArray arrayWithCapacity:count]; for (int i = 0; i < count; i++) { @@ -43,14 +50,15 @@ int showBrowserPicker(const char *title, const char *message, const char **label NSString *titleStr = title ? [NSString stringWithUTF8String:title] : @"Open link"; NSString *messageStr = message ? [NSString stringWithUTF8String:message] : @""; + NSString *tooltipStr = tooltip ? [NSString stringWithUTF8String:tooltip] : @""; __block int result = -1; if ([NSThread isMainThread]) { - result = runPickerOnMainThread(titleStr, messageStr, items); + result = runPickerOnMainThread(titleStr, messageStr, tooltipStr, items); } else { // URLs are handled on a background goroutine, but AppKit is main-thread only. dispatch_sync(dispatch_get_main_queue(), ^{ - result = runPickerOnMainThread(titleStr, messageStr, items); + result = runPickerOnMainThread(titleStr, messageStr, tooltipStr, items); }); } return result;