diff --git a/apps/finicky/src/browser/browsers.json b/apps/finicky/src/browser/browsers.json index abaa113..cb4e804 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": "Safari", + "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..a3a7120 100644 --- a/apps/finicky/src/browser/launcher.go +++ b/apps/finicky/src/browser/launcher.go @@ -203,50 +203,87 @@ 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 []string{} } - var profileNames []string + 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 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 +338,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 readFirefoxProfileNames(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/configfiles.go b/apps/finicky/src/config/configfiles.go index 0a67ce3..5128ba5 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,19 @@ 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() { + select { + case notify <- struct{}{}: + default: // drop if a notification is already pending + } + }) + cfw.debounceMu.Unlock() return nil } diff --git a/apps/finicky/src/config/vm.go b/apps/finicky/src/config/vm.go index a8085e0..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,39 +30,49 @@ 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 + content, err = os.ReadFile(bundlePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %v", err) + } + } + 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. +// 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(apiContent []byte, 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(apiContent, content); err != nil { return nil, err } - return vm, nil } -func (vm *VM) setup(embeddedFiles embed.FS, bundlePath string) 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) - } - } +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") @@ -73,8 +91,8 @@ func (vm *VM) setup(embeddedFiles embed.FS, bundlePath string) error { vm.runtime.Set("finicky", finicky) - if content != nil { - if _, err = vm.runtime.RunString(string(content)); err != nil { + if len(content) > 0 { + if _, err := vm.runtime.RunString(string(content)); err != nil { return fmt.Errorf("error while running config script: %v", err) } } else { @@ -122,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 4ee25d3..9a37fd4 100644 --- a/apps/finicky/src/main.go +++ b/apps/finicky/src/main.go @@ -9,13 +9,14 @@ package main import "C" import ( - "embed" + _ "embed" "encoding/base64" - "encoding/json" "finicky/browser" "finicky/config" "finicky/logger" - "finicky/shorturl" + "finicky/resolver" + "finicky/rules" + "finicky/util" "finicky/version" "finicky/window" "flag" @@ -30,14 +31,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 @@ -46,7 +40,7 @@ type UpdateInfo struct { type URLInfo struct { URL string - Opener *ProcessInfo + Opener *resolver.OpenerInfo OpenInBackground bool } @@ -57,7 +51,6 @@ 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 @@ -66,9 +59,9 @@ var forceWindowOpen bool = false var queueWindowOpen chan bool = make(chan bool) var lastError error var dryRun bool = false +var skipJSConfig bool = false var updateInfo UpdateInfo var configInfo *ConfigInfo -var currentConfigState *config.ConfigState var shouldKeepRunning bool = true func main() { @@ -77,7 +70,9 @@ func main() { runtime.LockOSThread() // Define command line flags - configPathPtr := flag.String("config", "", "Path to custom configuration file") + configPathPtr := flag.String("config", "", "Path to custom JS configuration file") + rulesPathPtr := flag.String("rules", "", "Path to custom rules JSON file") + noConfigPtr := flag.Bool("no-config", false, "Skip JS configuration file entirely") windowPtr := flag.Bool("window", false, "Force window to open") dryRunPtr := flag.Bool("dry-run", false, "Simulate without actually opening browsers") flag.Parse() @@ -88,6 +83,16 @@ func main() { slog.Debug("Using custom config path", "path", customConfigPath) } + if *rulesPathPtr != "" { + slog.Debug("Using custom rules path", "path", *rulesPathPtr) + rules.SetCustomPath(*rulesPathPtr) + } + + if *noConfigPtr { + slog.Debug("Skipping JS config") + skipJSConfig = true + } + if *windowPtr { forceWindowOpen = true } @@ -118,7 +123,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()) } @@ -132,13 +137,40 @@ 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)) + resolver.SetCachedRules(rf) + if vm == nil || !vm.IsJSConfig() { + if rf.DefaultBrowser == "" && len(rf.Rules) == 0 && rf.Options == nil { + 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(finickyConfigAPIJS, namespace, script) + if err != nil { + slog.Error("Failed to rebuild VM from rules", "error", err) + return + } + vm = newVM + shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning + go checkForUpdates() + } + } + const oneDay = 24 * time.Hour var showingWindow bool = false timeoutChan := time.After(1 * time.Second) updateChan := time.After(oneDay) - shouldKeepRunning = getConfigOption("keepRunning", true) + shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning if shouldKeepRunning { timeoutChan = nil } @@ -154,31 +186,14 @@ 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) - } + config, err := resolver.ResolveURL(vm, url, urlInfo.Opener, urlInfo.OpenInBackground) + if err != nil { + handleRuntimeError(err) } else { - slog.Warn("No configuration available, using default configuration") + lastError = nil } - - if browserConfig == nil { - browserConfig = &browser.BrowserConfig{ - Name: "com.apple.Safari", - AppType: "bundleId", - OpenInBackground: &urlInfo.OpenInBackground, - Profile: "", - Args: []string{}, - URL: url, - } - } - - 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)) @@ -193,12 +208,13 @@ 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 + go checkForUpdates() case shouldShowWindow := <-queueWindowOpen: if !showingWindow && shouldShowWindow { @@ -226,9 +242,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) { @@ -237,29 +251,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), @@ -298,15 +296,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 := resolver.ResolveURL(vm, urlString, nil, false) if err != nil { slog.Error("Failed to evaluate URL", "error", err) window.SendMessageToWebView("testUrlResult", map[string]interface{}{ @@ -315,81 +305,15 @@ 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) { - resolvedURL, err := shorturl.ResolveURL(url) - vm.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 - - vm.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 - } - vm.Set("opener", openerMap) - slog.Debug("Setting opener", "name", opener.Name, "bundleId", opener.BundleID, "path", opener.Path, "windowTitle", opener.WindowTitle) - } else { - vm.Set("opener", nil) - slog.Debug("No opener detected") - } - - openResult, err := vm.RunString("finickyConfigAPI.openUrl(url, opener, originalUrl, finalConfig)") - if err != nil { - return nil, fmt.Errorf("failed to evaluate URL in config: %v", err) - } - - resultJSON := openResult.ToObject(vm).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) @@ -473,7 +397,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 @@ -484,50 +408,80 @@ func setupVM(cfw *config.ConfigFileWatcher, embeddedFS embed.FS, namespace strin } }() - currentBundlePath, configPath, err := cfw.BundleConfig() + var currentBundlePath, configPath string + if !skipJSConfig { + var err2 error + currentBundlePath, configPath, err2 = cfw.BundleConfig() + if err2 != nil { + return nil, fmt.Errorf("failed to read config: %v", err2) + } + } - if err != nil { - return nil, fmt.Errorf("failed to read config: %v", err) + // Always seed the cached rules from disk so JSON rules are applied + // immediately on startup, even when a JS config is also present. + if rf, rulesErr := rules.Load(); rulesErr != nil { + slog.Warn("Failed to pre-load rules cache", "error", rulesErr) + } else { + resolver.SetCachedRules(rf) } - if currentBundlePath != "" { - vm, err = config.New(embeddedFS, namespace, currentBundlePath) + var newVM *config.VM + if currentBundlePath != "" { + newVM, err = config.New(finickyConfigAPIJS, 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 { + rf, rulesErr := rules.Load() + if rulesErr != nil { + slog.Warn("Failed to load rules file", "error", rulesErr) + } 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() } } + } - 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 + } - return vm, nil + cs := newVM.GetConfigState() + if cs != nil { + configInfo = &ConfigInfo{ + Handlers: cs.Handlers, + Rewrites: cs.Rewrites, + DefaultBrowser: cs.DefaultBrowser, + ConfigPath: configPath, + } } - return nil, nil + opts := newVM.GetAllConfigOptions() + logRequests = opts.LogRequests + + window.SendMessageToWebView("config", map[string]interface{}{ + "handlers": configInfo.Handlers, + "rewrites": configInfo.Rewrites, + "defaultBrowser": configInfo.DefaultBrowser, + "configPath": util.ShortenPath(configInfo.ConfigPath), + "isJSConfig": newVM.IsJSConfig(), + "options": map[string]interface{}{ + "keepRunning": opts.KeepRunning, + "hideIcon": opts.HideIcon, + "logRequests": opts.LogRequests, + "checkForUpdates": opts.CheckForUpdates, + }, + }) + + return newVM, nil } diff --git a/apps/finicky/src/main.h b/apps/finicky/src/main.h index e8029c4..2e1ae52 100644 --- a/apps/finicky/src/main.h +++ b/apps/finicky/src/main.h @@ -18,6 +18,7 @@ extern char* GetCurrentConfigPath(); @interface BrowseAppDelegate: NSObject @property (nonatomic) bool forceOpenWindow; @property (nonatomic) bool receivedURL; + @property (nonatomic) bool didFinishLaunching; @property (nonatomic) bool keepRunning; @property (nonatomic) bool showMenuItem; - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning; diff --git a/apps/finicky/src/main.m b/apps/finicky/src/main.m index 261191c..d7c34e1 100644 --- a/apps/finicky/src/main.m +++ b/apps/finicky/src/main.m @@ -22,12 +22,14 @@ - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)sho _showMenuItem = showMenuItem; _keepRunning = keepRunning; _receivedURL = false; + _didFinishLaunching = false; } return self; } // Use bool for openWindow and related logic - (void)applicationDidFinishLaunching:(NSNotification *)notification { + self.didFinishLaunching = true; bool openWindow = self.forceOpenWindow; if (!openWindow) { // Even if we aren't forcing the window to open, we still want to open it if didn't receive a URL @@ -108,6 +110,14 @@ - (void)applicationWillFinishLaunching:(NSNotification *)aNotification } - (bool)application:(NSApplication *)sender openFile:(NSString *)filename { + // macOS calls this for command-line arguments that are file paths (e.g. the + // --config flag value) before applicationDidFinishLaunching fires. Ignore + // those — they are flag values, not URLs the user wants routed through Finicky. + if (!self.didFinishLaunching) { + NSLog(@"Ignoring openFile during launch (likely a CLI flag value): %@", filename); + return false; + } + NSLog(@"Opening file: %@", filename); // Convert the file path to a file:// URL 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..5604f67 --- /dev/null +++ b/apps/finicky/src/resolver/resolver_test.go @@ -0,0 +1,279 @@ +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: []string{"*github.com/*"}, Browser: "Google Chrome"}, + {Match: []string{"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: []string{"*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) + } + }) + } +} + +// TestResolveURL_MergedJSAndJSON_WithCachedRules verifies that JSON rules are +// applied when a JS config is present and cached rules have been seeded +// (simulating the fix for the startup bug where SetCachedRules was not called +// when a JS config existed). +func TestResolveURL_MergedJSAndJSON_WithCachedRules(t *testing.T) { + jsConfig := jsVM(t, `({ + defaultBrowser: "Safari", + handlers: [ + { match: "*github.com/*", browser: "Firefox" } + ] + })`) + + SetCachedRules(rules.RulesFile{ + DefaultBrowser: "Safari", + Rules: []rules.Rule{ + {Match: []string{"linear.app/*"}, Browser: "Google Chrome"}, + }, + }) + t.Cleanup(func() { SetCachedRules(rules.RulesFile{}) }) + + tests := []struct { + url string + browser string + }{ + {"https://github.com/foo", "Firefox"}, // JS handler wins + {"https://linear.app/team/issue/1", "Google Chrome"}, // JSON rule applies + {"https://example.com", "Safari"}, // 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 new file mode 100644 index 0000000..c376e19 --- /dev/null +++ b/apps/finicky/src/rules/rules.go @@ -0,0 +1,222 @@ +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"` +} + +// UnmarshalJSON accepts both a single string and an array for the match field. +func (r *Rule) UnmarshalJSON(data []byte) error { + var raw struct { + Match json.RawMessage `json:"match"` + Browser string `json:"browser"` + Profile string `json:"profile,omitempty"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + r.Browser = raw.Browser + r.Profile = raw.Profile + if raw.Match != nil { + var s string + if err := json.Unmarshal(raw.Match, &s); err == nil { + r.Match = []string{s} + return nil + } + return json.Unmarshal(raw.Match, &r.Match) + } + return nil +} + +// MarshalJSON serializes match as a plain string when there is only one entry. +func (r Rule) MarshalJSON() ([]byte, error) { + type RuleAlias struct { + Match interface{} `json:"match"` + Browser string `json:"browser"` + Profile string `json:"profile,omitempty"` + } + var match interface{} + if len(r.Match) == 1 { + match = r.Match[0] + } else { + match = r.Match + } + return json.Marshal(RuleAlias{Match: match, Browser: r.Browser, Profile: r.Profile}) +} + +type Options struct { + KeepRunning *bool `json:"keepRunning,omitempty"` + HideIcon *bool `json:"hideIcon,omitempty"` + LogRequests *bool `json:"logRequests,omitempty"` + CheckForUpdates *bool `json:"checkForUpdates,omitempty"` +} + +type RulesFile struct { + DefaultBrowser string `json:"defaultBrowser"` + DefaultProfile string `json:"defaultProfile,omitempty"` + Options *Options `json:"options,omitempty"` + Rules []Rule `json:"rules"` +} + +var customPath string + +// SetCustomPath overrides the default rules.json location. Pass an empty +// string to revert to the default. Intended for testing and CLI flags. +func SetCustomPath(path string) { + customPath = path +} + +// GetPath returns the path to the rules JSON file. +// Returns the custom path if one was set via SetCustomPath, otherwise +// ~/Library/Application Support/Finicky/rules.json. +func GetPath() (string, error) { + if customPath != "" { + return customPath, nil + } + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "Finicky", "rules.json"), nil +} + +// 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 + } + 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 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 + } + + 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 { + // Filter out empty patterns + matches := make([]string, 0, len(r.Match)) + for _, m := range r.Match { + if m != "" { + matches = append(matches, m) + } + } + if len(matches) == 0 || r.Browser == "" { + continue + } + var matchVal interface{} + if len(matches) == 1 { + matchVal = matches[0] + } else { + matchVal = matches + } + 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": matchVal, + "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) + } + + if rf.Options == nil { + return fmt.Sprintf("var %s = {defaultBrowser: %s, handlers: %s};", + namespace, string(defaultBrowserJSON), string(handlersJSON)), nil + } + + opts := make(map[string]interface{}) + if rf.Options.KeepRunning != nil { + opts["keepRunning"] = *rf.Options.KeepRunning + } + if rf.Options.HideIcon != nil { + opts["hideIcon"] = *rf.Options.HideIcon + } + if rf.Options.LogRequests != nil { + opts["logRequests"] = *rf.Options.LogRequests + } + if rf.Options.CheckForUpdates != nil { + opts["checkForUpdates"] = *rf.Options.CheckForUpdates + } + + optsJSON, err := json.Marshal(opts) + if err != nil { + return "", fmt.Errorf("failed to marshal options: %v", err) + } + + return fmt.Sprintf("var %s = {defaultBrowser: %s, handlers: %s, options: %s};", + namespace, string(defaultBrowserJSON), string(handlersJSON), string(optsJSON)), nil +} diff --git a/apps/finicky/src/rules/rules_test.go b/apps/finicky/src/rules/rules_test.go new file mode 100644 index 0000000..0b81fc5 --- /dev/null +++ b/apps/finicky/src/rules/rules_test.go @@ -0,0 +1,192 @@ +package rules_test + +import ( + "os" + "path/filepath" + "reflect" + "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: []string{""}, Browser: "Firefox"}, // no match + {Match: []string{"example.com"}, Browser: ""}, // no browser + {Match: []string{"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: []string{"*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: []string{"*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: []string{"*github.com/*"}, Browser: "Firefox"}, + {Match: []string{"https://linear.app/*"}, Browser: "Google Chrome", Profile: "Work"}, + {Match: []string{"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: []string{"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: []string{"*github.com/*"}, Browser: "Google Chrome", Profile: "Personal"}, + {Match: []string{"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 !reflect.DeepEqual(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/apps/finicky/src/util/directories.go b/apps/finicky/src/util/directories.go index 356ea35..d38d2ac 100644 --- a/apps/finicky/src/util/directories.go +++ b/apps/finicky/src/util/directories.go @@ -4,7 +4,10 @@ package util #include "info.h" */ import "C" -import "fmt" +import ( + "fmt" + "strings" +) // UserHomeDir returns the user's home directory using NSHomeDirectory func UserHomeDir() (string, error) { @@ -15,6 +18,18 @@ func UserHomeDir() (string, error) { return dir, nil } +// ShortenPath replaces the user's home directory prefix with ~. +func ShortenPath(path string) string { + home, err := UserHomeDir() + if err != nil || home == "" { + return path + } + if strings.HasPrefix(path, home) { + return "~" + path[len(home):] + } + return path +} + // UserCacheDir returns the user's cache directory using NSSearchPathForDirectoriesInDomains func UserCacheDir() (string, error) { dir := C.GoString(C.getNSCacheDirectory()) diff --git a/apps/finicky/src/version/version.go b/apps/finicky/src/version/version.go index 8a9de53..2c00c48 100644 --- a/apps/finicky/src/version/version.go +++ b/apps/finicky/src/version/version.go @@ -236,6 +236,16 @@ func checkForUpdates() (releaseInfo *ReleaseInfo) { // CheckForUpdatesIfEnabled checks if updates should be performed based on VM configuration func CheckForUpdatesIfEnabled(vm *goja.Runtime) (releaseInfo *ReleaseInfo, updateCheckEnabled bool, err error) { + if mockVersion := os.Getenv("FINICKY_MOCK_UPDATE"); mockVersion != "" { + slog.Info("FINICKY_MOCK_UPDATE set, returning mock update", "version", mockVersion) + mockTag := strings.TrimPrefix(mockVersion, "v") + return &ReleaseInfo{ + HasUpdate: true, + LatestVersion: mockVersion, + DownloadUrl: fmt.Sprintf("https://github.com/johnste/finicky/releases/tag/v%s", mockTag), + ReleaseUrl: fmt.Sprintf("https://github.com/johnste/finicky/releases/tag/v%s", mockTag), + }, true, nil + } if vm == nil { // Check for updates if we don't have a VM diff --git a/apps/finicky/src/window/window.go b/apps/finicky/src/window/window.go index c1ba015..2500927 100644 --- a/apps/finicky/src/window/window.go +++ b/apps/finicky/src/window/window.go @@ -10,11 +10,15 @@ import "C" import ( "encoding/json" "finicky/assets" + "finicky/browser" + "finicky/rules" + "finicky/util" "finicky/version" "fmt" "io/fs" "log/slog" "net/http" + "os" "path/filepath" "strings" "sync" @@ -22,10 +26,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 +164,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 +195,80 @@ 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 + } + + path, _ := rules.GetPath() + var rulesPath string + if _, statErr := os.Stat(path); statErr == nil { + rulesPath = path + } + + type rulesResponse struct { + rules.RulesFile + Path string `json:"path,omitempty"` + } + SendMessageToWebView("rules", rulesResponse{RulesFile: rf, Path: util.ShortenPath(rulesPath)}) +} + +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)) + + // Send the path back so the UI badge appears if the file was just created. + path, _ := rules.GetPath() + type rulesResponse struct { + rules.RulesFile + Path string `json:"path,omitempty"` + } + SendMessageToWebView("rules", rulesResponse{RulesFile: rf, Path: util.ShortenPath(path)}) + + 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..b83555e 100644 --- a/packages/finicky-ui/src/App.svelte +++ b/packages/finicky-ui/src/App.svelte @@ -5,10 +5,20 @@ 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"; + import { toast } from "./lib/toast"; + + function basename(path: string): string { + return path.split("/").pop() || path; + } + + function showPathToast(label: string, description: string, path: string) { + toast.show(label, "info", `${description}\n${path}`, 5000); + } let version = "v0.0.0"; let buildInfo = "dev"; @@ -19,6 +29,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 +62,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) @@ -79,6 +101,10 @@ for (const msg of _preloadQueue) { handleMessage(msg); } + + // Request rules file info on startup so the footer badge appears immediately. + window.finicky.sendMessage({ type: "getRules" }); + window.finicky.sendMessage({ type: "getInstalledBrowsers" }); @@ -93,6 +119,10 @@ {updateInfo} {config} {numErrors} + {rulesFile} + {installedBrowsers} + {profilesByBrowser} + isJSConfig={config.isJSConfig ?? false} /> @@ -109,14 +139,26 @@ {version} /> + + + + @@ -149,7 +192,7 @@ } .container { - padding: 1rem; + padding: 1.25rem 1.25rem; max-width: 100%; box-sizing: border-box; display: flex; @@ -163,16 +206,18 @@ display: flex; flex: 0 0 auto; align-items: center; - gap: 0.5rem; - padding: 0.5rem; - background: var(--background); + gap: 0.6rem; + padding: 0.4rem 0.75rem; + background: var(--bg-nav); border-top: 1px solid var(--border-color); overflow: hidden; } .version { color: var(--text-secondary); - font-size: 0.9em; + font-size: 0.75em; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; } .content { @@ -185,38 +230,40 @@ .up-to-date { color: var(--log-success); - font-size: 0.8em; - opacity: 0.9; + font-size: 0.75em; } - .config-path { + .config-label { color: var(--text-secondary); - font-size: 0.8em; + font-size: 0.75em; + } + + .config-badge { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; - opacity: 0.8; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 500px; - flex-shrink: 1; - background: rgba(0, 0, 0, 0.2); - padding: 2px 6px; + font-size: 0.72em; + padding: 2px 8px; border-radius: 3px; + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background 0.15s ease, color 0.15s ease; } - .config-label { - color: var(--text-secondary); - font-size: 0.8em; - opacity: 0.8; + .config-badge:hover { + background: var(--button-hover); + color: var(--text-primary); } .config-status { - font-size: 0.8em; - opacity: 0.8; + font-size: 0.75em; + color: var(--text-secondary); } .config-status.warning { - color: #ffc107; + color: var(--log-warning); } .config-link { diff --git a/packages/finicky-ui/src/app.css b/packages/finicky-ui/src/app.css index 117ebf6..fdca9b7 100644 --- a/packages/finicky-ui/src/app.css +++ b/packages/finicky-ui/src/app.css @@ -5,8 +5,8 @@ font-weight: 400; color-scheme: dark; - color: rgb(204, 204, 204); - background-color: #1a1a1a; + color: #EBEBF5; + background-color: #1C1C1E; font-synthesis: none; text-rendering: optimizeLegibility; @@ -15,45 +15,66 @@ } :root { - --bg-primary: #1a1a1a; - --bg-hover: rgba(255, 255, 255, 0.1); - --bg-nav: #1a1a1a; - --text-primary: #ffffff; - --text-secondary: rgb(204, 204, 204); - --border-color: #333333; - --status-bg: #2d3436; - --log-bg: #232323; - --log-header-bg: #2a2a2a; - --button-bg: #3d3d3d; - --button-hover: #4a4a4a; - --log-error: #ff5252; - --log-warning: #ffb74d; - --log-success: #73d13d; - --log-debug: #b654ff; - --nav-active: transparent; - --nav-hover: transparent; - --accent-color: #b654ff; + --bg-primary: #1C1C1E; + --bg-nav: #2C2C2E; + --bg-hover: rgba(255, 255, 255, 0.07); + --card-bg: #2C2C2E; + --card-border: rgba(255, 255, 255, 0.12); + --nav-text: #EBEBF5; + --nav-text-secondary: #ADADB3; + --text-primary: #EBEBF5; + --text-secondary: #ADADB3; + --border-color: rgba(255, 255, 255, 0.15); + --status-bg: #3A3A3C; + --log-bg: #2C2C2E; + --log-header-bg: #3A3A3C; + --button-bg: #3A3A3C; + --button-hover: #48484A; + --input-bg: rgba(0, 0, 0, 0.3); + --inset-bg: rgba(255, 255, 255, 0.05); + --log-error: #FF453A; + --log-warning: #FFD60A; + --log-success: #30D158; + --log-debug: #0A84FF; + --nav-active: #0071E3; + --nav-active-text: #ffffff; + --nav-hover: rgba(255, 255, 255, 0.08); + --accent-color: #0A84FF; + --toggle-active: #30D158; } @media (prefers-color-scheme: light) { :root { - --bg-primary: #ffffff; + color-scheme: light; + color: #1C1C1E; + background-color: #F2F2F7; + + --bg-primary: #F2F2F7; + --bg-nav: #E8E8ED; --bg-hover: rgba(0, 0, 0, 0.04); - --bg-nav: #f3f3f3; - --text-primary: #333333; - --text-secondary: #737373; - --border-color: #dddddd; - --status-bg: #e8f5e9; - --log-bg: #fafafa; - --log-header-bg: #f5f5f5; - --button-bg: #ffffff; - --button-hover: #f0f0f0; - --log-error: #d32f2f; - --log-warning: #f57c00; - --log-success: #389e0d; - --log-debug: #b654ff; - --nav-active: #ffffff; - --nav-hover: #e8e8e8; + --card-bg: #FFFFFF; + --card-border: rgba(0, 0, 0, 0.1); + --nav-text: #1C1C1E; + --nav-text-secondary: #545458; + --text-primary: #1C1C1E; + --text-secondary: #545458; + --border-color: rgba(0, 0, 0, 0.15); + --status-bg: #F2F2F7; + --log-bg: #FFFFFF; + --log-header-bg: #F2F2F7; + --button-bg: #FFFFFF; + --button-hover: #F2F2F7; + --input-bg: rgba(0, 0, 0, 0.04); + --inset-bg: rgba(0, 0, 0, 0.03); + --log-error: #FF3B30; + --log-warning: #FF9F0A; + --log-success: #34C759; + --log-debug: #007AFF; + --nav-active: #007AFF; + --nav-active-text: #ffffff; + --nav-hover: rgba(0, 0, 0, 0.06); + --accent-color: #007AFF; + --toggle-active: #34C759; } } @@ -67,10 +88,10 @@ body { body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto; background: var(--bg-primary); - color: var(--text-secondary); + color: var(--text-primary); min-width: 800px; } a { - color: var(--text-secondary); + color: var(--accent-color); } diff --git a/packages/finicky-ui/src/components/BrowserProfileSelector.svelte b/packages/finicky-ui/src/components/BrowserProfileSelector.svelte new file mode 100644 index 0000000..61f158d --- /dev/null +++ b/packages/finicky-ui/src/components/BrowserProfileSelector.svelte @@ -0,0 +1,248 @@ + + +
+ {#if isCustom} + onSave?.()} + {disabled} + /> + + {:else} +
+ +
+ {/if} + + {#if !isCustom && browser && profileOptions(browser).length > 0} + {#if isProfileCustom} + onSave?.()} + {disabled} + /> + + {:else} +
+ +
+ {/if} + {/if} +
+ + diff --git a/packages/finicky-ui/src/components/OptionRow.svelte b/packages/finicky-ui/src/components/OptionRow.svelte new file mode 100644 index 0000000..6db94e2 --- /dev/null +++ b/packages/finicky-ui/src/components/OptionRow.svelte @@ -0,0 +1,178 @@ + + +{#snippet inner()} +
+
+ {label} + {hint} +
+ +
+{/snippet} + +{#if locked} + + + +{:else} +
+ {@render inner()} +
+{/if} + + diff --git a/packages/finicky-ui/src/components/PageContainer.svelte b/packages/finicky-ui/src/components/PageContainer.svelte index b1746e5..2d7c763 100644 --- a/packages/finicky-ui/src/components/PageContainer.svelte +++ b/packages/finicky-ui/src/components/PageContainer.svelte @@ -10,7 +10,7 @@ children: Snippet; center?: boolean; title?: string; - description?: string; + description?: Snippet; } = $props(); @@ -19,7 +19,7 @@

{title}

{#if description} -

{description}

+

{@render description()}

{/if}
{/if} @@ -28,12 +28,11 @@ diff --git a/packages/finicky-ui/src/components/TabBar.svelte b/packages/finicky-ui/src/components/TabBar.svelte index 1a752c2..0ccd4d7 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,11 +14,19 @@ label: "Preferences", component: PreferencesIcon, }, + { + path: "/rules", + label: "Rules", + component: RulesIcon, + }, { path: "/test", label: "Test", component: TestIcon, }, + ]; + + const bottomTabs = [ { path: "/troubleshoot", label: "Logs", @@ -39,53 +48,53 @@
- {#if tab.showErrors && numErrors > 0} -
{numErrors}
- {/if}
- - {tab.label} - + {tab.label}
{/each} +
+
+ {#each bottomTabs as tab} + +
+
+ + {#if tab.showErrors && numErrors > 0} +
{numErrors}
+ {/if} +
+ {tab.label} +
+ + {/each} +
diff --git a/packages/finicky-ui/src/components/Tooltip.svelte b/packages/finicky-ui/src/components/Tooltip.svelte new file mode 100644 index 0000000..8050d6d --- /dev/null +++ b/packages/finicky-ui/src/components/Tooltip.svelte @@ -0,0 +1,54 @@ + + + + {@render children()} + {text} + + + diff --git a/packages/finicky-ui/src/components/icons/Lock.svelte b/packages/finicky-ui/src/components/icons/Lock.svelte new file mode 100644 index 0000000..05b947b --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Lock.svelte @@ -0,0 +1,15 @@ + \ No newline at end of file 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/components/icons/Warning.svelte b/packages/finicky-ui/src/components/icons/Warning.svelte new file mode 100644 index 0000000..1878edc --- /dev/null +++ b/packages/finicky-ui/src/components/icons/Warning.svelte @@ -0,0 +1,15 @@ + + + + + diff --git a/packages/finicky-ui/src/components/icons/X.svelte b/packages/finicky-ui/src/components/icons/X.svelte new file mode 100644 index 0000000..0c493a1 --- /dev/null +++ b/packages/finicky-ui/src/components/icons/X.svelte @@ -0,0 +1,14 @@ + + + + diff --git a/packages/finicky-ui/src/lib/testUrlStore.ts b/packages/finicky-ui/src/lib/testUrlStore.ts index 5c974e3..c7adfac 100644 --- a/packages/finicky-ui/src/lib/testUrlStore.ts +++ b/packages/finicky-ui/src/lib/testUrlStore.ts @@ -8,3 +8,4 @@ export interface TestUrlResult { } export const testUrlResult = writable(null); +export const testUrlInput = writable(''); diff --git a/packages/finicky-ui/src/pages/About.svelte b/packages/finicky-ui/src/pages/About.svelte index 0c08b48..7c40155 100644 --- a/packages/finicky-ui/src/pages/About.svelte +++ b/packages/finicky-ui/src/pages/About.svelte @@ -10,136 +10,121 @@ -
- -
-
- - - Finicky icon - -

Version {version}

+
+ + + Finicky icon + +
+ Version {version}
+
-
+
+

+ Finicky is a macOS application that lets you set up rules to decide which + browser to open for every link. +

+ + View on GitHub + +
-
-

Credits

-

Created by John Sterling

-

Icon designed by @uetchy

-

View all contributors

-
+
+

Credits

+

+ Created by John Sterling +

+

+ Icon designed by @uetchy +

+

+ View all contributors +

+
-
-

Support Development

-

If you find Finicky useful, consider .

-
+
+

+ If you find Finicky useful, consider sponsoring the project. +

diff --git a/packages/finicky-ui/src/pages/LogViewer.svelte b/packages/finicky-ui/src/pages/LogViewer.svelte index cccedfb..8221252 100644 --- a/packages/finicky-ui/src/pages/LogViewer.svelte +++ b/packages/finicky-ui/src/pages/LogViewer.svelte @@ -14,28 +14,19 @@ let showDebug = $state(localStorage.getItem("showDebugLogs") === "true"); - // Copy logs to clipboard async function copyLogs() { const logEntries = messageBuffer .filter((entry) => showDebug || entry.level.toLowerCase() !== "debug") .map((entry) => { const time = new Date(entry.time).toISOString(); const baseMessage = `[${time}] [${entry.level.padEnd(5)}] ${entry.msg}`; - - // Get all extra fields (excluding level, msg, time, error) const extraFields = Object.entries(entry) - .filter( - ([key]: [string, any]) => - !["level", "msg", "time", "error"].includes(key) - ) + .filter(([key]: [string, any]) => !["level", "msg", "time", "error"].includes(key)) .map(([key, value]: [string, any]) => `${key}: ${value}`) .join(" | "); - - // Combine base message with extra fields and error if present const parts = [baseMessage]; if (extraFields) parts.push(extraFields); if (entry.error) parts.push(`Error: ${entry.error}`); - return parts.join(" | "); }) .join("\n"); @@ -45,102 +36,65 @@ toast.success("Logs copied to clipboard"); } catch (err) { console.error("Failed to copy logs:", err); - toast.error("Failed to copy logs", err instanceof Error ? err.message : (err?.toString() ?? 'unknown error')); + toast.error("Failed to copy logs", err instanceof Error ? err.message : (err?.toString() ?? "unknown error")); } } - - // Clear logs - function clearLogs() { - onClearLogs(); - } - -
-

Logs

-
-
+ {/snippet} diff --git a/packages/finicky-ui/src/pages/Rules.svelte b/packages/finicky-ui/src/pages/Rules.svelte new file mode 100644 index 0000000..f2449f9 --- /dev/null +++ b/packages/finicky-ui/src/pages/Rules.svelte @@ -0,0 +1,513 @@ + + + + {#snippet description()} + {#if isJSConfig} + The first matching rule wins. JS config is active — its handlers run first and take priority over these rules. + {:else} + The first matching rule wins. Use * as a wildcard, e.g. *example.com/*. + {/if} + {/snippet} + + {#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" + > +
+ + { + rules[i] = { ...rules[i], browser, profile }; + rowIsCustom[i] = isCustom; + rowProfileIsCustom[i] = false; + }} + onProfileChange={(profile, isProfileCustom) => { + rules[i] = { ...rules[i], profile }; + rowProfileIsCustom[i] = isProfileCustom; + }} + onRequestProfiles={(b) => window.finicky.sendMessage({ type: "getBrowserProfiles", browser: b })} + onSave={save} + onInput={scheduleSave} + /> + {#if !rule.browser && !rowIsCustom[i]} + + + Browser required + + {/if} + +
+ +
+
+ {#each rule.match as pattern, j} +
+
+ onRowMatchInput(i, j, e)} + onblur={() => save()} + use:autofocusNew={{ rule: i, pattern: j }} + /> + {#if patternNeedsWildcard(pattern)} + + {/if} +
+ {#if rule.match.length > 1} + + {/if} +
+ {/each} + +
+
+
+ {/each} +
+ {/if} + + +
+ + diff --git a/packages/finicky-ui/src/pages/StartPage.svelte b/packages/finicky-ui/src/pages/StartPage.svelte index 44985f9..71fb88e 100644 --- a/packages/finicky-ui/src/pages/StartPage.svelte +++ b/packages/finicky-ui/src/pages/StartPage.svelte @@ -1,21 +1,92 @@ - + + {#if hasConfig} + {#snippet description()}Current settings from your configuration file{/snippet} + {/if} {#if !hasConfig}
{/if} + +
+
+ + {#if isJSConfig} + + + + {:else} + Used when no rule matches + {/if} +
+ { + defaultBrowser = browser; + defaultProfile = profile; + defaultBrowserIsCustom = isCustom; + defaultProfileIsCustom = false; + }} + onProfileChange={(profile, isProfileCustom) => { + defaultProfile = profile; + defaultProfileIsCustom = isProfileCustom; + }} + onRequestProfiles={(b) => window.finicky.sendMessage({ type: "getBrowserProfiles", browser: b })} + onSave={save} + onInput={scheduleSave} + /> +
+
-
-
-
- Keep running - App stays open in the background -
- -
-
-
-
-
- Hide icon - Hide menu bar icon -
- -
-
-
-
-
- Log requests - Log all URL handling to file -
- -
-
-
-
-
- Check for updates - Automatically check for new versions -
- -
-
+ + + +
@@ -113,35 +188,70 @@ {#if updateInfo} {#if updateInfo.hasUpdate}
{:else if !updateInfo.updateCheckEnabled}

Update check is disabled

- - Check releases - + Check releases
{/if} {/if} - - diff --git a/packages/finicky-ui/src/pages/TestUrl.svelte b/packages/finicky-ui/src/pages/TestUrl.svelte index 6c6bf27..95cee7c 100644 --- a/packages/finicky-ui/src/pages/TestUrl.svelte +++ b/packages/finicky-ui/src/pages/TestUrl.svelte @@ -3,9 +3,9 @@ import LinkIcon from "../components/icons/Link.svelte"; import InfoIcon from "../components/icons/Info.svelte"; import SpinnerIcon from "../components/icons/Spinner.svelte"; - import { testUrlResult } from "../lib/testUrlStore"; + import { testUrlResult, testUrlInput } from "../lib/testUrlStore"; - let testUrl = ""; + let testUrl = $testUrlInput; let loading = false; // Debounce timer @@ -66,16 +66,14 @@ // Reactive statement to test URL whenever it changes $: if (testUrl !== undefined) { + testUrlInput.set(testUrl); testUrlAutomatically(); } - -
-
+ + {#snippet description()}Test how Finicky will handle a URL based on your current configuration{/snippet} +
{/if} -