Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

281 lines
8.0 KiB
Go

package server
import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"time"
)
// Opener represents an application that can open a project directory.
type Opener struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"` // "editor", "terminal", "files", "action"
Bin string `json:"bin"`
}
// openerCandidate is a binary we check for on PATH, or a macOS
// app bundle we check for on disk.
type openerCandidate struct {
id string
name string
kind string
bins []string // alternatives to try via LookPath
appPath string // macOS .app bundle path (checked via os.Stat)
}
// Linux + cross-platform candidates.
var openerCandidates = []openerCandidate{
// File managers
{"nautilus", "Files", "files", []string{"nautilus"}, ""},
{"dolphin", "Dolphin", "files", []string{"dolphin"}, ""},
{"thunar", "Thunar", "files", []string{"thunar"}, ""},
{"nemo", "Nemo", "files", []string{"nemo"}, ""},
{"pcmanfm", "PCManFM", "files", []string{"pcmanfm"}, ""},
// Editors / IDEs (GUI only — CLI editors like vim need a TTY)
{"cursor", "Cursor", "editor", []string{"cursor"}, ""},
{"vscode", "VS Code", "editor", []string{"code"}, ""},
{"zed", "Zed", "editor", []string{"zed"}, ""},
{"sublime", "Sublime Text", "editor", []string{"subl"}, ""},
{"emacs", "Emacs", "editor", []string{"emacs"}, ""},
{"intellij", "IntelliJ IDEA", "editor", []string{"idea"}, ""},
{"goland", "GoLand", "editor", []string{"goland"}, ""},
{"webstorm", "WebStorm", "editor", []string{"webstorm"}, ""},
// Terminals
{"ghostty", "Ghostty", "terminal", []string{"ghostty"}, ""},
{"kitty", "kitty", "terminal", []string{"kitty"}, ""},
{"alacritty", "Alacritty", "terminal", []string{"alacritty"}, ""},
{"wezterm", "WezTerm", "terminal", []string{"wezterm"}, ""},
{"gnome-terminal", "GNOME Terminal", "terminal", []string{"gnome-terminal"}, ""},
{"konsole", "Konsole", "terminal", []string{"konsole"}, ""},
{"xfce4-terminal", "Xfce Terminal", "terminal", []string{"xfce4-terminal"}, ""},
{"tilix", "Tilix", "terminal", []string{"tilix"}, ""},
{"xterm", "xterm", "terminal", []string{"xterm"}, ""},
}
// macOS-specific candidates.
var darwinOpenerCandidates = []openerCandidate{
// File manager is always Finder on macOS — use "open" command
{"finder", "Finder", "files", []string{"open"}, ""},
// Editors (GUI only — CLI editors like vim need a TTY)
{"cursor", "Cursor", "editor", []string{"cursor"}, ""},
{"vscode", "VS Code", "editor", []string{"code"}, ""},
{"zed", "Zed", "editor", []string{"zed"}, ""},
{"xcode", "Xcode", "editor", []string{"xed"}, ""},
{"sublime", "Sublime Text", "editor", []string{"subl"}, ""},
// Claude Desktop — detected via app bundle. Uses claude:// URL
// scheme for session handoff rather than terminal launch.
{"claude-desktop", "Claude Desktop", "action", nil, "/Applications/Claude.app"},
// Terminals — Ghostty, iTerm2, kitty, and Terminal.app are macOS
// GUI apps; detect via app bundle first, fall back to PATH binary
// for non-default installs (e.g. Homebrew formula).
{"ghostty", "Ghostty", "terminal", []string{
"ghostty",
"/Applications/Ghostty.app/Contents/MacOS/ghostty",
}, ""},
{"iterm2", "iTerm2", "terminal", nil, "/Applications/iTerm.app"},
{"kitty", "kitty", "terminal", []string{"kitty"}, "/Applications/kitty.app"},
{"alacritty", "Alacritty", "terminal", []string{"alacritty"}, ""},
{"wezterm", "WezTerm", "terminal", []string{"wezterm"}, ""},
{"terminal", "Terminal", "terminal", nil, "/System/Applications/Utilities/Terminal.app"},
}
// cachedOpeners stores detected openers with a TTL.
var (
openerCache []Opener
openerCacheMu sync.Mutex
openerCacheAt time.Time
openerCacheTTL = 60 * time.Second
)
func detectOpeners() []Opener {
openerCacheMu.Lock()
defer openerCacheMu.Unlock()
if time.Since(openerCacheAt) < openerCacheTTL && openerCache != nil {
return openerCache
}
candidates := openerCandidates
if runtime.GOOS == "darwin" {
candidates = darwinOpenerCandidates
}
var result []Opener
for _, c := range candidates {
// Try app bundle path first (macOS GUI apps).
if c.appPath != "" {
if _, err := os.Stat(c.appPath); err == nil {
result = append(result, Opener{
ID: c.id,
Name: c.name,
Kind: c.kind,
Bin: c.appPath,
})
continue
}
}
// Fall back to PATH-based binary detection.
for _, bin := range c.bins {
path, err := exec.LookPath(bin)
if err != nil {
continue
}
result = append(result, Opener{
ID: c.id,
Name: c.name,
Kind: c.kind,
Bin: path,
})
break // found one binary for this candidate
}
}
openerCache = result
openerCacheAt = time.Now()
return result
}
type openRequest struct {
OpenerID string `json:"opener_id"`
}
func launchOpener(o Opener, dir string) error {
var cmd *exec.Cmd
switch o.Kind {
case "files":
if runtime.GOOS == "darwin" {
cmd = exec.Command("open", dir)
} else {
cmd = exec.Command(o.Bin, dir)
}
case "editor":
if runtime.GOOS == "darwin" && o.ID == "xcode" {
cmd = exec.Command(o.Bin, dir)
} else {
cmd = exec.Command(o.Bin, dir)
}
case "terminal":
cmd = launchTerminalInDir(o, dir)
default:
return fmt.Errorf("unsupported opener kind: %s", o.Kind)
}
if cmd == nil {
return fmt.Errorf("could not build command for opener %s", o.Name)
}
cmd.Stdout = nil
cmd.Stderr = nil
cmd.Stdin = nil
if err := cmd.Start(); err != nil {
return err
}
go func() { _ = cmd.Wait() }()
return nil
}
// isAppBundle returns true if the path looks like a macOS .app bundle.
func isAppBundle(bin string) bool {
return strings.HasSuffix(bin, ".app")
}
// macExecCommand builds an exec.Cmd for macOS. If bin is an .app
// bundle it wraps with `open -na`; otherwise executes the binary
// directly. This keeps detection and launch consistent regardless
// of whether the opener was found via app bundle or PATH.
func macExecCommand(bin string, args ...string) *exec.Cmd {
if isAppBundle(bin) {
openArgs := []string{"-na", bin, "--args"}
openArgs = append(openArgs, args...)
return exec.Command("open", openArgs...)
}
return exec.Command(bin, args...)
}
func launchTerminalInDir(o Opener, dir string) *exec.Cmd {
if runtime.GOOS == "darwin" {
switch o.ID {
case "iterm2":
shellCmd := fmt.Sprintf(
"cd %s && exec bash", shellQuote(dir),
)
script := fmt.Sprintf(
`tell application "iTerm"
create window with default profile command "%s"
end tell`,
escapeForAppleScript(shellCmd),
)
return exec.Command("osascript", "-e", script)
case "terminal":
shellCmd := fmt.Sprintf("cd %s", shellQuote(dir))
script := fmt.Sprintf(
`tell application "Terminal"
activate
do script "%s"
end tell`,
escapeForAppleScript(shellCmd),
)
return exec.Command("osascript", "-e", script)
case "ghostty":
return macExecCommand(o.Bin,
"--working-directory="+dir)
case "kitty":
return macExecCommand(o.Bin, "-d", dir)
case "alacritty":
return macExecCommand(o.Bin,
"--working-directory", dir)
case "wezterm":
return macExecCommand(o.Bin,
"start", "--cwd", dir)
}
}
// Linux: launch via CLI binary directly.
switch o.ID {
case "kitty":
return exec.Command(o.Bin, "--directory", dir)
case "alacritty":
return exec.Command(o.Bin, "--working-directory", dir)
case "wezterm":
return exec.Command(o.Bin, "start", "--cwd", dir)
case "gnome-terminal":
return exec.Command(o.Bin, "--working-directory="+dir)
case "konsole":
return exec.Command(o.Bin, "--workdir", dir)
case "xfce4-terminal":
return exec.Command(o.Bin,
"--default-working-directory="+dir)
case "tilix":
return exec.Command(o.Bin, "--working-directory="+dir)
case "ghostty":
return exec.Command(o.Bin, "--working-directory="+dir)
default:
cmd := exec.Command(o.Bin)
cmd.Dir = dir
return cmd
}
}
// escapeForAppleScript escapes a string for embedding inside an
// AppleScript double-quoted string literal. Does NOT add outer quotes.
func escapeForAppleScript(s string) string {
return strings.NewReplacer(
"\n", " ",
"\r", " ",
`\`, `\\`,
`"`, `\"`,
).Replace(s)
}