Skip to content

Commit 0ec1d3d

Browse files
committed
feat: rules evaluation, redesign ui
1 parent fac938b commit 0ec1d3d

24 files changed

Lines changed: 1244 additions & 877 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: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,10 @@ func (cfw *ConfigFileWatcher) handleConfigFileEvent(event fsnotify.Event) error
369369
}
370370
notify := cfw.configChangeNotify
371371
cfw.debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
372-
notify <- struct{}{}
372+
select {
373+
case notify <- struct{}{}:
374+
default: // drop if a notification is already pending
375+
}
373376
})
374377
cfw.debounceMu.Unlock()
375378
return nil

apps/finicky/src/main.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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
}
@@ -417,6 +417,14 @@ func setupVM(cfw *config.ConfigFileWatcher, namespace string) (*config.VM, error
417417
}
418418
}
419419

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

422430
if currentBundlePath != "" {

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: 57 additions & 5 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
}

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/version/version.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,16 @@ func checkForUpdates() (releaseInfo *ReleaseInfo) {
236236

237237
// CheckForUpdatesIfEnabled checks if updates should be performed based on VM configuration
238238
func CheckForUpdatesIfEnabled(vm *goja.Runtime) (releaseInfo *ReleaseInfo, updateCheckEnabled bool, err error) {
239+
if mockVersion := os.Getenv("FINICKY_MOCK_UPDATE"); mockVersion != "" {
240+
slog.Info("FINICKY_MOCK_UPDATE set, returning mock update", "version", mockVersion)
241+
mockTag := strings.TrimPrefix(mockVersion, "v")
242+
return &ReleaseInfo{
243+
HasUpdate: true,
244+
LatestVersion: mockVersion,
245+
DownloadUrl: fmt.Sprintf("https://github.com/johnste/finicky/releases/tag/v%s", mockTag),
246+
ReleaseUrl: fmt.Sprintf("https://github.com/johnste/finicky/releases/tag/v%s", mockTag),
247+
}, true, nil
248+
}
239249

240250
if vm == nil {
241251
// Check for updates if we don't have a VM

0 commit comments

Comments
 (0)