| 1 | //go:build darwin |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "io" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "time" |
| 13 | ) |
| 14 | |
| 15 | func darwinInstalledApplicationIndex() map[string]string { |
| 16 | home, _ := os.UserHomeDir() |
| 17 | roots := []string{ |
| 18 | "/Applications", |
| 19 | "/System/Applications", |
| 20 | "/System/Applications/Utilities", |
| 21 | "/System/Library/CoreServices", |
| 22 | } |
| 23 | if home != "" { |
| 24 | roots = append(roots, filepath.Join(home, "Applications")) |
| 25 | } |
| 26 | index := make(map[string]string) |
| 27 | add := func(path string) { |
| 28 | path = strings.TrimSpace(path) |
| 29 | if info, err := os.Stat(path); err == nil && info.IsDir() && strings.HasSuffix(strings.ToLower(path), ".app") { |
| 30 | name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))) |
| 31 | if _, exists := index[name]; !exists { |
| 32 | index[name] = path |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | for _, root := range roots { |
| 37 | entries, err := os.ReadDir(root) |
| 38 | if err != nil { |
| 39 | continue |
| 40 | } |
| 41 | for _, entry := range entries { |
| 42 | if strings.HasSuffix(strings.ToLower(entry.Name()), ".app") { |
| 43 | add(filepath.Join(root, entry.Name())) |
| 44 | } |
| 45 | } |
| 46 | } |
| 47 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 48 | defer cancel() |
| 49 | out, err := exec.CommandContext(ctx, "/usr/bin/mdfind", "kMDItemContentType == 'com.apple.application-bundle'").Output() |
| 50 | if err == nil { |
| 51 | for _, line := range strings.Split(string(out), "\n") { |
| 52 | add(line) |
| 53 | } |
| 54 | } |
| 55 | return index |
| 56 | } |
| 57 | |
| 58 | func darwinApplicationPath(index map[string]string, names ...string) string { |
| 59 | for _, name := range names { |
| 60 | if path := index[strings.ToLower(strings.TrimSpace(name))]; path != "" { |
| 61 | return path |
| 62 | } |
| 63 | } |
| 64 | return "" |
| 65 | } |
| 66 | |
| 67 | func platformExternalOpenerSpecs() []externalOpenerSpec { |
| 68 | installed := darwinInstalledApplicationIndex() |
| 69 | var specs []externalOpenerSpec |
| 70 | addMode := func(id, name, kind, mode, _ string, appNames ...string) { |
| 71 | if path := darwinApplicationPath(installed, appNames...); path != "" { |
| 72 | specs = append(specs, externalOpenerSpec{ |
| 73 | View: ExternalOpenerView{ID: id, Name: name, Kind: kind}, |
| 74 | Target: path, |
| 75 | LaunchMode: mode, |
| 76 | IconSource: path, |
| 77 | }) |
| 78 | } |
| 79 | } |
| 80 | add := func(id, name, kind, bundleID string, appNames ...string) { |
| 81 | addMode(id, name, kind, "application", bundleID, appNames...) |
| 82 | } |
| 83 | |
| 84 | // Keep the order stable and close to the native Codex menu: common editors, |
| 85 | // the platform browser, terminals, then the broader IDE catalog. |
| 86 | add("vscode", "VS Code", externalOpenerEditor, "com.microsoft.VSCode", "Visual Studio Code") |
| 87 | add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "com.microsoft.VSCodeInsiders", "Visual Studio Code - Insiders") |
| 88 | add("cursor", "Cursor", externalOpenerEditor, "com.todesktop.230313mzl4w4u92", "Cursor") |
| 89 | add("finder", "Finder", externalOpenerFileManager, "com.apple.finder", "Finder") |
| 90 | add("terminal", "Terminal", externalOpenerTerminal, "com.apple.Terminal", "Terminal") |
| 91 | add("iterm", "iTerm2", externalOpenerTerminal, "com.googlecode.iterm2", "iTerm", "iTerm2") |
| 92 | addMode("ghostty", "Ghostty", externalOpenerTerminal, "ghostty", "com.mitchellh.ghostty", "Ghostty") |
| 93 | add("xcode", "Xcode", externalOpenerEditor, "com.apple.dt.Xcode", "Xcode") |
| 94 | add("android-studio", "Android Studio", externalOpenerEditor, "com.google.android.studio", "Android Studio") |
| 95 | add("goland", "GoLand", externalOpenerEditor, "com.jetbrains.goland", "GoLand") |
| 96 | add("pycharm", "PyCharm", externalOpenerEditor, "com.jetbrains.pycharm", "PyCharm", "PyCharm CE") |
| 97 | add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "com.jetbrains.intellij", "IntelliJ IDEA", "IntelliJ IDEA Ultimate", "IntelliJ IDEA CE") |
| 98 | add("webstorm", "WebStorm", externalOpenerEditor, "com.jetbrains.WebStorm", "WebStorm") |
| 99 | add("datagrip", "DataGrip", externalOpenerEditor, "com.jetbrains.datagrip", "DataGrip") |
| 100 | add("codebuddy", "CodeBuddy", externalOpenerEditor, "com.tencent.codebuddy", "CodeBuddy") |
| 101 | add("windsurf", "Windsurf", externalOpenerEditor, "com.exafunction.windsurf", "Windsurf") |
| 102 | add("zed", "Zed", externalOpenerEditor, "dev.zed.Zed", "Zed") |
| 103 | add("sublime-text", "Sublime Text", externalOpenerEditor, "com.sublimetext.4", "Sublime Text") |
| 104 | add("kiro", "Kiro", externalOpenerEditor, "dev.kiro.desktop", "Kiro") |
| 105 | return specs |
| 106 | } |
| 107 | |
| 108 | func darwinBundleIconPath(appPath string) string { |
| 109 | resources := filepath.Join(appPath, "Contents", "Resources") |
| 110 | infoPlist := filepath.Join(appPath, "Contents", "Info.plist") |
| 111 | for _, key := range []string{"CFBundleIconFile", "CFBundleIconName"} { |
| 112 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) |
| 113 | out, err := exec.CommandContext(ctx, "/usr/libexec/PlistBuddy", "-c", "Print :"+key, infoPlist).Output() |
| 114 | cancel() |
| 115 | if err != nil { |
| 116 | continue |
| 117 | } |
| 118 | name := strings.TrimSpace(string(out)) |
| 119 | if name == "" { |
| 120 | continue |
| 121 | } |
| 122 | if filepath.Ext(name) == "" { |
| 123 | name += ".icns" |
| 124 | } |
| 125 | path := filepath.Join(resources, name) |
| 126 | if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() { |
| 127 | return path |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // Older and some Electron bundles omit the icon key. The application icon |
| 132 | // is normally the largest ICNS resource; document-type icons are much smaller. |
| 133 | matches, _ := filepath.Glob(filepath.Join(resources, "*.icns")) |
| 134 | var best string |
| 135 | var bestSize int64 |
| 136 | for _, candidate := range matches { |
| 137 | if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > bestSize { |
| 138 | best = candidate |
| 139 | bestSize = info.Size() |
| 140 | } |
| 141 | } |
| 142 | return best |
| 143 | } |
| 144 | |
| 145 | func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string { |
| 146 | iconPath := darwinBundleIconPath(spec.IconSource) |
| 147 | if iconPath == "" { |
| 148 | return "" |
| 149 | } |
| 150 | tempDir, err := os.MkdirTemp("", "reasonix-opener-icon-*") |
| 151 | if err != nil { |
| 152 | return "" |
| 153 | } |
| 154 | defer os.RemoveAll(tempDir) |
| 155 | outPath := filepath.Join(tempDir, "icon.png") |
| 156 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) |
| 157 | defer cancel() |
| 158 | cmd := exec.CommandContext(ctx, "/usr/bin/sips", "-s", "format", "png", "-z", "64", "64", iconPath, "--out", outPath) |
| 159 | cmd.Stdout = io.Discard |
| 160 | cmd.Stderr = io.Discard |
| 161 | if err := cmd.Run(); err != nil { |
| 162 | return "" |
| 163 | } |
| 164 | data, err := os.ReadFile(outPath) |
| 165 | if err != nil { |
| 166 | return "" |
| 167 | } |
| 168 | return externalOpenerPNGDataURL(data) |
| 169 | } |
| 170 | |
| 171 | func darwinExternalOpenerCommand(spec externalOpenerSpec, path string) *exec.Cmd { |
| 172 | if spec.LaunchMode == "ghostty" { |
| 173 | return exec.Command("/usr/bin/open", "-na", spec.Target, "--args", "--working-directory="+path) |
| 174 | } |
| 175 | return exec.Command("/usr/bin/open", "-a", spec.Target, path) |
| 176 | } |
| 177 | |
| 178 | func launchPlatformExternalOpener(spec externalOpenerSpec, path string) error { |
| 179 | return startDetachedExternalOpener(darwinExternalOpenerCommand(spec, path)) |
| 180 | } |
| 181 |