diff --git a/internal/app/methods_driver.go b/internal/app/methods_driver.go index f8e90dc..74653d5 100644 --- a/internal/app/methods_driver.go +++ b/internal/app/methods_driver.go @@ -2,6 +2,7 @@ package app import ( "archive/zip" + "bytes" "crypto/sha256" "encoding/hex" "encoding/json" @@ -29,6 +30,86 @@ import ( "golang.org/x/mod/semver" ) +var ( + goBinaryLookPath = exec.LookPath + goBinaryStat = os.Stat + goBinaryCommand = func(name string, arg ...string) *exec.Cmd { + return exec.Command(name, arg...) + } +) + +// resolveGoBinaryPath 定位 Go 可执行文件,兼容 macOS 图形应用未继承 shell PATH 的场景 by AI.Coding +func resolveGoBinaryPath() (string, error) { + if goPath, err := goBinaryLookPath("go"); err == nil { + return goPath, nil + } + + // 修复点:GUI 进程常拿不到终端里的 PATH,这里补充常见安装位置兜底。 + commonCandidates := []string{ + "/opt/homebrew/bin/go", + "/usr/local/go/bin/go", + "/usr/local/bin/go", + } + for _, candidate := range commonCandidates { + if info, err := goBinaryStat(candidate); err == nil && !info.IsDir() { + return candidate, nil + } + } + + for _, shell := range candidateShellsForCommandLookup() { + cmd := goBinaryCommand(shell, "-lc", "command -v go") + output, err := cmd.Output() + if err != nil { + continue + } + goPath := resolveExistingPathFromCommandOutput(output) + if goPath == "" { + continue + } + if info, err := goBinaryStat(goPath); err == nil && !info.IsDir() { + return goPath, nil + } + } + + return "", exec.ErrNotFound +} + +// resolveExistingPathFromCommandOutput 从命令输出中提取真实存在的路径,避免 shell 启动脚本输出污染探测结果 by AI.Coding +func resolveExistingPathFromCommandOutput(value []byte) string { + for _, line := range bytes.Split(value, []byte{'\n'}) { + trimmed := strings.TrimSpace(string(line)) + if trimmed != "" { + if info, err := goBinaryStat(trimmed); err == nil && !info.IsDir() { + return trimmed + } + } + } + return "" +} + +// candidateShellsForCommandLookup 返回可能可用的 shell,用于回收用户登录环境中的 PATH by AI.Coding +func candidateShellsForCommandLookup() []string { + seen := make(map[string]struct{}, 4) + result := make([]string, 0, 4) + appendShell := func(value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + if _, ok := seen[trimmed]; ok { + return + } + seen[trimmed] = struct{}{} + result = append(result, trimmed) + } + + appendShell(os.Getenv("SHELL")) + appendShell("/bin/zsh") + appendShell("/bin/bash") + appendShell("/bin/sh") + return result +} + type driverDefinition struct { Type string `json:"type"` Name string `json:"name"` @@ -3182,7 +3263,7 @@ func downloadOptionalDriverAgentFromBundle(a *App, definition driverDefinition, func buildOptionalDriverAgentFromSource(definition driverDefinition, executablePath string, selectedVersion string) (string, error) { driverType := normalizeDriverType(definition.Type) displayName := resolveDriverDisplayName(definition) - goPath, lookErr := exec.LookPath("go") + goPath, lookErr := resolveGoBinaryPath() if lookErr != nil { return "", fmt.Errorf("当前环境未安装 Go,且未找到可用的 %s 预编译代理包", displayName) } diff --git a/internal/app/methods_driver_test.go b/internal/app/methods_driver_test.go new file mode 100644 index 0000000..beaeedc --- /dev/null +++ b/internal/app/methods_driver_test.go @@ -0,0 +1,156 @@ +package app + +import ( + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestCandidateShellsForCommandLookup_DedupesAndPreservesOrder(t *testing.T) { + originalShell := os.Getenv("SHELL") + t.Cleanup(func() { + _ = os.Setenv("SHELL", originalShell) + }) + if err := os.Setenv("SHELL", "/bin/zsh"); err != nil { + t.Fatalf("set SHELL: %v", err) + } + + got := candidateShellsForCommandLookup() + want := []string{"/bin/zsh", "/bin/bash", "/bin/sh"} + if len(got) != len(want) { + t.Fatalf("unexpected shell count: got %v want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("unexpected shell order: got %v want %v", got, want) + } + } +} + +func TestResolveGoBinaryPath_FallsBackToKnownLocation(t *testing.T) { + originalLookPath := goBinaryLookPath + originalStat := goBinaryStat + originalCommand := goBinaryCommand + t.Cleanup(func() { + goBinaryLookPath = originalLookPath + goBinaryStat = originalStat + goBinaryCommand = originalCommand + }) + + goBinaryLookPath = func(file string) (string, error) { + return "", exec.ErrNotFound + } + goBinaryStat = func(name string) (os.FileInfo, error) { + if name == "/opt/homebrew/bin/go" { + return fakeFileInfo{name: "go"}, nil + } + return nil, os.ErrNotExist + } + goBinaryCommand = func(name string, arg ...string) *exec.Cmd { + t.Fatalf("shell fallback should not run when common path exists") + return nil + } + + got, err := resolveGoBinaryPath() + if err != nil { + t.Fatalf("resolveGoBinaryPath returned error: %v", err) + } + if got != "/opt/homebrew/bin/go" { + t.Fatalf("unexpected go path: %s", got) + } +} + +func TestResolveGoBinaryPath_FallsBackToShellOutput(t *testing.T) { + originalLookPath := goBinaryLookPath + originalStat := goBinaryStat + originalCommand := goBinaryCommand + originalShell := os.Getenv("SHELL") + t.Cleanup(func() { + goBinaryLookPath = originalLookPath + goBinaryStat = originalStat + goBinaryCommand = originalCommand + _ = os.Setenv("SHELL", originalShell) + }) + if err := os.Setenv("SHELL", "/custom/shell"); err != nil { + t.Fatalf("set SHELL: %v", err) + } + + goBinaryLookPath = func(file string) (string, error) { + return "", exec.ErrNotFound + } + goBinaryStat = func(name string) (os.FileInfo, error) { + if name == "/Users/test/go/bin/go" { + return fakeFileInfo{name: "go"}, nil + } + return nil, os.ErrNotExist + } + var called []string + goBinaryCommand = func(name string, arg ...string) *exec.Cmd { + called = append(called, name) + if len(called) == 1 { + return exec.Command("/bin/sh", "-c", "printf 'welcome\\n/Users/test/go/bin/go\\n'") + } + return exec.Command("/bin/sh", "-c", "exit 1") + } + + got, err := resolveGoBinaryPath() + if err != nil { + t.Fatalf("resolveGoBinaryPath returned error: %v", err) + } + if got != "/Users/test/go/bin/go" { + t.Fatalf("unexpected go path from shell fallback: %s", got) + } + if len(called) == 0 || called[0] != "/custom/shell" { + t.Fatalf("expected shell lookup to start with SHELL env, got %v", called) + } +} + +func TestResolveExistingPathFromCommandOutput_SkipsNoiseLines(t *testing.T) { + originalStat := goBinaryStat + t.Cleanup(func() { + goBinaryStat = originalStat + }) + goBinaryStat = func(name string) (os.FileInfo, error) { + if name == "/opt/homebrew/bin/go" { + return fakeFileInfo{name: "go"}, nil + } + return nil, os.ErrNotExist + } + + got := resolveExistingPathFromCommandOutput([]byte("\n notice \n /opt/homebrew/bin/go \n /usr/local/bin/go \n")) + if got != "/opt/homebrew/bin/go" { + t.Fatalf("unexpected parsed path: %q", got) + } +} + +type fakeFileInfo struct { + name string +} + +func (f fakeFileInfo) Name() string { return f.name } +func (f fakeFileInfo) Size() int64 { return 0 } +func (f fakeFileInfo) Mode() os.FileMode { return 0o755 } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return false } +func (f fakeFileInfo) Sys() any { return nil } + +func TestCandidateShellsForCommandLookup_IgnoresBlankShell(t *testing.T) { + originalShell := os.Getenv("SHELL") + t.Cleanup(func() { + _ = os.Setenv("SHELL", originalShell) + }) + if err := os.Setenv("SHELL", " "); err != nil { + t.Fatalf("set SHELL: %v", err) + } + + got := candidateShellsForCommandLookup() + joined := strings.Join(got, ",") + if strings.Contains(joined, " ") { + t.Fatalf("expected blank shell to be ignored: %v", got) + } + if len(got) != 3 { + t.Fatalf("unexpected shell list: %v", got) + } +}