From 6a458b1126fbcce6b617766b6c73132ebf22a79a Mon Sep 17 00:00:00 2001 From: alexytsu Date: Mon, 21 Sep 2026 06:16:20 -0400 Subject: [PATCH 1/6] feat: support XDG_CONFIG_HOME for config file discovery Honor the XDG Base Directory spec when locating the config file: $XDG_CONFIG_HOME/finicky.{js,ts} and $XDG_CONFIG_HOME/finicky/finicky.{js,ts} are searched, falling back to ~/.config when the variable is unset. ~/.finicky.{js,ts} remains the first match for backward compatibility. Refs johnste/finicky#298, johnste/finicky#441 --- README.md | 8 +++ apps/finicky/src/config/configfiles.go | 15 +++-- apps/finicky/src/config/configfiles_test.go | 62 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 apps/finicky/src/config/configfiles_test.go diff --git a/README.md b/README.md index 65ad1636..03800c58 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,14 @@ Finicky is a macOS application that allows you to set up rules that decide which - Download from [releases](https://github.com/johnste/finicky/releases) - Or install via homebrew: `brew install --cask finicky` - Create a JavaScript or TypeScript configuration file at `~/.finicky.js`. Have a look at the example configuration below, or in the `example-config` folder. + + Finicky looks for a configuration file in the following locations (first match wins), where `$XDG_CONFIG_HOME` defaults to `~/.config` when unset: + + - `~/.finicky.js` / `~/.finicky.ts` + - `$XDG_CONFIG_HOME/finicky.js` / `$XDG_CONFIG_HOME/finicky.ts` + - `$XDG_CONFIG_HOME/finicky/finicky.js` / `$XDG_CONFIG_HOME/finicky/finicky.ts` + + Note: apps launched from Finder don't inherit shell environment variables, so a custom `$XDG_CONFIG_HOME` set only in your shell profile won't be seen by Finicky unless it's set via `launchctl setenv`. The `~/.config` fallback always works. - Start Finicky (in Applications, or through Spotlight/Alfred/Raycast) and allow it to be set as the default browser. Starting Finicky manually opens the configuration/troubleshooting window. ## Basic configuration diff --git a/apps/finicky/src/config/configfiles.go b/apps/finicky/src/config/configfiles.go index 795743d9..64e6a1c9 100644 --- a/apps/finicky/src/config/configfiles.go +++ b/apps/finicky/src/config/configfiles.go @@ -71,13 +71,20 @@ func (cfw *ConfigFileWatcher) GetConfigPaths() []string { if cfw.customConfigPath != "" { configPaths = append(configPaths, cfw.customConfigPath) } else { + // XDG Base Directory spec: use $XDG_CONFIG_HOME when set, + // falling back to ~/.config + xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") + if xdgConfigHome == "" { + xdgConfigHome = filepath.Join(homeDir, ".config") + } + configPaths = append(configPaths, "~/.finicky.js", "~/.finicky.ts", - "~/.config/finicky.js", - "~/.config/finicky.ts", - "~/.config/finicky/finicky.js", - "~/.config/finicky/finicky.ts", + filepath.Join(xdgConfigHome, "finicky.js"), + filepath.Join(xdgConfigHome, "finicky.ts"), + filepath.Join(xdgConfigHome, "finicky", "finicky.js"), + filepath.Join(xdgConfigHome, "finicky", "finicky.ts"), ) } diff --git a/apps/finicky/src/config/configfiles_test.go b/apps/finicky/src/config/configfiles_test.go new file mode 100644 index 00000000..418a1478 --- /dev/null +++ b/apps/finicky/src/config/configfiles_test.go @@ -0,0 +1,62 @@ +package config + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestGetConfigPathsDefaultsToDotConfig(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + + cfw := &ConfigFileWatcher{} + paths := cfw.GetConfigPaths() + + if len(paths) != 6 { + t.Fatalf("expected 6 config paths, got %d: %v", len(paths), paths) + } + + for _, p := range paths { + if strings.Contains(p, "~") { + t.Errorf("path %q was not expanded", p) + } + } + + if !strings.HasSuffix(paths[2], filepath.Join(".config", "finicky.js")) { + t.Errorf("expected fallback to ~/.config, got %q", paths[2]) + } + if !strings.HasSuffix(paths[4], filepath.Join(".config", "finicky", "finicky.js")) { + t.Errorf("expected fallback to ~/.config/finicky, got %q", paths[4]) + } +} + +func TestGetConfigPathsHonorsXDGConfigHome(t *testing.T) { + xdgHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgHome) + + cfw := &ConfigFileWatcher{} + paths := cfw.GetConfigPaths() + + expected := []string{ + filepath.Join(xdgHome, "finicky.js"), + filepath.Join(xdgHome, "finicky.ts"), + filepath.Join(xdgHome, "finicky", "finicky.js"), + filepath.Join(xdgHome, "finicky", "finicky.ts"), + } + + for i, want := range expected { + got := paths[i+2] + if got != want { + t.Errorf("paths[%d] = %q, want %q", i+2, got, want) + } + } +} + +func TestGetConfigPathsCustomPathWins(t *testing.T) { + cfw := &ConfigFileWatcher{customConfigPath: "/tmp/custom-finicky.js"} + paths := cfw.GetConfigPaths() + + if len(paths) != 1 || paths[0] != "/tmp/custom-finicky.js" { + t.Errorf("expected only the custom path, got %v", paths) + } +} From 42e866a94efb4229e8fd7e8e6ce212573d5072d6 Mon Sep 17 00:00:00 2001 From: alexytsu Date: Thu, 24 Sep 2026 11:43:13 -0400 Subject: [PATCH 2/6] fix: watch nearest existing ancestor for not-yet-created config dirs fsnotify watches are not recursive, so if $XDG_CONFIG_HOME/finicky did not exist at startup the watcher never noticed a config file created inside it later. Watch the nearest existing ancestor of each candidate folder and refresh watches when an intermediate directory is created. Addresses CodeRabbit review on johnste/finicky#555 --- apps/finicky/src/config/configfiles.go | 69 +++++++++++++++++---- apps/finicky/src/config/configfiles_test.go | 36 +++++++++++ 2 files changed, 92 insertions(+), 13 deletions(-) diff --git a/apps/finicky/src/config/configfiles.go b/apps/finicky/src/config/configfiles.go index 64e6a1c9..ade2d29d 100644 --- a/apps/finicky/src/config/configfiles.go +++ b/apps/finicky/src/config/configfiles.go @@ -250,26 +250,38 @@ func (cfw *ConfigFileWatcher) StartWatching() error { // Use a map to track unique folders uniqueFolders := make(map[string]bool) for _, path := range configPaths { - expandedPath := os.ExpandEnv(path) - folder := filepath.Dir(expandedPath) + folder := filepath.Dir(path) uniqueFolders[folder] = true } + // Folders may not exist yet (e.g. $XDG_CONFIG_HOME/finicky), so + // watch the nearest existing ancestor of each and refresh watches + // as intermediate directories get created. fsnotify watches are + // not recursive. + watchedFolders := make(map[string]bool) + addWatches := func() { + for folder := range uniqueFolders { + ancestor := nearestExistingDir(folder) + if ancestor == "" || watchedFolders[ancestor] { + continue + } + if err := cfw.watcher.Add(ancestor); err != nil { + slog.Debug("Error watching folder", "folder", ancestor, "error", err) + } else { + watchedFolders[ancestor] = true + } + } + } + addWatches() + // Convert to slice for logging var watchPaths []string - for folder := range uniqueFolders { + for folder := range watchedFolders { watchPaths = append(watchPaths, folder) } slog.Debug("Watching for config files", "paths", watchPaths) - // Add each unique folder to the watcher - for folder := range uniqueFolders { - if err := cfw.watcher.Add(folder); err != nil { - slog.Debug("Error watching folder", "folder", folder, "error", err) - } - } - detectedCreation := false for !detectedCreation { select { @@ -283,14 +295,19 @@ func (cfw *ConfigFileWatcher) StartWatching() error { eventName := event.Name isConfigFile := false for _, path := range configPaths { - expandedPath := os.ExpandEnv(path) - if eventName == expandedPath { + if eventName == path { isConfigFile = true break } } if !isConfigFile { + // A directory on the way to a config folder may + // have been created; start watching it so a config + // file created inside it is detected. + if event.Has(fsnotify.Create) && isAncestorOfAny(eventName, uniqueFolders) { + addWatches() + } break } @@ -301,7 +318,7 @@ func (cfw *ConfigFileWatcher) StartWatching() error { return err } - for folder := range uniqueFolders { + for folder := range watchedFolders { if err := cfw.watcher.Remove(folder); err != nil { slog.Debug("Error removing watch on folder", "folder", folder, "error", err) } @@ -388,6 +405,32 @@ func (cfw *ConfigFileWatcher) handleConfigFileEvent(event fsnotify.Event) error return nil } +// nearestExistingDir walks up from path until it finds a directory that +// exists, returning "" if none does +func nearestExistingDir(path string) string { + for { + if info, err := os.Stat(path); err == nil && info.IsDir() { + return path + } + parent := filepath.Dir(path) + if parent == path { + return "" + } + path = parent + } +} + +// isAncestorOfAny reports whether path equals, or is an ancestor of, any of +// the given folders +func isAncestorOfAny(path string, folders map[string]bool) bool { + for folder := range folders { + if folder == path || strings.HasPrefix(folder, path+string(os.PathSeparator)) { + return true + } + } + return false +} + // resolveSymlink resolves a symlink to its target file path // If the path is not a symlink, it returns the original path func resolveSymlink(path string) (string, error) { diff --git a/apps/finicky/src/config/configfiles_test.go b/apps/finicky/src/config/configfiles_test.go index 418a1478..403aa2d2 100644 --- a/apps/finicky/src/config/configfiles_test.go +++ b/apps/finicky/src/config/configfiles_test.go @@ -52,6 +52,42 @@ func TestGetConfigPathsHonorsXDGConfigHome(t *testing.T) { } } +func TestNearestExistingDir(t *testing.T) { + base := t.TempDir() + + if got := nearestExistingDir(base); got != base { + t.Errorf("nearestExistingDir(%q) = %q, want the dir itself", base, got) + } + + missing := filepath.Join(base, "finicky", "nested") + if got := nearestExistingDir(missing); got != base { + t.Errorf("nearestExistingDir(%q) = %q, want nearest ancestor %q", missing, got, base) + } +} + +func TestIsAncestorOfAny(t *testing.T) { + folders := map[string]bool{ + "/home/user/.config/finicky": true, + } + + cases := []struct { + path string + want bool + }{ + {"/home/user/.config/finicky", true}, + {"/home/user/.config", true}, + {"/home/user", true}, + {"/home/user/.config/finick", false}, + {"/home/other", false}, + } + + for _, c := range cases { + if got := isAncestorOfAny(c.path, folders); got != c.want { + t.Errorf("isAncestorOfAny(%q) = %v, want %v", c.path, got, c.want) + } + } +} + func TestGetConfigPathsCustomPathWins(t *testing.T) { cfw := &ConfigFileWatcher{customConfigPath: "/tmp/custom-finicky.js"} paths := cfw.GetConfigPaths() From 8ba500075b7061b66306b019665a931352546de4 Mon Sep 17 00:00:00 2001 From: alexytsu Date: Thu, 24 Sep 2026 19:01:39 -0400 Subject: [PATCH 3/6] fix: handle deleted/recreated dirs and pre-populated config dirs in watcher Use the watcher's live WatchList as the source of truth instead of a separate map, so a watched directory that is deleted (fsnotify drops the watch automatically) and later recreated gets watched again. When a directory is moved into place with a config file already inside, no file-creation event follows the directory event, so check for a config file right after adding the new watch and load it directly. Addresses CodeRabbit review round 2 on johnste/finicky#555 --- apps/finicky/src/config/configfiles.go | 53 ++++++++++++++++---------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/apps/finicky/src/config/configfiles.go b/apps/finicky/src/config/configfiles.go index ade2d29d..81968dc1 100644 --- a/apps/finicky/src/config/configfiles.go +++ b/apps/finicky/src/config/configfiles.go @@ -257,30 +257,36 @@ func (cfw *ConfigFileWatcher) StartWatching() error { // Folders may not exist yet (e.g. $XDG_CONFIG_HOME/finicky), so // watch the nearest existing ancestor of each and refresh watches // as intermediate directories get created. fsnotify watches are - // not recursive. - watchedFolders := make(map[string]bool) + // not recursive. The watcher's live watch list is the source of + // truth: fsnotify drops a watch itself when a directory is + // deleted, so a directory that is removed and recreated gets + // watched again here. addWatches := func() { + watched := make(map[string]bool) + for _, path := range cfw.watcher.WatchList() { + watched[path] = true + } for folder := range uniqueFolders { ancestor := nearestExistingDir(folder) - if ancestor == "" || watchedFolders[ancestor] { + if ancestor == "" || watched[ancestor] { continue } if err := cfw.watcher.Add(ancestor); err != nil { slog.Debug("Error watching folder", "folder", ancestor, "error", err) - } else { - watchedFolders[ancestor] = true } } } addWatches() - // Convert to slice for logging - var watchPaths []string - for folder := range watchedFolders { - watchPaths = append(watchPaths, folder) + removeAllWatches := func() { + for _, path := range cfw.watcher.WatchList() { + if err := cfw.watcher.Remove(path); err != nil { + slog.Debug("Error removing watch on folder", "folder", path, "error", err) + } + } } - slog.Debug("Watching for config files", "paths", watchPaths) + slog.Debug("Watching for config files", "paths", cfw.watcher.WatchList()) detectedCreation := false for !detectedCreation { @@ -301,13 +307,22 @@ func (cfw *ConfigFileWatcher) StartWatching() error { } } - if !isConfigFile { - // A directory on the way to a config folder may - // have been created; start watching it so a config - // file created inside it is detected. - if event.Has(fsnotify.Create) && isAncestorOfAny(eventName, uniqueFolders) { - addWatches() + if !isConfigFile && event.Has(fsnotify.Create) && isAncestorOfAny(eventName, uniqueFolders) { + // A directory on the way to a config folder was + // created; start watching it so a config file + // created inside it is detected. + addWatches() + + // The directory may have been moved into place + // with a config file already inside it, in which + // case no separate file event will follow. + if foundPath, err := cfw.GetConfigPath(false); err == nil { + event = fsnotify.Event{Name: foundPath, Op: fsnotify.Create} + isConfigFile = true } + } + + if !isConfigFile { break } @@ -318,11 +333,7 @@ func (cfw *ConfigFileWatcher) StartWatching() error { return err } - for folder := range watchedFolders { - if err := cfw.watcher.Remove(folder); err != nil { - slog.Debug("Error removing watch on folder", "folder", folder, "error", err) - } - } + removeAllWatches() } case err, ok := <-cfw.watcher.Errors: From b9d1ecfd3e98c95aecea2444cf67158ea64ecc86 Mon Sep 17 00:00:00 2001 From: alexytsu Date: Fri, 25 Sep 2026 00:33:25 -0400 Subject: [PATCH 4/6] fix: rebuild ancestor watches when a watched directory is removed or renamed fsnotify drops a directory's watch when it is deleted, and the directory's ancestors are not necessarily watched (e.g. a custom $XDG_CONFIG_HOME outside ~/.config), so a recreated directory could go unnoticed. Re-add watches for the nearest existing ancestors on Remove/Rename events. Addresses CodeRabbit review round 3 on johnste/finicky#555 --- apps/finicky/src/config/configfiles.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/finicky/src/config/configfiles.go b/apps/finicky/src/config/configfiles.go index 81968dc1..a4c70d54 100644 --- a/apps/finicky/src/config/configfiles.go +++ b/apps/finicky/src/config/configfiles.go @@ -296,6 +296,13 @@ func (cfw *ConfigFileWatcher) StartWatching() error { return fmt.Errorf("watcher closed") } + // A removed or renamed directory loses its fsnotify watch, + // and its ancestors may not be watched; fall back to the + // nearest existing ancestor so a recreation is detected. + if event.Has(fsnotify.Remove) || event.Has(fsnotify.Rename) { + addWatches() + } + if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) { // Check if the event path matches any of our config paths eventName := event.Name From 463d36dfe6eebc126033bda669e073a3b8e0e120 Mon Sep 17 00:00:00 2001 From: alexytsu Date: Fri, 25 Sep 2026 01:36:59 -0400 Subject: [PATCH 5/6] fix: ignore relative XDG_CONFIG_HOME values per XDG spec --- apps/finicky/src/config/configfiles.go | 6 +++--- apps/finicky/src/config/configfiles_test.go | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/finicky/src/config/configfiles.go b/apps/finicky/src/config/configfiles.go index a4c70d54..35d3c1d8 100644 --- a/apps/finicky/src/config/configfiles.go +++ b/apps/finicky/src/config/configfiles.go @@ -71,10 +71,10 @@ func (cfw *ConfigFileWatcher) GetConfigPaths() []string { if cfw.customConfigPath != "" { configPaths = append(configPaths, cfw.customConfigPath) } else { - // XDG Base Directory spec: use $XDG_CONFIG_HOME when set, - // falling back to ~/.config + // XDG Base Directory spec: use $XDG_CONFIG_HOME when set to an + // absolute path, falling back to ~/.config (relative values are ignored) xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") - if xdgConfigHome == "" { + if xdgConfigHome == "" || !filepath.IsAbs(xdgConfigHome) { xdgConfigHome = filepath.Join(homeDir, ".config") } diff --git a/apps/finicky/src/config/configfiles_test.go b/apps/finicky/src/config/configfiles_test.go index 403aa2d2..d43d90cc 100644 --- a/apps/finicky/src/config/configfiles_test.go +++ b/apps/finicky/src/config/configfiles_test.go @@ -52,6 +52,22 @@ func TestGetConfigPathsHonorsXDGConfigHome(t *testing.T) { } } +func TestGetConfigPathsIgnoresRelativeXDGConfigHome(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "relative/config") + + cfw := &ConfigFileWatcher{} + paths := cfw.GetConfigPaths() + + for _, p := range paths { + if strings.Contains(p, "relative") { + t.Errorf("relative XDG_CONFIG_HOME should be ignored, got %q", p) + } + } + if !strings.HasSuffix(paths[2], filepath.Join(".config", "finicky.js")) { + t.Errorf("expected fallback to ~/.config, got %q", paths[2]) + } +} + func TestNearestExistingDir(t *testing.T) { base := t.TempDir() From 833d88adccdc7c79a0bfa664eeb35e4573f52bd6 Mon Sep 17 00:00:00 2001 From: alexytsu Date: Fri, 25 Sep 2026 01:51:16 -0400 Subject: [PATCH 6/6] test: cover watcher lifecycle when config dirs don't exist yet --- apps/finicky/src/config/configfiles_test.go | 48 +++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/apps/finicky/src/config/configfiles_test.go b/apps/finicky/src/config/configfiles_test.go index d43d90cc..94fbefec 100644 --- a/apps/finicky/src/config/configfiles_test.go +++ b/apps/finicky/src/config/configfiles_test.go @@ -1,9 +1,13 @@ package config import ( + "os" "path/filepath" "strings" "testing" + "time" + + "github.com/fsnotify/fsnotify" ) func TestGetConfigPathsDefaultsToDotConfig(t *testing.T) { @@ -112,3 +116,47 @@ func TestGetConfigPathsCustomPathWins(t *testing.T) { t.Errorf("expected only the custom path, got %v", paths) } } + +func TestWatcherPicksUpConfigCreatedInMissingDirs(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "a", "b", "finicky.js") + + watcher, err := fsnotify.NewWatcher() + if err != nil { + t.Fatal(err) + } + notify := make(chan struct{}, 1) + cfw := &ConfigFileWatcher{ + watcher: watcher, + customConfigPath: configPath, + configChangeNotify: notify, + cache: &ConfigCache{cachePath: filepath.Join(root, "cache.json")}, + } + defer cfw.TearDown() + go cfw.StartWatching() + + waitForNotify := func(what string) { + t.Helper() + select { + case <-notify: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for notification after %s", what) + } + } + + // Give the watcher time to watch the nearest existing ancestor (root) + time.Sleep(100 * time.Millisecond) + + if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(configPath, []byte("export default {}\n"), 0644); err != nil { + t.Fatal(err) + } + waitForNotify("creating config in missing directories") + + if err := os.WriteFile(configPath, []byte("export default { defaultBrowser: \"Safari\" }\n"), 0644); err != nil { + t.Fatal(err) + } + waitForNotify("editing config") +}