-
-
Notifications
You must be signed in to change notification settings - Fork 189
UI Rules and lots of things to support it #512
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a1ed5f5
27d13e5
6cdba9d
ca9ab10
fac938b
6b6115e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -8,6 +8,7 @@ import ( | |||||||||||||||||||||||||||||||||||||||||||||||||
| "os" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "path/filepath" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "strings" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "sync" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| "github.com/evanw/esbuild/pkg/api" | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| cfw.debounceMu.Unlock() | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+365
to
+377
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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.goRepository: 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 2Repository: 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 5Repository: 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 -50Repository: 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.goRepository: 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.goRepository: 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.goRepository: 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.goRepository: 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 -20Repository: 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 -40Repository: 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 -30Repository: 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.goRepository: johnste/finicky Length of output: 812 Debounce callback can block and defeat event coalescing. The timer goroutine sends to 💡 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: johnste/finicky
Length of output: 794
🏁 Script executed:
fd -t f "window\.go" --type fRepository: johnste/finicky
Length of output: 92
🏁 Script executed:
fd -t f "detect\." --type fRepository: johnste/finicky
Length of output: 93
🏁 Script executed:
Repository: johnste/finicky
Length of output: 681
🏁 Script executed:
Repository: johnste/finicky
Length of output: 103
🏁 Script executed:
Repository: johnste/finicky
Length of output: 3794
🏁 Script executed:
Repository: johnste/finicky
Length of output: 249
🏁 Script executed:
sed -n '140,180p' apps/finicky/src/window/window.goRepository: johnste/finicky
Length of output: 1093
🏁 Script executed:
sed -n '260,270p' apps/finicky/src/window/window.goRepository: johnste/finicky
Length of output: 423
🏁 Script executed:
rg "HandleWebViewMessage" --type go --type m --type h -B 5 -A 5Repository: johnste/finicky
Length of output: 84
🏁 Script executed:
Repository: johnste/finicky
Length of output: 1446
🏁 Script executed:
sed -n '1,100p' apps/finicky/src/window/window.mRepository: 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.mRepository: johnste/finicky
Length of output: 117
🏁 Script executed:
sed -n '165,195p' apps/finicky/src/window.mRepository: 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.mRepository: johnste/finicky
Length of output: 121
🏁 Script executed:
sed -n '165,195p' apps/finicky/src/window/window.mRepository: johnste/finicky
Length of output: 1371
🌐 Web query:
WKScriptMessageHandler didReceiveScriptMessage main thread macOS documentation💡 Result:
Apple’s
WKScriptMessageHandlercallback (userContentController(_:didReceive:)/ Obj‑CuserContentController: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
UIRespondersubclass (iOS) but on “random threads” when it’s a plainNSObject, and the recommended fix is to force main-actor/main-thread handling (e.g.,@MainActororDispatchQueue.main.async). [2]What to do (macOS included):
DispatchQueue.main.async { ... }.) [2]Sources
@MainActorworkaround discussion. [2]🏁 Script executed:
rg "GetInstalledBrowsers" --type go -B 3 -A 3Repository: johnste/finicky
Length of output: 1491
🏁 Script executed:
rg "NSWorkspace\|NSBundle\|NSFileManager" apps/finicky/src/browser/detect.goRepository: johnste/finicky
Length of output: 41
Run the browser enumeration on the main thread.
The Objective-C helper
getAllHttpsHandlerNames()touchesNSWorkspace,NSBundle, andNSFileManager—all AppKit APIs that require main-thread access. The call path fromwindow/window.mline 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 theHandleWebViewMessage()call in thedidReceiveScriptMessage:handler, or move the AppKit work onto the main thread inside the Objective-C helper itself.🤖 Prompt for AI Agents