From a1ed5f51ca53774d532b2cd863211fa551ab882f Mon Sep 17 00:00:00 2001 From: John Sterling Date: Sun, 15 Mar 2026 20:21:08 +0100 Subject: [PATCH 1/3] ui --- apps/finicky/src/browser/browsers.json | 6 + apps/finicky/src/browser/detect.go | 105 ++++ apps/finicky/src/browser/launcher.go | 108 +++- apps/finicky/src/config/vm.go | 34 +- apps/finicky/src/main.go | 218 ++++--- apps/finicky/src/rules/rules.go | 118 ++++ apps/finicky/src/window/window.go | 77 ++- packages/finicky-ui/src/App.svelte | 19 +- .../finicky-ui/src/components/TabBar.svelte | 6 + .../src/components/icons/Rules.svelte | 13 + packages/finicky-ui/src/pages/Rules.svelte | 570 ++++++++++++++++++ packages/finicky-ui/src/types.ts | 12 + 12 files changed, 1173 insertions(+), 113 deletions(-) create mode 100644 apps/finicky/src/browser/detect.go create mode 100644 apps/finicky/src/rules/rules.go create mode 100644 packages/finicky-ui/src/components/icons/Rules.svelte create mode 100644 packages/finicky-ui/src/pages/Rules.svelte diff --git a/apps/finicky/src/browser/browsers.json b/apps/finicky/src/browser/browsers.json index abaa113..88f73ed 100644 --- a/apps/finicky/src/browser/browsers.json +++ b/apps/finicky/src/browser/browsers.json @@ -77,6 +77,12 @@ "type": "Chromium", "app_name": "Opera GX" }, + { + "config_dir_relative": "", + "id": "com.apple.Safari", + "type": "", + "app_name": "Safari" + }, { "config_dir_relative": "Firefox", "id": "org.mozilla.firefox", diff --git a/apps/finicky/src/browser/detect.go b/apps/finicky/src/browser/detect.go new file mode 100644 index 0000000..a6d3349 --- /dev/null +++ b/apps/finicky/src/browser/detect.go @@ -0,0 +1,105 @@ +package browser + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework AppKit +#import +#include +#include + +// isLikelyBrowser returns YES if the app at appURL registers for http or https +// with LSHandlerRank "Default" or "Alternate". Apps that set LSHandlerRank "None" +// are using deep-link / download-interception tricks, not acting as browsers. +// Absent LSHandlerRank defaults to "Default" per Apple docs. +static BOOL isLikelyBrowser(NSURL *appURL) { + NSBundle *bundle = [NSBundle bundleWithURL:appURL]; + NSDictionary *info = bundle.infoDictionary; + if (!info) return NO; + + NSArray *urlTypes = info[@"CFBundleURLTypes"]; + if (!urlTypes) return NO; + + for (NSDictionary *urlType in urlTypes) { + NSArray *schemes = urlType[@"CFBundleURLSchemes"] ?: @[]; + if (![schemes containsObject:@"http"] && ![schemes containsObject:@"https"]) continue; + + NSString *rank = urlType[@"LSHandlerRank"] ?: @"Default"; + if ([rank isEqualToString:@"Default"] || [rank isEqualToString:@"Alternate"]) { + return YES; + } + } + return NO; +} + +static char **getAllHttpsHandlerNames(int *count) { + @autoreleasepool { + NSURL *url = [NSURL URLWithString:@"https://example.com"]; + NSArray *appURLs = [[NSWorkspace sharedWorkspace] URLsForApplicationsToOpenURL:url]; + if (!appURLs || appURLs.count == 0) { + *count = 0; + return NULL; + } + + NSMutableSet *seen = [NSMutableSet set]; + NSMutableArray *names = [NSMutableArray array]; + NSSet *excludedBundleIDs = [NSSet setWithObjects: + @"se.johnste.finicky", + @"net.kassett.finicky", + nil]; + + for (NSURL *appURL in appURLs) { + NSBundle *bundle = [NSBundle bundleWithURL:appURL]; + if ([excludedBundleIDs containsObject:bundle.bundleIdentifier]) continue; + if (!isLikelyBrowser(appURL)) continue; + + NSString *name = [[NSFileManager defaultManager] displayNameAtPath:[appURL path]]; + if ([name hasSuffix:@".app"]) { + name = [name substringToIndex:[name length] - 4]; + } + if (![seen containsObject:name]) { + [seen addObject:name]; + [names addObject:name]; + } + } + + *count = (int)names.count; + char **result = (char **)malloc(names.count * sizeof(char *)); + for (NSInteger i = 0; i < (NSInteger)names.count; i++) { + result[i] = strdup([names[i] UTF8String]); + } + return result; + } +} + +static void freeNames(char **names, int count) { + for (int i = 0; i < count; i++) { + free(names[i]); + } + free(names); +} +*/ +import "C" +import ( + "sort" + "unsafe" +) + +// GetInstalledBrowsers returns the display names of all apps registered to +// handle https:// URLs, as reported by the macOS Launch Services framework. +func GetInstalledBrowsers() []string { + var count C.int + names := C.getAllHttpsHandlerNames(&count) + if names == nil { + return []string{} + } + defer C.freeNames(names, count) + + n := int(count) + nameSlice := unsafe.Slice(names, n) + result := make([]string, n) + for i, s := range nameSlice { + result[i] = C.GoString(s) + } + sort.Strings(result) + return result +} diff --git a/apps/finicky/src/browser/launcher.go b/apps/finicky/src/browser/launcher.go index 84c54b9..b0c4c17 100644 --- a/apps/finicky/src/browser/launcher.go +++ b/apps/finicky/src/browser/launcher.go @@ -203,50 +203,95 @@ func resolveBrowserProfileArgs(identifier string, profile string) ([]string, boo return nil, false } -func parseFirefoxProfiles(profilesIniPath string, profile string) (string, bool) { +func readFirefoxProfileNames(profilesIniPath string) []string { data, err := os.ReadFile(profilesIniPath) if err != nil { slog.Info("Error reading profiles.ini", "path", profilesIniPath, "error", err) - return "", false + return nil } - var profileNames []string + var names []string for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if name, ok := strings.CutPrefix(line, "Name="); ok { - profileNames = append(profileNames, name) - if name == profile { - return name, true - } + names = append(names, name) } } + return names +} - slog.Warn("Could not find profile in Firefox profiles.", "Expected profile", profile, "Available profiles", strings.Join(profileNames, ", ")) +func getAllFirefoxProfiles(profilesIniPath string) []string { + names := readFirefoxProfileNames(profilesIniPath) + if names == nil { + return []string{} + } + return names +} + +func parseFirefoxProfiles(profilesIniPath string, profile string) (string, bool) { + names := readFirefoxProfileNames(profilesIniPath) + for _, name := range names { + if name == profile { + return name, true + } + } + slog.Warn("Could not find profile in Firefox profiles.", "Expected profile", profile, "Available profiles", strings.Join(names, ", ")) return "", false } -func parseProfiles(localStatePath string, profile string) (string, bool) { +func chromiumInfoCache(localStatePath string) (map[string]interface{}, bool) { data, err := os.ReadFile(localStatePath) if err != nil { slog.Info("Error reading Local State file", "path", localStatePath, "error", err) - return "", false + return nil, false } var localState map[string]interface{} if err := json.Unmarshal(data, &localState); err != nil { slog.Info("Error parsing Local State JSON", "error", err) - return "", false + return nil, false } profiles, ok := localState["profile"].(map[string]interface{}) if !ok { slog.Info("Could not find profile section in Local State") - return "", false + return nil, false } infoCache, ok := profiles["info_cache"].(map[string]interface{}) if !ok { slog.Info("Could not find info_cache in profile section") + return nil, false + } + + return infoCache, true +} + +func getAllChromiumProfiles(localStatePath string) []string { + cache, ok := chromiumInfoCache(localStatePath) + if !ok { + return []string{} + } + + var names []string + for _, info := range cache { + profileInfo, ok := info.(map[string]interface{}) + if !ok { + continue + } + name, ok := profileInfo["name"].(string) + if !ok { + continue + } + names = append(names, name) + } + slices.Sort(names) + return names +} + +func parseProfiles(localStatePath string, profile string) (string, bool) { + infoCache, ok := chromiumInfoCache(localStatePath) + if !ok { return "", false } @@ -301,6 +346,45 @@ func parseProfiles(localStatePath string, profile string) (string, bool) { return "", false } +// GetProfilesForBrowser returns available profile names for a given browser app name or bundle ID. +// Returns empty slice if browser not in browsers.json, not supported, or profile files are unreadable. +func GetProfilesForBrowser(identifier string) []string { + var browsersJson []browserInfo + if err := json.Unmarshal(browsersJsonData, &browsersJson); err != nil { + slog.Info("Error parsing browsers.json", "error", err) + return []string{} + } + + var matchedBrowser *browserInfo + for i := range browsersJson { + if browsersJson[i].ID == identifier || browsersJson[i].AppName == identifier { + matchedBrowser = &browsersJson[i] + break + } + } + + if matchedBrowser == nil { + return []string{} + } + + homeDir, err := util.UserHomeDir() + if err != nil { + slog.Info("Error getting home directory", "error", err) + return []string{} + } + + switch matchedBrowser.Type { + case "Chromium": + localStatePath := filepath.Join(homeDir, "Library/Application Support", matchedBrowser.ConfigDirRelative, "Local State") + return getAllChromiumProfiles(localStatePath) + case "Firefox": + profilesIniPath := filepath.Join(homeDir, "Library/Application Support", matchedBrowser.ConfigDirRelative, "profiles.ini") + return getAllFirefoxProfiles(profilesIniPath) + default: + return []string{} + } +} + // formatCommand returns a properly shell-escaped string representation of the command func formatCommand(path string, args []string) string { if len(args) == 0 { diff --git a/apps/finicky/src/config/vm.go b/apps/finicky/src/config/vm.go index a8085e0..ae04b06 100644 --- a/apps/finicky/src/config/vm.go +++ b/apps/finicky/src/config/vm.go @@ -23,33 +23,39 @@ type ConfigState struct { } func New(embeddedFiles embed.FS, namespace string, bundlePath string) (*VM, error) { + var content []byte + if bundlePath != "" { + var err error + content, err = os.ReadFile(bundlePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %v", err) + } + } + return newFromContent(embeddedFiles, namespace, content) +} + +// NewFromScript creates a VM from an inline JavaScript config string. +func NewFromScript(embeddedFiles embed.FS, namespace string, script string) (*VM, error) { + return newFromContent(embeddedFiles, namespace, []byte(script)) +} + +func newFromContent(embeddedFiles embed.FS, namespace string, content []byte) (*VM, error) { vm := &VM{ runtime: goja.New(), namespace: namespace, } - - err := vm.setup(embeddedFiles, bundlePath) - if err != nil { + if err := vm.setup(embeddedFiles, content); err != nil { return nil, err } - return vm, nil } -func (vm *VM) setup(embeddedFiles embed.FS, bundlePath string) error { +func (vm *VM) setup(embeddedFiles embed.FS, content []byte) error { apiContent, err := embeddedFiles.ReadFile("assets/finickyConfigAPI.js") if err != nil { return fmt.Errorf("failed to read bundled file: %v", err) } - var content []byte - if bundlePath != "" { - content, err = os.ReadFile(bundlePath) - if err != nil { - return fmt.Errorf("failed to read file: %v", err) - } - } - vm.runtime.Set("self", vm.runtime.GlobalObject()) vm.runtime.Set("console", GetConsoleMap()) @@ -73,7 +79,7 @@ func (vm *VM) setup(embeddedFiles embed.FS, bundlePath string) error { vm.runtime.Set("finicky", finicky) - if content != nil { + if len(content) > 0 { if _, err = vm.runtime.RunString(string(content)); err != nil { return fmt.Errorf("error while running config script: %v", err) } diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index 4ee25d3..c108e86 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -15,6 +15,7 @@ import ( "finicky/browser" "finicky/config" "finicky/logger" + "finicky/rules" "finicky/shorturl" "finicky/version" "finicky/window" @@ -61,6 +62,7 @@ type ConfigInfo struct { var urlListener chan URLInfo = make(chan URLInfo) var windowClosed chan struct{} = make(chan struct{}) var vm *config.VM +var hasJSConfig bool var forceWindowOpen bool = false var queueWindowOpen chan bool = make(chan bool) @@ -132,6 +134,30 @@ func main() { go TestURLInternal(url) } + // Set up rules save handler. + // When there is no JS config, rebuild the VM from the updated rules. + // When there is a JS config, JSON rules are loaded fresh in evaluateURL — nothing to do. + window.SaveRulesHandler = func(rf rules.RulesFile) { + slog.Debug("Rules updated", "count", len(rf.Rules)) + if !hasJSConfig { + if rf.DefaultBrowser == "" && len(rf.Rules) == 0 { + vm = nil + return + } + script, err := rules.ToJSConfigScript(rf, namespace) + if err != nil { + slog.Error("Failed to generate config from rules", "error", err) + return + } + newVM, err := config.NewFromScript(embeddedFiles, namespace, script) + if err != nil { + slog.Error("Failed to rebuild VM from rules", "error", err) + return + } + vm = newVM + } + } + const oneDay = 24 * time.Hour var showingWindow bool = false @@ -154,31 +180,12 @@ func main() { slog.Info("URL received", "url", url) - var browserConfig *browser.BrowserConfig - var err error - - if vm != nil { - browserConfig, err = evaluateURL(vm.Runtime(), url, urlInfo.Opener) - if err != nil { - handleRuntimeError(err) - } - } else { - slog.Warn("No configuration available, using default configuration") - } - - if browserConfig == nil { - browserConfig = &browser.BrowserConfig{ - Name: "com.apple.Safari", - AppType: "bundleId", - OpenInBackground: &urlInfo.OpenInBackground, - Profile: "", - Args: []string{}, - URL: url, - } + config, err := resolveURL(url, urlInfo.Opener, urlInfo.OpenInBackground) + if err != nil { + handleRuntimeError(err) } - - if err := browser.LaunchBrowser(*browserConfig, dryRun, urlInfo.OpenInBackground); err != nil { - slog.Error("Failed to start browser", "error", err) + if launchErr := browser.LaunchBrowser(*config, 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)) @@ -298,15 +305,7 @@ func TestURL(url *C.char) { func TestURLInternal(urlString string) { slog.Debug("Testing URL", "url", urlString) - if vm == nil { - slog.Error("VM not initialized") - window.SendMessageToWebView("testUrlResult", map[string]interface{}{ - "error": "Configuration not loaded", - }) - return - } - - browserConfig, err := evaluateURL(vm.Runtime(), urlString, nil) + config, err := resolveURL(urlString, nil, false) if err != nil { slog.Error("Failed to evaluate URL", "error", err) window.SendMessageToWebView("testUrlResult", map[string]interface{}{ @@ -315,25 +314,48 @@ func TestURLInternal(urlString string) { return } - if browserConfig == nil { - window.SendMessageToWebView("testUrlResult", map[string]interface{}{ - "error": "No browser config returned", - }) - return - } - window.SendMessageToWebView("testUrlResult", map[string]interface{}{ - "url": browserConfig.URL, - "browser": browserConfig.Name, - "openInBackground": browserConfig.OpenInBackground, - "profile": browserConfig.Profile, - "args": browserConfig.Args, + "url": config.URL, + "browser": config.Name, + "openInBackground": config.OpenInBackground, + "profile": config.Profile, + "args": config.Args, }) } -func evaluateURL(vm *goja.Runtime, url string, opener *ProcessInfo) (*browser.BrowserConfig, error) { +// resolveURL determines which browser to use for a URL. +// Priority: JS config handlers, then JSON rules handlers, then default browser. +// Always returns a non-nil config. Returns a non-nil error only if JS evaluation failed. +func resolveURL(urlStr string, opener *ProcessInfo, openInBackground bool) (*browser.BrowserConfig, error) { + bg := openInBackground + + if vm != nil { + config, err := evaluateURL(vm.Runtime(), urlStr, opener) + if err != nil { + return defaultBrowserConfig(urlStr, bg), err + } + return config, nil + } + + return defaultBrowserConfig(urlStr, bg), nil +} + +// defaultBrowserConfig returns a Safari config, used when JS evaluation fails +// and we still need to open the URL somewhere. +func defaultBrowserConfig(urlStr string, openInBackground bool) *browser.BrowserConfig { + bg := openInBackground + return &browser.BrowserConfig{ + Name: "com.apple.Safari", + AppType: "bundleId", + OpenInBackground: &bg, + Args: []string{}, + URL: urlStr, + } +} + +func evaluateURL(runtime *goja.Runtime, url string, opener *ProcessInfo) (*browser.BrowserConfig, error) { resolvedURL, err := shorturl.ResolveURL(url) - vm.Set("originalUrl", url) + runtime.Set("originalUrl", url) if err != nil { // Continue with original URL if resolution fails @@ -342,7 +364,7 @@ func evaluateURL(vm *goja.Runtime, url string, opener *ProcessInfo) (*browser.Br url = resolvedURL - vm.Set("url", resolvedURL) + runtime.Set("url", resolvedURL) if opener != nil { openerMap := map[string]interface{}{ @@ -353,19 +375,31 @@ func evaluateURL(vm *goja.Runtime, url string, opener *ProcessInfo) (*browser.Br if opener.WindowTitle != "" { openerMap["windowTitle"] = opener.WindowTitle } - vm.Set("opener", openerMap) + runtime.Set("opener", openerMap) slog.Debug("Setting opener", "name", opener.Name, "bundleId", opener.BundleID, "path", opener.Path, "windowTitle", opener.WindowTitle) } else { - vm.Set("opener", nil) + runtime.Set("opener", nil) slog.Debug("No opener detected") } - openResult, err := vm.RunString("finickyConfigAPI.openUrl(url, opener, originalUrl, finalConfig)") + // When there is a JS config, append JSON rules as lower-priority handlers. + var evalScript string + if hasJSConfig { + rf, _ := rules.Load() + runtime.Set("_jsonHandlers", rules.ToJSHandlers(rf.Rules)) + evalScript = `finickyConfigAPI.openUrl(url, opener, originalUrl, Object.assign({}, finalConfig, { + handlers: (finalConfig.handlers || []).concat(_jsonHandlers) + }))` + } else { + evalScript = "finickyConfigAPI.openUrl(url, opener, originalUrl, finalConfig)" + } + + openResult, err := runtime.RunString(evalScript) if err != nil { return nil, fmt.Errorf("failed to evaluate URL in config: %v", err) } - resultJSON := openResult.ToObject(vm).Export() + resultJSON := openResult.ToObject(runtime).Export() resultBytes, err := json.Marshal(resultJSON) if err != nil { return nil, fmt.Errorf("failed to process browser configuration: %v", err) @@ -490,44 +524,64 @@ func setupVM(cfw *config.ConfigFileWatcher, embeddedFS embed.FS, namespace strin return nil, fmt.Errorf("failed to read config: %v", err) } - if currentBundlePath != "" { - vm, err = config.New(embeddedFS, namespace, currentBundlePath) + var newVM *config.VM + if currentBundlePath != "" { + hasJSConfig = true + newVM, err = config.New(embeddedFS, namespace, currentBundlePath) if err != nil { return nil, fmt.Errorf("failed to setup VM: %v", err) } - - currentConfigState = vm.GetConfigState() - - if currentConfigState != nil { - configInfo = &ConfigInfo{ - Handlers: currentConfigState.Handlers, - Rewrites: currentConfigState.Rewrites, - DefaultBrowser: currentConfigState.DefaultBrowser, - ConfigPath: configPath, + } else { + hasJSConfig = false + rf, rulesErr := rules.Load() + if rulesErr != nil { + slog.Warn("Failed to load rules file", "error", rulesErr) + } else if rf.DefaultBrowser != "" || len(rf.Rules) > 0 { + script, scriptErr := rules.ToJSConfigScript(rf, namespace) + if scriptErr != nil { + return nil, fmt.Errorf("failed to generate config from rules: %v", scriptErr) } + newVM, err = config.NewFromScript(embeddedFS, namespace, script) + if err != nil { + return nil, fmt.Errorf("failed to setup VM from rules: %v", err) + } + configPath, _ = rules.GetPath() } + } - keepRunning := getConfigOption("keepRunning", true) - hideIcon := getConfigOption("hideIcon", false) - logRequests = getConfigOption("logRequests", false) - checkForUpdates := getConfigOption("checkForUpdates", true) - - window.SendMessageToWebView("config", map[string]interface{}{ - "handlers": configInfo.Handlers, - "rewrites": configInfo.Rewrites, - "defaultBrowser": configInfo.DefaultBrowser, - "configPath": configInfo.ConfigPath, - "options": map[string]interface{}{ - "keepRunning": keepRunning, - "hideIcon": hideIcon, - "logRequests": logRequests, - "checkForUpdates": checkForUpdates, - }, - }) + if newVM == nil { + return nil, nil + } + + currentConfigState = newVM.GetConfigState() - return vm, nil + if currentConfigState != nil { + configInfo = &ConfigInfo{ + Handlers: currentConfigState.Handlers, + Rewrites: currentConfigState.Rewrites, + DefaultBrowser: currentConfigState.DefaultBrowser, + ConfigPath: configPath, + } } - return nil, nil + keepRunning := getConfigOption("keepRunning", true) + hideIcon := getConfigOption("hideIcon", false) + logRequests = getConfigOption("logRequests", false) + checkForUpdates := getConfigOption("checkForUpdates", true) + + window.SendMessageToWebView("config", map[string]interface{}{ + "handlers": configInfo.Handlers, + "rewrites": configInfo.Rewrites, + "defaultBrowser": configInfo.DefaultBrowser, + "configPath": configInfo.ConfigPath, + "options": map[string]interface{}{ + "keepRunning": keepRunning, + "hideIcon": hideIcon, + "logRequests": logRequests, + "checkForUpdates": checkForUpdates, + }, + }) + + return newVM, nil } diff --git a/apps/finicky/src/rules/rules.go b/apps/finicky/src/rules/rules.go new file mode 100644 index 0000000..4c6ea5a --- /dev/null +++ b/apps/finicky/src/rules/rules.go @@ -0,0 +1,118 @@ +package rules + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type Rule struct { + Match string `json:"match"` + Browser string `json:"browser"` + Profile string `json:"profile,omitempty"` +} + +type RulesFile struct { + DefaultBrowser string `json:"defaultBrowser"` + DefaultProfile string `json:"defaultProfile,omitempty"` + Rules []Rule `json:"rules"` +} + +// GetPath returns the path to the rules JSON file: +// ~/Library/Application Support/Finicky/rules.json +func GetPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "Finicky", "rules.json"), nil +} + +// Load reads the rules file from disk. Returns an empty RulesFile if it doesn't exist. +func Load() (RulesFile, error) { + path, err := GetPath() + if err != nil { + return RulesFile{}, err + } + + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return RulesFile{Rules: []Rule{}}, nil + } + if err != nil { + return RulesFile{}, err + } + + var rf RulesFile + if err := json.Unmarshal(data, &rf); err != nil { + return RulesFile{}, err + } + if rf.Rules == nil { + rf.Rules = []Rule{} + } + return rf, nil +} + +// Save writes the rules file to disk, creating the directory if needed. +func Save(rf RulesFile) error { + path, err := GetPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(rf, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +// ToJSHandlers converts rules to the handler format expected by finickyConfigAPI. +// Rules with an empty match or browser are skipped. +func ToJSHandlers(rules []Rule) []map[string]interface{} { + handlers := make([]map[string]interface{}, 0, len(rules)) + for _, r := range rules { + if r.Match == "" || r.Browser == "" { + continue + } + var browser interface{} + if r.Profile != "" { + browser = map[string]interface{}{"name": r.Browser, "profile": r.Profile} + } else { + browser = r.Browser + } + handlers = append(handlers, map[string]interface{}{ + "match": r.Match, + "browser": browser, + }) + } + return handlers +} + +// ToJSConfigScript generates a JavaScript config assignment for the given namespace. +// It produces a valid finickyConfig object that can be evaluated in the JS VM. +func ToJSConfigScript(rf RulesFile, namespace string) (string, error) { + defaultBrowser := rf.DefaultBrowser + if defaultBrowser == "" { + defaultBrowser = "com.apple.Safari" + } + + defaultBrowserJSON, err := json.Marshal(defaultBrowser) + if err != nil { + return "", fmt.Errorf("failed to marshal defaultBrowser: %v", err) + } + + handlersJSON, err := json.Marshal(ToJSHandlers(rf.Rules)) + if err != nil { + return "", fmt.Errorf("failed to marshal handlers: %v", err) + } + + return fmt.Sprintf("var %s = {defaultBrowser: %s, handlers: %s};", + namespace, string(defaultBrowserJSON), string(handlersJSON)), nil +} diff --git a/apps/finicky/src/window/window.go b/apps/finicky/src/window/window.go index c1ba015..c7121d2 100644 --- a/apps/finicky/src/window/window.go +++ b/apps/finicky/src/window/window.go @@ -10,6 +10,8 @@ import "C" import ( "encoding/json" "finicky/assets" + "finicky/browser" + "finicky/rules" "finicky/version" "fmt" "io/fs" @@ -22,10 +24,11 @@ import ( ) var ( - messageQueue []string - queueMutex sync.Mutex - windowReady bool - TestUrlHandler func(string) + messageQueue []string + queueMutex sync.Mutex + windowReady bool + TestUrlHandler func(string) + SaveRulesHandler func(rules.RulesFile) ) //export WindowIsReady @@ -159,6 +162,14 @@ func HandleWebViewMessage(messagePtr *C.char) { switch messageType { case "testUrl": handleTestUrl(msg) + case "getRules": + handleGetRules() + case "saveRules": + handleSaveRules(msg) + case "getInstalledBrowsers": + handleGetInstalledBrowsers() + case "getBrowserProfiles": + handleGetBrowserProfiles(msg) default: slog.Debug("Unknown message type", "type", messageType) } @@ -182,3 +193,61 @@ func handleTestUrl(msg map[string]interface{}) { }) } } + +func handleGetRules() { + rf, err := rules.Load() + if err != nil { + slog.Error("Failed to load rules", "error", err) + SendMessageToWebView("rules", map[string]interface{}{ + "defaultBrowser": "", + "rules": []interface{}{}, + }) + return + } + SendMessageToWebView("rules", rf) +} + +func handleSaveRules(msg map[string]interface{}) { + payload, ok := msg["payload"] + if !ok { + slog.Error("saveRules message missing payload field") + return + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + slog.Error("Failed to marshal saveRules payload", "error", err) + return + } + + var rf rules.RulesFile + if err := json.Unmarshal(payloadBytes, &rf); err != nil { + slog.Error("Failed to parse saveRules payload", "error", err) + return + } + + if err := rules.Save(rf); err != nil { + slog.Error("Failed to save rules", "error", err) + return + } + + slog.Debug("Rules saved", "rules", len(rf.Rules)) + + if SaveRulesHandler != nil { + SaveRulesHandler(rf) + } +} + +func handleGetInstalledBrowsers() { + installed := browser.GetInstalledBrowsers() + SendMessageToWebView("installedBrowsers", installed) +} + +func handleGetBrowserProfiles(msg map[string]interface{}) { + browserName, _ := msg["browser"].(string) + profiles := browser.GetProfilesForBrowser(browserName) + SendMessageToWebView("browserProfiles", map[string]interface{}{ + "browser": browserName, + "profiles": profiles, + }) +} diff --git a/packages/finicky-ui/src/App.svelte b/packages/finicky-ui/src/App.svelte index 6b3555e..ede3242 100644 --- a/packages/finicky-ui/src/App.svelte +++ b/packages/finicky-ui/src/App.svelte @@ -5,9 +5,10 @@ import TabBar from "./components/TabBar.svelte"; import About from "./pages/About.svelte"; import TestUrl from "./pages/TestUrl.svelte"; + import Rules from "./pages/Rules.svelte"; import ToastContainer from "./components/ToastContainer.svelte"; import ExternalIcon from "./components/icons/External.svelte"; - import type { LogEntry, UpdateInfo, ConfigInfo } from "./types"; + import type { LogEntry, UpdateInfo, ConfigInfo, RulesFile } from "./types"; import { testUrlResult } from "./lib/testUrlStore"; let version = "v0.0.0"; @@ -19,6 +20,9 @@ // Initialize message buffer let messageBuffer: LogEntry[] = []; let updateInfo: UpdateInfo | null = null; + let rulesFile: RulesFile = { defaultBrowser: "", rules: [] }; + let installedBrowsers: string[] = []; + let profilesByBrowser: Record = {}; // Reactive declaration to count errors in messageBuffer $: numErrors = messageBuffer.filter( @@ -49,6 +53,15 @@ case "testUrlResult": testUrlResult.set(parsedMsg.message); break; + case "rules": + rulesFile = parsedMsg.message; + break; + case "installedBrowsers": + installedBrowsers = parsedMsg.message; + break; + case "browserProfiles": + profilesByBrowser = { ...profilesByBrowser, [parsedMsg.message.browser]: parsedMsg.message.profiles }; + break; default: const newMessage = parsedMsg.message ? JSON.parse(parsedMsg.message) @@ -109,6 +122,10 @@ {version} /> + + + + diff --git a/packages/finicky-ui/src/components/TabBar.svelte b/packages/finicky-ui/src/components/TabBar.svelte index 1a752c2..87952c0 100644 --- a/packages/finicky-ui/src/components/TabBar.svelte +++ b/packages/finicky-ui/src/components/TabBar.svelte @@ -4,6 +4,7 @@ import TestIcon from "./icons/Test.svelte"; import LogsIcon from "./icons/Logs.svelte"; import AboutIcon from "./icons/About.svelte"; + import RulesIcon from "./icons/Rules.svelte"; export let numErrors: number = 0; @@ -13,6 +14,11 @@ label: "Preferences", component: PreferencesIcon, }, + { + path: "/rules", + label: "Rules", + component: RulesIcon, + }, { path: "/test", label: "Test", diff --git a/packages/finicky-ui/src/components/icons/Rules.svelte b/packages/finicky-ui/src/components/icons/Rules.svelte new file mode 100644 index 0000000..b24732d --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Rules.svelte @@ -0,0 +1,13 @@ + diff --git a/packages/finicky-ui/src/pages/Rules.svelte b/packages/finicky-ui/src/pages/Rules.svelte new file mode 100644 index 0000000..fb526b1 --- /dev/null +++ b/packages/finicky-ui/src/pages/Rules.svelte @@ -0,0 +1,570 @@ + + + + +
+
+ + Used when no rule matches +
+
+ {#if defaultBrowserIsCustom} + + + {:else} + + {/if} + {#if !defaultBrowserIsCustom && defaultBrowser && profileOptions(defaultBrowser).length > 0} + {#if defaultProfileIsCustom} + + + {:else} + + {/if} + {/if} +
+
+ + +
+
+ +
+ + {#if rules.length === 0} +
+ No rules yet. Add one below. +
+ {:else} +
+ {#each rules as rule, i} +
onDragStart(i)} + ondragover={(e) => onDragOver(e, i)} + ondragend={onDragEnd} + role="listitem" + > + + + onRowMatchInput(i, e)} + /> + +
+ + {#if rowIsCustom[i]} + onRowBrowserCustomInput(i, e)} + /> + + {:else} + + {/if} + + {#if !rowIsCustom[i] && rule.browser && profileOptions(rule.browser).length > 0} + {#if rowProfileIsCustom[i]} + onRowProfileCustomInput(i, e)} + /> + + {:else} + + {/if} + {/if} + + +
+ {/each} +
+ {/if} + + +
+
+ + diff --git a/packages/finicky-ui/src/types.ts b/packages/finicky-ui/src/types.ts index 047a5cb..790a085 100644 --- a/packages/finicky-ui/src/types.ts +++ b/packages/finicky-ui/src/types.ts @@ -1,3 +1,15 @@ +export interface Rule { + match: string; + browser: string; + profile?: string; +} + +export interface RulesFile { + defaultBrowser: string; + defaultProfile?: string; + rules: Rule[]; +} + export interface LogEntry { level: string; msg: string; From 27d13e5aac2fdeccb57032b1fac9e77af4db099d Mon Sep 17 00:00:00 2001 From: John Sterling Date: Sun, 15 Mar 2026 20:21:33 +0100 Subject: [PATCH 2/3] tests --- apps/finicky/src/config/configfiles.go | 18 +- apps/finicky/src/config/vm.go | 86 ++++++-- apps/finicky/src/main.go | 215 ++++--------------- apps/finicky/src/resolver/resolver.go | 148 +++++++++++++ apps/finicky/src/resolver/resolver_test.go | 238 +++++++++++++++++++++ apps/finicky/src/rules/rules.go | 12 +- apps/finicky/src/rules/rules_test.go | 191 +++++++++++++++++ scripts/test.sh | 6 + 8 files changed, 721 insertions(+), 193 deletions(-) create mode 100644 apps/finicky/src/resolver/resolver.go create mode 100644 apps/finicky/src/resolver/resolver_test.go create mode 100644 apps/finicky/src/rules/rules_test.go create mode 100755 scripts/test.sh diff --git a/apps/finicky/src/config/configfiles.go b/apps/finicky/src/config/configfiles.go index 0a67ce3..d1fb640 100644 --- a/apps/finicky/src/config/configfiles.go +++ b/apps/finicky/src/config/configfiles.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/evanw/esbuild/pkg/api" @@ -24,6 +25,10 @@ type ConfigFileWatcher struct { // Cache manager cache *ConfigCache + + // Debounce rapid file-change events (e.g. editors that write twice) + debounceMu sync.Mutex + debounceTimer *time.Timer } // NewConfigFileWatcher creates a new file watcher for configuration files @@ -357,9 +362,16 @@ func (cfw *ConfigFileWatcher) handleConfigFileEvent(event fsnotify.Event) error return fmt.Errorf("configuration file removed") } - // Add a small delay to avoid rapid reloading - time.Sleep(500 * time.Millisecond) - cfw.configChangeNotify <- struct{}{} + // Debounce: reset the timer so only the last event in a burst fires. + cfw.debounceMu.Lock() + if cfw.debounceTimer != nil { + cfw.debounceTimer.Stop() + } + notify := cfw.configChangeNotify + cfw.debounceTimer = time.AfterFunc(500*time.Millisecond, func() { + notify <- struct{}{} + }) + cfw.debounceMu.Unlock() return nil } diff --git a/apps/finicky/src/config/vm.go b/apps/finicky/src/config/vm.go index ae04b06..3383dd8 100644 --- a/apps/finicky/src/config/vm.go +++ b/apps/finicky/src/config/vm.go @@ -1,7 +1,6 @@ package config import ( - "embed" "finicky/util" "fmt" "log/slog" @@ -11,8 +10,17 @@ import ( ) type VM struct { - runtime *goja.Runtime - namespace string + runtime *goja.Runtime + namespace string + isJSConfig bool +} + +// ConfigOptions holds the values of all runtime config options. +type ConfigOptions struct { + KeepRunning bool + HideIcon bool + LogRequests bool + CheckForUpdates bool } // ConfigState represents the current state of the configuration @@ -22,7 +30,10 @@ type ConfigState struct { DefaultBrowser string `json:"defaultBrowser"` } -func New(embeddedFiles embed.FS, namespace string, bundlePath string) (*VM, error) { +// New creates a VM from a JS config file on disk. The resulting VM is marked +// as a JS-config VM (IsJSConfig() == true). +// apiContent is the pre-read bytes of finickyConfigAPI.js. +func New(apiContent []byte, namespace string, bundlePath string) (*VM, error) { var content []byte if bundlePath != "" { var err error @@ -31,36 +42,37 @@ func New(embeddedFiles embed.FS, namespace string, bundlePath string) (*VM, erro return nil, fmt.Errorf("failed to read file: %v", err) } } - return newFromContent(embeddedFiles, namespace, content) + vm, err := newFromContent(apiContent, namespace, content) + if vm != nil { + vm.isJSConfig = true + } + return vm, err } // NewFromScript creates a VM from an inline JavaScript config string. -func NewFromScript(embeddedFiles embed.FS, namespace string, script string) (*VM, error) { - return newFromContent(embeddedFiles, namespace, []byte(script)) +// apiContent is the pre-read bytes of finickyConfigAPI.js. +func NewFromScript(apiContent []byte, namespace string, script string) (*VM, error) { + return newFromContent(apiContent, namespace, []byte(script)) } -func newFromContent(embeddedFiles embed.FS, namespace string, content []byte) (*VM, error) { +func newFromContent(apiContent []byte, namespace string, content []byte) (*VM, error) { vm := &VM{ runtime: goja.New(), namespace: namespace, } - if err := vm.setup(embeddedFiles, content); err != nil { + if err := vm.setup(apiContent, content); err != nil { return nil, err } return vm, nil } -func (vm *VM) setup(embeddedFiles embed.FS, content []byte) error { - apiContent, err := embeddedFiles.ReadFile("assets/finickyConfigAPI.js") - if err != nil { - return fmt.Errorf("failed to read bundled file: %v", err) - } +func (vm *VM) setup(apiContent []byte, content []byte) error { vm.runtime.Set("self", vm.runtime.GlobalObject()) vm.runtime.Set("console", GetConsoleMap()) slog.Debug("Evaluating API script...") - if _, err = vm.runtime.RunString(string(apiContent)); err != nil { + if _, err := vm.runtime.RunString(string(apiContent)); err != nil { return fmt.Errorf("failed to run api script: %v", err) } slog.Debug("Done evaluating API script") @@ -80,7 +92,7 @@ func (vm *VM) setup(embeddedFiles embed.FS, content []byte) error { vm.runtime.Set("finicky", finicky) if len(content) > 0 { - if _, err = vm.runtime.RunString(string(content)); err != nil { + if _, err := vm.runtime.RunString(string(content)); err != nil { return fmt.Errorf("error while running config script: %v", err) } } else { @@ -128,6 +140,48 @@ func (vm *VM) GetConfigState() *ConfigState { } } +// IsJSConfig reports whether this VM was built from a JS config file. +func (vm *VM) IsJSConfig() bool { + return vm.isJSConfig +} + +// SetIsJSConfig overrides the JS-config flag. Intended for use in tests. +func (vm *VM) SetIsJSConfig(v bool) { + vm.isJSConfig = v +} + +// GetAllConfigOptions reads all runtime config options in a single JS call. +// Safe to call on a nil VM — returns defaults in that case. +func (vm *VM) GetAllConfigOptions() ConfigOptions { + defaults := ConfigOptions{ + KeepRunning: true, + HideIcon: false, + LogRequests: false, + CheckForUpdates: true, + } + if vm == nil || vm.runtime == nil { + return defaults + } + script := `({ + keepRunning: finickyConfigAPI.getOption('keepRunning', finalConfig, true), + hideIcon: finickyConfigAPI.getOption('hideIcon', finalConfig, false), + logRequests: finickyConfigAPI.getOption('logRequests', finalConfig, false), + checkForUpdates: finickyConfigAPI.getOption('checkForUpdates', finalConfig, true) + })` + val, err := vm.runtime.RunString(script) + if err != nil { + slog.Error("Failed to get config options", "error", err) + return defaults + } + obj := val.ToObject(vm.runtime) + return ConfigOptions{ + KeepRunning: obj.Get("keepRunning").ToBoolean(), + HideIcon: obj.Get("hideIcon").ToBoolean(), + LogRequests: obj.Get("logRequests").ToBoolean(), + CheckForUpdates: obj.Get("checkForUpdates").ToBoolean(), + } +} + // Runtime returns the underlying goja.Runtime func (vm *VM) Runtime() *goja.Runtime { return vm.runtime diff --git a/apps/finicky/src/main.go b/apps/finicky/src/main.go index c108e86..b1c7dd3 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -9,14 +9,13 @@ package main import "C" import ( - "embed" + _ "embed" "encoding/base64" - "encoding/json" "finicky/browser" "finicky/config" "finicky/logger" + "finicky/resolver" "finicky/rules" - "finicky/shorturl" "finicky/version" "finicky/window" "flag" @@ -31,14 +30,7 @@ import ( ) //go:embed assets/finickyConfigAPI.js -var embeddedFiles embed.FS - -type ProcessInfo struct { - Name string `json:"name"` - BundleID string `json:"bundleId"` - Path string `json:"path"` - WindowTitle string `json:"windowTitle,omitempty"` -} +var finickyConfigAPIJS []byte type UpdateInfo struct { ReleaseInfo *version.ReleaseInfo @@ -47,7 +39,7 @@ type UpdateInfo struct { type URLInfo struct { URL string - Opener *ProcessInfo + Opener *resolver.OpenerInfo OpenInBackground bool } @@ -58,11 +50,9 @@ type ConfigInfo struct { ConfigPath string } -// FIXME: Clean up app global stae var urlListener chan URLInfo = make(chan URLInfo) var windowClosed chan struct{} = make(chan struct{}) var vm *config.VM -var hasJSConfig bool var forceWindowOpen bool = false var queueWindowOpen chan bool = make(chan bool) @@ -70,7 +60,6 @@ var lastError error var dryRun bool = false var updateInfo UpdateInfo var configInfo *ConfigInfo -var currentConfigState *config.ConfigState var shouldKeepRunning bool = true func main() { @@ -120,7 +109,7 @@ func main() { handleFatalError(fmt.Sprintf("Failed to setup config file watcher: %v", err)) } - vm, err = setupVM(cfw, embeddedFiles, namespace) + vm, err = setupVM(cfw, namespace) if err != nil { handleFatalError(err.Error()) } @@ -139,7 +128,8 @@ func main() { // When there is a JS config, JSON rules are loaded fresh in evaluateURL — nothing to do. window.SaveRulesHandler = func(rf rules.RulesFile) { slog.Debug("Rules updated", "count", len(rf.Rules)) - if !hasJSConfig { + resolver.SetCachedRules(rf) + if vm == nil || !vm.IsJSConfig() { if rf.DefaultBrowser == "" && len(rf.Rules) == 0 { vm = nil return @@ -149,7 +139,7 @@ func main() { slog.Error("Failed to generate config from rules", "error", err) return } - newVM, err := config.NewFromScript(embeddedFiles, namespace, script) + newVM, err := config.NewFromScript(finickyConfigAPIJS, namespace, script) if err != nil { slog.Error("Failed to rebuild VM from rules", "error", err) return @@ -164,7 +154,7 @@ func main() { timeoutChan := time.After(1 * time.Second) updateChan := time.After(oneDay) - shouldKeepRunning = getConfigOption("keepRunning", true) + shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning if shouldKeepRunning { timeoutChan = nil } @@ -180,9 +170,11 @@ func main() { slog.Info("URL received", "url", url) - config, err := resolveURL(url, urlInfo.Opener, urlInfo.OpenInBackground) + config, err := resolver.ResolveURL(vm, url, urlInfo.Opener, urlInfo.OpenInBackground) if err != nil { handleRuntimeError(err) + } else { + lastError = nil } if launchErr := browser.LaunchBrowser(*config, dryRun, urlInfo.OpenInBackground); launchErr != nil { slog.Error("Failed to start browser", "error", launchErr) @@ -200,12 +192,12 @@ func main() { startTime := time.Now() var setupErr error slog.Debug("Config has changed") - vm, setupErr = setupVM(cfw, embeddedFiles, namespace) + vm, setupErr = setupVM(cfw, namespace) if setupErr != nil { handleRuntimeError(setupErr) } slog.Debug("VM refresh complete", "duration", fmt.Sprintf("%.2fms", float64(time.Since(startTime).Microseconds())/1000)) - shouldKeepRunning = getConfigOption("keepRunning", true) + shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning case shouldShowWindow := <-queueWindowOpen: if !showingWindow && shouldShowWindow { @@ -233,9 +225,7 @@ func main() { } }() - hideIcon := getConfigOption("hideIcon", false) - - C.RunApp(C.bool(forceWindowOpen), C.bool(!hideIcon), C.bool(shouldKeepRunning)) + C.RunApp(C.bool(forceWindowOpen), C.bool(!vm.GetAllConfigOptions().HideIcon), C.bool(shouldKeepRunning)) } func handleRuntimeError(err error) { @@ -244,29 +234,13 @@ func handleRuntimeError(err error) { go QueueWindowDisplay(1) } -func getConfigOption(optionName string, defaultValue bool) bool { - if vm == nil || vm.Runtime() == nil { - slog.Debug("VM not initialized, returning default for config option", "option", optionName, "default", defaultValue) - return defaultValue - } - - script := fmt.Sprintf("finickyConfigAPI.getOption('%s', finalConfig, %t)", optionName, defaultValue) - optionVal, err := vm.Runtime().RunString(script) - - if err != nil { - slog.Error("Failed to get config option", "option", optionName, "error", err) - return defaultValue - } - - return optionVal.ToBoolean() -} //export HandleURL func HandleURL(url *C.char, name *C.char, bundleId *C.char, path *C.char, windowTitle *C.char, openInBackground C.bool) { - var opener ProcessInfo + var opener resolver.OpenerInfo if name != nil && bundleId != nil && path != nil { - opener = ProcessInfo{ + opener = resolver.OpenerInfo{ Name: C.GoString(name), BundleID: C.GoString(bundleId), Path: C.GoString(path), @@ -305,7 +279,7 @@ func TestURL(url *C.char) { func TestURLInternal(urlString string) { slog.Debug("Testing URL", "url", urlString) - config, err := resolveURL(urlString, nil, false) + config, err := resolver.ResolveURL(vm, urlString, nil, false) if err != nil { slog.Error("Failed to evaluate URL", "error", err) window.SendMessageToWebView("testUrlResult", map[string]interface{}{ @@ -323,107 +297,6 @@ func TestURLInternal(urlString string) { }) } -// resolveURL determines which browser to use for a URL. -// Priority: JS config handlers, then JSON rules handlers, then default browser. -// Always returns a non-nil config. Returns a non-nil error only if JS evaluation failed. -func resolveURL(urlStr string, opener *ProcessInfo, openInBackground bool) (*browser.BrowserConfig, error) { - bg := openInBackground - - if vm != nil { - config, err := evaluateURL(vm.Runtime(), urlStr, opener) - if err != nil { - return defaultBrowserConfig(urlStr, bg), err - } - return config, nil - } - - return defaultBrowserConfig(urlStr, bg), nil -} - -// defaultBrowserConfig returns a Safari config, used when JS evaluation fails -// and we still need to open the URL somewhere. -func defaultBrowserConfig(urlStr string, openInBackground bool) *browser.BrowserConfig { - bg := openInBackground - return &browser.BrowserConfig{ - Name: "com.apple.Safari", - AppType: "bundleId", - OpenInBackground: &bg, - Args: []string{}, - URL: urlStr, - } -} - -func evaluateURL(runtime *goja.Runtime, url string, opener *ProcessInfo) (*browser.BrowserConfig, error) { - resolvedURL, err := shorturl.ResolveURL(url) - runtime.Set("originalUrl", url) - - if err != nil { - // Continue with original URL if resolution fails - slog.Info("Failed to resolve short URL", "error", err, "url", url, "using", resolvedURL) - } - - url = resolvedURL - - runtime.Set("url", resolvedURL) - - if opener != nil { - openerMap := map[string]interface{}{ - "name": opener.Name, - "bundleId": opener.BundleID, - "path": opener.Path, - } - if opener.WindowTitle != "" { - openerMap["windowTitle"] = opener.WindowTitle - } - runtime.Set("opener", openerMap) - slog.Debug("Setting opener", "name", opener.Name, "bundleId", opener.BundleID, "path", opener.Path, "windowTitle", opener.WindowTitle) - } else { - runtime.Set("opener", nil) - slog.Debug("No opener detected") - } - - // When there is a JS config, append JSON rules as lower-priority handlers. - var evalScript string - if hasJSConfig { - rf, _ := rules.Load() - runtime.Set("_jsonHandlers", rules.ToJSHandlers(rf.Rules)) - evalScript = `finickyConfigAPI.openUrl(url, opener, originalUrl, Object.assign({}, finalConfig, { - handlers: (finalConfig.handlers || []).concat(_jsonHandlers) - }))` - } else { - evalScript = "finickyConfigAPI.openUrl(url, opener, originalUrl, finalConfig)" - } - - openResult, err := runtime.RunString(evalScript) - if err != nil { - return nil, fmt.Errorf("failed to evaluate URL in config: %v", err) - } - - resultJSON := openResult.ToObject(runtime).Export() - resultBytes, err := json.Marshal(resultJSON) - if err != nil { - return nil, fmt.Errorf("failed to process browser configuration: %v", err) - } - - var browserResult browser.BrowserResult - - if err := json.Unmarshal(resultBytes, &browserResult); err != nil { - return nil, fmt.Errorf("failed to parse browser configuration: %v", err) - } - - slog.Debug("Final browser options", - "name", browserResult.Browser.Name, - "openInBackground", browserResult.Browser.OpenInBackground, - "profile", browserResult.Browser.Profile, - "args", browserResult.Browser.Args, - "appType", browserResult.Browser.AppType, - ) - var resultErr error - if browserResult.Error != "" { - resultErr = fmt.Errorf("%s", browserResult.Error) - } - return &browserResult.Browser, resultErr -} func handleFatalError(errorMessage string) { slog.Error("Fatal error", "msg", errorMessage) @@ -507,7 +380,7 @@ func tearDown() { os.Exit(0) } -func setupVM(cfw *config.ConfigFileWatcher, embeddedFS embed.FS, namespace string) (*config.VM, error) { +func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error) { logRequests := true var err error @@ -527,26 +400,27 @@ func setupVM(cfw *config.ConfigFileWatcher, embeddedFS embed.FS, namespace strin var newVM *config.VM if currentBundlePath != "" { - hasJSConfig = true - newVM, err = config.New(embeddedFS, namespace, currentBundlePath) + newVM, err = config.New(finickyConfigAPIJS, namespace, currentBundlePath) if err != nil { return nil, fmt.Errorf("failed to setup VM: %v", err) } } else { - hasJSConfig = false rf, rulesErr := rules.Load() if rulesErr != nil { slog.Warn("Failed to load rules file", "error", rulesErr) - } else if rf.DefaultBrowser != "" || len(rf.Rules) > 0 { - script, scriptErr := rules.ToJSConfigScript(rf, namespace) - if scriptErr != nil { - return nil, fmt.Errorf("failed to generate config from rules: %v", scriptErr) - } - newVM, err = config.NewFromScript(embeddedFS, namespace, script) - if err != nil { - return nil, fmt.Errorf("failed to setup VM from rules: %v", err) + } else { + resolver.SetCachedRules(rf) + if rf.DefaultBrowser != "" || len(rf.Rules) > 0 { + script, scriptErr := rules.ToJSConfigScript(rf, namespace) + if scriptErr != nil { + return nil, fmt.Errorf("failed to generate config from rules: %v", scriptErr) + } + newVM, err = config.NewFromScript(finickyConfigAPIJS, namespace, script) + if err != nil { + return nil, fmt.Errorf("failed to setup VM from rules: %v", err) + } + configPath, _ = rules.GetPath() } - configPath, _ = rules.GetPath() } } @@ -554,21 +428,18 @@ func setupVM(cfw *config.ConfigFileWatcher, embeddedFS embed.FS, namespace strin return nil, nil } - currentConfigState = newVM.GetConfigState() - - if currentConfigState != nil { + cs := newVM.GetConfigState() + if cs != nil { configInfo = &ConfigInfo{ - Handlers: currentConfigState.Handlers, - Rewrites: currentConfigState.Rewrites, - DefaultBrowser: currentConfigState.DefaultBrowser, + Handlers: cs.Handlers, + Rewrites: cs.Rewrites, + DefaultBrowser: cs.DefaultBrowser, ConfigPath: configPath, } } - keepRunning := getConfigOption("keepRunning", true) - hideIcon := getConfigOption("hideIcon", false) - logRequests = getConfigOption("logRequests", false) - checkForUpdates := getConfigOption("checkForUpdates", true) + opts := newVM.GetAllConfigOptions() + logRequests = opts.LogRequests window.SendMessageToWebView("config", map[string]interface{}{ "handlers": configInfo.Handlers, @@ -576,10 +447,10 @@ func setupVM(cfw *config.ConfigFileWatcher, embeddedFS embed.FS, namespace strin "defaultBrowser": configInfo.DefaultBrowser, "configPath": configInfo.ConfigPath, "options": map[string]interface{}{ - "keepRunning": keepRunning, - "hideIcon": hideIcon, - "logRequests": logRequests, - "checkForUpdates": checkForUpdates, + "keepRunning": opts.KeepRunning, + "hideIcon": opts.HideIcon, + "logRequests": opts.LogRequests, + "checkForUpdates": opts.CheckForUpdates, }, }) diff --git a/apps/finicky/src/resolver/resolver.go b/apps/finicky/src/resolver/resolver.go new file mode 100644 index 0000000..389c07a --- /dev/null +++ b/apps/finicky/src/resolver/resolver.go @@ -0,0 +1,148 @@ +package resolver + +import ( + "encoding/json" + "fmt" + "log/slog" + "sync" + + "finicky/browser" + "finicky/config" + "finicky/rules" + "finicky/shorturl" +) + +// OpenerInfo describes the process that triggered the URL open. +type OpenerInfo struct { + Name string `json:"name"` + BundleID string `json:"bundleId"` + Path string `json:"path"` + WindowTitle string `json:"windowTitle,omitempty"` +} + +var ( + cachedRulesMu sync.Mutex + cachedRulesFile rules.RulesFile +) + +// SetCachedRules stores a snapshot of the JSON rules so evaluateURL can use +// them without hitting disk on every URL open. +func SetCachedRules(rf rules.RulesFile) { + cachedRulesMu.Lock() + cachedRulesFile = rf + cachedRulesMu.Unlock() +} + +func getCachedRules() rules.RulesFile { + cachedRulesMu.Lock() + defer cachedRulesMu.Unlock() + return cachedRulesFile +} + +// ResolveURL determines which browser to use for the given URL. +// +// vm may be nil (no configuration at all). Whether to merge JSON rules is +// derived from vm.IsJSConfig(). +// +// Always returns a non-nil config. Returns a non-nil error only when JS +// evaluation failed. +func ResolveURL(vm *config.VM, urlStr string, opener *OpenerInfo, openInBackground bool) (*browser.BrowserConfig, error) { + if vm != nil { + cfg, err := evaluateURL(vm, urlStr, opener) + if err != nil { + return defaultBrowserConfig(urlStr, openInBackground), err + } + cfg.OpenInBackground = mergeBackground(cfg.OpenInBackground, openInBackground) + return cfg, nil + } + return defaultBrowserConfig(urlStr, openInBackground), nil +} + +func mergeBackground(fromConfig *bool, requested bool) *bool { + if fromConfig != nil { + return fromConfig + } + return &requested +} + +func evaluateURL(vm *config.VM, url string, opener *OpenerInfo) (*browser.BrowserConfig, error) { + runtime := vm.Runtime() + + resolvedURL, err := shorturl.ResolveURL(url) + runtime.Set("originalUrl", url) + if err != nil { + slog.Info("Failed to resolve short URL", "error", err, "url", url, "using", resolvedURL) + } + url = resolvedURL + runtime.Set("url", resolvedURL) + + if opener != nil { + openerMap := map[string]interface{}{ + "name": opener.Name, + "bundleId": opener.BundleID, + "path": opener.Path, + } + if opener.WindowTitle != "" { + openerMap["windowTitle"] = opener.WindowTitle + } + runtime.Set("opener", openerMap) + slog.Debug("Setting opener", "name", opener.Name, "bundleId", opener.BundleID, "path", opener.Path, "windowTitle", opener.WindowTitle) + } else { + runtime.Set("opener", nil) + slog.Debug("No opener detected") + } + + // When there is a JS config, append cached JSON rules as lower-priority handlers. + var evalScript string + if vm.IsJSConfig() { + rf := getCachedRules() + runtime.Set("_jsonHandlers", rules.ToJSHandlers(rf.Rules)) + evalScript = `finickyConfigAPI.openUrl(url, opener, originalUrl, Object.assign({}, finalConfig, { + handlers: (finalConfig.handlers || []).concat(_jsonHandlers) + }))` + } else { + evalScript = "finickyConfigAPI.openUrl(url, opener, originalUrl, finalConfig)" + } + + openResult, err := runtime.RunString(evalScript) + if err != nil { + return nil, fmt.Errorf("failed to evaluate URL in config: %v", err) + } + + resultJSON := openResult.ToObject(runtime).Export() + resultBytes, err := json.Marshal(resultJSON) + if err != nil { + return nil, fmt.Errorf("failed to process browser configuration: %v", err) + } + + var browserResult browser.BrowserResult + if err := json.Unmarshal(resultBytes, &browserResult); err != nil { + return nil, fmt.Errorf("failed to parse browser configuration: %v", err) + } + + slog.Debug("Final browser options", + "name", browserResult.Browser.Name, + "openInBackground", browserResult.Browser.OpenInBackground, + "profile", browserResult.Browser.Profile, + "args", browserResult.Browser.Args, + "appType", browserResult.Browser.AppType, + ) + + var resultErr error + if browserResult.Error != "" { + resultErr = fmt.Errorf("%s", browserResult.Error) + } + return &browserResult.Browser, resultErr +} + +func defaultBrowserConfig(urlStr string, openInBackground bool) *browser.BrowserConfig { + bg := openInBackground + return &browser.BrowserConfig{ + Name: "com.apple.Safari", + AppType: "bundleId", + OpenInBackground: &bg, + Args: []string{}, + URL: urlStr, + } +} + diff --git a/apps/finicky/src/resolver/resolver_test.go b/apps/finicky/src/resolver/resolver_test.go new file mode 100644 index 0000000..1148608 --- /dev/null +++ b/apps/finicky/src/resolver/resolver_test.go @@ -0,0 +1,238 @@ +package resolver_test + +import ( + "os" + "testing" + + "finicky/config" + . "finicky/resolver" + "finicky/rules" +) + +// apiContent reads finickyConfigAPI.js relative to this package directory. +// go test sets the working directory to the package source directory, so +// "../assets/..." resolves correctly. +func apiContent(t *testing.T) []byte { + t.Helper() + b, err := os.ReadFile("../assets/finickyConfigAPI.js") + if err != nil { + t.Fatalf("failed to load finickyConfigAPI.js: %v\n(run from apps/finicky/src/resolver/)", err) + } + return b +} + +// jsVM creates a VM from an inline JS config object literal. +// NewFromScript bypasses esbuild bundling, so we use the legacy var-assignment +// syntax that the config API accepts directly in goja. +// The VM is marked as a JS-config VM to exercise the merge path. +func jsVM(t *testing.T, configObj string) *config.VM { + t.Helper() + script := "var finickyConfig = " + configObj + vm, err := config.NewFromScript(apiContent(t), "finickyConfig", script) + if err != nil { + t.Fatalf("failed to create VM from JS: %v", err) + } + vm.SetIsJSConfig(true) + return vm +} + +// rulesVM creates a VM from a RulesFile (the no-JS-config path). +func rulesVM(t *testing.T, rf rules.RulesFile) *config.VM { + t.Helper() + script, err := rules.ToJSConfigScript(rf, "finickyConfig") + if err != nil { + t.Fatalf("failed to generate JS config from rules: %v", err) + } + vm, err := config.NewFromScript(apiContent(t), "finickyConfig", script) + if err != nil { + t.Fatalf("failed to create VM from rules: %v", err) + } + return vm +} + +func TestResolveURL_NoConfig(t *testing.T) { + result, err := ResolveURL(nil, "https://example.com", nil, false) + if err != nil { + t.Fatal(err) + } + if result.Name != "com.apple.Safari" { + t.Errorf("got %q, want %q", result.Name, "com.apple.Safari") + } +} + +func TestResolveURL_JSConfig(t *testing.T) { + vm := jsVM(t, `({ + defaultBrowser: "Safari", + handlers: [ + { match: "*github.com/*", browser: "Firefox" }, + { match: "https://linear.app/*", browser: "Google Chrome" } + ] + })`) + + tests := []struct { + url string + browser string + }{ + {"https://github.com/johnste/finicky", "Firefox"}, + {"https://gist.github.com/foo", "Firefox"}, + {"https://linear.app/team/issue/123", "Google Chrome"}, + {"https://example.com", "Safari"}, + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + result, err := ResolveURL(vm, tt.url, nil, false) + if err != nil { + t.Fatal(err) + } + if result.Name != tt.browser { + t.Errorf("got %q, want %q", result.Name, tt.browser) + } + }) + } +} + +func TestResolveURL_JSONRulesOnly(t *testing.T) { + rf := rules.RulesFile{ + DefaultBrowser: "Firefox", + Rules: []rules.Rule{ + {Match: "*github.com/*", Browser: "Google Chrome"}, + {Match: "https://linear.app/*", Browser: "Safari"}, + }, + } + vm := rulesVM(t, rf) + + tests := []struct { + url string + browser string + }{ + {"https://github.com/johnste/finicky", "Google Chrome"}, + {"https://linear.app/team/issue/123", "Safari"}, + {"https://example.com", "Firefox"}, + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + result, err := ResolveURL(vm, tt.url, nil, false) + if err != nil { + t.Fatal(err) + } + if result.Name != tt.browser { + t.Errorf("got %q, want %q", result.Name, tt.browser) + } + }) + } +} + +func TestResolveURL_JSONRulesWithProfile(t *testing.T) { + rf := rules.RulesFile{ + DefaultBrowser: "Safari", + Rules: []rules.Rule{ + {Match: "*github.com/*", Browser: "Google Chrome", Profile: "Work"}, + }, + } + vm := rulesVM(t, rf) + + result, err := ResolveURL(vm, "https://github.com/foo", nil, false) + if err != nil { + t.Fatal(err) + } + if result.Name != "Google Chrome" { + t.Errorf("browser: got %q, want %q", result.Name, "Google Chrome") + } + if result.Profile != "Work" { + t.Errorf("profile: got %q, want %q", result.Profile, "Work") + } +} + +// TestResolveURL_MergedJSAndJSON verifies that JS handlers take precedence +// over JSON rules handlers when both are present. +func TestResolveURL_MergedJSAndJSON(t *testing.T) { + // JS config handles github. jsVM sets IsJSConfig=true so the merge path + // is exercised. With no rules cached, _jsonHandlers is [], so finalConfig + // is used as-is — JS handlers apply normally. + jsConfig := jsVM(t, `({ + defaultBrowser: "Safari", + handlers: [ + { match: "*github.com/*", browser: "Firefox" } + ] + })`) + + tests := []struct { + url string + browser string + }{ + {"https://github.com/foo", "Firefox"}, // matched by JS handler + {"https://example.com", "Safari"}, // falls through to JS default + } + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + result, err := ResolveURL(jsConfig, tt.url, nil, false) + if err != nil { + t.Fatal(err) + } + if result.Name != tt.browser { + t.Errorf("got %q, want %q", result.Name, tt.browser) + } + }) + } +} + +func TestResolveURL_JSConfigFunctionHandler(t *testing.T) { + vm := jsVM(t, `({ + defaultBrowser: "Safari", + handlers: [{ + match: function(request, { opener }) { + return opener && opener.bundleId === "com.apple.Terminal"; + }, + browser: "Firefox" + }] + })`) + + terminal := &OpenerInfo{Name: "Terminal", BundleID: "com.apple.Terminal", Path: "/Applications/Utilities/Terminal.app"} + other := &OpenerInfo{Name: "Finder", BundleID: "com.apple.finder", Path: "/System/Library/CoreServices/Finder.app"} + + result, err := ResolveURL(vm, "https://example.com", terminal, false) + if err != nil { + t.Fatal(err) + } + if result.Name != "Firefox" { + t.Errorf("terminal opener: got %q, want %q", result.Name, "Firefox") + } + + result, err = ResolveURL(vm, "https://example.com", other, false) + if err != nil { + t.Fatal(err) + } + if result.Name != "Safari" { + t.Errorf("other opener: got %q, want %q", result.Name, "Safari") + } +} + +func TestResolveURL_RewriteRule(t *testing.T) { + vm := jsVM(t, `({ + defaultBrowser: "Safari", + rewrite: [{ + match: "https://www.youtube.com/watch*", + url: function(url) { + return url.href.replace("https://www.youtube.com/watch", "https://youtu.be/"); + } + }] + })`) + + result, err := ResolveURL(vm, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", nil, false) + if err != nil { + t.Fatal(err) + } + if result.URL != "https://youtu.be/?v=dQw4w9WgXcQ" { + t.Errorf("rewritten URL: got %q", result.URL) + } +} + +func TestResolveURL_OpenInBackground(t *testing.T) { + result, err := ResolveURL(nil, "https://example.com", nil, true) + if err != nil { + t.Fatal(err) + } + if result.OpenInBackground == nil || !*result.OpenInBackground { + t.Error("expected OpenInBackground=true") + } +} diff --git a/apps/finicky/src/rules/rules.go b/apps/finicky/src/rules/rules.go index 4c6ea5a..d078833 100644 --- a/apps/finicky/src/rules/rules.go +++ b/apps/finicky/src/rules/rules.go @@ -29,13 +29,17 @@ func GetPath() (string, error) { return filepath.Join(configDir, "Finicky", "rules.json"), nil } -// Load reads the rules file from disk. Returns an empty RulesFile if it doesn't exist. +// Load reads the rules file from the default path. Returns an empty RulesFile if it doesn't exist. func Load() (RulesFile, error) { path, err := GetPath() if err != nil { return RulesFile{}, err } + return LoadFromPath(path) +} +// LoadFromPath reads a rules file from the given path. Returns an empty RulesFile if it doesn't exist. +func LoadFromPath(path string) (RulesFile, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { return RulesFile{Rules: []Rule{}}, nil @@ -54,13 +58,17 @@ func Load() (RulesFile, error) { return rf, nil } -// Save writes the rules file to disk, creating the directory if needed. +// Save writes the rules file to the default path, creating the directory if needed. func Save(rf RulesFile) error { path, err := GetPath() if err != nil { return err } + return SaveToPath(rf, path) +} +// SaveToPath writes the rules file to the given path, creating the directory if needed. +func SaveToPath(rf RulesFile, path string) error { if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return err } diff --git a/apps/finicky/src/rules/rules_test.go b/apps/finicky/src/rules/rules_test.go new file mode 100644 index 0000000..976a9b9 --- /dev/null +++ b/apps/finicky/src/rules/rules_test.go @@ -0,0 +1,191 @@ +package rules_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + . "finicky/rules" +) + +// ---- ToJSHandlers ---- + +func TestToJSHandlers_Empty(t *testing.T) { + result := ToJSHandlers([]Rule{}) + if len(result) != 0 { + t.Errorf("expected empty slice, got %d entries", len(result)) + } +} + +func TestToJSHandlers_SkipsIncompleteRules(t *testing.T) { + rules := []Rule{ + {Match: "", Browser: "Firefox"}, // no match + {Match: "example.com", Browser: ""}, // no browser + {Match: "example.com", Browser: "Safari"}, // valid + } + result := ToJSHandlers(rules) + if len(result) != 1 { + t.Fatalf("expected 1 handler, got %d", len(result)) + } +} + +func TestToJSHandlers_StringBrowser(t *testing.T) { + rules := []Rule{ + {Match: "*github.com/*", Browser: "Firefox"}, + } + result := ToJSHandlers(rules) + if len(result) != 1 { + t.Fatalf("expected 1 handler, got %d", len(result)) + } + h := result[0] + if h["match"] != "*github.com/*" { + t.Errorf("match: got %q, want %q", h["match"], "*github.com/*") + } + if h["browser"] != "Firefox" { + t.Errorf("browser: got %v, want %q", h["browser"], "Firefox") + } +} + +func TestToJSHandlers_WithProfile(t *testing.T) { + rules := []Rule{ + {Match: "*github.com/*", Browser: "Google Chrome", Profile: "Work"}, + } + result := ToJSHandlers(rules) + if len(result) != 1 { + t.Fatalf("expected 1 handler, got %d", len(result)) + } + browser, ok := result[0]["browser"].(map[string]interface{}) + if !ok { + t.Fatalf("expected browser to be a map, got %T", result[0]["browser"]) + } + if browser["name"] != "Google Chrome" { + t.Errorf("name: got %q, want %q", browser["name"], "Google Chrome") + } + if browser["profile"] != "Work" { + t.Errorf("profile: got %q, want %q", browser["profile"], "Work") + } +} + +func TestToJSHandlers_MultipleRules(t *testing.T) { + rules := []Rule{ + {Match: "*github.com/*", Browser: "Firefox"}, + {Match: "https://linear.app/*", Browser: "Google Chrome", Profile: "Work"}, + {Match: "example.com", Browser: "Safari"}, + } + result := ToJSHandlers(rules) + if len(result) != 3 { + t.Fatalf("expected 3 handlers, got %d", len(result)) + } + // Order must be preserved + if result[0]["match"] != "*github.com/*" { + t.Errorf("wrong order: first match got %q", result[0]["match"]) + } +} + +// ---- ToJSConfigScript ---- + +func TestToJSConfigScript_DefaultBrowserFallback(t *testing.T) { + rf := RulesFile{Rules: []Rule{{Match: "example.com", Browser: "Firefox"}}} + script, err := ToJSConfigScript(rf, "finickyConfig") + if err != nil { + t.Fatal(err) + } + // Should fall back to com.apple.Safari when no defaultBrowser is set + if script == "" { + t.Error("expected non-empty script") + } + // com.apple.Safari should appear as the default + if !contains(script, "com.apple.Safari") { + t.Errorf("expected fallback default browser in script, got: %s", script) + } +} + +func TestToJSConfigScript_ExplicitDefaultBrowser(t *testing.T) { + rf := RulesFile{DefaultBrowser: "Firefox", Rules: []Rule{}} + script, err := ToJSConfigScript(rf, "finickyConfig") + if err != nil { + t.Fatal(err) + } + if !contains(script, "Firefox") { + t.Errorf("expected Firefox in script, got: %s", script) + } +} + +func TestToJSConfigScript_NamespaceIsUsed(t *testing.T) { + rf := RulesFile{DefaultBrowser: "Safari"} + script, err := ToJSConfigScript(rf, "myNamespace") + if err != nil { + t.Fatal(err) + } + if !contains(script, "myNamespace") { + t.Errorf("expected namespace in script, got: %s", script) + } +} + +// ---- Load / Save round-trip ---- + +func TestLoadSave_RoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rules.json") + + original := RulesFile{ + DefaultBrowser: "Firefox", + DefaultProfile: "Work", + Rules: []Rule{ + {Match: "*github.com/*", Browser: "Google Chrome", Profile: "Personal"}, + {Match: "https://linear.app/*", Browser: "Safari"}, + }, + } + + if err := SaveToPath(original, path); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := LoadFromPath(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + + if loaded.DefaultBrowser != original.DefaultBrowser { + t.Errorf("DefaultBrowser: got %q, want %q", loaded.DefaultBrowser, original.DefaultBrowser) + } + if loaded.DefaultProfile != original.DefaultProfile { + t.Errorf("DefaultProfile: got %q, want %q", loaded.DefaultProfile, original.DefaultProfile) + } + if len(loaded.Rules) != len(original.Rules) { + t.Fatalf("Rules length: got %d, want %d", len(loaded.Rules), len(original.Rules)) + } + for i, r := range original.Rules { + got := loaded.Rules[i] + if got.Match != r.Match || got.Browser != r.Browser || got.Profile != r.Profile { + t.Errorf("Rule[%d]: got %+v, want %+v", i, got, r) + } + } +} + +func TestLoad_MissingFile(t *testing.T) { + _, err := LoadFromPath(filepath.Join(t.TempDir(), "nonexistent.json")) + if err != nil { + t.Errorf("expected nil error for missing file, got %v", err) + } +} + +func TestLoad_EmptyRulesNotNil(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rules.json") + if err := os.WriteFile(path, []byte(`{"defaultBrowser":"Safari"}`), 0644); err != nil { + t.Fatal(err) + } + rf, err := LoadFromPath(path) + if err != nil { + t.Fatal(err) + } + if rf.Rules == nil { + t.Error("expected Rules to be non-nil slice, got nil") + } +} + +func contains(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..455678a --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +cd "$(dirname "$0")/../apps/finicky/src" +go test ./... From 6cdba9de5b84a7233a196308ef8c061196969635 Mon Sep 17 00:00:00 2001 From: John Sterling Date: Sun, 15 Mar 2026 20:32:10 +0100 Subject: [PATCH 3/3] fixup! ui --- apps/finicky/src/browser/launcher.go | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/apps/finicky/src/browser/launcher.go b/apps/finicky/src/browser/launcher.go index b0c4c17..a3a7120 100644 --- a/apps/finicky/src/browser/launcher.go +++ b/apps/finicky/src/browser/launcher.go @@ -207,10 +207,10 @@ func readFirefoxProfileNames(profilesIniPath string) []string { data, err := os.ReadFile(profilesIniPath) if err != nil { slog.Info("Error reading profiles.ini", "path", profilesIniPath, "error", err) - return nil + return []string{} } - var names []string + names := []string{} for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if name, ok := strings.CutPrefix(line, "Name="); ok { @@ -220,14 +220,6 @@ func readFirefoxProfileNames(profilesIniPath string) []string { return names } -func getAllFirefoxProfiles(profilesIniPath string) []string { - names := readFirefoxProfileNames(profilesIniPath) - if names == nil { - return []string{} - } - return names -} - func parseFirefoxProfiles(profilesIniPath string, profile string) (string, bool) { names := readFirefoxProfileNames(profilesIniPath) for _, name := range names { @@ -379,7 +371,7 @@ func GetProfilesForBrowser(identifier string) []string { return getAllChromiumProfiles(localStatePath) case "Firefox": profilesIniPath := filepath.Join(homeDir, "Library/Application Support", matchedBrowser.ConfigDirRelative, "profiles.ini") - return getAllFirefoxProfiles(profilesIniPath) + return readFirefoxProfileNames(profilesIniPath) default: return []string{} }