From c2d2ee6ae8d2dbb0625104d02e314c16f337b653 Mon Sep 17 00:00:00 2001 From: lc6464 <64722907+lc6464@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:50:10 +0800 Subject: [PATCH] fix(web): improve version parsing and timeout behavior --- cmd/picoclaw-launcher-tui/ui/gateway.go | 5 +- web/backend/api/version.go | 112 ++++++++++++++++++++- web/backend/api/version_test.go | 126 +++++++++++++++++++----- 3 files changed, 216 insertions(+), 27 deletions(-) diff --git a/cmd/picoclaw-launcher-tui/ui/gateway.go b/cmd/picoclaw-launcher-tui/ui/gateway.go index 1138c12db..c14162505 100644 --- a/cmd/picoclaw-launcher-tui/ui/gateway.go +++ b/cmd/picoclaw-launcher-tui/ui/gateway.go @@ -35,14 +35,15 @@ func getPidPath() string { } func isProcessRunning(pid int) bool { - if runtime.GOOS == "windows" { + switch runtime.GOOS { + case "windows": cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)) output, err := cmd.Output() if err != nil { return false } return strings.Contains(string(output), strconv.Itoa(pid)) - } else if runtime.GOOS == "darwin" { + case "darwin": cmd := exec.Command("ps", "aux") output, err := cmd.Output() if err != nil { diff --git a/web/backend/api/version.go b/web/backend/api/version.go index a3b7f2aba..c7d884766 100644 --- a/web/backend/api/version.go +++ b/web/backend/api/version.go @@ -1,10 +1,18 @@ package api import ( + "bufio" + "context" "encoding/json" + "fmt" "net/http" + "os/exec" + "regexp" + "runtime" + "strings" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/utils" ) type systemVersionResponse struct { @@ -14,18 +22,116 @@ type systemVersionResponse struct { GoVersion string `json:"go_version"` } +var ( + // Reuse the launcher gateway startup window so embedded/slow devices + // have enough time for first-run command initialization. + versionCmdTimeout = gatewayStartupWindow + findPicoclawBinaryForInfo = utils.FindPicoclawBinary + runPicoclawVersionOutput = executePicoclawVersion + versionLinePattern = regexp.MustCompile(`\bpicoclaw\s+([^\s(]+)(?:\s+\(git:\s*([^)]+)\))?`) + ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) +) + func (h *Handler) registerVersionRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/system/version", h.handleGetVersion) } +// handleGetVersion returns runtime version information for web clients. func (h *Handler) handleGetVersion(w http.ResponseWriter, _ *http.Request) { - buildTime, goVer := config.FormatBuildInfo() + versionInfo := h.resolveSystemVersionInfo() w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(systemVersionResponse{ + json.NewEncoder(w).Encode(versionInfo) +} + +// resolveSystemVersionInfo prefers the actual picoclaw binary version output, +// and falls back to launcher build metadata when command execution fails. +func (h *Handler) resolveSystemVersionInfo() systemVersionResponse { + buildTime, goVer := config.FormatBuildInfo() + fallback := systemVersionResponse{ Version: config.GetVersion(), GitCommit: config.GitCommit, BuildTime: buildTime, GoVersion: goVer, - }) + } + + execPath := strings.TrimSpace(findPicoclawBinaryForInfo()) + if execPath == "" { + return fallback + } + + ctx, cancel := context.WithTimeout(context.Background(), versionCmdTimeout) + defer cancel() + + output, err := runPicoclawVersionOutput(ctx, execPath) + if err != nil { + return fallback + } + + parsed, ok := parsePicoclawVersionOutput(output) + if !ok { + return fallback + } + + if parsed.GoVersion == "" { + parsed.GoVersion = fallback.GoVersion + if parsed.GoVersion == "" { + parsed.GoVersion = runtime.Version() + } + } + + return parsed +} + +// executePicoclawVersion runs version commands against the discovered picoclaw +// executable. It tries subcommand form first, then --version fallback. +func executePicoclawVersion(ctx context.Context, execPath string) (string, error) { + out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput() + if err == nil { + return string(out), nil + } + + flagOut, flagErr := exec.CommandContext(ctx, execPath, "--version").CombinedOutput() + if flagErr == nil { + return string(flagOut), nil + } + + return string(out), fmt.Errorf("failed to execute version command: %w", err) +} + +// parsePicoclawVersionOutput extracts version/build/go fields from CLI output. +// It accepts banner/ANSI-decorated output and only requires the version line. +func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) { + var result systemVersionResponse + + scanner := bufio.NewScanner(strings.NewReader(raw)) + for scanner.Scan() { + line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), "")) + if line == "" { + continue + } + + if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 { + result.Version = strings.TrimSpace(match[1]) + if len(match) > 2 { + result.GitCommit = strings.TrimSpace(match[2]) + } + continue + } + + if buildValue, ok := strings.CutPrefix(line, "Build:"); ok { + result.BuildTime = strings.TrimSpace(buildValue) + continue + } + + if goValue, ok := strings.CutPrefix(line, "Go:"); ok { + result.GoVersion = strings.TrimSpace(goValue) + } + } + + if result.Version == "" { + return systemVersionResponse{}, false + } + + return result, true } diff --git a/web/backend/api/version_test.go b/web/backend/api/version_test.go index 46cb844f3..14c0704b7 100644 --- a/web/backend/api/version_test.go +++ b/web/backend/api/version_test.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "runtime" @@ -10,22 +12,88 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) -func TestGetSystemVersion(t *testing.T) { +func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) { originalVersion := config.Version originalGitCommit := config.GitCommit originalBuildTime := config.BuildTime originalGoVersion := config.GoVersion + originalFinder := findPicoclawBinaryForInfo + originalRunner := runPicoclawVersionOutput t.Cleanup(func() { config.Version = originalVersion config.GitCommit = originalGitCommit config.BuildTime = originalBuildTime config.GoVersion = originalGoVersion + findPicoclawBinaryForInfo = originalFinder + runPicoclawVersionOutput = originalRunner }) - config.Version = "v1.2.3" - config.GitCommit = "deadbeef" - config.BuildTime = "2026-03-27T12:34:56Z" - config.GoVersion = "go1.24.1" + config.Version = "dev" + config.GitCommit = "" + config.BuildTime = "" + config.GoVersion = "" + + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil + } + + h := NewHandler("") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got systemVersionResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if got.Version != "v1.2.3" { + t.Fatalf("version = %q, want %q", got.Version, "v1.2.3") + } + if got.GitCommit != "deadbeef" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef") + } + if got.BuildTime != "2026-03-27T12:34:56Z" { + t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z") + } + if got.GoVersion != "go1.25.8" { + t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8") + } +} + +func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) { + originalVersion := config.Version + originalGitCommit := config.GitCommit + originalBuildTime := config.BuildTime + originalGoVersion := config.GoVersion + originalFinder := findPicoclawBinaryForInfo + originalRunner := runPicoclawVersionOutput + t.Cleanup(func() { + config.Version = originalVersion + config.GitCommit = originalGitCommit + config.BuildTime = originalBuildTime + config.GoVersion = originalGoVersion + findPicoclawBinaryForInfo = originalFinder + runPicoclawVersionOutput = originalRunner + }) + + config.Version = "v9.9.9" + config.GitCommit = "cafebabe" + config.BuildTime = "2026-03-27T10:43:34+0000" + config.GoVersion = "go1.25.8" + + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "", errors.New("binary unavailable") + } h := NewHandler("") mux := http.NewServeMux() @@ -58,16 +126,40 @@ func TestGetSystemVersion(t *testing.T) { } } -func TestGetSystemVersionUsesRuntimeGoVersionFallback(t *testing.T) { +func TestParsePicoclawVersionOutput(t *testing.T) { + raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n" + got, ok := parsePicoclawVersionOutput(raw) + if !ok { + t.Fatal("parsePicoclawVersionOutput() should parse valid output") + } + if got.Version != "18ec263" { + t.Fatalf("version = %q, want %q", got.Version, "18ec263") + } + if got.GitCommit != "18ec2631" { + t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631") + } + if got.BuildTime != "2026-03-27T10:43:34+0000" { + t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000") + } + if got.GoVersion != "go1.25.8" { + t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8") + } +} + +func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) { originalVersion := config.Version originalGitCommit := config.GitCommit originalBuildTime := config.BuildTime originalGoVersion := config.GoVersion + originalFinder := findPicoclawBinaryForInfo + originalRunner := runPicoclawVersionOutput t.Cleanup(func() { config.Version = originalVersion config.GitCommit = originalGitCommit config.BuildTime = originalBuildTime config.GoVersion = originalGoVersion + findPicoclawBinaryForInfo = originalFinder + runPicoclawVersionOutput = originalRunner }) config.Version = "dev" @@ -75,23 +167,13 @@ func TestGetSystemVersionUsesRuntimeGoVersionFallback(t *testing.T) { config.BuildTime = "" config.GoVersion = "" + findPicoclawBinaryForInfo = func() string { return "picoclaw" } + runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) { + return "picoclaw v1.0.0\n", nil + } + h := NewHandler("") - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil) - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var got systemVersionResponse - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("unmarshal response: %v", err) - } - + got := h.resolveSystemVersionInfo() if got.GoVersion != runtime.Version() { t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version()) }