Skip to content

Commit 4bcceab

Browse files
committed
feat: rules evaluation, redesign ui
1 parent fac938b commit 4bcceab

31 files changed

Lines changed: 1541 additions & 1008 deletions

apps/finicky/src/browser/browsers.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
{
8181
"config_dir_relative": "",
8282
"id": "com.apple.Safari",
83-
"type": "",
83+
"type": "Safari",
8484
"app_name": "Safari"
8585
},
8686
{

apps/finicky/src/config/configfiles.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,10 @@ func (cfw *ConfigFileWatcher) handleConfigFileEvent(event fsnotify.Event) error
358358
slog.Debug("Configuration file removed", "path", event.Name)
359359
// Clear the cache when config is removed
360360
cfw.cache.Clear()
361-
cfw.configChangeNotify <- struct{}{}
361+
select {
362+
case cfw.configChangeNotify <- struct{}{}:
363+
default:
364+
}
362365
return fmt.Errorf("configuration file removed")
363366
}
364367

@@ -369,7 +372,10 @@ func (cfw *ConfigFileWatcher) handleConfigFileEvent(event fsnotify.Event) error
369372
}
370373
notify := cfw.configChangeNotify
371374
cfw.debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
372-
notify <- struct{}{}
375+
select {
376+
case notify <- struct{}{}:
377+
default: // drop if a notification is already pending
378+
}
373379
})
374380
cfw.debounceMu.Unlock()
375381
return nil

apps/finicky/src/main.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func main() {
116116
}()
117117

118118
namespace := "finickyConfig"
119-
configChange := make(chan struct{})
119+
configChange := make(chan struct{}, 1)
120120
cfw, err := config.NewConfigFileWatcher(customConfigPath, namespace, configChange)
121121

122122
if err != nil {
@@ -144,7 +144,7 @@ func main() {
144144
slog.Debug("Rules updated", "count", len(rf.Rules))
145145
resolver.SetCachedRules(rf)
146146
if vm == nil || !vm.IsJSConfig() {
147-
if rf.DefaultBrowser == "" && len(rf.Rules) == 0 {
147+
if rf.DefaultBrowser == "" && len(rf.Rules) == 0 && rf.Options == nil {
148148
vm = nil
149149
return
150150
}
@@ -251,7 +251,6 @@ func handleRuntimeError(err error) {
251251
go QueueWindowDisplay(1)
252252
}
253253

254-
255254
//export HandleURL
256255
func HandleURL(url *C.char, name *C.char, bundleId *C.char, path *C.char, windowTitle *C.char, openInBackground C.bool) {
257256
var opener resolver.OpenerInfo
@@ -314,7 +313,6 @@ func TestURLInternal(urlString string) {
314313
})
315314
}
316315

317-
318316
func handleFatalError(errorMessage string) {
319317
slog.Error("Fatal error", "msg", errorMessage)
320318
lastError = fmt.Errorf("%s", errorMessage)
@@ -417,6 +415,14 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error
417415
}
418416
}
419417

418+
// Always seed the cached rules from disk so JSON rules are applied
419+
// immediately on startup, even when a JS config is also present.
420+
if rf, rulesErr := rules.Load(); rulesErr != nil {
421+
slog.Warn("Failed to pre-load rules cache", "error", rulesErr)
422+
} else {
423+
resolver.SetCachedRules(rf)
424+
}
425+
420426
var newVM *config.VM
421427

422428
if currentBundlePath != "" {

apps/finicky/src/main.h

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ extern char* GetCurrentConfigPath();
1818
@interface BrowseAppDelegate: NSObject<NSApplicationDelegate>
1919
@property (nonatomic) bool forceOpenWindow;
2020
@property (nonatomic) bool receivedURL;
21-
@property (nonatomic) bool didFinishLaunching;
2221
@property (nonatomic) bool keepRunning;
2322
@property (nonatomic) bool showMenuItem;
2423
- (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)showMenuItem keepRunning:(bool)keepRunning;

apps/finicky/src/main.m

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,12 @@ - (instancetype)initWithForceOpenWindow:(bool)forceOpenWindow initShow:(bool)sho
2222
_showMenuItem = showMenuItem;
2323
_keepRunning = keepRunning;
2424
_receivedURL = false;
25-
_didFinishLaunching = false;
2625
}
2726
return self;
2827
}
2928

3029
// Use bool for openWindow and related logic
3130
- (void)applicationDidFinishLaunching:(NSNotification *)notification {
32-
self.didFinishLaunching = true;
3331
bool openWindow = self.forceOpenWindow;
3432
if (!openWindow) {
3533
// Even if we aren't forcing the window to open, we still want to open it if didn't receive a URL
@@ -110,13 +108,7 @@ - (void)applicationWillFinishLaunching:(NSNotification *)aNotification
110108
}
111109

112110
- (bool)application:(NSApplication *)sender openFile:(NSString *)filename {
113-
// macOS calls this for command-line arguments that are file paths (e.g. the
114-
// --config flag value) before applicationDidFinishLaunching fires. Ignore
115-
// those — they are flag values, not URLs the user wants routed through Finicky.
116-
if (!self.didFinishLaunching) {
117-
NSLog(@"Ignoring openFile during launch (likely a CLI flag value): %@", filename);
118-
return false;
119-
}
111+
self.receivedURL = true;
120112

121113
NSLog(@"Opening file: %@", filename);
122114

apps/finicky/src/resolver/resolver_test.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ func TestResolveURL_JSONRulesOnly(t *testing.T) {
9595
rf := rules.RulesFile{
9696
DefaultBrowser: "Firefox",
9797
Rules: []rules.Rule{
98-
{Match: "*github.com/*", Browser: "Google Chrome"},
99-
{Match: "https://linear.app/*", Browser: "Safari"},
98+
{Match: []string{"*github.com/*"}, Browser: "Google Chrome"},
99+
{Match: []string{"https://linear.app/*"}, Browser: "Safari"},
100100
},
101101
}
102102
vm := rulesVM(t, rf)
@@ -126,7 +126,7 @@ func TestResolveURL_JSONRulesWithProfile(t *testing.T) {
126126
rf := rules.RulesFile{
127127
DefaultBrowser: "Safari",
128128
Rules: []rules.Rule{
129-
{Match: "*github.com/*", Browser: "Google Chrome", Profile: "Work"},
129+
{Match: []string{"*github.com/*"}, Browser: "Google Chrome", Profile: "Work"},
130130
},
131131
}
132132
vm := rulesVM(t, rf)
@@ -176,6 +176,47 @@ func TestResolveURL_MergedJSAndJSON(t *testing.T) {
176176
}
177177
}
178178

179+
// TestResolveURL_MergedJSAndJSON_WithCachedRules verifies that JSON rules are
180+
// applied when a JS config is present and cached rules have been seeded
181+
// (simulating the fix for the startup bug where SetCachedRules was not called
182+
// when a JS config existed).
183+
func TestResolveURL_MergedJSAndJSON_WithCachedRules(t *testing.T) {
184+
jsConfig := jsVM(t, `({
185+
defaultBrowser: "Safari",
186+
handlers: [
187+
{ match: "*github.com/*", browser: "Firefox" }
188+
]
189+
})`)
190+
191+
SetCachedRules(rules.RulesFile{
192+
DefaultBrowser: "Safari",
193+
Rules: []rules.Rule{
194+
{Match: []string{"linear.app/*"}, Browser: "Google Chrome"},
195+
},
196+
})
197+
t.Cleanup(func() { SetCachedRules(rules.RulesFile{}) })
198+
199+
tests := []struct {
200+
url string
201+
browser string
202+
}{
203+
{"https://github.com/foo", "Firefox"}, // JS handler wins
204+
{"https://linear.app/team/issue/1", "Google Chrome"}, // JSON rule applies
205+
{"https://example.com", "Safari"}, // JS default
206+
}
207+
for _, tt := range tests {
208+
t.Run(tt.url, func(t *testing.T) {
209+
result, err := ResolveURL(jsConfig, tt.url, nil, false)
210+
if err != nil {
211+
t.Fatal(err)
212+
}
213+
if result.Name != tt.browser {
214+
t.Errorf("got %q, want %q", result.Name, tt.browser)
215+
}
216+
})
217+
}
218+
}
219+
179220
func TestResolveURL_JSConfigFunctionHandler(t *testing.T) {
180221
vm := jsVM(t, `({
181222
defaultBrowser: "Safari",

apps/finicky/src/rules/rules.go

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,48 @@ import (
88
)
99

1010
type Rule struct {
11-
Match string `json:"match"`
12-
Browser string `json:"browser"`
13-
Profile string `json:"profile,omitempty"`
11+
Match []string `json:"match"`
12+
Browser string `json:"browser"`
13+
Profile string `json:"profile,omitempty"`
14+
}
15+
16+
// UnmarshalJSON accepts both a single string and an array for the match field.
17+
func (r *Rule) UnmarshalJSON(data []byte) error {
18+
var raw struct {
19+
Match json.RawMessage `json:"match"`
20+
Browser string `json:"browser"`
21+
Profile string `json:"profile,omitempty"`
22+
}
23+
if err := json.Unmarshal(data, &raw); err != nil {
24+
return err
25+
}
26+
r.Browser = raw.Browser
27+
r.Profile = raw.Profile
28+
if raw.Match != nil {
29+
var s string
30+
if err := json.Unmarshal(raw.Match, &s); err == nil {
31+
r.Match = []string{s}
32+
return nil
33+
}
34+
return json.Unmarshal(raw.Match, &r.Match)
35+
}
36+
return nil
37+
}
38+
39+
// MarshalJSON serializes match as a plain string when there is only one entry.
40+
func (r Rule) MarshalJSON() ([]byte, error) {
41+
type RuleAlias struct {
42+
Match interface{} `json:"match"`
43+
Browser string `json:"browser"`
44+
Profile string `json:"profile,omitempty"`
45+
}
46+
var match interface{}
47+
if len(r.Match) == 1 {
48+
match = r.Match[0]
49+
} else {
50+
match = r.Match
51+
}
52+
return json.Marshal(RuleAlias{Match: match, Browser: r.Browser, Profile: r.Profile})
1453
}
1554

1655
type Options struct {
@@ -106,17 +145,30 @@ func SaveToPath(rf RulesFile, path string) error {
106145
func ToJSHandlers(rules []Rule) []map[string]interface{} {
107146
handlers := make([]map[string]interface{}, 0, len(rules))
108147
for _, r := range rules {
109-
if r.Match == "" || r.Browser == "" {
148+
// Filter out empty patterns
149+
matches := make([]string, 0, len(r.Match))
150+
for _, m := range r.Match {
151+
if m != "" {
152+
matches = append(matches, m)
153+
}
154+
}
155+
if len(matches) == 0 || r.Browser == "" {
110156
continue
111157
}
158+
var matchVal interface{}
159+
if len(matches) == 1 {
160+
matchVal = matches[0]
161+
} else {
162+
matchVal = matches
163+
}
112164
var browser interface{}
113165
if r.Profile != "" {
114166
browser = map[string]interface{}{"name": r.Browser, "profile": r.Profile}
115167
} else {
116168
browser = r.Browser
117169
}
118170
handlers = append(handlers, map[string]interface{}{
119-
"match": r.Match,
171+
"match": matchVal,
120172
"browser": browser,
121173
})
122174
}
@@ -142,7 +194,7 @@ func ToJSConfigScript(rf RulesFile, namespace string) (string, error) {
142194
}
143195

144196
if rf.Options == nil {
145-
return fmt.Sprintf("var %s = {defaultBrowser: %s, handlers: %s};",
197+
return fmt.Sprintf("var %s = {default: {defaultBrowser: %s, handlers: %s}};",
146198
namespace, string(defaultBrowserJSON), string(handlersJSON)), nil
147199
}
148200

@@ -165,6 +217,6 @@ func ToJSConfigScript(rf RulesFile, namespace string) (string, error) {
165217
return "", fmt.Errorf("failed to marshal options: %v", err)
166218
}
167219

168-
return fmt.Sprintf("var %s = {defaultBrowser: %s, handlers: %s, options: %s};",
220+
return fmt.Sprintf("var %s = {default: {defaultBrowser: %s, handlers: %s, options: %s}};",
169221
namespace, string(defaultBrowserJSON), string(handlersJSON), string(optsJSON)), nil
170222
}

apps/finicky/src/rules/rules_test.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package rules_test
33
import (
44
"os"
55
"path/filepath"
6+
"reflect"
67
"strings"
78
"testing"
89

@@ -20,9 +21,9 @@ func TestToJSHandlers_Empty(t *testing.T) {
2021

2122
func TestToJSHandlers_SkipsIncompleteRules(t *testing.T) {
2223
rules := []Rule{
23-
{Match: "", Browser: "Firefox"}, // no match
24-
{Match: "example.com", Browser: ""}, // no browser
25-
{Match: "example.com", Browser: "Safari"}, // valid
24+
{Match: []string{""}, Browser: "Firefox"}, // no match
25+
{Match: []string{"example.com"}, Browser: ""}, // no browser
26+
{Match: []string{"example.com"}, Browser: "Safari"}, // valid
2627
}
2728
result := ToJSHandlers(rules)
2829
if len(result) != 1 {
@@ -32,7 +33,7 @@ func TestToJSHandlers_SkipsIncompleteRules(t *testing.T) {
3233

3334
func TestToJSHandlers_StringBrowser(t *testing.T) {
3435
rules := []Rule{
35-
{Match: "*github.com/*", Browser: "Firefox"},
36+
{Match: []string{"*github.com/*"}, Browser: "Firefox"},
3637
}
3738
result := ToJSHandlers(rules)
3839
if len(result) != 1 {
@@ -49,7 +50,7 @@ func TestToJSHandlers_StringBrowser(t *testing.T) {
4950

5051
func TestToJSHandlers_WithProfile(t *testing.T) {
5152
rules := []Rule{
52-
{Match: "*github.com/*", Browser: "Google Chrome", Profile: "Work"},
53+
{Match: []string{"*github.com/*"}, Browser: "Google Chrome", Profile: "Work"},
5354
}
5455
result := ToJSHandlers(rules)
5556
if len(result) != 1 {
@@ -69,9 +70,9 @@ func TestToJSHandlers_WithProfile(t *testing.T) {
6970

7071
func TestToJSHandlers_MultipleRules(t *testing.T) {
7172
rules := []Rule{
72-
{Match: "*github.com/*", Browser: "Firefox"},
73-
{Match: "https://linear.app/*", Browser: "Google Chrome", Profile: "Work"},
74-
{Match: "example.com", Browser: "Safari"},
73+
{Match: []string{"*github.com/*"}, Browser: "Firefox"},
74+
{Match: []string{"https://linear.app/*"}, Browser: "Google Chrome", Profile: "Work"},
75+
{Match: []string{"example.com"}, Browser: "Safari"},
7576
}
7677
result := ToJSHandlers(rules)
7778
if len(result) != 3 {
@@ -86,7 +87,7 @@ func TestToJSHandlers_MultipleRules(t *testing.T) {
8687
// ---- ToJSConfigScript ----
8788

8889
func TestToJSConfigScript_DefaultBrowserFallback(t *testing.T) {
89-
rf := RulesFile{Rules: []Rule{{Match: "example.com", Browser: "Firefox"}}}
90+
rf := RulesFile{Rules: []Rule{{Match: []string{"example.com"}, Browser: "Firefox"}}}
9091
script, err := ToJSConfigScript(rf, "finickyConfig")
9192
if err != nil {
9293
t.Fatal(err)
@@ -133,8 +134,8 @@ func TestLoadSave_RoundTrip(t *testing.T) {
133134
DefaultBrowser: "Firefox",
134135
DefaultProfile: "Work",
135136
Rules: []Rule{
136-
{Match: "*github.com/*", Browser: "Google Chrome", Profile: "Personal"},
137-
{Match: "https://linear.app/*", Browser: "Safari"},
137+
{Match: []string{"*github.com/*"}, Browser: "Google Chrome", Profile: "Personal"},
138+
{Match: []string{"https://linear.app/*"}, Browser: "Safari"},
138139
},
139140
}
140141

@@ -158,7 +159,7 @@ func TestLoadSave_RoundTrip(t *testing.T) {
158159
}
159160
for i, r := range original.Rules {
160161
got := loaded.Rules[i]
161-
if got.Match != r.Match || got.Browser != r.Browser || got.Profile != r.Profile {
162+
if !reflect.DeepEqual(got.Match, r.Match) || got.Browser != r.Browser || got.Profile != r.Profile {
162163
t.Errorf("Rule[%d]: got %+v, want %+v", i, got, r)
163164
}
164165
}

apps/finicky/src/util/directories.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func ShortenPath(path string) string {
2424
if err != nil || home == "" {
2525
return path
2626
}
27-
if strings.HasPrefix(path, home) {
27+
if path == home || strings.HasPrefix(path, home+"/") {
2828
return "~" + path[len(home):]
2929
}
3030
return path
@@ -37,4 +37,4 @@ func UserCacheDir() (string, error) {
3737
return "", fmt.Errorf("failed to get user cache directory")
3838
}
3939
return dir, nil
40-
}
40+
}

0 commit comments

Comments
 (0)