Skip to content

UI Rules and lots of things to support it#512

Merged
johnste merged 7 commits intomainfrom
feat/ui-rules
Apr 18, 2026
Merged

UI Rules and lots of things to support it#512
johnste merged 7 commits intomainfrom
feat/ui-rules

Conversation

@johnste
Copy link
Copy Markdown
Owner

@johnste johnste commented Mar 15, 2026

Fixes #380

Summary by CodeRabbit

  • New Features

    • JSON rules config support alongside JS configs; in-app Rules editor and Rules page
    • Browser discovery and per‑browser profile listing; default browser/profile UI and selector
    • Runtime resolver for URL→browser decisioning; save/load rules from UI; webview messaging for rules and browsers
  • Style

    • Updated dark/light theme tokens and UI layout/components (new controls, icons, tooltips)
  • Tests

    • Added unit tests for resolver and rules logic
  • Chores

    • Added development and test helper scripts

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements Issue #380 by introducing a separate JSON-backed “rules” configuration that can be edited from the UI, plus plumbing to load/save rules and merge them with an existing JS config (JS handlers take priority).

Changes:

  • Add rules.json model + persistence in the Go app, including conversion into finickyConfig-compatible handlers and VM rebuild when running without JS config.
  • Add a new Rules UI page with browser/profile selection and rule editing (including reordering) and wire it into app navigation and native messaging.
  • Extend config/runtime wiring (resolver, window messaging, installed browsers + profiles, config watcher debounce) to support the new UI configuration flow.

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
testdata/rules.json Example rules.json fixture for dev/testing scenarios.
testdata/config.js Example JS config fixture to test precedence vs rules.json.
scripts/test.sh Convenience script to run Go tests from apps/finicky/src.
scripts/dev.sh Dev runner for multiple config/rules scenarios + mock updates.
packages/finicky-ui/src/utils/text.ts Stops exporting internal helper used by log formatting.
packages/finicky-ui/src/types.ts Adds UI types for rules file + exposes isJSConfig in config info.
packages/finicky-ui/src/pages/TestUrl.svelte Persists test URL input via a store and updates styling.
packages/finicky-ui/src/pages/StartPage.svelte Adds editable preferences UI (default browser/profile + options) with JS-config locking.
packages/finicky-ui/src/pages/Rules.svelte New Rules page (edit rules, patterns, drag reorder, save debounce, profiles fetch).
packages/finicky-ui/src/pages/LogViewer.svelte UI tweak (checkbox accent color).
packages/finicky-ui/src/pages/About.svelte Layout/style refresh of About page content.
packages/finicky-ui/src/lib/testUrlStore.ts Adds store for test URL input persistence.
packages/finicky-ui/src/components/TabBar.svelte Adds Rules tab and reorganizes tab groups + nav styling.
packages/finicky-ui/src/components/PageContainer.svelte Allows HTML in descriptions and tweaks layout spacing/typography.
packages/finicky-ui/src/components/OptionRow.svelte New reusable toggle row with locked-state UX.
packages/finicky-ui/src/components/icons/Rules.svelte New Rules icon.
packages/finicky-ui/src/components/icons/Lock.svelte New Lock icon used for readonly/locked controls.
packages/finicky-ui/src/components/BrowserProfileSelector.svelte New browser + optional profile selector used by Rules/Preferences.
packages/finicky-ui/src/App.svelte Wires new routes/messages (rules, installed browsers, profiles) + footer config badges.
packages/finicky-ui/src/app.css Introduces new theme variables and updates overall styling.
apps/finicky/src/window/window.go Adds WebView message handlers for rules CRUD + installed browsers/profiles.
apps/finicky/src/version/version.go Supports FINICKY_MOCK_UPDATE env var for UI/dev testing.
apps/finicky/src/util/directories.go Adds ShortenPath helper for user-facing path display.
apps/finicky/src/rules/rules.go New rules package: load/save rules.json + JSON<->JS config conversion.
apps/finicky/src/rules/rules_test.go Unit tests for rules package behavior.
apps/finicky/src/resolver/resolver.go New resolver that merges cached JSON rules with JS config evaluation.
apps/finicky/src/resolver/resolver_test.go Tests for resolver behavior across no-config/JS-only/JSON-only/merged cases.
apps/finicky/src/main.m Ignores spurious openFile calls during launch (CLI flag paths).
apps/finicky/src/main.h Adds didFinishLaunching state for launch-time filtering.
apps/finicky/src/main.go Adds --rules/--no-config, VM/rules wiring, and switches URL evaluation to resolver.
apps/finicky/src/config/vm.go Refactors VM creation to accept API bytes + adds IsJSConfig + bulk option retrieval.
apps/finicky/src/config/configfiles.go Debounces config file watcher notifications.
apps/finicky/src/browser/launcher.go Adds browser profile enumeration and refactors profile parsing helpers.
apps/finicky/src/browser/detect.go Adds installed browser detection via Launch Services (Cgo/AppKit).
apps/finicky/src/browser/browsers.json Adds Safari entry (type recognized for completeness).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/finicky-ui/src/components/PageContainer.svelte
Comment thread packages/finicky-ui/src/types.ts
Comment thread apps/finicky/src/rules/rules_test.go
Comment thread apps/finicky/src/rules/rules_test.go
Comment thread apps/finicky/src/rules/rules_test.go
Comment thread apps/finicky/src/rules/rules_test.go Outdated
Comment on lines +136 to +137
{Match: "*github.com/*", Browser: "Google Chrome", Profile: "Personal"},
{Match: "https://linear.app/*", Browser: "Safari"},
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this round-trip test, rule literals use Match: "..." even though Rule.Match is []string. Update both entries to []string{...} so the test compiles and actually exercises the JSON single-string marshal/unmarshal behavior you want to verify.

Suggested change
{Match: "*github.com/*", Browser: "Google Chrome", Profile: "Personal"},
{Match: "https://linear.app/*", Browser: "Safari"},
{Match: []string{"*github.com/*"}, Browser: "Google Chrome", Profile: "Personal"},
{Match: []string{"https://linear.app/*"}, Browser: "Safari"},

Copilot uses AI. Check for mistakes.
Comment thread apps/finicky/src/config/configfiles.go
Comment thread apps/finicky/src/main.go
Comment on lines +140 to +165
// Set up rules save handler.
// When there is no JS config, rebuild the VM from the updated rules.
// When there is a JS config, JSON rules are loaded fresh in evaluateURL — nothing to do.
window.SaveRulesHandler = func(rf rules.RulesFile) {
slog.Debug("Rules updated", "count", len(rf.Rules))
resolver.SetCachedRules(rf)
if vm == nil || !vm.IsJSConfig() {
if rf.DefaultBrowser == "" && len(rf.Rules) == 0 {
vm = nil
return
}
script, err := rules.ToJSConfigScript(rf, namespace)
if err != nil {
slog.Error("Failed to generate config from rules", "error", err)
return
}
newVM, err := config.NewFromScript(finickyConfigAPIJS, namespace, script)
if err != nil {
slog.Error("Failed to rebuild VM from rules", "error", err)
return
}
vm = newVM
shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning
go checkForUpdates()
}
}
Copy link

Copilot AI Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window.SaveRulesHandler mutates the global vm (and triggers update checks) from the WebView message handler path, while vm is also read/used concurrently in the main event-loop goroutine (URL resolution and config reload). This introduces a real data race and can lead to crashes or inconsistent config evaluation. Route rule-save events through the existing select loop via a channel (or guard all vm reads/writes with a mutex) so VM rebuilds and shouldKeepRunning updates happen in one place.

Copilot uses AI. Check for mistakes.
@johnste
Copy link
Copy Markdown
Owner Author

johnste commented Apr 7, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 7, 2026

✅ Actions performed

Full review triggered.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

This PR adds JSON rule support and UI for editing rules, implements macOS browser/profile detection, introduces a centralized resolver that merges JS config and cached JSON rules, refactors VM/config handling, and wires native UI messaging for rules and browser/profile discovery.

Changes

Cohort / File(s) Summary
Browser detection & registry
apps/finicky/src/browser/browsers.json, apps/finicky/src/browser/detect.go
Inserted a Safari entry into the browser registry and added macOS Launch Services-based browser discovery (cgo) with bundle filtering and URL-handler inspection.
Browser profiles / launcher
apps/finicky/src/browser/launcher.go
Refactored Firefox/Chromium profile parsing into helpers and added GetProfilesForBrowser(identifier string) to list available profiles.
Resolution core
apps/finicky/src/resolver/resolver.go, apps/finicky/src/resolver/resolver_test.go
New resolver package: resolves URLs to a non-nil BrowserConfig, evaluates JS config when present, merges cached JSON rules, handles short URLs and open-in-background semantics; includes tests.
Rules model & persistence
apps/finicky/src/rules/rules.go, apps/finicky/src/rules/rules_test.go
New rules package with Rule, Options, RulesFile, JSON (un)marshalling, custom path handling, Load/Save, and conversion to JS handlers/config script; includes tests.
VM / config handling
apps/finicky/src/config/vm.go, apps/finicky/src/config/configfiles.go
Changed VM constructors to accept raw API/script content and added JS-config flag accessors; replaced blocking delay with a timer-based, non-blocking debounce for config file watcher.
Main app flow & CLI
apps/finicky/src/main.go, apps/finicky/src/main.m
Refactored URL evaluation to use resolver.ResolveURL, added -rules and -no-config flags, integrated rules caching and VM rebuilds from JSON rules, and ensured file-open events set receivedURL.
Window & native messaging
apps/finicky/src/window/window.go
Added webview message handlers: getRules, saveRules, getInstalledBrowsers, getBrowserProfiles; implemented responses and SaveRulesHandler callback.
Utilities & version check
apps/finicky/src/util/directories.go, apps/finicky/src/version/version.go
Added ShortenPath helper and an environment-variable short-circuit (FINICKY_MOCK_UPDATE) for update checks.
UI: pages, components, types, styles
packages/finicky-ui/src/** (App.svelte, pages/, components/, icons/*, types.ts, app.css, lib/testUrlStore.ts)
Added Rules page and editor, BrowserProfileSelector, OptionRow, Tooltip and icons, updated App to request rules/installed browsers, new types for rules and options, theme/token overhaul, and related UI wiring.
Small UI tweaks & behavior
packages/finicky-ui/src/utils/text.ts, packages/finicky-ui/src/components/PageContainer.svelte, packages/finicky-ui/src/pages/*
Made helper non-exported, accepted snippet descriptions, adjusted layouts, and updated several page components to integrate rules/options state.
Dev & test tooling, samples
scripts/dev.sh, scripts/test.sh, testdata/config.js, testdata/rules.json
Added dev scenario launcher, test runner script, and sample JS/JSON config files for testing scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Main as Main Process
    participant Resolver as Resolver
    participant VM as Config VM
    participant Rules as Rules Cache
    participant Launcher as Browser Launcher

    User->>Main: URL received
    Main->>Resolver: ResolveURL(vm, url, opener, bg)
    alt VM (JS config) present
        Resolver->>VM: evaluateURL(originalUrl, opener, cachedRules?)
        VM-->>Resolver: BrowserResult (or error)
        Resolver->>Rules: getCachedRules()
        Rules-->>Resolver: JSON handlers
        Resolver->>Resolver: merge JS result with cached rules
    else No JS config
        Resolver->>Rules: getCachedRules()
        Rules-->>Resolver: BrowserResult from JSON
    end
    Resolver-->>Main: BrowserConfig (URL, profile, OpenInBackground)
    Main->>Launcher: Launch browser with resolved config
    Launcher-->>User: URL opened
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 In burrows of code I nibbled through rules,

JSON and JS stitched into tidy tools,
Browsers found, profiles tallied and neat,
Resolver hums so URLs find their seat,
A tiny rabbit cheers—your app's complete!

🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (1 warning, 2 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.12% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title is vague and generic. While it mentions 'UI Rules', it uses broad phrasing like 'lots of things' that doesn't convey specific information about the changeset's primary objective. Revise to a more specific and descriptive title such as 'Add UI for managing rules and configuration options' or 'Implement rules editing in UI with profile support'.
Out of Scope Changes check ❓ Inconclusive Most changes align with issue #380 objectives. However, some changes appear tangential: browser detection (detect.go), profile querying (GetProfilesForBrowser), URL resolver refactoring (resolver.go), and environment variable mocking (FINICKY_MOCK_UPDATE) extend beyond the stated UI Config requirement. Clarify whether browser detection, profile querying, resolver refactoring, and update-check mocking are necessary prerequisites for #380 or represent additional scope creep that should be addressed separately.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed The PR addresses the primary objectives of issue #380: enabling UI-editable settings [OptionRow, StartPage], saving settings in a separate file [rules.json via SaveRulesHandler], and implementing a separate rules management interface [Rules.svelte, BrowserProfileSelector].

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ui-rules

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 golangci-lint (2.11.4)

level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies"


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/finicky-ui/src/pages/TestUrl.svelte (1)

61-71: ⚠️ Potential issue | 🟡 Minor

Subscription cleanup not handled on component destroy.

The testUrlResult.subscribe() call at line 62 returns an unsubscribe function that is never called. In Svelte, manual subscriptions outside of $: reactive statements or template $store syntax need explicit cleanup to prevent memory leaks when the component is destroyed.

🛠️ Proposed fix using onDestroy
 <script lang="ts">
+  import { onDestroy } from "svelte";
   import PageContainer from "../components/PageContainer.svelte";
   // ... other imports
 
   // Subscribe to test results
-  testUrlResult.subscribe(() => {
+  const unsubscribe = testUrlResult.subscribe(() => {
     clearTimeout(loadingTimer);
     loading = false;
   });
+
+  onDestroy(() => {
+    unsubscribe();
+    clearTimeout(debounceTimer);
+    clearTimeout(loadingTimer);
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/TestUrl.svelte` around lines 61 - 71, The
subscription to testUrlResult is never cleaned up; capture the unsubscribe
function returned by testUrlResult.subscribe(...) (e.g., const unsubscribe =
testUrlResult.subscribe(...)) and register cleanup in Svelte's onDestroy by
calling unsubscribe() (and also clearTimeout(loadingTimer) if still pending) to
avoid memory leaks; modify the block around testUrlResult.subscribe, referencing
testUrlResult, loadingTimer, and loading, and add an onDestroy import/use to
call unsubscribe() and any timeout cleanup.
apps/finicky/src/main.go (1)

257-287: ⚠️ Potential issue | 🟠 Major

Preserve nil when Launch Services provides no opener.

This always enqueues &opener, so the resolver sees a truthy opener object with empty fields instead of null. JS handlers that distinguish “no opener” from “known opener” will misroute.

Suggested fix
 func HandleURL(url *C.char, name *C.char, bundleId *C.char, path *C.char, windowTitle *C.char, openInBackground C.bool) {
-	var opener resolver.OpenerInfo
+	var opener *resolver.OpenerInfo

 	if name != nil && bundleId != nil && path != nil {
-		opener = resolver.OpenerInfo{
+		opener = &resolver.OpenerInfo{
 			Name:     C.GoString(name),
 			BundleID: C.GoString(bundleId),
 			Path:     C.GoString(path),
 		}
 		if windowTitle != nil {
 			opener.WindowTitle = C.GoString(windowTitle)
@@
 	urlListener <- URLInfo{
 		URL:              urlString,
-		Opener:           &opener,
+		Opener:           opener,
 		OpenInBackground: bool(openInBackground),
 	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/main.go` around lines 257 - 287, The code always enqueues a
non-nil pointer to a zero-value resolver.OpenerInfo even when Launch Services
provided no opener; change the local opener to be a pointer (var opener
*resolver.OpenerInfo) and only allocate & populate it inside the existing if
(name != nil && bundleId != nil && path != nil) block (set WindowTitle on that
allocated struct if provided). Then pass opener (which will be nil when no
opener was provided) into URLInfo so JS can distinguish “no opener” from an
empty opener.
🧹 Nitpick comments (13)
packages/finicky-ui/src/types.ts (1)

7-18: Consider reusing ConfigOptions for options shapes.

RulesFile.options and ConfigInfo.options duplicate the same optional fields. Reusing Partial<ConfigOptions> reduces drift risk.

♻️ Suggested refactor
 export interface RulesFile {
   defaultBrowser: string;
   defaultProfile?: string;
-  options?: {
-    keepRunning?: boolean;
-    hideIcon?: boolean;
-    logRequests?: boolean;
-    checkForUpdates?: boolean;
-  };
+  options?: Partial<ConfigOptions>;
   rules: Rule[];
   path?: string;
 }
...
 export interface ConfigInfo {
   configPath: string;
   isJSConfig?: boolean;
   handlers?: number;
   rewrites?: number;
   defaultBrowser?: string;
-  options?: {
-    keepRunning?: boolean;
-    hideIcon?: boolean;
-    logRequests?: boolean;
-    checkForUpdates?: boolean;
-  };
+  options?: Partial<ConfigOptions>;
 }

Also applies to: 67-72

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/types.ts` around lines 7 - 18, RulesFile defines an
inline options shape that duplicates ConfigInfo.options; change both
RulesFile.options and ConfigInfo.options to reuse the existing ConfigOptions
type by declaring them as Partial<ConfigOptions> (or ConfigOptions | undefined
if you prefer full type) to avoid duplicated field definitions—update the
interface references to use Partial<ConfigOptions> and remove the duplicated
inline object shape in RulesFile and the corresponding duplicated definition in
ConfigInfo so the shared ConfigOptions type is the single source of truth.
packages/finicky-ui/src/components/icons/Rules.svelte (1)

1-13: Apply the same SVG accessibility treatment here.

For decorative use, mark this SVG with aria-hidden="true" (and optionally focusable="false").

♿ Suggested tweak
 <svg
   xmlns="http://www.w3.org/2000/svg"
+  aria-hidden="true"
+  focusable="false"
   width="24"
   height="24"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/components/icons/Rules.svelte` around lines 1 - 13,
The Rules.svelte SVG is missing accessibility attributes for decorative icons;
update the <svg> element in Rules.svelte (the SVG with class "lucide
lucide-list-filter") to include aria-hidden="true" and optionally
focusable="false" so it is ignored by assistive technologies when used
decoratively, keeping no change to visual rendering or other attributes.
packages/finicky-ui/src/components/icons/Lock.svelte (1)

1-15: Mark decorative icon SVG as non-accessible.

If this icon is decorative, add aria-hidden="true" (and optionally focusable="false") to avoid extra screen reader noise.

♿ Suggested tweak
 <svg
   xmlns="http://www.w3.org/2000/svg"
+  aria-hidden="true"
+  focusable="false"
   width="16"
   height="16"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/components/icons/Lock.svelte` around lines 1 - 15,
The Lock.svelte SVG is decorative and should be hidden from assistive tech;
update the root <svg> element (the one with class "lucide lucide-lock" in
Lock.svelte) to include aria-hidden="true" and optionally focusable="false" so
screen readers skip it; if this icon ever becomes meaningful, replace those
attributes with an accessible title/role instead.
packages/finicky-ui/src/app.css (1)

1-44: Consider consolidating the two :root blocks.

There are two separate :root blocks (lines 1-15 and 17-44). While valid CSS, this split reduces maintainability. The first block sets base styles and explicit color values, while the second defines CSS custom properties. Consider merging them into a single :root block for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/app.css` around lines 1 - 44, The file contains two
separate :root blocks—one with base font and color-scheme properties and another
with CSS custom properties (e.g., --bg-primary, --nav-text, --accent-color);
merge them into a single :root block by moving the base declarations
(font-family, line-height, font-weight, color-scheme, color, background-color,
font-synthesis, text-rendering, -webkit-font-smoothing, -moz-osx-font-smoothing)
together with all custom property definitions (the --* variables) and remove the
duplicate :root to improve maintainability and avoid fragmentation.
packages/finicky-ui/src/pages/TestUrl.svelte (1)

216-218: Hard-coded color values reduce theme consistency.

The .hint-message uses hard-coded rgba(245, 188, 28, ...) values instead of theme variables. Consider using var(--log-warning) with appropriate alpha for consistency with the rest of the theme system.

♻️ Suggested fix
   .hint-message {
     display: flex;
     align-items: center;
     gap: 10px;
     padding: 12px 16px;
-    background: rgba(245, 188, 28, 0.06);
-    border: 1px solid rgba(245, 188, 28, 0.25);
+    background: color-mix(in srgb, var(--log-warning) 6%, transparent);
+    border: 1px solid color-mix(in srgb, var(--log-warning) 25%, transparent);
     border-radius: 8px;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/TestUrl.svelte` around lines 216 - 218, Replace
the hard-coded rgba colors in the .hint-message style with the theme variable so
the component uses the shared color token; specifically, update the background
and border that currently use rgba(245, 188, 28, ...) to use var(--log-warning)
with appropriate alpha (e.g., via color-mix() or by using an RGB CSS variable
and applying alpha) in the .hint-message rule inside TestUrl.svelte so the hint
matches the app theme.
packages/finicky-ui/src/components/OptionRow.svelte (2)

120-143: Hard-coded colors in toggle styling reduce theme flexibility.

The toggle slider uses hard-coded colors (#666, #ddd, #111111) instead of CSS variables. This may cause visual inconsistency if the theme changes or in high-contrast modes.

♻️ Suggested theme-aware colors
   .toggle-slider {
     position: absolute;
     cursor: pointer;
     top: 0;
     left: 0;
     right: 0;
     bottom: 0;
-    background-color: `#666`;
+    background-color: var(--text-secondary);
     transition: 0.3s;
     border-radius: 24px;
   }

   .toggle-slider:before {
     position: absolute;
     content: "";
     height: 18px;
     width: 18px;
     left: 3px;
     bottom: 3px;
-    background-color: `#ddd`;
+    background-color: var(--bg-primary);
     transition: 0.3s;
     border-radius: 50%;
   }
   
   .toggle input:checked + .toggle-slider:before {
     transform: translateX(20px);
-    background-color: `#111111`;
+    background-color: var(--bg-primary);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/components/OptionRow.svelte` around lines 120 - 143,
Replace the three hard-coded color values used in the toggle CSS (.toggle-slider
background-color `#666`, .toggle-slider:before background-color `#ddd`, and
checked .toggle-slider:before background-color `#111111`) with theme-aware CSS
variables (e.g., use existing --toggle-active for the checked track and
add/choose variables such as --toggle-bg and --toggle-thumb for the track and
thumb defaults) so the rules in .toggle-slider, .toggle-slider:before, and
.toggle input:checked + .toggle-slider:before derive colors from variables
instead of fixed hex values; ensure sensible fallback values are provided in the
root or component :root so themes without those variables still render
predictably.

27-28: Verify onchange handler binding syntax for Svelte 5.

In Svelte 5, using {onchange} as a shorthand on native elements binds to the DOM event. However, the prop type is () => void while DOM onchange events pass an Event argument. If onchange is meant to be called on state change, this works, but if consumers expect event data, the signature mismatch could cause confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/components/OptionRow.svelte` around lines 27 - 28,
The current shorthand {onchange} on the input in OptionRow.svelte can mismatch
Svelte 5 DOM event typing: decide whether the exported prop onchange should
receive the DOM Event or be a no-arg callback; if it should receive the Event,
replace the shorthand with an explicit event binding on:change={onchange} and
update the prop type to (e: Event) => void, otherwise wrap the call to drop the
event payload (on:change={() => onchange?.()}) or change the prop name to
something like onToggle to make the no-arg intent explicit; ensure this change
is applied alongside the existing bind:checked and locked usage so consumers get
the expected signature.
packages/finicky-ui/src/pages/Rules.svelte (2)

176-183: Draggable list item missing keyboard accessibility.

The rule rows are draggable via mouse but lack keyboard support for reordering. Users relying on keyboard navigation cannot reorder rules. Consider adding keyboard event handlers (e.g., arrow keys with modifier) or ARIA live regions for accessibility compliance.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/Rules.svelte` around lines 176 - 183, The
draggable rule rows (element with class "rule-row", using dragIndex and handlers
onDragStart/onDragOver/onDragEnd) lack keyboard accessibility; add keyboard
support by making each rule-row focusable (tabindex="0"), manage an
"active/dragging" state via keyboard (e.g., onKeyDown onRuleKeyDown) that starts
a keyboard-reorder mode on a modifier key (Space or Enter) and then moves the
item with ArrowUp/ArrowDown to call the same reorder logic used by
onDragStart/onDragOver (or a shared moveRule(indexFrom, indexTo) helper), update
ARIA attributes (aria-grabbed, role="option" or "listitem",
aria-posinset/aria-setsize) while reordering, and announce changes via a small
ARIA live region when an item is picked up, moved, or dropped so keyboard users
receive feedback; wire cleanup to onKeyUp/Escape to cancel and ensure onDragEnd
behavior is mirrored.

162-167: Inline HTML in description prop bypasses content sanitization.

Using inline HTML with style attributes in the description (line 165) works because it's hardcoded, but this pattern could become a security risk if the description ever accepts dynamic content. Consider using a Svelte component or CSS class instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/Rules.svelte` around lines 162 - 167, The
description prop currently embeds inline HTML with a style attribute in the
PageContainer usage (PageContainer, description, isJSConfig) which bypasses
sanitization; replace that inline HTML by creating a small presentational Svelte
component (e.g., JsConfigBadge or DescriptionWithBadge) or add a CSS class
(e.g., .accent-badge) and use a plain text/element slot on PageContainer instead
of passing raw HTML in the description prop; update the PageContainer API (or
its consumer) to accept a description slot or component and revise the
Rules.svelte usage to render <JsConfigBadge/> or a <span class="accent-badge">JS
config is active</span> so styling is done via CSS and no inline style HTML is
injected.
packages/finicky-ui/src/components/BrowserProfileSelector.svelte (2)

204-207: Hardcoded color breaks the CSS variable pattern.

The clear button hover uses a hardcoded color #f44336 while the rest of the component uses CSS custom properties. Consider using a CSS variable for consistency with the design system.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/components/BrowserProfileSelector.svelte` around
lines 204 - 207, The .clear-btn:hover rule in BrowserProfileSelector.svelte uses
a hardcoded color `#f44336`; change it to use the component/design-system CSS
variable (e.g., replace with var(--color-danger) or the project's equivalent
like var(--fg-danger)), and include a sensible fallback (e.g.,
var(--color-danger, `#f44336`)) to preserve behavior if the variable is missing;
update the .clear-btn:hover selector to reference that CSS custom property so
the component follows the existing variable pattern.

82-90: Consider adding accessibility attributes to form controls.

The <input> elements and <select> elements lack associated <label> elements or aria-label attributes. The clear buttons also lack accessible names. This could impact keyboard and screen reader users.

♿ Suggested accessibility improvements
     <input
       class="browser-input"
       type="text"
       placeholder="e.g. Firefox"
+      aria-label="Custom browser name"
       value={browser}
       oninput={handleBrowserInput}
       {disabled}
     />
-    <button class="clear-btn" onclick={handleClearBrowser}>✕</button>
+    <button class="clear-btn" onclick={handleClearBrowser} aria-label="Clear browser">✕</button>

Apply similar changes to the profile input and clear button as well.

Also applies to: 108-116

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/components/BrowserProfileSelector.svelte` around
lines 82 - 90, The form controls in BrowserProfileSelector.svelte lack
accessible names; add explicit labels or aria-label attributes for the text
inputs (the element with class "browser-input" handled by handleBrowserInput and
the profile input referenced around lines 108-116) and for any <select>
controls, and give the clear buttons (the button invoking handleClearBrowser and
the profile clear button) accessible names via aria-label or aria-labelledby;
ensure labels are programmatically associated (for example using <label
for="..."> or matching id+aria-labelledby) so screen readers and keyboard users
can identify each control.
apps/finicky/src/rules/rules.go (2)

69-75: Global customPath is not thread-safe.

The package-level customPath variable is accessed without synchronization. If SetCustomPath and GetPath are ever called concurrently (e.g., during tests with parallel execution), this could lead to data races.

Since this is documented as intended for testing and CLI flags (typically set once at startup), the risk is low, but consider using sync.Once or a mutex if concurrent access becomes possible.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/rules/rules.go` around lines 69 - 75, The package-level
variable customPath is not protected against concurrent access by SetCustomPath
and readers (e.g., GetPath), risking data races; protect reads and writes by
introducing a sync.RWMutex (e.g., customPathMu) and use customPathMu.Lock() /
Unlock() in SetCustomPath and customPathMu.RLock() / RUnlock() around any
GetPath or other readers, ensuring all accesses to customPath are synchronized
(alternatively use sync/atomic for pointer updates or sync.Once if the value is
only set once).

129-141: Consider atomic file writes to prevent corruption.

os.WriteFile writes directly to the target file. If the process is interrupted mid-write (crash, power loss), the file could be left in a corrupted partial state. For user configuration files, an atomic write pattern (write to temp file, then rename) is safer.

🛡️ Suggested atomic write pattern
 func SaveToPath(rf RulesFile, path string) error {
 	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
 		return err
 	}

 	data, err := json.MarshalIndent(rf, "", "  ")
 	if err != nil {
 		return err
 	}

-	return os.WriteFile(path, data, 0644)
+	// Write to temp file first, then atomically rename
+	tmpPath := path + ".tmp"
+	if err := os.WriteFile(tmpPath, data, 0644); err != nil {
+		return err
+	}
+	return os.Rename(tmpPath, path)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/rules/rules.go` around lines 129 - 141, SaveToPath currently
writes directly with os.WriteFile which can leave a partial/corrupt file on
interruption; change it to perform an atomic write: after
json.MarshalIndent(rf...), create a temp file in the same directory (use
filepath.Dir(path)), write the data to that temp file, flush and sync the file
(fsync) and then atomically rename the temp file to the target path (os.Rename);
ensure you still call os.MkdirAll before creating the temp file and return any
errors from the temp write/sync/rename steps instead of calling os.WriteFile.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/finicky/src/browser/detect.go`:
- Around line 34-71: The helper getAllHttpsHandlerNames() uses AppKit APIs and
must run on the main thread; update the call site in didReceiveScriptMessage:
(the WKScriptMessageHandler callback) to marshal to the main thread by wrapping
the HandleWebViewMessage(...) invocation in
dispatch_sync(dispatch_get_main_queue(), ^{ ... }) so all
NSWorkspace/NSBundle/NSFileManager work happens on the main queue, or
alternatively perform the main-thread dispatch inside getAllHttpsHandlerNames()
itself to ensure its body runs on the main thread.

In `@apps/finicky/src/config/configfiles.go`:
- Around line 365-374: The debounce timer's callback (created in
cfw.debounceTimer via time.AfterFunc) performs a blocking send to
cfw.configChangeNotify while holding no lock, which lets timer goroutines pile
up and defeats coalescing; change the callback to perform a non-blocking send
(e.g., use a select with case notify <- struct{}{} and a default to drop the
signal if the channel is full) so pending timers won't block, or alternatively
make configChangeNotify a size-1 buffered channel and still use a non-blocking
send to ensure only one coalesced notification is delivered; adjust the timer
callback that references cfw.debounceTimer, cfw.debounceMu, and
configChangeNotify accordingly.

In `@apps/finicky/src/main.go`:
- Around line 147-149: The current early-return sets vm = nil when
rf.DefaultBrowser == "" && len(rf.Rules) == 0 which incorrectly treats
option-only rules files as empty; update the condition in the VM-initialization
logic (the block referencing rf, vm, DefaultBrowser and Rules) to instead detect
whether any persisted settings exist (e.g., check fields like rf.KeepRunning,
rf.HideIcon, rf.LogRequests, rf.CheckForUpdates, etc.) and only set vm = nil
when no rules and no other persisted settings are present; apply the same fix to
the analogous conditional later in the file (the block around the other rf/ vm
check).
- Around line 151-163: After swapping in newVM (from config.NewFromScript) you
must also apply the saved logRequests setting so the logger reflects the rebuilt
config immediately; after setting vm = newVM and updating shouldKeepRunning =
vm.GetAllConfigOptions().KeepRunning, call logger.SetupFile(...) (or the
existing logger initialization routine) with the new VM's logRequests value
(e.g., vm.GetAllConfigOptions().LogRequests or
newVM.GetAllConfigOptions().LogRequests) before launching checkForUpdates() so
the logRequests toggle takes effect without a restart.
- Around line 143-165: The SaveRulesHandler replaces the global vm and other
goroutines (TestURLInternal, the main event loop via resolver.ResolveURL and
evaluateURL) concurrently read/write the same goja.Runtime; make VM access
concurrency-safe by adding a package-level sync.RWMutex (e.g., vmMu) and use
vmMu.Lock() when replacing or mutating vm and vmMu.RLock() when reading/using it
(including in resolver.ResolveURL, evaluateURL, TestURLInternal, and any
main-loop accesses); ensure nil checks and assignments of vm and
shouldKeepRunning are done under the same lock and release promptly, and add
locking around calls that execute JS on the runtime so only one goroutine
accesses goja.Runtime at a time (apply same protection to the other spots noted
in the comment).

In `@apps/finicky/src/main.m`:
- Around line 112-119: The current application:openFile: early-return discards
legitimate pre-launch file-open events; change it to enqueue or handle only true
CLI flag values: add a mutable array property (e.g., pendingOpenFiles) on the
app delegate and when !self.didFinishLaunching, if filename looks like a CLI
flag (e.g., startsWith "-" or equals known flag tokens like "--config") ignore
it, otherwise append the filename to pendingOpenFiles for processing later in
applicationDidFinishLaunching (process and clear pendingOpenFiles there); ensure
application:openFile: no longer unconditionally returns false during launch so
legitimate file-open requests are preserved.

In `@apps/finicky/src/rules/rules.go`:
- Around line 178-199: ToJSConfigScript currently interpolates the namespace
directly into the generated JS ("var %s = ..."), allowing JS injection if
namespace is untrusted; fix by either validating the namespace parameter (in
ToJSConfigScript) against a safe JS identifier regex (e.g.
^[A-Za-z_$][A-Za-z0-9_$]*$) and returning an error for invalid values, or avoid
direct interpolation entirely by using a safe bracket assignment pattern with
the JSON-marshaled namespace (e.g. assign to
window[<json-stringified-namespace>] instead of "var <namespace>") so special
characters cannot break the generated script; update the fmt.Sprintf call that
emits the var assignment accordingly and add tests for both valid and invalid
namespace inputs.

In `@apps/finicky/src/util/directories.go`:
- Around line 27-29: The current prefix check using strings.HasPrefix(path,
home) can spuriously match directories like "/Users/alice2"; update the
condition in the path-shortening logic to only treat path as inside home when it
is exactly equal to home or when it has home followed by a path separator (e.g.
check path == home || strings.HasPrefix(path, home+string(os.PathSeparator))).
Apply this change where the code references path and home in the directories.go
shortening routine so "~" substitution only happens for true home-boundary
matches.

In `@apps/finicky/src/version/version.go`:
- Around line 239-245: The FINICKY_MOCK_UPDATE handling returns URLs by directly
embedding mockVersion which can double-up a leading "v" (e.g., "vv1.2.3");
update the block in the function that reads FINICKY_MOCK_UPDATE so you normalize
the value (e.g., strings.TrimSpace then strings.TrimPrefix(mockVersion, "v") and
TrimPrefix(..., "V")) before composing DownloadUrl and ReleaseUrl while keeping
LatestVersion set to the original or normalized form as desired; modify the code
around the ReleaseInfo construction (the block that sets
HasUpdate/LatestVersion/DownloadUrl/ReleaseUrl) to use the normalized tag string
for the URL fmt.Sprintf calls.

In `@apps/finicky/src/window/window.go`:
- Around line 267-274: handleGetBrowserProfiles silently proceeds when
msg["browser"] is missing or not a string, producing an empty browserName and
confusing output; validate the input and return an error message instead: check
msg["browser"] exists and is a non-empty string at the start of
handleGetBrowserProfiles, and if invalid send an error via SendMessageToWebView
(e.g., event "browserProfilesError" or include an "error" field) rather than
calling browser.GetProfilesForBrowser; reference the function
handleGetBrowserProfiles and the calls to browser.GetProfilesForBrowser and
SendMessageToWebView when making the change.
- Around line 223-260: The handler handleSaveRules currently logs errors from
rules.Save(rf) but never notifies the UI; modify the error branch after
rules.Save(rf) to send a failure message back to the webview (using
SendMessageToWebView) containing a status/error string (e.g.,
{"status":"error","error": err.Error()}) so the UI can show feedback, and keep
the existing success path (sending rulesResponse and invoking SaveRulesHandler)
unchanged; reference the rules.Save call, the SendMessageToWebView function, and
the rules.GetPath/SaveRulesHandler flow when implementing the error response.

In `@packages/finicky-ui/src/App.svelte`:
- Around line 104-107: The startup sendMessage calls may run before the webkit
message handler is ready; wrap the calls to window.finicky.sendMessage({ type:
"getRules" }) and window.finicky.sendMessage({ type: "getInstalledBrowsers" })
in a Svelte onMount handler (import onMount from "svelte") and/or add a
readiness check (e.g., guard that window.webkit?.messageHandlers?.finicky or
window.finicky exists) before sending so messages are deferred until the bridge
is available; update App.svelte to perform the sendMessage calls inside onMount
with the readiness check to avoid silent failures.
- Around line 153-160: Replace the Svelte 4 event syntax on native buttons with
standard DOM attributes for Svelte 5: change the two occurrences using on:click
that call showPathToast (the button rendering basename(config.configPath) and
the one rendering basename(rulesFile.path)) to use onclick instead, preserving
the same inline arrow function arguments (basename(...), the label
"Configuration loaded from", and the full path argument) and keep the
conditional blocks and className intact.

In `@packages/finicky-ui/src/components/PageContainer.svelte`:
- Around line 21-22: The component currently injects raw HTML via the
description prop using {`@html` description}; change the public API to make this a
consciously trusted sink by introducing a new prop name or slot (e.g.,
descriptionHtml prop or a named <slot name="description">) and stop calling
{`@html` description}. Update PageContainer.svelte to render plain text for the
existing description prop (e.g., {description}) and only use {`@html`
descriptionHtml} (or render the named slot) so the intent is explicit, and
update callers like Rules.svelte to pass hardcoded HTML into the new
descriptionHtml prop or into the named slot instead of the original description
prop.

In `@packages/finicky-ui/src/pages/Rules.svelte`:
- Around line 46-61: The debounce timer set by scheduleSave() (saveTimer) isn't
cleared on component teardown; add a Svelte onDestroy handler that calls
clearTimeout(saveTimer) (and clears or nulls saveTimer and pendingSave if
desired) to prevent the timeout from firing after the component is unmounted;
locate scheduleSave, saveTimer, and SAVE_DEBOUNCE in the file and register
onDestroy to perform the cleanup.
- Around line 127-131: The $effect block that calls window.finicky.sendMessage({
type: "getRules" }) and window.finicky.sendMessage({ type:
"getInstalledBrowsers" }) is being used for mount-only behavior but will re-run
on reactive updates; replace it with Svelte's onMount (import onMount from
"svelte") and move those sendMessage calls into an onMount callback (or
alternatively wrap them in untrack) so the messages are sent only once on
component mount; update the block that currently uses $effect to call onMount(()
=> { ... }) referencing the same window.finicky.sendMessage calls.

In `@packages/finicky-ui/src/pages/StartPage.svelte`:
- Around line 38-51: The reactive sync block currently assigns
defaultBrowserIsCustom and therefore depends on installedBrowsers, causing
backend updates to rerun the block and overwrite local edits; remove the
defaultBrowserIsCustom assignment from the big $: block (the block that sets
keepRunning, hideIcon, logRequests, checkForUpdates, defaultBrowser,
defaultProfile) and add a separate reactive statement like the existing
defaultProfileIsCustom one that computes defaultBrowserIsCustom = defaultBrowser
!== "" && !installedBrowsers.includes(defaultBrowser), so only that derived flag
reacts to installedBrowsers while defaultBrowser/defaultProfile stay stable
(references: defaultBrowserIsCustom, installedBrowsers, defaultProfileIsCustom,
profilesByBrowser, defaultBrowser, defaultProfile).

In `@scripts/dev.sh`:
- Around line 48-80: Default the scenario selector to "4" (both) before the case
block so an empty $1 runs scenario 4; modify the code around the existing case
"$1" in block (and the scenario labels 0|normal .. 4|both) to use a
pre-initialized value instead of raw $1 — e.g., if $1 is empty set a local
variable or reset positional parameters so the case tests a non-empty selector
(default "4") and ensure existing flags like --mock-update remain honored;
update references from case "$1" in to case "$SCENARIO" in (or set -- 4 ...) so
./scripts/dev.sh without args executes the "4|both" branch.

---

Outside diff comments:
In `@apps/finicky/src/main.go`:
- Around line 257-287: The code always enqueues a non-nil pointer to a
zero-value resolver.OpenerInfo even when Launch Services provided no opener;
change the local opener to be a pointer (var opener *resolver.OpenerInfo) and
only allocate & populate it inside the existing if (name != nil && bundleId !=
nil && path != nil) block (set WindowTitle on that allocated struct if
provided). Then pass opener (which will be nil when no opener was provided) into
URLInfo so JS can distinguish “no opener” from an empty opener.

In `@packages/finicky-ui/src/pages/TestUrl.svelte`:
- Around line 61-71: The subscription to testUrlResult is never cleaned up;
capture the unsubscribe function returned by testUrlResult.subscribe(...) (e.g.,
const unsubscribe = testUrlResult.subscribe(...)) and register cleanup in
Svelte's onDestroy by calling unsubscribe() (and also clearTimeout(loadingTimer)
if still pending) to avoid memory leaks; modify the block around
testUrlResult.subscribe, referencing testUrlResult, loadingTimer, and loading,
and add an onDestroy import/use to call unsubscribe() and any timeout cleanup.

---

Nitpick comments:
In `@apps/finicky/src/rules/rules.go`:
- Around line 69-75: The package-level variable customPath is not protected
against concurrent access by SetCustomPath and readers (e.g., GetPath), risking
data races; protect reads and writes by introducing a sync.RWMutex (e.g.,
customPathMu) and use customPathMu.Lock() / Unlock() in SetCustomPath and
customPathMu.RLock() / RUnlock() around any GetPath or other readers, ensuring
all accesses to customPath are synchronized (alternatively use sync/atomic for
pointer updates or sync.Once if the value is only set once).
- Around line 129-141: SaveToPath currently writes directly with os.WriteFile
which can leave a partial/corrupt file on interruption; change it to perform an
atomic write: after json.MarshalIndent(rf...), create a temp file in the same
directory (use filepath.Dir(path)), write the data to that temp file, flush and
sync the file (fsync) and then atomically rename the temp file to the target
path (os.Rename); ensure you still call os.MkdirAll before creating the temp
file and return any errors from the temp write/sync/rename steps instead of
calling os.WriteFile.

In `@packages/finicky-ui/src/app.css`:
- Around line 1-44: The file contains two separate :root blocks—one with base
font and color-scheme properties and another with CSS custom properties (e.g.,
--bg-primary, --nav-text, --accent-color); merge them into a single :root block
by moving the base declarations (font-family, line-height, font-weight,
color-scheme, color, background-color, font-synthesis, text-rendering,
-webkit-font-smoothing, -moz-osx-font-smoothing) together with all custom
property definitions (the --* variables) and remove the duplicate :root to
improve maintainability and avoid fragmentation.

In `@packages/finicky-ui/src/components/BrowserProfileSelector.svelte`:
- Around line 204-207: The .clear-btn:hover rule in
BrowserProfileSelector.svelte uses a hardcoded color `#f44336`; change it to use
the component/design-system CSS variable (e.g., replace with var(--color-danger)
or the project's equivalent like var(--fg-danger)), and include a sensible
fallback (e.g., var(--color-danger, `#f44336`)) to preserve behavior if the
variable is missing; update the .clear-btn:hover selector to reference that CSS
custom property so the component follows the existing variable pattern.
- Around line 82-90: The form controls in BrowserProfileSelector.svelte lack
accessible names; add explicit labels or aria-label attributes for the text
inputs (the element with class "browser-input" handled by handleBrowserInput and
the profile input referenced around lines 108-116) and for any <select>
controls, and give the clear buttons (the button invoking handleClearBrowser and
the profile clear button) accessible names via aria-label or aria-labelledby;
ensure labels are programmatically associated (for example using <label
for="..."> or matching id+aria-labelledby) so screen readers and keyboard users
can identify each control.

In `@packages/finicky-ui/src/components/icons/Lock.svelte`:
- Around line 1-15: The Lock.svelte SVG is decorative and should be hidden from
assistive tech; update the root <svg> element (the one with class "lucide
lucide-lock" in Lock.svelte) to include aria-hidden="true" and optionally
focusable="false" so screen readers skip it; if this icon ever becomes
meaningful, replace those attributes with an accessible title/role instead.

In `@packages/finicky-ui/src/components/icons/Rules.svelte`:
- Around line 1-13: The Rules.svelte SVG is missing accessibility attributes for
decorative icons; update the <svg> element in Rules.svelte (the SVG with class
"lucide lucide-list-filter") to include aria-hidden="true" and optionally
focusable="false" so it is ignored by assistive technologies when used
decoratively, keeping no change to visual rendering or other attributes.

In `@packages/finicky-ui/src/components/OptionRow.svelte`:
- Around line 120-143: Replace the three hard-coded color values used in the
toggle CSS (.toggle-slider background-color `#666`, .toggle-slider:before
background-color `#ddd`, and checked .toggle-slider:before background-color
`#111111`) with theme-aware CSS variables (e.g., use existing --toggle-active
for the checked track and add/choose variables such as --toggle-bg and
--toggle-thumb for the track and thumb defaults) so the rules in .toggle-slider,
.toggle-slider:before, and .toggle input:checked + .toggle-slider:before derive
colors from variables instead of fixed hex values; ensure sensible fallback
values are provided in the root or component :root so themes without those
variables still render predictably.
- Around line 27-28: The current shorthand {onchange} on the input in
OptionRow.svelte can mismatch Svelte 5 DOM event typing: decide whether the
exported prop onchange should receive the DOM Event or be a no-arg callback; if
it should receive the Event, replace the shorthand with an explicit event
binding on:change={onchange} and update the prop type to (e: Event) => void,
otherwise wrap the call to drop the event payload (on:change={() =>
onchange?.()}) or change the prop name to something like onToggle to make the
no-arg intent explicit; ensure this change is applied alongside the existing
bind:checked and locked usage so consumers get the expected signature.

In `@packages/finicky-ui/src/pages/Rules.svelte`:
- Around line 176-183: The draggable rule rows (element with class "rule-row",
using dragIndex and handlers onDragStart/onDragOver/onDragEnd) lack keyboard
accessibility; add keyboard support by making each rule-row focusable
(tabindex="0"), manage an "active/dragging" state via keyboard (e.g., onKeyDown
onRuleKeyDown) that starts a keyboard-reorder mode on a modifier key (Space or
Enter) and then moves the item with ArrowUp/ArrowDown to call the same reorder
logic used by onDragStart/onDragOver (or a shared moveRule(indexFrom, indexTo)
helper), update ARIA attributes (aria-grabbed, role="option" or "listitem",
aria-posinset/aria-setsize) while reordering, and announce changes via a small
ARIA live region when an item is picked up, moved, or dropped so keyboard users
receive feedback; wire cleanup to onKeyUp/Escape to cancel and ensure onDragEnd
behavior is mirrored.
- Around line 162-167: The description prop currently embeds inline HTML with a
style attribute in the PageContainer usage (PageContainer, description,
isJSConfig) which bypasses sanitization; replace that inline HTML by creating a
small presentational Svelte component (e.g., JsConfigBadge or
DescriptionWithBadge) or add a CSS class (e.g., .accent-badge) and use a plain
text/element slot on PageContainer instead of passing raw HTML in the
description prop; update the PageContainer API (or its consumer) to accept a
description slot or component and revise the Rules.svelte usage to render
<JsConfigBadge/> or a <span class="accent-badge">JS config is active</span> so
styling is done via CSS and no inline style HTML is injected.

In `@packages/finicky-ui/src/pages/TestUrl.svelte`:
- Around line 216-218: Replace the hard-coded rgba colors in the .hint-message
style with the theme variable so the component uses the shared color token;
specifically, update the background and border that currently use rgba(245, 188,
28, ...) to use var(--log-warning) with appropriate alpha (e.g., via color-mix()
or by using an RGB CSS variable and applying alpha) in the .hint-message rule
inside TestUrl.svelte so the hint matches the app theme.

In `@packages/finicky-ui/src/types.ts`:
- Around line 7-18: RulesFile defines an inline options shape that duplicates
ConfigInfo.options; change both RulesFile.options and ConfigInfo.options to
reuse the existing ConfigOptions type by declaring them as
Partial<ConfigOptions> (or ConfigOptions | undefined if you prefer full type) to
avoid duplicated field definitions—update the interface references to use
Partial<ConfigOptions> and remove the duplicated inline object shape in
RulesFile and the corresponding duplicated definition in ConfigInfo so the
shared ConfigOptions type is the single source of truth.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2f612b7e-b0cc-479b-97d3-91cd15fa65fd

📥 Commits

Reviewing files that changed from the base of the PR and between 08eb1d3 and 213bf35.

📒 Files selected for processing (35)
  • apps/finicky/src/browser/browsers.json
  • apps/finicky/src/browser/detect.go
  • apps/finicky/src/browser/launcher.go
  • apps/finicky/src/config/configfiles.go
  • apps/finicky/src/config/vm.go
  • apps/finicky/src/main.go
  • apps/finicky/src/main.h
  • apps/finicky/src/main.m
  • apps/finicky/src/resolver/resolver.go
  • apps/finicky/src/resolver/resolver_test.go
  • apps/finicky/src/rules/rules.go
  • apps/finicky/src/rules/rules_test.go
  • apps/finicky/src/util/directories.go
  • apps/finicky/src/version/version.go
  • apps/finicky/src/window/window.go
  • packages/finicky-ui/src/App.svelte
  • packages/finicky-ui/src/app.css
  • packages/finicky-ui/src/components/BrowserProfileSelector.svelte
  • packages/finicky-ui/src/components/OptionRow.svelte
  • packages/finicky-ui/src/components/PageContainer.svelte
  • packages/finicky-ui/src/components/TabBar.svelte
  • packages/finicky-ui/src/components/icons/Lock.svelte
  • packages/finicky-ui/src/components/icons/Rules.svelte
  • packages/finicky-ui/src/lib/testUrlStore.ts
  • packages/finicky-ui/src/pages/About.svelte
  • packages/finicky-ui/src/pages/LogViewer.svelte
  • packages/finicky-ui/src/pages/Rules.svelte
  • packages/finicky-ui/src/pages/StartPage.svelte
  • packages/finicky-ui/src/pages/TestUrl.svelte
  • packages/finicky-ui/src/types.ts
  • packages/finicky-ui/src/utils/text.ts
  • scripts/dev.sh
  • scripts/test.sh
  • testdata/config.js
  • testdata/rules.json

Comment thread apps/finicky/src/browser/detect.go
Comment thread apps/finicky/src/config/configfiles.go
Comment thread apps/finicky/src/main.go
Comment on lines +143 to +165
window.SaveRulesHandler = func(rf rules.RulesFile) {
slog.Debug("Rules updated", "count", len(rf.Rules))
resolver.SetCachedRules(rf)
if vm == nil || !vm.IsJSConfig() {
if rf.DefaultBrowser == "" && len(rf.Rules) == 0 {
vm = nil
return
}
script, err := rules.ToJSConfigScript(rf, namespace)
if err != nil {
slog.Error("Failed to generate config from rules", "error", err)
return
}
newVM, err := config.NewFromScript(finickyConfigAPIJS, namespace, script)
if err != nil {
slog.Error("Failed to rebuild VM from rules", "error", err)
return
}
vm = newVM
shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning
go checkForUpdates()
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, verify the file structure and read the main.go file to understand the context
git ls-files apps/finicky/src/main.go | head -5

Repository: johnste/finicky

Length of output: 83


🏁 Script executed:

# Get file info and line count
wc -l apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 87


🏁 Script executed:

# Read the SaveRulesHandler section (lines 143-165) and surrounding context
sed -n '135,170p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 1161


🏁 Script executed:

# Check lines 189-190 and context
sed -n '185,195p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 409


🏁 Script executed:

# Check lines 299-300 and context
sed -n '295,305p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 389


🏁 Script executed:

# Search for resolver.ResolveURL to understand what it does
rg "ResolveURL" apps/finicky/src/ -A 5 -B 2

Repository: johnste/finicky

Length of output: 14133


🏁 Script executed:

# Search for goja.Runtime usage and any mutex definitions
rg "goja\.Runtime|sync\.Mutex|sync\.RWMutex" apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 85


🏁 Script executed:

# Look at the vm definition and how it's used globally
rg "var vm|vm =" apps/finicky/src/main.go | head -20

Repository: johnste/finicky

Length of output: 140


🏁 Script executed:

# Read the evaluateURL function to see how vm/runtime is used
sed -n '1,150p' apps/finicky/src/resolver/resolver.go | tail -100

Repository: johnste/finicky

Length of output: 3213


🏁 Script executed:

# Search for evaluateURL full implementation
rg "func evaluateURL" apps/finicky/src/resolver/resolver.go -A 30

Repository: johnste/finicky

Length of output: 1065


🏁 Script executed:

# Check the full evaluateURL and runtime.Set calls
sed -n '1,200p' apps/finicky/src/resolver/resolver.go

Repository: johnste/finicky

Length of output: 4313


🏁 Script executed:

# Look for any synchronization primitives in main.go
rg "sync\.|mutex|lock|RWMutex|Mutex" apps/finicky/src/main.go -i

Repository: johnste/finicky

Length of output: 82


🏁 Script executed:

# Check if there are goroutines spawned that access vm
rg "go " apps/finicky/src/main.go | head -20

Repository: johnste/finicky

Length of output: 342


🏁 Script executed:

# Look at line 138-180 to see the full context around SaveRulesHandler
sed -n '138,180p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 1264


🏁 Script executed:

# Check if goja.Runtime is documented as thread-safe and look at lock usage
rg "LockOSThread|Lock|Unlock" apps/finicky/src/config/config.go -B 2 -A 2

Repository: johnste/finicky

Length of output: 132


🏁 Script executed:

# Look for the config.VM structure and Runtime() method
rg "type VM struct|func.*Runtime\(\)" apps/finicky/src/config/config.go -A 5

Repository: johnste/finicky

Length of output: 132


🏁 Script executed:

# Check how many goroutines actually access resolver.ResolveURL
rg "resolver\.ResolveURL|TestURLInternal" apps/finicky/src/main.go -B 2 -A 2

Repository: johnste/finicky

Length of output: 657


🏁 Script executed:

# Look at the main event loop to understand threading model
sed -n '165,250p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 2320


🏁 Script executed:

# Check if there's any synchronization in the main event loop where SaveRulesHandler might be called
rg "SaveRulesHandler|window\." apps/finicky/src/main.go | head -20

Repository: johnste/finicky

Length of output: 573


🏁 Script executed:

# Check the goja import and documentation
grep -n "goja" apps/finicky/src/config/config.go | head -10

Repository: johnste/finicky

Length of output: 125


🏁 Script executed:

# Find config package files
fd "config" apps/finicky/src --type f -name "*.go"

Repository: johnste/finicky

Length of output: 289


🏁 Script executed:

# Look for VM type definition
rg "type VM struct" apps/finicky/src -A 10

Repository: johnste/finicky

Length of output: 629


🏁 Script executed:

# Check for any mutex or synchronization in the config package
rg "Mutex|Lock|sync\." apps/finicky/src/config -A 2 -B 2

Repository: johnste/finicky

Length of output: 730


🏁 Script executed:

# Find the config.VM Runtime method
rg "func.*\(\s*vm\s*\*VM\s*\).*Runtime" apps/finicky/src -A 5

Repository: johnste/finicky

Length of output: 209


🏁 Script executed:

# Check if goja.Runtime is thread-safe by looking at documentation or comments
web_search
goja Runtime thread safety concurrency

Repository: johnste/finicky

Length of output: 152


🌐 Web query:

goja Runtime thread safety and goroutine safety

💡 Result:

  • goja.Runtime is not goroutine-safe / thread-safe. A single *goja.Runtime “can only be used by a single goroutine at a time.” If you need concurrency, create multiple runtimes, or serialize all access through one owning goroutine (e.g., an event-loop pattern). [1]
  • You generally can’t share JS object values between runtimes. The README notes it’s “not possible to pass object values between runtimes.” (Primitives are OK.) [1]
  • Promise resolve/reject funcs aren’t goroutine-safe either. Runtime.NewPromise() returns resolve/reject functions that “must not be called in parallel with VM running”; the docs show scheduling back onto the VM/event-loop goroutine. [2]
  • If you do share host objects across runtimes, the host side must be goroutine-safe. NewSharedDynamicObject / NewSharedDynamicArray can create objects sharable across multiple runtimes, but the provided DynamicObject/DynamicArray “must be goroutine-safe.” [3]

Sources:
[1] goja README / FAQ on pkg.go.dev (goroutine safety + cross-runtime value passing) (pkg.go.dev)
[2] Runtime.NewPromise docs (resolve/reject not goroutine-safe; schedule on loop) (pkg.go.dev)
[3] NewSharedDynamicObject / NewSharedDynamicArray docs (must be goroutine-safe) (pkg.go.dev)

Citations:


🏁 Script executed:

# Let me check the entire VM structure and its methods
sed -n '1,100p' apps/finicky/src/config/vm.go

Repository: johnste/finicky

Length of output: 2824


🏁 Script executed:

# Check all calls to vm from the main event loop
sed -n '165,300p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 3757


🏁 Script executed:

# Look for synchronization when vm is accessed
rg "vm\." apps/finicky/src/main.go | head -30

Repository: johnste/finicky

Length of output: 405


🏁 Script executed:

# Check how SaveRulesHandler is called and from what context
rg "SaveRulesHandler" apps/finicky/src -B 5 -A 5

Repository: johnste/finicky

Length of output: 2075


🏁 Script executed:

# Look at the event loop more carefully - understand all places where vm is accessed
sed -n '180,320p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 4023


🏁 Script executed:

# Verify TestURLInternal context - is it indeed spawned in a goroutine?
sed -n '135,145p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 491


🌐 Web query:

goja Runtime state isolation multiple evaluations concurrent

💡 Result:

  • State isolation: A goja.Runtime is a full JS VM instance with its own global object/heap. If you run multiple RunString/RunScript/eval() calls on the same Runtime, they share globals and mutated state across evaluations. For isolation between evaluations, create a new goja.Runtime per isolated context (or explicitly reset/recreate the global environment yourself). [1]

  • Concurrency: goja.Runtime is not goroutine-safe. A single runtime instance may only be used by one goroutine at a time; you must not evaluate concurrently on the same runtime. To run scripts concurrently, use one runtime per goroutine, or funnel all access to a runtime through a single “owner” goroutine / event-loop style scheduler. [1][2]

  • Common pattern for concurrent hosts: Implement an event loop (or use one like goja_nodejs) and only touch the runtime from the loop’s goroutine; other goroutines post tasks into the loop. [1][2][3]

Sources: [1] goja README (“Is it goroutine-safe?”) (github.com), [2] pkg.go.dev docs noting Runtime methods are not safe for concurrent use and recommending an event loop (pkg.go.dev), [3] example repo emphasizing all VM interactions must happen on the owning goroutine (github.com)

Citations:


🏁 Script executed:

# Final verification: check if there's any channel or synchronization mechanism that gates SaveRulesHandler or TestURLInternal
rg "SaveRulesHandler|TestURLInternal" apps/finicky/src/window -B 10 -A 2

Repository: johnste/finicky

Length of output: 1565


Protect shared goja.Runtime access with synchronization.

SaveRulesHandler replaces the global vm while TestURLInternal (spawned in its own goroutine) and the main event loop both read and mutate that same instance via resolver.ResolveURL. goja.Runtime is not goroutine-safe and can only be used by a single goroutine at a time. Each resolver.ResolveURL call invokes evaluateURL, which writes per-request globals like url, opener, and _jsonHandlers into the runtime, then executes JS that reads them. Concurrent access across the UI-triggered SaveRulesHandler, the spawned test-URL goroutine, and the main event loop will cause race conditions and state bleed. Guard all VM reads/writes with a mutex, or funnel all VM access through a single owning goroutine using channels.

Also applies to: lines 189–190, 301–302

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/main.go` around lines 143 - 165, The SaveRulesHandler
replaces the global vm and other goroutines (TestURLInternal, the main event
loop via resolver.ResolveURL and evaluateURL) concurrently read/write the same
goja.Runtime; make VM access concurrency-safe by adding a package-level
sync.RWMutex (e.g., vmMu) and use vmMu.Lock() when replacing or mutating vm and
vmMu.RLock() when reading/using it (including in resolver.ResolveURL,
evaluateURL, TestURLInternal, and any main-loop accesses); ensure nil checks and
assignments of vm and shouldKeepRunning are done under the same lock and release
promptly, and add locking around calls that execute JS on the runtime so only
one goroutine accesses goja.Runtime at a time (apply same protection to the
other spots noted in the comment).

Comment thread apps/finicky/src/main.go Outdated
Comment thread apps/finicky/src/main.go Outdated
Comment on lines +151 to +163
script, err := rules.ToJSConfigScript(rf, namespace)
if err != nil {
slog.Error("Failed to generate config from rules", "error", err)
return
}
newVM, err := config.NewFromScript(finickyConfigAPIJS, namespace, script)
if err != nil {
slog.Error("Failed to rebuild VM from rules", "error", err)
return
}
vm = newVM
shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning
go checkForUpdates()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Apply logRequests after rebuilding from saved rules.

This branch swaps in newVM but only re-syncs KeepRunning and update checks. The UI now saves logRequests too, yet logger.SetupFile(...) is never re-run here, so that toggle won’t take effect until a later reload or restart.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/main.go` around lines 151 - 163, After swapping in newVM
(from config.NewFromScript) you must also apply the saved logRequests setting so
the logger reflects the rebuilt config immediately; after setting vm = newVM and
updating shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning, call
logger.SetupFile(...) (or the existing logger initialization routine) with the
new VM's logRequests value (e.g., vm.GetAllConfigOptions().LogRequests or
newVM.GetAllConfigOptions().LogRequests) before launching checkForUpdates() so
the logRequests toggle takes effect without a restart.

Comment thread packages/finicky-ui/src/components/PageContainer.svelte Outdated
Comment thread packages/finicky-ui/src/pages/Rules.svelte
Comment thread packages/finicky-ui/src/pages/Rules.svelte
Comment thread packages/finicky-ui/src/pages/StartPage.svelte
Comment thread scripts/dev.sh Outdated
@johnste johnste changed the title Feat/UI rules UI Rules and lots of things to support it Apr 7, 2026
@johnste johnste force-pushed the feat/ui-rules branch 2 times, most recently from 85e4e98 to 0ec1d3d Compare April 7, 2026 21:03
@johnste johnste force-pushed the feat/ui-rules branch 4 times, most recently from 07f7bec to c90c2b6 Compare April 17, 2026 22:17
@johnste
Copy link
Copy Markdown
Owner Author

johnste commented Apr 17, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 17, 2026

✅ Actions performed

Full review triggered.

@johnste johnste marked this pull request as ready for review April 17, 2026 22:47
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

♻️ Duplicate comments (5)
apps/finicky/src/version/version.go (1)

239-246: ⚠️ Potential issue | 🟡 Minor

Normalize the mock tag more defensively.

Line 241 only strips lowercase v, so values like V1.2.3 or v1.2.3 still produce malformed release URLs. Trim whitespace first, then strip both v and V before formatting the links.

Suggested fix
 if mockVersion := os.Getenv("FINICKY_MOCK_UPDATE"); mockVersion != "" {
 	slog.Info("FINICKY_MOCK_UPDATE set, returning mock update", "version", mockVersion)
-	mockTag := strings.TrimPrefix(mockVersion, "v")
+	mockTag := strings.TrimSpace(mockVersion)
+	mockTag = strings.TrimPrefix(mockTag, "v")
+	mockTag = strings.TrimPrefix(mockTag, "V")
 	return &ReleaseInfo{
 		HasUpdate:     true,
 		LatestVersion: mockVersion,
 		DownloadUrl:   fmt.Sprintf("https://github.com/johnste/finicky/releases/tag/v%s", mockTag),
 		ReleaseUrl:    fmt.Sprintf("https://github.com/johnste/finicky/releases/tag/v%s", mockTag),
 	}, true, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/version/version.go` around lines 239 - 246, The mock-tag
normalization is too narrow: when FINICKY_MOCK_UPDATE contains surrounding
whitespace or an uppercase "V" the generated mockTag and URLs are malformed; in
the block that reads os.Getenv("FINICKY_MOCK_UPDATE") (the mockVersion variable
and mockTag usage that returns a *ReleaseInfo), first
strings.TrimSpace(mockVersion) to remove whitespace, then strip both lowercase
and uppercase prefixes (e.g. call strings.TrimPrefix twice or use a small helper
to remove "v"/"V") before assigning mockTag, and keep using mockTag when
constructing DownloadUrl and ReleaseUrl so links are valid.
packages/finicky-ui/src/App.svelte (2)

157-164: ⚠️ Potential issue | 🟡 Minor

Replace on:click with onclick for Svelte 5 compatibility.

As previously noted, Svelte 5 requires standard DOM event attributes (onclick) for native HTML elements like <button>. The current on:click syntax is deprecated.

Proposed fix
-        <button class="config-badge" on:click={() => showPathToast(basename(config.configPath), "Configuration loaded from", config.configPath)}>
+        <button class="config-badge" onclick={() => showPathToast(basename(config.configPath), "Configuration loaded from", config.configPath)}>
           ✓ {basename(config.configPath)}
         </button>
       {/if}
       {`#if` rulesFile.path && rulesFile.path !== config.configPath}
-        <button class="config-badge" on:click={() => showPathToast(basename(rulesFile.path!), "Configuration loaded from", rulesFile.path!)}>
+        <button class="config-badge" onclick={() => showPathToast(basename(rulesFile.path!), "Configuration loaded from", rulesFile.path!)}>
           ✓ {basename(rulesFile.path)}
         </button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/App.svelte` around lines 157 - 164, Replace the
Svelte-specific event bindings on the configuration badge buttons with standard
DOM attributes for Svelte 5: change both occurrences of on:click on the buttons
that call showPathToast (the one using basename(config.configPath) and the one
using basename(rulesFile.path!)) to use onclick instead, preserving the exact
arrow function call and arguments to showPathToast so the handlers (basename,
config.configPath, rulesFile.path, and showPathToast) remain unchanged.

109-111: ⚠️ Potential issue | 🟡 Minor

Startup message requests could fail silently before window.finicky is ready.

As previously noted, these sendMessage calls execute during component initialization before window.webkit?.messageHandlers?.finicky may be fully ready. Consider deferring until onMount to ensure the native bridge is available.

Suggested fix using onMount
+import { onMount } from "svelte";

 // Drain messages that arrived before the Svelte app was ready.
 for (const msg of _preloadQueue) {
   handleMessage(msg);
 }

-// Request rules file info on startup so the footer badge appears immediately.
-window.finicky.sendMessage({ type: "getRules" });
-window.finicky.sendMessage({ type: "getInstalledBrowsers" });
+onMount(() => {
+  // Request rules file info on startup so the footer badge appears immediately.
+  window.finicky.sendMessage({ type: "getRules" });
+  window.finicky.sendMessage({ type: "getInstalledBrowsers" });
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/App.svelte` around lines 109 - 111, The startup
sendMessage calls (window.finicky.sendMessage({ type: "getRules" }) and
window.finicky.sendMessage({ type: "getInstalledBrowsers" })) run during
component init and can fail before the native bridge is ready; move these calls
into an onMount handler (import and use Svelte's onMount) and guard for the
bridge (e.g., check window.finicky or window.webkit?.messageHandlers?.finicky)
before calling sendMessage so the requests are deferred until the native bridge
is available.
apps/finicky/src/window/window.go (2)

268-275: ⚠️ Potential issue | 🟡 Minor

Silent failure when browser name is missing or wrong type.

As previously noted, if msg["browser"] is missing or not a string, browserName will be empty, leading to a potentially confusing response. The issue remains unaddressed.

Proposed validation
 func handleGetBrowserProfiles(msg map[string]interface{}) {
-	browserName, _ := msg["browser"].(string)
+	browserName, ok := msg["browser"].(string)
+	if !ok || browserName == "" {
+		slog.Error("getBrowserProfiles message missing or invalid browser field")
+		return
+	}
 	profiles := browser.GetProfilesForBrowser(browserName)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/window/window.go` around lines 268 - 275,
handleGetBrowserProfiles silently proceeds when msg["browser"] is missing or not
a string; validate that msg["browser"] is present and of type string (assign to
browserName only if ok), and if invalid return early with a clear error payload
via SendMessageToWebView (e.g., include an "error" or "status":"invalid_request"
field) instead of calling browser.GetProfilesForBrowser with an empty name;
update logic around handleGetBrowserProfiles, browserName,
browser.GetProfilesForBrowser and the SendMessageToWebView call to perform this
validation and emit the error response.

223-246: ⚠️ Potential issue | 🟡 Minor

Early validation failures still lack UI feedback.

The rules.Save error at line 244 now sends a saveRulesError message (addressing the past review), but the earlier validation failures at lines 226-227, 232-233, and 238-239 still return silently without notifying the UI. Users won't know why their save failed in these cases.

Proposed fix to add error responses for all failure paths
 func handleSaveRules(msg map[string]interface{}) {
 	payload, ok := msg["payload"]
 	if !ok {
 		slog.Error("saveRules message missing payload field")
+		SendMessageToWebView("saveRulesError", map[string]interface{}{"error": "Missing payload"})
 		return
 	}

 	payloadBytes, err := json.Marshal(payload)
 	if err != nil {
 		slog.Error("Failed to marshal saveRules payload", "error", err)
+		SendMessageToWebView("saveRulesError", map[string]interface{}{"error": "Invalid payload format"})
 		return
 	}

 	var rf rules.RulesFile
 	if err := json.Unmarshal(payloadBytes, &rf); err != nil {
 		slog.Error("Failed to parse saveRules payload", "error", err)
+		SendMessageToWebView("saveRulesError", map[string]interface{}{"error": "Failed to parse rules"})
 		return
 	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/window/window.go` around lines 223 - 246, The handler
handleSaveRules currently returns silently on early failures (missing payload,
json.Marshal error, json.Unmarshal error); update each failure path to call
SendMessageToWebView with a descriptive error event (e.g., "saveRulesError") and
include the error details or a clear message (for missing payload include a
message like "missing payload", for marshal/unmarshal include err.Error())
before returning, keeping the existing Save error behavior intact so the UI
receives feedback for all failure cases.
🧹 Nitpick comments (5)
packages/finicky-ui/src/pages/TestUrl.svelte (2)

6-8: Consider using the store directly or a derived binding.

The current pattern initializes testUrl from $testUrlInput once at component creation. If $testUrlInput is updated externally (e.g., from another component), this component won't reflect those changes. If bidirectional sync is needed, consider using $testUrlInput directly in the template, or use $effect to keep them synchronized.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/TestUrl.svelte` around lines 6 - 8, The
component initializes a local let testUrl = $testUrlInput once, so it won’t
update when the store changes; replace that pattern by either using
$testUrlInput directly in the template (remove the local testUrl) or make
testUrl reactive to the store with a reactive assignment ($: testUrl =
$testUrlInput) or bind the input to the store (bind:value={$testUrlInput}) so
the component stays synchronized with the testUrlInput store; update any
references to the local testUrl accordingly.

214-216: Consider using theme variables for warning colors.

The hint message uses hardcoded rgba(245, 188, 28, ...) values instead of the --log-warning theme variable. This could cause inconsistency if the warning color is adjusted in app.css.

Suggested approach
 .hint-message {
   display: flex;
   align-items: center;
   gap: 10px;
   padding: 12px 16px;
-  background: rgba(245, 188, 28, 0.06);
-  border: 1px solid rgba(245, 188, 28, 0.25);
+  background: color-mix(in srgb, var(--log-warning) 6%, transparent);
+  border: 1px solid color-mix(in srgb, var(--log-warning) 25%, transparent);
   border-radius: 8px;

Alternatively, define dedicated --hint-bg and --hint-border tokens in app.css.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/TestUrl.svelte` around lines 214 - 216, The CSS
block for the hint message currently hardcodes rgba(245,188,28,...) for
background and border; replace those hardcoded values with the theme token
--log-warning (e.g. use rgba(var(--log-warning) / 0.06) for background and
rgba(var(--log-warning) / 0.25) for border) or, if you prefer explicit tokens,
add new variables --hint-bg and --hint-border to app.css and reference them here
in TestUrl.svelte so the hint styling follows the global theme; update the
background and border declarations in the same CSS selector that contains the
current background/border/border-radius rules.
packages/finicky-ui/src/components/Tooltip.svelte (1)

15-18: Consider adding accessibility attributes.

The tooltip works for mouse users but screen readers won't announce the tooltip content. Consider adding aria-describedby or title attribute for accessibility.

Example with basic accessibility
-<span class="tooltip-anchor" class:block>
+<span class="tooltip-anchor" class:block title={text}>
   {`@render` children()}
   <span class="tooltip-text">{text}</span>
 </span>

Or for richer accessibility, use aria-describedby with a unique ID linking the anchor to the tooltip text.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/components/Tooltip.svelte` around lines 15 - 18, The
tooltip isn't exposed to screen readers; update Tooltip.svelte to generate a
unique id (e.g., tooltipId) and attach it to the tooltip text element (the span
with class "tooltip-text"), add aria-describedby={tooltipId} to the anchor span
(class "tooltip-anchor") and also include a fallback title attribute on the
anchor; mark the tooltip text with role="tooltip" and ensure the anchor is
focusable (tabindex) so keyboard users trigger the description. Use the existing
children() and text symbols when wiring these attributes.
apps/finicky/src/resolver/resolver.go (1)

23-40: Consider using sync.RWMutex for better read concurrency.

getCachedRules is a read-only operation called on every URL resolution, while SetCachedRules is called infrequently (only on rule saves). Using RWMutex would allow concurrent reads.

♻️ Proposed optimization
 var (
-	cachedRulesMu   sync.Mutex
+	cachedRulesMu   sync.RWMutex
 	cachedRulesFile rules.RulesFile
 )

 func SetCachedRules(rf rules.RulesFile) {
 	cachedRulesMu.Lock()
 	cachedRulesFile = rf
 	cachedRulesMu.Unlock()
 }

 func getCachedRules() rules.RulesFile {
-	cachedRulesMu.Lock()
-	defer cachedRulesMu.Unlock()
+	cachedRulesMu.RLock()
+	defer cachedRulesMu.RUnlock()
 	return cachedRulesFile
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/resolver/resolver.go` around lines 23 - 40, The current
mutex (cachedRulesMu) serializes all access; change its type from sync.Mutex to
sync.RWMutex, keep cachedRulesFile as is, call cachedRulesMu.Lock()/Unlock() in
SetCachedRules and use cachedRulesMu.RLock()/RUnlock() in getCachedRules so
multiple concurrent readers of getCachedRules can proceed while writes still
exclusive; update the variable declaration for cachedRulesMu and replace the
locking calls in SetCachedRules and getCachedRules accordingly (referencing
cachedRulesMu, SetCachedRules, getCachedRules, cachedRulesFile).
packages/finicky-ui/src/pages/StartPage.svelte (1)

57-85: Duplicated payload construction between save() and scheduleSave().

Both functions construct an identical payload object for the saveRules message. This duplication increases maintenance burden and risk of drift.

♻️ Extract payload construction into a helper
+  function buildRulesPayload() {
+    return {
+      ...rulesFile,
+      defaultBrowser,
+      defaultProfile,
+      options: { keepRunning, hideIcon, logRequests, checkForUpdates },
+    };
+  }
+
   function save() {
     if (isJSConfig) return;
     clearTimeout(saveTimer);
     window.finicky.sendMessage({
       type: "saveRules",
-      payload: {
-        ...rulesFile,
-        defaultBrowser,
-        defaultProfile,
-        options: { keepRunning, hideIcon, logRequests, checkForUpdates },
-      },
+      payload: buildRulesPayload(),
     });
   }

   function scheduleSave() {
     if (isJSConfig) return;
     clearTimeout(saveTimer);
     saveTimer = setTimeout(() => {
       window.finicky.sendMessage({
         type: "saveRules",
-        payload: {
-          ...rulesFile,
-          defaultBrowser,
-          defaultProfile,
-          options: { keepRunning, hideIcon, logRequests, checkForUpdates },
-        },
+        payload: buildRulesPayload(),
       });
     }, SAVE_DEBOUNCE);
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/StartPage.svelte` around lines 57 - 85,
Duplicate payload creation in save() and scheduleSave() should be extracted into
a single helper (e.g., buildSavePayload) that returns the object sent with the
"saveRules" message; update save() and the setTimeout callback in scheduleSave()
to call that helper and reuse the same payload, keeping existing guards
(isJSConfig), timeout handling (saveTimer/clearTimeout), and SAVE_DEBOUNCE
behavior intact and referencing rulesFile, defaultBrowser, defaultProfile and
options: { keepRunning, hideIcon, logRequests, checkForUpdates }.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/finicky/src/browser/detect.go`:
- Around line 50-61: The loop in detect.go uses displayNameAtPath (presentation
label) as the durable key, which breaks profile matching; switch to using
bundle.bundleIdentifier as the stable identifier (use bundle.bundleIdentifier
when checking/adding to seen and when emitting the detected browser id) and keep
the display name from displayNameAtPath separately for UI rendering (e.g.,
store/displayName in a parallel field or object). Update logic around
isLikelyBrowser, seen, names and the values produced from appURLs so the
identifier returned is the bundle ID while the human name remains available for
UI.

In `@apps/finicky/src/main.go`:
- Around line 173-176: The code calls vm.GetAllConfigOptions() without checking
vm for nil, which can panic because setupVM may return (nil, nil); update all
places that call vm.GetAllConfigOptions() (e.g., the block setting
shouldKeepRunning and timeoutChan and the other occurrences around where vm is
used) to first verify vm != nil and handle the nil case (e.g., treat as default
config or skip calling GetAllConfigOptions and set shouldKeepRunning/timeoutChan
appropriately) and ensure any downstream logic that assumes vm is non-nil either
returns an error or uses safe defaults; specifically adjust the code paths that
obtain vm from setupVM to guard before invoking GetAllConfigOptions.
- Around line 211-217: After setupVM returns, vm may be nil and calling
vm.GetAllConfigOptions() causes a nil pointer panic; modify the logic after
setupVM(cfw, namespace) so you first handle setupErr via
handleRuntimeError(setupErr) as before, then check if vm != nil before calling
vm.GetAllConfigOptions() or launching checkForUpdates(); if vm is nil set
shouldKeepRunning = false (or leave previous value if that is intended) and
avoid calling vm.GetAllConfigOptions() and starting go checkForUpdates(),
otherwise read shouldKeepRunning = vm.GetAllConfigOptions().KeepRunning and then
start go checkForUpdates().
- Around line 245-246: The call to C.RunApp can dereference a nil vm when
setupVM returns (nil, nil); update the code around setupVM and the C.RunApp
invocation to guard against a nil vm by checking vm != nil before calling
vm.GetAllConfigOptions().HideIcon (or by using a safe default boolean when vm is
nil), e.g., compute shouldHideIcon := false; if vm != nil { shouldHideIcon =
vm.GetAllConfigOptions().HideIcon } and then pass C.bool(!shouldHideIcon) into
C.RunApp; ensure references are to setupVM, vm, GetAllConfigOptions, HideIcon
and C.RunApp.

In `@apps/finicky/src/rules/rules.go`:
- Around line 180-221: ToJSConfigScript currently always serializes
rf.DefaultBrowser as a plain string, ignoring rf.DefaultProfile; change it to
emit the same {name, profile} object shape used for handler browsers when
rf.DefaultProfile != "" so fallback routing preserves the selected profile.
Concretely, replace the logic that sets defaultBrowserJSON: build an interface{}
defaultBrowserObj = rf.DefaultBrowser (string) unless rf.DefaultProfile != "" in
which case set defaultBrowserObj = map[string]string{"name": rf.DefaultBrowser,
"profile": rf.DefaultProfile}, then json.Marshal(defaultBrowserObj) and use that
JSON in the returned config; keep error handling around the marshal as-is.
Target symbols: ToJSConfigScript, rf.DefaultBrowser, rf.DefaultProfile,
defaultBrowserJSON.

In `@packages/finicky-ui/src/components/BrowserProfileSelector.svelte`:
- Line 127: The `.empty` class condition uses `!browser` inside a block that
only renders when `browser` is truthy, so it can never be true; update the class
directive on the select wrapper in BrowserProfileSelector.svelte to check the
selected profile instead (e.g. replace `!browser` with `!profile` or
`!selectedProfile`) so it becomes `class:empty={required && !profile &&
!isCustom}` (or `!selectedProfile` if that is the prop name), ensuring the empty
state reflects a missing profile selection.

In `@packages/finicky-ui/src/pages/LogViewer.svelte`:
- Around line 45-58: The description slot is wrapped by PageContainer in a <p>,
so having a <div class="log-controls"> inside the description produces invalid
HTML; change the block rendered by the description() snippet to use phrasing
content (e.g., replace the outer <div class="log-controls"> with a <span
class="log-controls"> or otherwise move the controls out of the description
slot) while retaining the inputs and handlers (bind:checked={showDebug},
onchange storing to localStorage, and the buttons using onClearLogs and
copyLogs) so the UI and behavior remain unchanged but the markup is valid.

In `@packages/finicky-ui/src/pages/Rules.svelte`:
- Around line 146-189: The inner $effect that updates rowProfileIsCustom is
currently declared inside the outer $effect (the block manipulating rules,
rowIsCustom and fetching profiles) which causes duplicate effect subscriptions
on each outer re-run; extract that inner $effect into its own top-level $effect
(so it's not nested), keep its logic referencing rules, profilesByBrowser and
rowProfileIsCustom unchanged, and ensure the outer $effect only performs rules
normalization and the untrack fetch loop (including proper closure of the
untrack call) so effects are registered once and no duplicate subscriptions are
created.

---

Duplicate comments:
In `@apps/finicky/src/version/version.go`:
- Around line 239-246: The mock-tag normalization is too narrow: when
FINICKY_MOCK_UPDATE contains surrounding whitespace or an uppercase "V" the
generated mockTag and URLs are malformed; in the block that reads
os.Getenv("FINICKY_MOCK_UPDATE") (the mockVersion variable and mockTag usage
that returns a *ReleaseInfo), first strings.TrimSpace(mockVersion) to remove
whitespace, then strip both lowercase and uppercase prefixes (e.g. call
strings.TrimPrefix twice or use a small helper to remove "v"/"V") before
assigning mockTag, and keep using mockTag when constructing DownloadUrl and
ReleaseUrl so links are valid.

In `@apps/finicky/src/window/window.go`:
- Around line 268-275: handleGetBrowserProfiles silently proceeds when
msg["browser"] is missing or not a string; validate that msg["browser"] is
present and of type string (assign to browserName only if ok), and if invalid
return early with a clear error payload via SendMessageToWebView (e.g., include
an "error" or "status":"invalid_request" field) instead of calling
browser.GetProfilesForBrowser with an empty name; update logic around
handleGetBrowserProfiles, browserName, browser.GetProfilesForBrowser and the
SendMessageToWebView call to perform this validation and emit the error
response.
- Around line 223-246: The handler handleSaveRules currently returns silently on
early failures (missing payload, json.Marshal error, json.Unmarshal error);
update each failure path to call SendMessageToWebView with a descriptive error
event (e.g., "saveRulesError") and include the error details or a clear message
(for missing payload include a message like "missing payload", for
marshal/unmarshal include err.Error()) before returning, keeping the existing
Save error behavior intact so the UI receives feedback for all failure cases.

In `@packages/finicky-ui/src/App.svelte`:
- Around line 157-164: Replace the Svelte-specific event bindings on the
configuration badge buttons with standard DOM attributes for Svelte 5: change
both occurrences of on:click on the buttons that call showPathToast (the one
using basename(config.configPath) and the one using basename(rulesFile.path!))
to use onclick instead, preserving the exact arrow function call and arguments
to showPathToast so the handlers (basename, config.configPath, rulesFile.path,
and showPathToast) remain unchanged.
- Around line 109-111: The startup sendMessage calls
(window.finicky.sendMessage({ type: "getRules" }) and
window.finicky.sendMessage({ type: "getInstalledBrowsers" })) run during
component init and can fail before the native bridge is ready; move these calls
into an onMount handler (import and use Svelte's onMount) and guard for the
bridge (e.g., check window.finicky or window.webkit?.messageHandlers?.finicky)
before calling sendMessage so the requests are deferred until the native bridge
is available.

---

Nitpick comments:
In `@apps/finicky/src/resolver/resolver.go`:
- Around line 23-40: The current mutex (cachedRulesMu) serializes all access;
change its type from sync.Mutex to sync.RWMutex, keep cachedRulesFile as is,
call cachedRulesMu.Lock()/Unlock() in SetCachedRules and use
cachedRulesMu.RLock()/RUnlock() in getCachedRules so multiple concurrent readers
of getCachedRules can proceed while writes still exclusive; update the variable
declaration for cachedRulesMu and replace the locking calls in SetCachedRules
and getCachedRules accordingly (referencing cachedRulesMu, SetCachedRules,
getCachedRules, cachedRulesFile).

In `@packages/finicky-ui/src/components/Tooltip.svelte`:
- Around line 15-18: The tooltip isn't exposed to screen readers; update
Tooltip.svelte to generate a unique id (e.g., tooltipId) and attach it to the
tooltip text element (the span with class "tooltip-text"), add
aria-describedby={tooltipId} to the anchor span (class "tooltip-anchor") and
also include a fallback title attribute on the anchor; mark the tooltip text
with role="tooltip" and ensure the anchor is focusable (tabindex) so keyboard
users trigger the description. Use the existing children() and text symbols when
wiring these attributes.

In `@packages/finicky-ui/src/pages/StartPage.svelte`:
- Around line 57-85: Duplicate payload creation in save() and scheduleSave()
should be extracted into a single helper (e.g., buildSavePayload) that returns
the object sent with the "saveRules" message; update save() and the setTimeout
callback in scheduleSave() to call that helper and reuse the same payload,
keeping existing guards (isJSConfig), timeout handling (saveTimer/clearTimeout),
and SAVE_DEBOUNCE behavior intact and referencing rulesFile, defaultBrowser,
defaultProfile and options: { keepRunning, hideIcon, logRequests,
checkForUpdates }.

In `@packages/finicky-ui/src/pages/TestUrl.svelte`:
- Around line 6-8: The component initializes a local let testUrl = $testUrlInput
once, so it won’t update when the store changes; replace that pattern by either
using $testUrlInput directly in the template (remove the local testUrl) or make
testUrl reactive to the store with a reactive assignment ($: testUrl =
$testUrlInput) or bind the input to the store (bind:value={$testUrlInput}) so
the component stays synchronized with the testUrlInput store; update any
references to the local testUrl accordingly.
- Around line 214-216: The CSS block for the hint message currently hardcodes
rgba(245,188,28,...) for background and border; replace those hardcoded values
with the theme token --log-warning (e.g. use rgba(var(--log-warning) / 0.06) for
background and rgba(var(--log-warning) / 0.25) for border) or, if you prefer
explicit tokens, add new variables --hint-bg and --hint-border to app.css and
reference them here in TestUrl.svelte so the hint styling follows the global
theme; update the background and border declarations in the same CSS selector
that contains the current background/border/border-radius rules.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0f48f6dc-44ee-4e42-ad1e-faa65cd8b223

📥 Commits

Reviewing files that changed from the base of the PR and between 08eb1d3 and 4bcceab.

📒 Files selected for processing (37)
  • apps/finicky/src/browser/browsers.json
  • apps/finicky/src/browser/detect.go
  • apps/finicky/src/browser/launcher.go
  • apps/finicky/src/config/configfiles.go
  • apps/finicky/src/config/vm.go
  • apps/finicky/src/main.go
  • apps/finicky/src/main.m
  • apps/finicky/src/resolver/resolver.go
  • apps/finicky/src/resolver/resolver_test.go
  • apps/finicky/src/rules/rules.go
  • apps/finicky/src/rules/rules_test.go
  • apps/finicky/src/util/directories.go
  • apps/finicky/src/version/version.go
  • apps/finicky/src/window/window.go
  • packages/finicky-ui/src/App.svelte
  • packages/finicky-ui/src/app.css
  • packages/finicky-ui/src/components/BrowserProfileSelector.svelte
  • packages/finicky-ui/src/components/OptionRow.svelte
  • packages/finicky-ui/src/components/PageContainer.svelte
  • packages/finicky-ui/src/components/TabBar.svelte
  • packages/finicky-ui/src/components/Tooltip.svelte
  • packages/finicky-ui/src/components/icons/Lock.svelte
  • packages/finicky-ui/src/components/icons/Rules.svelte
  • packages/finicky-ui/src/components/icons/Warning.svelte
  • packages/finicky-ui/src/components/icons/X.svelte
  • packages/finicky-ui/src/lib/testUrlStore.ts
  • packages/finicky-ui/src/pages/About.svelte
  • packages/finicky-ui/src/pages/LogViewer.svelte
  • packages/finicky-ui/src/pages/Rules.svelte
  • packages/finicky-ui/src/pages/StartPage.svelte
  • packages/finicky-ui/src/pages/TestUrl.svelte
  • packages/finicky-ui/src/types.ts
  • packages/finicky-ui/src/utils/text.ts
  • scripts/dev.sh
  • scripts/test.sh
  • testdata/config.js
  • testdata/rules.json

Comment on lines +50 to +61
for (NSURL *appURL in appURLs) {
NSBundle *bundle = [NSBundle bundleWithURL:appURL];
if ([excludedBundleIDs containsObject:bundle.bundleIdentifier]) continue;
if (!isLikelyBrowser(appURL)) continue;

NSString *name = [[NSFileManager defaultManager] displayNameAtPath:[appURL path]];
if ([name hasSuffix:@".app"]) {
name = [name substringToIndex:[name length] - 4];
}
if (![seen containsObject:name]) {
[seen addObject:name];
[names addObject:name];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Return a stable browser identifier here.

displayNameAtPath: is a presentation label, not a durable key. The new profile flow in apps/finicky/src/browser/launcher.go only matches bundle IDs or browsers.json app_name values, so localized or user-renamed apps will stop resolving profiles even though detection succeeded.

Please key this API on the bundle ID and keep the display name separate for UI rendering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/browser/detect.go` around lines 50 - 61, The loop in
detect.go uses displayNameAtPath (presentation label) as the durable key, which
breaks profile matching; switch to using bundle.bundleIdentifier as the stable
identifier (use bundle.bundleIdentifier when checking/adding to seen and when
emitting the detected browser id) and keep the display name from
displayNameAtPath separately for UI rendering (e.g., store/displayName in a
parallel field or object). Update logic around isLikelyBrowser, seen, names and
the values produced from appURLs so the identifier returned is the bundle ID
while the human name remains available for UI.

Comment thread apps/finicky/src/main.go Outdated
Comment thread apps/finicky/src/main.go Outdated
Comment thread apps/finicky/src/main.go Outdated
Comment thread apps/finicky/src/rules/rules.go
/>
<button class="clear-btn" onclick={handleClearProfile} aria-label="Clear profile"><XIcon /></button>
{:else}
<div class="select-wrapper" class:empty={required && !browser && !isCustom}>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The .empty class condition can never be true for the profile dropdown.

The condition required && !browser && !isCustom is inside a block that only renders when browser is truthy (line 114). Since browser is guaranteed to be non-empty here, !browser is always false, making the .empty class unreachable.

If the intent is to highlight when a profile is required but not selected, the condition should likely be:

Suggested fix
-    <div class="select-wrapper" class:empty={required && !browser && !isCustom}>
+    <div class="select-wrapper" class:empty={required && !profile && !isProfileCustom}>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div class="select-wrapper" class:empty={required && !browser && !isCustom}>
<div class="select-wrapper" class:empty={required && !profile && !isProfileCustom}>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/components/BrowserProfileSelector.svelte` at line
127, The `.empty` class condition uses `!browser` inside a block that only
renders when `browser` is truthy, so it can never be true; update the class
directive on the select wrapper in BrowserProfileSelector.svelte to check the
selected profile instead (e.g. replace `!browser` with `!profile` or
`!selectedProfile`) so it becomes `class:empty={required && !profile &&
!isCustom}` (or `!selectedProfile` if that is the prop name), ensuring the empty
state reflects a missing profile selection.

Comment on lines +45 to +58
{#snippet description()}
<div class="log-controls">
<label class="debug-toggle">
<input
type="checkbox"
bind:checked={showDebug}
onchange={() => localStorage.setItem("showDebugLogs", String(showDebug))}
/>
Show debug
</label>
<button onclick={clearLogs}>Clear</button>
<button onclick={copyLogs}>Copy logs</button>
<button onclick={onClearLogs}>Clear</button>
<button onclick={copyLogs}>Copy</button>
</div>
</div>
{/snippet}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use phrasing content in the description snippet.

PageContainer wraps description in a <p> (packages/finicky-ui/src/components/PageContainer.svelte:19-23), so the <div> here produces invalid HTML and the browser will reparent these controls unpredictably. Use phrasing content here (for example a <span class="log-controls">) or move the controls outside the description slot.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/LogViewer.svelte` around lines 45 - 58, The
description slot is wrapped by PageContainer in a <p>, so having a <div
class="log-controls"> inside the description produces invalid HTML; change the
block rendered by the description() snippet to use phrasing content (e.g.,
replace the outer <div class="log-controls"> with a <span class="log-controls">
or otherwise move the controls out of the description slot) while retaining the
inputs and handlers (bind:checked={showDebug}, onchange storing to localStorage,
and the buttons using onClearLogs and copyLogs) so the UI and behavior remain
unchanged but the markup is valid.

Comment on lines +146 to +189
$effect(() => {
if (pendingSave) return;
const newRules = rulesFile.rules.map((r: Rule) => ({
...r,
match: Array.isArray(r.match) ? r.match : r.match ? [r.match as unknown as string] : [""],
}));
rules = newRules;
rowIsCustom = newRules.map(
(r) => r.browser !== "" && !installedBrowsers.includes(r.browser)
);
rowProfileIsCustom = newRules.map(
(r) => {
const profile = r.profile ?? "";
if (profile === "") return false;
const profiles = untrack(() => profilesByBrowser[r.browser]);
if (profiles === undefined) return false; // not fetched yet — correct later
return !profiles.includes(profile);
}
);
// Fetch profiles for browsers not yet loaded — untrack to avoid re-triggering on profilesByBrowser changes
const browsersToFetch = new Set<string>();
for (const r of newRules) {
if (r.browser) browsersToFetch.add(r.browser);
}
untrack(() => {
for (const b of browsersToFetch) {
if (profilesByBrowser[b] === undefined) {
window.finicky.sendMessage({ type: "getBrowserProfiles", browser: b });
}
}
});

// Once profile lists arrive, correct any rowProfileIsCustom entries that were
// deferred because profilesByBrowser wasn't loaded yet during the initial sync.
$effect(() => {
rules.forEach((r, i) => {
const profile = r.profile ?? "";
if (profile === "") return;
const profiles = profilesByBrowser[r.browser];
if (profiles === undefined) return; // still loading
rowProfileIsCustom[i] = !profiles.includes(profile);
});
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Nested $effect will create duplicate effect subscriptions on each re-run.

The $effect starting at line 180 is nested inside the $effect starting at line 146. Each time the outer effect re-runs, it creates a new inner effect subscription without cleaning up the previous one, leading to multiple executions and potential memory leaks.

🐛 Proposed fix - move inner effect outside
   $effect(() => {
     if (pendingSave) return;
     const newRules = rulesFile.rules.map((r: Rule) => ({
       ...r,
       match: Array.isArray(r.match) ? r.match : r.match ? [r.match as unknown as string] : [""],
     }));
     rules = newRules;
     rowIsCustom = newRules.map(
       (r) => r.browser !== "" && !installedBrowsers.includes(r.browser)
     );
     rowProfileIsCustom = newRules.map(
       (r) => {
         const profile = r.profile ?? "";
         if (profile === "") return false;
         const profiles = untrack(() => profilesByBrowser[r.browser]);
         if (profiles === undefined) return false;
         return !profiles.includes(profile);
       }
     );
     // Fetch profiles for browsers not yet loaded
     const browsersToFetch = new Set<string>();
     for (const r of newRules) {
       if (r.browser) browsersToFetch.add(r.browser);
     }
     untrack(() => {
       for (const b of browsersToFetch) {
         if (profilesByBrowser[b] === undefined) {
           window.finicky.sendMessage({ type: "getBrowserProfiles", browser: b });
         }
       }
     });
-
-  // Once profile lists arrive, correct any rowProfileIsCustom entries that were
-  // deferred because profilesByBrowser wasn't loaded yet during the initial sync.
-  $effect(() => {
-    rules.forEach((r, i) => {
-      const profile = r.profile ?? "";
-      if (profile === "") return;
-      const profiles = profilesByBrowser[r.browser];
-      if (profiles === undefined) return; // still loading
-      rowProfileIsCustom[i] = !profiles.includes(profile);
-    });
-  });
   });
+
+  // Once profile lists arrive, correct any rowProfileIsCustom entries that were
+  // deferred because profilesByBrowser wasn't loaded yet during the initial sync.
+  $effect(() => {
+    rules.forEach((r, i) => {
+      const profile = r.profile ?? "";
+      if (profile === "") return;
+      const profiles = profilesByBrowser[r.browser];
+      if (profiles === undefined) return; // still loading
+      rowProfileIsCustom[i] = !profiles.includes(profile);
+    });
+  });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$effect(() => {
if (pendingSave) return;
const newRules = rulesFile.rules.map((r: Rule) => ({
...r,
match: Array.isArray(r.match) ? r.match : r.match ? [r.match as unknown as string] : [""],
}));
rules = newRules;
rowIsCustom = newRules.map(
(r) => r.browser !== "" && !installedBrowsers.includes(r.browser)
);
rowProfileIsCustom = newRules.map(
(r) => {
const profile = r.profile ?? "";
if (profile === "") return false;
const profiles = untrack(() => profilesByBrowser[r.browser]);
if (profiles === undefined) return false; // not fetched yet — correct later
return !profiles.includes(profile);
}
);
// Fetch profiles for browsers not yet loaded — untrack to avoid re-triggering on profilesByBrowser changes
const browsersToFetch = new Set<string>();
for (const r of newRules) {
if (r.browser) browsersToFetch.add(r.browser);
}
untrack(() => {
for (const b of browsersToFetch) {
if (profilesByBrowser[b] === undefined) {
window.finicky.sendMessage({ type: "getBrowserProfiles", browser: b });
}
}
});
// Once profile lists arrive, correct any rowProfileIsCustom entries that were
// deferred because profilesByBrowser wasn't loaded yet during the initial sync.
$effect(() => {
rules.forEach((r, i) => {
const profile = r.profile ?? "";
if (profile === "") return;
const profiles = profilesByBrowser[r.browser];
if (profiles === undefined) return; // still loading
rowProfileIsCustom[i] = !profiles.includes(profile);
});
});
});
$effect(() => {
if (pendingSave) return;
const newRules = rulesFile.rules.map((r: Rule) => ({
...r,
match: Array.isArray(r.match) ? r.match : r.match ? [r.match as unknown as string] : [""],
}));
rules = newRules;
rowIsCustom = newRules.map(
(r) => r.browser !== "" && !installedBrowsers.includes(r.browser)
);
rowProfileIsCustom = newRules.map(
(r) => {
const profile = r.profile ?? "";
if (profile === "") return false;
const profiles = untrack(() => profilesByBrowser[r.browser]);
if (profiles === undefined) return false; // not fetched yet — correct later
return !profiles.includes(profile);
}
);
// Fetch profiles for browsers not yet loaded — untrack to avoid re-triggering on profilesByBrowser changes
const browsersToFetch = new Set<string>();
for (const r of newRules) {
if (r.browser) browsersToFetch.add(r.browser);
}
untrack(() => {
for (const b of browsersToFetch) {
if (profilesByBrowser[b] === undefined) {
window.finicky.sendMessage({ type: "getBrowserProfiles", browser: b });
}
}
});
});
// Once profile lists arrive, correct any rowProfileIsCustom entries that were
// deferred because profilesByBrowser wasn't loaded yet during the initial sync.
$effect(() => {
rules.forEach((r, i) => {
const profile = r.profile ?? "";
if (profile === "") return;
const profiles = profilesByBrowser[r.browser];
if (profiles === undefined) return; // still loading
rowProfileIsCustom[i] = !profiles.includes(profile);
});
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/finicky-ui/src/pages/Rules.svelte` around lines 146 - 189, The inner
$effect that updates rowProfileIsCustom is currently declared inside the outer
$effect (the block manipulating rules, rowIsCustom and fetching profiles) which
causes duplicate effect subscriptions on each outer re-run; extract that inner
$effect into its own top-level $effect (so it's not nested), keep its logic
referencing rules, profilesByBrowser and rowProfileIsCustom unchanged, and
ensure the outer $effect only performs rules normalization and the untrack fetch
loop (including proper closure of the untrack call) so effects are registered
once and no duplicate subscriptions are created.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
apps/finicky/src/main.go (3)

143-167: ⚠️ Potential issue | 🔴 Critical

Race condition on shared vm variable remains unaddressed.

SaveRulesHandler writes to the global vm (line 161) without synchronization. Meanwhile, the main event loop reads vm at line 193 (resolver.ResolveURL), TestURLInternal reads it at line 308 (spawned in a goroutine via line 137), and checkForUpdates reads it at line 365 (spawned in goroutines at lines 133, 164, 222, 233). Since goja.Runtime is not goroutine-safe, concurrent access can cause race conditions and state corruption.

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/main.go` around lines 143 - 167, The handler
SaveRulesHandler mutates the shared global vm without synchronization while
resolver.ResolveURL, TestURLInternal, and checkForUpdates read it concurrently,
causing race conditions; add a synchronization primitive (e.g., a package-level
sync.RWMutex named vmMu) and wrap all writes to vm (the assignment in
SaveRulesHandler and any other code that replaces vm) with vmMu.Lock()/Unlock()
and wrap all reads (resolver.ResolveURL, TestURLInternal, checkForUpdates and
any goroutines that call vm methods) with vmMu.RLock()/RUnlock(); ensure that
when you call vm methods you hold the appropriate lock for the duration of the
call to prevent concurrent access to the goja.Runtime instance.

161-165: ⚠️ Potential issue | 🟠 Major

logRequests setting not applied after rebuilding VM from rules.

After setting vm = newVM, the code updates shouldKeepRunning and triggers checkForUpdates(), but doesn't call logger.SetupFile() with the new LogRequests value. The UI-saved logRequests toggle won't take effect until the next reload or restart.

,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/main.go` around lines 161 - 165, After assigning the rebuilt
VM (vm = newVM), the new VM's LogRequests setting isn't applied to the logger;
call logger.SetupFile(...) with the new value so UI toggled logRequests takes
effect immediately. In the block where vm is replaced (after vm = newVM and when
vm != nil), read the new config via vm.GetAllConfigOptions().LogRequests and
invoke logger.SetupFile with that boolean (and any existing parameters used
elsewhere) so the logger reflects the rebuilt VM's LogRequests setting without
requiring a restart; ensure this mirrors the initial logger setup behavior used
on startup.

449-449: ⚠️ Potential issue | 🟡 Minor

Inconsistent handling of options-only rules files.

The condition here only checks rf.DefaultBrowser != "" || len(rf.Rules) > 0, but SaveRulesHandler at line 147 additionally checks rf.Options == nil. A rules file containing only UI settings (e.g., keepRunning, hideIcon) without any rules or default browser will be ignored here, while the same file would be processed in SaveRulesHandler.

🐛 Proposed fix for consistency
-			if rf.DefaultBrowser != "" || len(rf.Rules) > 0 {
+			if rf.DefaultBrowser != "" || len(rf.Rules) > 0 || rf.Options != nil {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/main.go` at line 449, The branch that decides whether to
process a parsed rules file only checks rf.DefaultBrowser != "" || len(rf.Rules)
> 0, which misses files that contain only UI options; update that condition to
also treat rf.Options != nil as a valid payload so option-only files are handled
the same way as in SaveRulesHandler (referencing rf.DefaultBrowser, rf.Rules,
rf.Options and the SaveRulesHandler behavior) — modify the conditional in the
same function to include rf.Options != nil and ensure downstream code that
processes the rules/options handles nil vs non-nil Options consistently.
🧹 Nitpick comments (1)
apps/finicky/src/main.go (1)

430-434: Redundant rules loading when no JS config is present.

When currentBundlePath == "", rules are loaded at lines 430-434 (unconditionally) and then loaded again at lines 444-448. Consider reusing the already-loaded rf from the first load to avoid redundant disk I/O.

♻️ Proposed refactor to eliminate double loading
+	var preloadedRules rules.RulesFile
+	var preloadErr error
 	// Always seed the cached rules from disk so JSON rules are applied
 	// immediately on startup, even when a JS config is also present.
-	if rf, rulesErr := rules.Load(); rulesErr != nil {
+	if preloadedRules, preloadErr = rules.Load(); preloadErr != nil {
 		slog.Warn("Failed to pre-load rules cache", "error", rulesErr)
 	} else {
-		resolver.SetCachedRules(rf)
+		resolver.SetCachedRules(preloadedRules)
 	}

 	var newVM *config.VM

 	if currentBundlePath != "" {
 		newVM, err = config.New(finickyConfigAPIJS, namespace, currentBundlePath)
 		if err != nil {
 			return nil, fmt.Errorf("failed to setup VM: %v", err)
 		}
 	} else {
-		rf, rulesErr := rules.Load()
-		if rulesErr != nil {
-			slog.Warn("Failed to load rules file", "error", rulesErr)
-		} else {
-			resolver.SetCachedRules(rf)
-			if rf.DefaultBrowser != "" || len(rf.Rules) > 0 {
+		if preloadErr == nil {
+			if preloadedRules.DefaultBrowser != "" || len(preloadedRules.Rules) > 0 || preloadedRules.Options != nil {
-				script, scriptErr := rules.ToJSConfigScript(rf, namespace)
+				script, scriptErr := rules.ToJSConfigScript(preloadedRules, namespace)

Also applies to: 444-448

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/main.go` around lines 430 - 434, The code unconditionally
calls rules.Load() twice when currentBundlePath == "" leading to redundant disk
I/O; modify the logic so the first successful load (rf from the initial
rules.Load() block) is reused instead of calling rules.Load() again: preserve rf
in a variable in the surrounding scope and, where the later block currently
calls rules.Load() again (the second rules.Load() and resolver.SetCachedRules
call), check if rf is already non-nil and call resolver.SetCachedRules(rf)
directly, only invoking rules.Load() if rf is nil; ensure references to
currentBundlePath, rf, rules.Load, and resolver.SetCachedRules are used to
locate and update the two loading sites.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@apps/finicky/src/main.go`:
- Around line 143-167: The handler SaveRulesHandler mutates the shared global vm
without synchronization while resolver.ResolveURL, TestURLInternal, and
checkForUpdates read it concurrently, causing race conditions; add a
synchronization primitive (e.g., a package-level sync.RWMutex named vmMu) and
wrap all writes to vm (the assignment in SaveRulesHandler and any other code
that replaces vm) with vmMu.Lock()/Unlock() and wrap all reads
(resolver.ResolveURL, TestURLInternal, checkForUpdates and any goroutines that
call vm methods) with vmMu.RLock()/RUnlock(); ensure that when you call vm
methods you hold the appropriate lock for the duration of the call to prevent
concurrent access to the goja.Runtime instance.
- Around line 161-165: After assigning the rebuilt VM (vm = newVM), the new VM's
LogRequests setting isn't applied to the logger; call logger.SetupFile(...) with
the new value so UI toggled logRequests takes effect immediately. In the block
where vm is replaced (after vm = newVM and when vm != nil), read the new config
via vm.GetAllConfigOptions().LogRequests and invoke logger.SetupFile with that
boolean (and any existing parameters used elsewhere) so the logger reflects the
rebuilt VM's LogRequests setting without requiring a restart; ensure this
mirrors the initial logger setup behavior used on startup.
- Line 449: The branch that decides whether to process a parsed rules file only
checks rf.DefaultBrowser != "" || len(rf.Rules) > 0, which misses files that
contain only UI options; update that condition to also treat rf.Options != nil
as a valid payload so option-only files are handled the same way as in
SaveRulesHandler (referencing rf.DefaultBrowser, rf.Rules, rf.Options and the
SaveRulesHandler behavior) — modify the conditional in the same function to
include rf.Options != nil and ensure downstream code that processes the
rules/options handles nil vs non-nil Options consistently.

---

Nitpick comments:
In `@apps/finicky/src/main.go`:
- Around line 430-434: The code unconditionally calls rules.Load() twice when
currentBundlePath == "" leading to redundant disk I/O; modify the logic so the
first successful load (rf from the initial rules.Load() block) is reused instead
of calling rules.Load() again: preserve rf in a variable in the surrounding
scope and, where the later block currently calls rules.Load() again (the second
rules.Load() and resolver.SetCachedRules call), check if rf is already non-nil
and call resolver.SetCachedRules(rf) directly, only invoking rules.Load() if rf
is nil; ensure references to currentBundlePath, rf, rules.Load, and
resolver.SetCachedRules are used to locate and update the two loading sites.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 71dab91a-6112-4bfa-b9c8-44c0d8a7e60b

📥 Commits

Reviewing files that changed from the base of the PR and between 4bcceab and 956c33b.

📒 Files selected for processing (4)
  • apps/finicky/src/main.go
  • apps/finicky/src/rules/rules.go
  • packages/finicky-ui/src/pages/Rules.svelte
  • packages/finicky-ui/src/pages/StartPage.svelte
✅ Files skipped from review due to trivial changes (1)
  • packages/finicky-ui/src/pages/Rules.svelte
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/finicky/src/rules/rules.go
  • packages/finicky-ui/src/pages/StartPage.svelte

@johnste johnste merged commit 8a8836a into main Apr 18, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI Config

2 participants