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": "",
"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;
}
}

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
18 changes: 15 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,16 @@ 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() {
notify <- struct{}{}
})
cfw.debounceMu.Unlock()
return nil
}

Expand Down
Loading
Loading