diff --git a/README.md b/README.md index 65ad163..03800c5 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 795743d..35d3c1d 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 to an + // absolute path, falling back to ~/.config (relative values are ignored) + xdgConfigHome := os.Getenv("XDG_CONFIG_HOME") + if xdgConfigHome == "" || !filepath.IsAbs(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"), ) } @@ -243,26 +250,44 @@ 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 } - // Convert to slice for logging - var watchPaths []string - for folder := range uniqueFolders { - watchPaths = append(watchPaths, folder) + // 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. 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 == "" || watched[ancestor] { + continue + } + if err := cfw.watcher.Add(ancestor); err != nil { + slog.Debug("Error watching folder", "folder", ancestor, "error", err) + } + } } + addWatches() - 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) + 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", cfw.watcher.WatchList()) + detectedCreation := false for !detectedCreation { select { @@ -271,18 +296,39 @@ 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 isConfigFile := false for _, path := range configPaths { - expandedPath := os.ExpandEnv(path) - if eventName == expandedPath { + if eventName == path { isConfigFile = true break } } + 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 } @@ -294,11 +340,7 @@ func (cfw *ConfigFileWatcher) StartWatching() error { return err } - for folder := range uniqueFolders { - 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: @@ -381,6 +423,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 new file mode 100644 index 0000000..94fbefe --- /dev/null +++ b/apps/finicky/src/config/configfiles_test.go @@ -0,0 +1,162 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/fsnotify/fsnotify" +) + +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 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() + + 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() + + if len(paths) != 1 || paths[0] != "/tmp/custom-finicky.js" { + 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") +}