Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 91 additions & 23 deletions apps/finicky/src/config/configfiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
)
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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:
Expand Down Expand Up @@ -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) {
Expand Down
162 changes: 162 additions & 0 deletions apps/finicky/src/config/configfiles_test.go
Original file line number Diff line number Diff line change
@@ -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")
}