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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/finicky/src/browser/browsers.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@
"type": "Chromium",
"app_name": "Opera GX"
},
{
"config_dir_relative": "",
"id": "com.apple.Safari",
"type": "Safari",
"app_name": "Safari"
},
{
"config_dir_relative": "Firefox",
"id": "org.mozilla.firefox",
Expand Down
105 changes: 105 additions & 0 deletions apps/finicky/src/browser/detect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package browser

/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework AppKit
#import <AppKit/AppKit.h>
#include <stdlib.h>
#include <string.h>

// isLikelyBrowser returns YES if the app at appURL registers for http or https
// with LSHandlerRank "Default" or "Alternate". Apps that set LSHandlerRank "None"
// are using deep-link / download-interception tricks, not acting as browsers.
// Absent LSHandlerRank defaults to "Default" per Apple docs.
static BOOL isLikelyBrowser(NSURL *appURL) {
NSBundle *bundle = [NSBundle bundleWithURL:appURL];
NSDictionary *info = bundle.infoDictionary;
if (!info) return NO;

NSArray *urlTypes = info[@"CFBundleURLTypes"];
if (!urlTypes) return NO;

for (NSDictionary *urlType in urlTypes) {
NSArray *schemes = urlType[@"CFBundleURLSchemes"] ?: @[];
if (![schemes containsObject:@"http"] && ![schemes containsObject:@"https"]) continue;

NSString *rank = urlType[@"LSHandlerRank"] ?: @"Default";
if ([rank isEqualToString:@"Default"] || [rank isEqualToString:@"Alternate"]) {
return YES;
}
}
return NO;
}

static char **getAllHttpsHandlerNames(int *count) {
@autoreleasepool {
NSURL *url = [NSURL URLWithString:@"https://example.com"];
NSArray<NSURL *> *appURLs = [[NSWorkspace sharedWorkspace] URLsForApplicationsToOpenURL:url];
if (!appURLs || appURLs.count == 0) {
*count = 0;
return NULL;
}

NSMutableSet<NSString *> *seen = [NSMutableSet set];
NSMutableArray<NSString *> *names = [NSMutableArray array];
NSSet *excludedBundleIDs = [NSSet setWithObjects:
@"se.johnste.finicky",
@"net.kassett.finicky",
nil];

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];
}
}

*count = (int)names.count;
char **result = (char **)malloc(names.count * sizeof(char *));
for (NSInteger i = 0; i < (NSInteger)names.count; i++) {
result[i] = strdup([names[i] UTF8String]);
}
return result;
}
Comment on lines +34 to +71
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:

find . -type f -name "*.go" | head -20

Repository: johnste/finicky

Length of output: 794


🏁 Script executed:

fd -t f "window\.go" --type f

Repository: johnste/finicky

Length of output: 92


🏁 Script executed:

fd -t f "detect\." --type f

Repository: johnste/finicky

Length of output: 93


🏁 Script executed:

git ls-files | grep -E "(browser|window)" | head -30

Repository: johnste/finicky

Length of output: 681


🏁 Script executed:

file apps/finicky/src/browser/detect.go

Repository: johnste/finicky

Length of output: 103


🏁 Script executed:

cat -n apps/finicky/src/browser/detect.go

Repository: johnste/finicky

Length of output: 3794


🏁 Script executed:

grep -n "GetInstalledBrowsers" apps/finicky/src/browser/*.go

Repository: johnste/finicky

Length of output: 249


🏁 Script executed:

sed -n '140,180p' apps/finicky/src/window/window.go

Repository: johnste/finicky

Length of output: 1093


🏁 Script executed:

sed -n '260,270p' apps/finicky/src/window/window.go

Repository: johnste/finicky

Length of output: 423


🏁 Script executed:

rg "HandleWebViewMessage" --type go --type m --type h -B 5 -A 5

Repository: johnste/finicky

Length of output: 84


🏁 Script executed:

grep -n "window.h\|window.m\|bridge\|WebView" apps/finicky/src/window/window.m | head -40

Repository: johnste/finicky

Length of output: 1446


🏁 Script executed:

sed -n '1,100p' apps/finicky/src/window/window.m

Repository: johnste/finicky

Length of output: 3711


🏁 Script executed:

rg "GetInstalledBrowsers\|dispatch.*main" apps/finicky/src/

Repository: johnste/finicky

Length of output: 41


🏁 Script executed:

grep -n "didReceiveScriptMessage\|WKScriptMessageHandler" apps/finicky/src/window.m

Repository: johnste/finicky

Length of output: 117


🏁 Script executed:

sed -n '165,195p' apps/finicky/src/window.m

Repository: johnste/finicky

Length of output: 127


🏁 Script executed:

rg "GetInstalledBrowsers\|dispatch.*main" apps/finicky/src/

Repository: johnste/finicky

Length of output: 41


🏁 Script executed:

grep -n "didReceiveScriptMessage\|WKScriptMessageHandler" apps/finicky/src/window/window.m

Repository: johnste/finicky

Length of output: 121


🏁 Script executed:

sed -n '165,195p' apps/finicky/src/window/window.m

Repository: johnste/finicky

Length of output: 1371


🌐 Web query:

WKScriptMessageHandler didReceiveScriptMessage main thread macOS documentation

💡 Result:

Apple’s WKScriptMessageHandler callback (userContentController(_:didReceive:) / Obj‑C userContentController:didReceiveScriptMessage:) is documented as being invoked when a script message is received, but the documentation does not promise it runs on the main thread. [1]

In practice, WebKit may call it off the main thread depending on how your handler is structured; for example, developers have observed it being on the main thread when the handler is a UIResponder subclass (iOS) but on “random threads” when it’s a plain NSObject, and the recommended fix is to force main-actor/main-thread handling (e.g., @MainActor or DispatchQueue.main.async). [2]

What to do (macOS included):

  • If you touch UI/AppKit from the callback, hop to the main thread/main actor yourself.
  • Swift (safe default):
    extension MyHandler: WKScriptMessageHandler {
      `@MainActor`
      func userContentController(_ userContentController: WKUserContentController,
                                 didReceive message: WKScriptMessage) {
        // UI-safe work here
      }
    }
    (Or wrap UI work in DispatchQueue.main.async { ... }.) [2]

Sources

  1. Apple-doc mirror showing the official description/signature (no thread guarantee stated). [1]
  2. Observed non-main-thread delivery + @MainActor workaround discussion. [2]

🏁 Script executed:

rg "GetInstalledBrowsers" --type go -B 3 -A 3

Repository: johnste/finicky

Length of output: 1491


🏁 Script executed:

rg "NSWorkspace\|NSBundle\|NSFileManager" apps/finicky/src/browser/detect.go

Repository: johnste/finicky

Length of output: 41


Run the browser enumeration on the main thread.

The Objective-C helper getAllHttpsHandlerNames() touches NSWorkspace, NSBundle, and NSFileManager—all AppKit APIs that require main-thread access. The call path from window/window.m line 173 (didReceiveScriptMessage:) invokes this code without marshalling to the main thread first. WKScriptMessageHandler callbacks are not guaranteed to run on the main thread, which makes this undefined behavior. This can cause crashes or intermittent browser detection failures.

Add dispatch_sync(dispatch_get_main_queue(), ^{ ... }) around the HandleWebViewMessage() call in the didReceiveScriptMessage: handler, or move the AppKit work onto the main thread inside the Objective-C helper itself.

🤖 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 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.

}

static void freeNames(char **names, int count) {
for (int i = 0; i < count; i++) {
free(names[i]);
}
free(names);
}
*/
import "C"
import (
"sort"
"unsafe"
)

// GetInstalledBrowsers returns the display names of all apps registered to
// handle https:// URLs, as reported by the macOS Launch Services framework.
func GetInstalledBrowsers() []string {
var count C.int
names := C.getAllHttpsHandlerNames(&count)
if names == nil {
return []string{}
}
defer C.freeNames(names, count)

n := int(count)
nameSlice := unsafe.Slice(names, n)
result := make([]string, n)
for i, s := range nameSlice {
result[i] = C.GoString(s)
}
sort.Strings(result)
return result
}
100 changes: 88 additions & 12 deletions apps/finicky/src/browser/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,50 +203,87 @@ func resolveBrowserProfileArgs(identifier string, profile string) ([]string, boo
return nil, false
}

func parseFirefoxProfiles(profilesIniPath string, profile string) (string, bool) {
func readFirefoxProfileNames(profilesIniPath string) []string {
data, err := os.ReadFile(profilesIniPath)
if err != nil {
slog.Info("Error reading profiles.ini", "path", profilesIniPath, "error", err)
return "", false
return []string{}
}

var profileNames []string
names := []string{}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if name, ok := strings.CutPrefix(line, "Name="); ok {
profileNames = append(profileNames, name)
if name == profile {
return name, true
}
names = append(names, name)
}
}
return names
}

slog.Warn("Could not find profile in Firefox profiles.", "Expected profile", profile, "Available profiles", strings.Join(profileNames, ", "))
func parseFirefoxProfiles(profilesIniPath string, profile string) (string, bool) {
names := readFirefoxProfileNames(profilesIniPath)
for _, name := range names {
if name == profile {
return name, true
}
}
slog.Warn("Could not find profile in Firefox profiles.", "Expected profile", profile, "Available profiles", strings.Join(names, ", "))
return "", false
}

func parseProfiles(localStatePath string, profile string) (string, bool) {
func chromiumInfoCache(localStatePath string) (map[string]interface{}, bool) {
data, err := os.ReadFile(localStatePath)
if err != nil {
slog.Info("Error reading Local State file", "path", localStatePath, "error", err)
return "", false
return nil, false
}

var localState map[string]interface{}
if err := json.Unmarshal(data, &localState); err != nil {
slog.Info("Error parsing Local State JSON", "error", err)
return "", false
return nil, false
}

profiles, ok := localState["profile"].(map[string]interface{})
if !ok {
slog.Info("Could not find profile section in Local State")
return "", false
return nil, false
}

infoCache, ok := profiles["info_cache"].(map[string]interface{})
if !ok {
slog.Info("Could not find info_cache in profile section")
return nil, false
}

return infoCache, true
}

func getAllChromiumProfiles(localStatePath string) []string {
cache, ok := chromiumInfoCache(localStatePath)
if !ok {
return []string{}
}

var names []string
for _, info := range cache {
profileInfo, ok := info.(map[string]interface{})
if !ok {
continue
}
name, ok := profileInfo["name"].(string)
if !ok {
continue
}
names = append(names, name)
}
slices.Sort(names)
return names
}

func parseProfiles(localStatePath string, profile string) (string, bool) {
infoCache, ok := chromiumInfoCache(localStatePath)
if !ok {
return "", false
}

Expand Down Expand Up @@ -301,6 +338,45 @@ func parseProfiles(localStatePath string, profile string) (string, bool) {
return "", false
}

// GetProfilesForBrowser returns available profile names for a given browser app name or bundle ID.
// Returns empty slice if browser not in browsers.json, not supported, or profile files are unreadable.
func GetProfilesForBrowser(identifier string) []string {
var browsersJson []browserInfo
if err := json.Unmarshal(browsersJsonData, &browsersJson); err != nil {
slog.Info("Error parsing browsers.json", "error", err)
return []string{}
}

var matchedBrowser *browserInfo
for i := range browsersJson {
if browsersJson[i].ID == identifier || browsersJson[i].AppName == identifier {
matchedBrowser = &browsersJson[i]
break
}
}

if matchedBrowser == nil {
return []string{}
}

homeDir, err := util.UserHomeDir()
if err != nil {
slog.Info("Error getting home directory", "error", err)
return []string{}
}

switch matchedBrowser.Type {
case "Chromium":
localStatePath := filepath.Join(homeDir, "Library/Application Support", matchedBrowser.ConfigDirRelative, "Local State")
return getAllChromiumProfiles(localStatePath)
case "Firefox":
profilesIniPath := filepath.Join(homeDir, "Library/Application Support", matchedBrowser.ConfigDirRelative, "profiles.ini")
return readFirefoxProfileNames(profilesIniPath)
default:
return []string{}
}
}

// formatCommand returns a properly shell-escaped string representation of the command
func formatCommand(path string, args []string) string {
if len(args) == 0 {
Expand Down
21 changes: 18 additions & 3 deletions apps/finicky/src/config/configfiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"

"github.com/evanw/esbuild/pkg/api"
Expand All @@ -24,6 +25,10 @@ type ConfigFileWatcher struct {

// Cache manager
cache *ConfigCache

// Debounce rapid file-change events (e.g. editors that write twice)
debounceMu sync.Mutex
debounceTimer *time.Timer
}

// NewConfigFileWatcher creates a new file watcher for configuration files
Expand Down Expand Up @@ -357,9 +362,19 @@ func (cfw *ConfigFileWatcher) handleConfigFileEvent(event fsnotify.Event) error
return fmt.Errorf("configuration file removed")
}

// Add a small delay to avoid rapid reloading
time.Sleep(500 * time.Millisecond)
cfw.configChangeNotify <- struct{}{}
// Debounce: reset the timer so only the last event in a burst fires.
cfw.debounceMu.Lock()
if cfw.debounceTimer != nil {
cfw.debounceTimer.Stop()
}
notify := cfw.configChangeNotify
cfw.debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
select {
case notify <- struct{}{}:
default: // drop if a notification is already pending
}
})
Comment on lines +365 to +376
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.

The debounce timer callback sends on an unbuffered configChangeNotify channel from a timer goroutine. If the receiver is busy (e.g. resolving/launching a browser), this send can block and potentially accumulate stuck goroutines after successive file events. Consider making the channel buffered (size 1) and/or doing a non-blocking send (select with default) to coalesce bursts safely.

Copilot uses AI. Check for mistakes.
cfw.debounceMu.Unlock()
Comment on lines +365 to +377
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify unbuffered channel producer/consumer relationship and debounce send points.
rg -nP 'configChange\s*:=\s*make\(chan struct{}\)' apps/finicky/src/main.go
rg -nP 'time\.AfterFunc|configChangeNotify <- struct{}\{\}|notify <- struct{}\{\}' apps/finicky/src/config/configfiles.go

Repository: johnste/finicky

Length of output: 240


🏁 Script executed:

# Find all usages of configChangeNotify to understand initialization and consumption
rg -n 'configChangeNotify' apps/finicky/src/config/configfiles.go apps/finicky/src/main.go -B 2 -A 2

Repository: johnste/finicky

Length of output: 1970


🏁 Script executed:

# Check the main loop to see how it consumes the configChange channel
rg -n 'select' apps/finicky/src/main.go -B 5 -A 5

Repository: johnste/finicky

Length of output: 265


🏁 Script executed:

# Verify if there are other goroutines that might block the main loop
rg -n 'go func\(\)' apps/finicky/src/main.go -A 3 | head -50

Repository: johnste/finicky

Length of output: 322


🏁 Script executed:

# Get the full select statement in main.go to see all channel cases
sed -n '175,200p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 790


🏁 Script executed:

# Find where configChange is created and check how many receivers are on it
sed -n '110,130p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 631


🏁 Script executed:

# Check if there are multiple sends to configChange/configChangeNotify
rg -n '<-.*configChange|configChange.*<-' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 86


🏁 Script executed:

# Get the complete select statement in the URL listener goroutine
sed -n '180,225p' apps/finicky/src/main.go

Repository: johnste/finicky

Length of output: 1456


🏁 Script executed:

# Check if there's a context deadline or timeout mechanism that could prevent blocking
rg -n 'context\.|timeout|Context' apps/finicky/src/main.go | head -20

Repository: johnste/finicky

Length of output: 306


🏁 Script executed:

# Check ResolveURL implementation to understand if it's a blocking operation
rg -n 'func.*ResolveURL' apps/finicky/src/ -A 20 | head -40

Repository: johnste/finicky

Length of output: 2738


🏁 Script executed:

# Check LaunchBrowser to see if it blocks
rg -n 'func.*LaunchBrowser' apps/finicky/src/ -A 10 | head -30

Repository: johnste/finicky

Length of output: 831


🏁 Script executed:

# Verify the debounce logic: are there multiple debounceTimer.Stop() calls?
sed -n '355,380p' apps/finicky/src/config/configfiles.go

Repository: johnste/finicky

Length of output: 812


Debounce callback can block and defeat event coalescing.

The timer goroutine sends to configChangeNotify on line 372. With an unbuffered channel, this send blocks if the main loop is busy processing a URL (lines 182–199 in main.go call ResolveURL and LaunchBrowser, which are synchronous operations). When file changes occur in rapid succession, multiple blocked timer goroutines accumulate waiting to send, defeating the debounce's purpose of producing a single coalesced notification.

💡 Safer coalescing pattern
  notify := cfw.configChangeNotify
  cfw.debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
-   notify <- struct{}{}
+   select {
+   case notify <- struct{}{}:
+   default:
+     // Drop if a notification is already pending/being handled.
+   }
  })
📝 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
// Debounce: reset the timer so only the last event in a burst fires.
cfw.debounceMu.Lock()
if cfw.debounceTimer != nil {
cfw.debounceTimer.Stop()
}
notify := cfw.configChangeNotify
cfw.debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
notify <- struct{}{}
})
cfw.debounceMu.Unlock()
// Debounce: reset the timer so only the last event in a burst fires.
cfw.debounceMu.Lock()
if cfw.debounceTimer != nil {
cfw.debounceTimer.Stop()
}
notify := cfw.configChangeNotify
cfw.debounceTimer = time.AfterFunc(500*time.Millisecond, func() {
select {
case notify <- struct{}{}:
default:
// Drop if a notification is already pending/being handled.
}
})
cfw.debounceMu.Unlock()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/finicky/src/config/configfiles.go` around lines 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.

return nil
}

Expand Down
Loading
Loading