From 754cabf87341b91aaa35e064d63a16fe82fca19c Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:00:14 +0200 Subject: [PATCH 1/2] Open urls in a chosen Safari profile Chromium and Firefox take a profile on the command line, but Safari has no command line or scripting API for profiles at all: `open -a Safari` always lands in whichever profile was last active. The one mechanism Safari does expose is its File menu, which gains a "New Window" item per profile. So a Safari browser with a profile now presses that item through the Accessibility API, waits for the new window, and hands the url over with a plain `open`, which Safari loads into its active window, now the profile window. Safari reuses that window's blank tab, so no stray tab is left. The press happens inside Finicky, which already needs Accessibility for the opener window title. Driving the menu from an osascript child instead fails with "osascript is not allowed assistive access", because macOS checks the permission against osascript rather than Finicky, and granting it to osascript would hand UI control to every script on the machine. Safari is always brought forward for a profile, since the url follows the active window. Menu titles are localized, so a missing item is reported by name rather than guessed at. The browsers.json lookup is factored into findBrowserInfo so the launcher can check a browser's type first. --- README.md | 13 +++ apps/finicky/src/browser/launcher.go | 26 ++++-- apps/finicky/src/browser/safari.go | 109 ++++++++++++++++++++++++ apps/finicky/src/browser/safari_menu.h | 15 ++++ apps/finicky/src/browser/safari_menu.m | 108 +++++++++++++++++++++++ apps/finicky/src/browser/safari_test.go | 76 +++++++++++++++++ 6 files changed, 341 insertions(+), 6 deletions(-) create mode 100644 apps/finicky/src/browser/safari.go create mode 100644 apps/finicky/src/browser/safari_menu.h create mode 100644 apps/finicky/src/browser/safari_menu.m create mode 100644 apps/finicky/src/browser/safari_test.go diff --git a/README.md b/README.md index 65ad163..bebdd04 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,19 @@ export default { }; ``` +### Safari profiles + +Safari has no command line option for choosing a profile, so Finicky opens a new window in the profile through Safari's File menu ("New Work Window") and then hands the url to it. This needs Accessibility permission for Finicky, which macOS asks for the first time it is used. Safari profile urls always open in the foreground, and the profile name must match the File menu of English Safari. + +```js +handlers: [ + { + match: "*.example.com/*", + browser: { name: "Safari", profile: "Work" }, + }, +], +``` + See the [configuration](#configuration) for all the features Finicky supports. ## Configuration diff --git a/apps/finicky/src/browser/launcher.go b/apps/finicky/src/browser/launcher.go index a3a7120..ca36830 100644 --- a/apps/finicky/src/browser/launcher.go +++ b/apps/finicky/src/browser/launcher.go @@ -66,6 +66,17 @@ func LaunchBrowser(config BrowserConfig, dryRun bool, openInBackgroundByDefault openArgs = append(openArgs, "-g") } + // Safari cannot be pointed at a profile through command line arguments, so + // a Safari profile takes its own path entirely. + if config.Profile != "" { + if matched := findBrowserInfo(config.Name); matched != nil && matched.Type == "Safari" { + if openInBackground { + slog.Debug("Safari profiles always open in the foreground", "profile", config.Profile) + } + return launchSafariProfile(config.URL, config.Profile, dryRun) + } + } + // Handle profile and custom args profileArgs, ok := resolveBrowserProfileArgs(config.Name, config.Profile) hasCustomArgs := len(config.Args) > 0 @@ -147,22 +158,25 @@ func LaunchBrowser(config BrowserConfig, dryRun bool, openInBackgroundByDefault return nil } -func resolveBrowserProfileArgs(identifier string, profile string) ([]string, bool) { +// findBrowserInfo looks a browser up in browsers.json by bundle ID or app name, +// returning nil when it is not a known browser. +func findBrowserInfo(identifier string) *browserInfo { var browsersJson []browserInfo if err := json.Unmarshal(browsersJsonData, &browsersJson); err != nil { slog.Info("Error parsing browsers.json", "error", err) - return nil, false + return nil } - // Try to find matching browser by bundle ID - var matchedBrowser *browserInfo for _, browser := range browsersJson { if browser.ID == identifier || browser.AppName == identifier { - matchedBrowser = &browser - break + return &browser } } + return nil +} +func resolveBrowserProfileArgs(identifier string, profile string) ([]string, bool) { + matchedBrowser := findBrowserInfo(identifier) if matchedBrowser == nil { return nil, false } diff --git a/apps/finicky/src/browser/safari.go b/apps/finicky/src/browser/safari.go new file mode 100644 index 0000000..e9dd190 --- /dev/null +++ b/apps/finicky/src/browser/safari.go @@ -0,0 +1,109 @@ +package browser + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Cocoa -framework ApplicationServices +#include +#include "safari_menu.h" +*/ +import "C" + +import ( + "fmt" + "log/slog" + "os/exec" + "strings" + "time" + "unsafe" +) + +// Opening a url in a chosen Safari profile. +// +// Safari has no command line or scripting API for profiles: `open -a Safari` +// always lands in whichever profile was last active. The one mechanism it +// does expose is its File menu, which gains a "New Window" item per +// profile. So the window is opened by pressing that item through the +// Accessibility API, which makes it Safari's active window, and the url is +// then handed over with a plain `open`, which Safari loads into that window. + +// safariMenuTimeoutSeconds bounds the wait for the new window to appear. +const safariMenuTimeoutSeconds = 5 + +// safariStartupDelay lets a freshly launched Safari open its own first window +// before the profile window is requested, so the two are not confused. +const safariStartupDelay = 2 * time.Second + +// Results of pressing a Safari menu item, mirrored from safari_menu.h so that +// Go code, tests included, can name them. +const ( + safariMenuOK = C.SAFARI_MENU_OK + safariMenuNotTrusted = C.SAFARI_MENU_NOT_TRUSTED + safariMenuNotRunning = C.SAFARI_MENU_NOT_RUNNING + safariMenuNoItem = C.SAFARI_MENU_NO_ITEM + safariMenuTimeout = C.SAFARI_MENU_TIMEOUT +) + +// safariMenuItemTitle is the File menu item Safari shows for a profile. +func safariMenuItemTitle(profile string) string { + return "New " + profile + " Window" +} + +// safariOpenURLCommand hands url to Safari. The url is its own argument and is +// never interpreted by a shell or a script. +func safariOpenURLCommand(url string) *exec.Cmd { + return exec.Command("open", "-a", "Safari", url) +} + +// safariMenuError explains a failed menu press in terms a user can act on. +func safariMenuError(status int, profile string) error { + switch status { + case safariMenuOK: + return nil + case safariMenuNotTrusted: + return fmt.Errorf("Finicky needs Accessibility permission to open Safari profile %q (System Settings > Privacy & Security > Accessibility)", profile) + case safariMenuNotRunning: + return fmt.Errorf("Safari is not running, so profile %q could not be opened", profile) + case safariMenuNoItem: + return fmt.Errorf("Safari has no %q menu item, so there is no profile named %q", safariMenuItemTitle(profile), profile) + case safariMenuTimeout: + return fmt.Errorf("Safari did not open a window for profile %q in time", profile) + default: + return fmt.Errorf("unexpected result %d opening Safari profile %q", status, profile) + } +} + +func pressSafariMenuItem(title string) int { + cTitle := C.CString(title) + defer C.free(unsafe.Pointer(cTitle)) + return int(C.safariOpenWindowFromMenu(cTitle, C.double(safariMenuTimeoutSeconds))) +} + +// launchSafariProfile opens url in the named Safari profile. Safari is always +// brought forward: `open` loads the url into Safari's active window, so the new +// profile window has to become it, which rules out opening in the background. +func launchSafariProfile(url string, profile string, dryRun bool) error { + title := safariMenuItemTitle(profile) + if dryRun { + slog.Debug("Would open url in Safari profile (dry run)", "profile", profile, "menuItem", title, "url", url) + return nil + } + + slog.Debug("Opening url in Safari profile", "profile", profile, "url", url) + status := pressSafariMenuItem(title) + if status == safariMenuNotRunning { + if err := exec.Command("open", "-a", "Safari").Run(); err != nil { + return fmt.Errorf("launching Safari failed: %v", err) + } + time.Sleep(safariStartupDelay) + status = pressSafariMenuItem(title) + } + if err := safariMenuError(status, profile); err != nil { + return err + } + + output, err := safariOpenURLCommand(url).CombinedOutput() + if err != nil { + return fmt.Errorf("opening url in Safari profile %q failed: %v: %s", profile, err, strings.TrimSpace(string(output))) + } + return nil +} diff --git a/apps/finicky/src/browser/safari_menu.h b/apps/finicky/src/browser/safari_menu.h new file mode 100644 index 0000000..30ee04d --- /dev/null +++ b/apps/finicky/src/browser/safari_menu.h @@ -0,0 +1,15 @@ +#ifndef SAFARI_MENU_H +#define SAFARI_MENU_H + +#define SAFARI_MENU_OK 0 +#define SAFARI_MENU_NOT_TRUSTED 1 +#define SAFARI_MENU_NOT_RUNNING 2 +#define SAFARI_MENU_NO_ITEM 3 +#define SAFARI_MENU_TIMEOUT 4 + +// Brings Safari forward, presses the File menu item titled menuTitle through +// the Accessibility API and waits up to timeoutSeconds for a new window to +// appear. Returns one of the SAFARI_MENU_ codes above. +int safariOpenWindowFromMenu(const char *menuTitle, double timeoutSeconds); + +#endif diff --git a/apps/finicky/src/browser/safari_menu.m b/apps/finicky/src/browser/safari_menu.m new file mode 100644 index 0000000..f7394b6 --- /dev/null +++ b/apps/finicky/src/browser/safari_menu.m @@ -0,0 +1,108 @@ +#import +#import +#import +#import "safari_menu.h" + +static bool titleIs(AXUIElementRef element, CFStringRef title) { + CFTypeRef value = NULL; + if (AXUIElementCopyAttributeValue(element, kAXTitleAttribute, &value) != kAXErrorSuccess || value == NULL) { + return false; + } + bool match = CFGetTypeID(value) == CFStringGetTypeID() && + CFStringCompare((CFStringRef)value, title, 0) == kCFCompareEqualTo; + CFRelease(value); + return match; +} + +// Returns a retained reference to the first child of element titled title, or +// NULL. A NULL title matches the first child. +static AXUIElementRef copyChild(AXUIElementRef element, CFStringRef title) { + CFTypeRef children = NULL; + if (AXUIElementCopyAttributeValue(element, kAXChildrenAttribute, &children) != kAXErrorSuccess || children == NULL) { + return NULL; + } + AXUIElementRef found = NULL; + for (CFIndex i = 0; i < CFArrayGetCount((CFArrayRef)children); i++) { + AXUIElementRef child = (AXUIElementRef)CFArrayGetValueAtIndex((CFArrayRef)children, i); + if (title == NULL || titleIs(child, title)) { + found = (AXUIElementRef)CFRetain(child); + break; + } + } + CFRelease(children); + return found; +} + +static CFIndex windowCount(AXUIElementRef app) { + CFIndex count = 0; + if (AXUIElementGetAttributeValueCount(app, kAXWindowsAttribute, &count) != kAXErrorSuccess) { + return -1; + } + return count; +} + +int safariOpenWindowFromMenu(const char *menuTitle, double timeoutSeconds) { + @autoreleasepool { + // Pressing the menu happens in this process so that Finicky's own + // Accessibility permission applies. Driving it from an osascript child + // fails, because the check is made against osascript instead. + // Asking with the prompt option makes macOS offer the grant instead of + // failing silently on first use. + NSDictionary *options = @{(id)kAXTrustedCheckOptionPrompt: @YES}; + if (!AXIsProcessTrustedWithOptions((CFDictionaryRef)options)) { + return SAFARI_MENU_NOT_TRUSTED; + } + + NSArray *running = [NSRunningApplication runningApplicationsWithBundleIdentifier:@"com.apple.Safari"]; + if ([running count] == 0) { + return SAFARI_MENU_NOT_RUNNING; + } + NSRunningApplication *safari = [running objectAtIndex:0]; + // The url is handed over afterwards with `open`, which lands in the + // profile of Safari's active window, so the new window has to be it. + [safari activateWithOptions:0]; + + CFStringRef title = CFStringCreateWithCString(NULL, menuTitle, kCFStringEncodingUTF8); + AXUIElementRef app = AXUIElementCreateApplication([safari processIdentifier]); + int result = SAFARI_MENU_NO_ITEM; + + CFTypeRef menuBar = NULL; + AXUIElementRef fileItem = NULL; + AXUIElementRef fileMenu = NULL; + AXUIElementRef item = NULL; + + // Menu titles are localized; this matches English Safari, and a missing + // item is reported rather than guessed at. + if (AXUIElementCopyAttributeValue(app, kAXMenuBarAttribute, &menuBar) == kAXErrorSuccess && menuBar != NULL) { + fileItem = copyChild((AXUIElementRef)menuBar, CFSTR("File")); + } + if (fileItem != NULL) { + fileMenu = copyChild(fileItem, NULL); + } + if (fileMenu != NULL) { + item = copyChild(fileMenu, title); + } + + if (item != NULL) { + CFIndex before = windowCount(app); + AXUIElementPerformAction(item, kAXPressAction); + + result = SAFARI_MENU_TIMEOUT; + for (double waited = 0; waited < timeoutSeconds; waited += 0.1) { + if (windowCount(app) > before) { + result = SAFARI_MENU_OK; + break; + } + usleep(100000); + } + } + + if (item != NULL) CFRelease(item); + if (fileMenu != NULL) CFRelease(fileMenu); + if (fileItem != NULL) CFRelease(fileItem); + if (menuBar != NULL) CFRelease(menuBar); + CFRelease(app); + CFRelease(title); + return result; + } +} diff --git a/apps/finicky/src/browser/safari_test.go b/apps/finicky/src/browser/safari_test.go new file mode 100644 index 0000000..32ce0e0 --- /dev/null +++ b/apps/finicky/src/browser/safari_test.go @@ -0,0 +1,76 @@ +package browser + +import ( + "strings" + "testing" +) + +func TestFindBrowserInfoIdentifiesSafari(t *testing.T) { + for _, identifier := range []string{"Safari", "com.apple.Safari"} { + info := findBrowserInfo(identifier) + if info == nil { + t.Fatalf("findBrowserInfo(%q) = nil, want the Safari entry", identifier) + } + if info.Type != "Safari" { + t.Errorf("findBrowserInfo(%q).Type = %q, want Safari", identifier, info.Type) + } + } + + if info := findBrowserInfo("Not A Browser"); info != nil { + t.Errorf("findBrowserInfo of an unknown app = %+v, want nil", info) + } +} + +func TestSafariMenuItemTitle(t *testing.T) { + if got := safariMenuItemTitle("Work"); got != "New Work Window" { + t.Errorf("safariMenuItemTitle(Work) = %q, want New Work Window", got) + } +} + +func TestSafariOpenURLCommandPassesTheURLAsItsOwnArgument(t *testing.T) { + url := `https://example.com/?q="quoted"&x=$(echo hi)` + cmd := safariOpenURLCommand(url) + + want := []string{"open", "-a", "Safari", url} + if len(cmd.Args) != len(want) { + t.Fatalf("args = %q, want %q", cmd.Args, want) + } + for i := range want { + if cmd.Args[i] != want[i] { + t.Errorf("arg %d = %q, want %q", i, cmd.Args[i], want[i]) + } + } +} + +func TestSafariMenuErrorExplainsEachFailure(t *testing.T) { + if err := safariMenuError(safariMenuOK, "Work"); err != nil { + t.Errorf("OK status gave error %v", err) + } + + tests := []struct { + status int + want string + }{ + {safariMenuNotTrusted, "Accessibility"}, + {safariMenuNotRunning, "not running"}, + {safariMenuNoItem, `no profile named "Work"`}, + {safariMenuTimeout, "in time"}, + {99, "unexpected result 99"}, + } + for _, test := range tests { + err := safariMenuError(test.status, "Work") + if err == nil { + t.Errorf("status %d gave no error", test.status) + continue + } + if !strings.Contains(err.Error(), test.want) { + t.Errorf("status %d error = %q, want it to mention %q", test.status, err, test.want) + } + } +} + +func TestLaunchSafariProfileDryRunRunsNothing(t *testing.T) { + if err := launchSafariProfile("https://example.com", "Work", true); err != nil { + t.Errorf("dry run returned %v, want nil", err) + } +} From 445ec327c8e3278ffcd2879c1e8e9003ca0a2270 Mon Sep 17 00:00:00 2001 From: seidnerj <4147381+seidnerj@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:43:46 +0200 Subject: [PATCH 2/2] Retry the Safari menu after launch and never report a window that cannot be counted A slow Safari start can leave the menu bar empty, which was reported as a missing profile; the press is now retried a few times. If the starting window count cannot be read, the press is skipped instead of any later count passing as a new window. --- apps/finicky/src/browser/safari.go | 16 ++++++++++++++-- apps/finicky/src/browser/safari_menu.h | 1 + apps/finicky/src/browser/safari_menu.m | 12 ++++++++---- apps/finicky/src/browser/safari_test.go | 1 + 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/finicky/src/browser/safari.go b/apps/finicky/src/browser/safari.go index e9dd190..e774564 100644 --- a/apps/finicky/src/browser/safari.go +++ b/apps/finicky/src/browser/safari.go @@ -33,6 +33,10 @@ const safariMenuTimeoutSeconds = 5 // before the profile window is requested, so the two are not confused. const safariStartupDelay = 2 * time.Second +// safariStartupAttempts bounds how often the menu is tried after launching +// Safari, since a slow start can leave its menu bar empty for a while. +const safariStartupAttempts = 3 + // Results of pressing a Safari menu item, mirrored from safari_menu.h so that // Go code, tests included, can name them. const ( @@ -41,6 +45,7 @@ const ( safariMenuNotRunning = C.SAFARI_MENU_NOT_RUNNING safariMenuNoItem = C.SAFARI_MENU_NO_ITEM safariMenuTimeout = C.SAFARI_MENU_TIMEOUT + safariMenuNoWindows = C.SAFARI_MENU_NO_WINDOWS ) // safariMenuItemTitle is the File menu item Safari shows for a profile. @@ -67,6 +72,8 @@ func safariMenuError(status int, profile string) error { return fmt.Errorf("Safari has no %q menu item, so there is no profile named %q", safariMenuItemTitle(profile), profile) case safariMenuTimeout: return fmt.Errorf("Safari did not open a window for profile %q in time", profile) + case safariMenuNoWindows: + return fmt.Errorf("could not read Safari's windows, so profile %q was not opened", profile) default: return fmt.Errorf("unexpected result %d opening Safari profile %q", status, profile) } @@ -94,8 +101,13 @@ func launchSafariProfile(url string, profile string, dryRun bool) error { if err := exec.Command("open", "-a", "Safari").Run(); err != nil { return fmt.Errorf("launching Safari failed: %v", err) } - time.Sleep(safariStartupDelay) - status = pressSafariMenuItem(title) + for attempt := 0; attempt < safariStartupAttempts; attempt++ { + time.Sleep(safariStartupDelay) + status = pressSafariMenuItem(title) + if status != safariMenuNotRunning && status != safariMenuNoItem { + break + } + } } if err := safariMenuError(status, profile); err != nil { return err diff --git a/apps/finicky/src/browser/safari_menu.h b/apps/finicky/src/browser/safari_menu.h index 30ee04d..f9afd12 100644 --- a/apps/finicky/src/browser/safari_menu.h +++ b/apps/finicky/src/browser/safari_menu.h @@ -6,6 +6,7 @@ #define SAFARI_MENU_NOT_RUNNING 2 #define SAFARI_MENU_NO_ITEM 3 #define SAFARI_MENU_TIMEOUT 4 +#define SAFARI_MENU_NO_WINDOWS 5 // Brings Safari forward, presses the File menu item titled menuTitle through // the Accessibility API and waits up to timeoutSeconds for a new window to diff --git a/apps/finicky/src/browser/safari_menu.m b/apps/finicky/src/browser/safari_menu.m index f7394b6..b3e2344 100644 --- a/apps/finicky/src/browser/safari_menu.m +++ b/apps/finicky/src/browser/safari_menu.m @@ -84,11 +84,15 @@ int safariOpenWindowFromMenu(const char *menuTitle, double timeoutSeconds) { } if (item != NULL) { + // Without a starting count a new window cannot be told apart, so + // the press is skipped rather than reported as a success. CFIndex before = windowCount(app); - AXUIElementPerformAction(item, kAXPressAction); - - result = SAFARI_MENU_TIMEOUT; - for (double waited = 0; waited < timeoutSeconds; waited += 0.1) { + result = SAFARI_MENU_NO_WINDOWS; + if (before >= 0) { + AXUIElementPerformAction(item, kAXPressAction); + result = SAFARI_MENU_TIMEOUT; + } + for (double waited = 0; before >= 0 && waited < timeoutSeconds; waited += 0.1) { if (windowCount(app) > before) { result = SAFARI_MENU_OK; break; diff --git a/apps/finicky/src/browser/safari_test.go b/apps/finicky/src/browser/safari_test.go index 32ce0e0..a5508e9 100644 --- a/apps/finicky/src/browser/safari_test.go +++ b/apps/finicky/src/browser/safari_test.go @@ -55,6 +55,7 @@ func TestSafariMenuErrorExplainsEachFailure(t *testing.T) { {safariMenuNotRunning, "not running"}, {safariMenuNoItem, `no profile named "Work"`}, {safariMenuTimeout, "in time"}, + {safariMenuNoWindows, "windows"}, {99, "unexpected result 99"}, } for _, test := range tests {