Repository navigation
Open urls in a chosen Safari profile #564
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
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,121 @@ | ||
| package browser | ||
|
|
||
| /* | ||
| #cgo CFLAGS: -x objective-c | ||
| #cgo LDFLAGS: -framework Cocoa -framework ApplicationServices | ||
| #include <stdlib.h> | ||
| #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 <profile> 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 | ||
|
|
||
| // 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 ( | ||
| 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 | ||
| safariMenuNoWindows = C.SAFARI_MENU_NO_WINDOWS | ||
| ) | ||
|
|
||
| // 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) | ||
| 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) | ||
| } | ||
| } | ||
|
|
||
| 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) | ||
| } | ||
| 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 | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| #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 | ||
| #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 | ||
| // appear. Returns one of the SAFARI_MENU_ codes above. | ||
| int safariOpenWindowFromMenu(const char *menuTitle, double timeoutSeconds); | ||
|
|
||
| #endif |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| #import <Cocoa/Cocoa.h> | ||
| #import <ApplicationServices/ApplicationServices.h> | ||
| #import <unistd.h> | ||
| #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) { | ||
| // 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); | ||
| result = SAFARI_MENU_NO_WINDOWS; | ||
| if (before >= 0) { | ||
| AXUIElementPerformAction(item, kAXPressAction); | ||
|
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. 🔎 Supported by static analysis
Check the File menu press result before polling.
A window that opens during the poll for an unrelated reason also satisfies the count check. The function then returns Return a failure as soon as the press fails. 🐛 Suggested fix
Declare
|
||
| result = SAFARI_MENU_TIMEOUT; | ||
| } | ||
| for (double waited = 0; before >= 0 && waited < timeoutSeconds; waited += 0.1) { | ||
| if (windowCount(app) > before) { | ||
| result = SAFARI_MENU_OK; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| 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"}, | ||
| {safariMenuNoWindows, "windows"}, | ||
| {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) | ||
| } | ||
| } |
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.
Retry an unreadable window count during Safari startup.
If the first retry finds the menu item but cannot read Safari's window count, the C helper returns
SAFARI_MENU_NO_WINDOWS. This condition breaks the retry loop, even though a later read could succeed. Keep the press skipped, but retry this status during startup. Apple's Accessibility API documents a failure when an application has not responded. (developer.apple.com)