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/askpicker.go b/apps/finicky/src/askpicker.go new file mode 100644 index 0000000..3357c42 --- /dev/null +++ b/apps/finicky/src/askpicker.go @@ -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 +} diff --git a/apps/finicky/src/askpicker_test.go b/apps/finicky/src/askpicker_test.go new file mode 100644 index 0000000..d5cdfa2 --- /dev/null +++ b/apps/finicky/src/askpicker_test.go @@ -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") + } +} 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..426d542 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 @@ -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)) @@ -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 diff --git a/apps/finicky/src/picker/picker.go b/apps/finicky/src/picker/picker.go new file mode 100644 index 0000000..76b982c --- /dev/null +++ b/apps/finicky/src/picker/picker.go @@ -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 +#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)))) +} diff --git a/apps/finicky/src/picker/picker.h b/apps/finicky/src/picker/picker.h new file mode 100644 index 0000000..36899e0 --- /dev/null +++ b/apps/finicky/src/picker/picker.h @@ -0,0 +1,9 @@ +#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. 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 new file mode 100644 index 0000000..740cce7 --- /dev/null +++ b/apps/finicky/src/picker/picker.m @@ -0,0 +1,66 @@ +#import +#import "picker.h" + +// Builds and runs the alert. Must be called on the main thread. +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) { + [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"]; + + // The message text is truncated to keep the alert on screen, so the whole + // url goes on the window as a tooltip. NSAlert does not expose its own text + // fields, so the hover target is the dialog rather than the url itself. + if ([tooltip length] > 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]; + + 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 *tooltip, 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] : @""; + NSString *tooltipStr = tooltip ? [NSString stringWithUTF8String:tooltip] : @""; + + __block int result = -1; + if ([NSThread isMainThread]) { + 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, tooltipStr, items); + }); + } + return result; + } +} 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) + } +} 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(