Files
picoclaw/web/backend/api/version.go
T
肆月 2861fd90ab fix(launcher): hide console flashes in all Windows child processes (#3061)
* fix(launcher): hide console flashes in all Windows child processes

PR #2654 only applied HideWindow to child processes in gateway.go (powershell, tasklist, ps). Several other files still use exec.Command directly, causing visible console windows on Windows.

- startup.go: reg query/add/delete for autostart registry

- version.go: picoclaw version subcommand

- runtime.go: rundll32 for browser launch

- onboard.go: picoclaw onboard subcommand

Add launcherExecCommand to the utils package (matching the api package pattern) and replace all bare exec.Command calls on Windows paths.

* refactor: consolidate launcherExecCommand into utils package

Export LauncherExecCommand and ApplyLauncherProcAttrs from the utils
package as the single source of truth. The api package now imports
and delegates to these exported functions, eliminating code duplication.

Addresses review feedback from imguoguo on PR #3061.
2026-06-11 15:10:56 +08:00

348 lines
8.3 KiB
Go

package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils"
)
type systemVersionResponse struct {
Version string `json:"version"`
GitCommit string `json:"git_commit,omitempty"`
BuildTime string `json:"build_time,omitempty"`
GoVersion string `json:"go_version"`
}
type cachedSystemVersion struct {
value systemVersionResponse
gatewayPID int
}
type systemVersionCache struct {
mu sync.Mutex
current cachedSystemVersion
hasCurrent bool
inflightCh chan struct{}
}
func newSystemVersionCache() *systemVersionCache {
return &systemVersionCache{}
}
var (
// 15 seconds matches the gateway startup window used elsewhere in launcher flow,
// giving slow/embedded hosts enough time for first command invocation while
// staying independent from cross-file init ordering.
versionCmdTimeout = 15 * time.Second
maxVersionResolveAttempts = 3
findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo
runPicoclawVersionOutput = executePicoclawVersion
currentGatewayVersionState = gatewayVersionState
launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig
versionInfoCache = newSystemVersionCache()
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
versionLinePattern = regexp.MustCompile(
`^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` +
`(?:\s+\(git:\s*([^)]+)\))?\s*$`,
)
)
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, r *http.Request) {
versionInfo := h.resolveSystemVersionInfo(r.Context())
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(versionInfo); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
// and falls back to launcher build metadata when command execution fails.
func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
for range maxVersionResolveAttempts {
gatewayPID, gatewayAlive := currentGatewayVersionState()
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
return cached
}
leader, ok := versionInfoCache.waitOrStart(ctx)
if !ok {
return fallbackSystemVersionInfo()
}
if !leader {
continue
}
resolved := h.resolveSystemVersionInfoUncached(ctx)
gatewayPID, gatewayAlive = currentGatewayVersionState()
versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
return resolved
}
return fallbackSystemVersionInfo()
}
func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
if ctx == nil {
ctx = context.Background()
}
fallback := fallbackSystemVersionInfo()
execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
if execPath == "" {
return fallback
}
cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
defer cancel()
output, err := runPicoclawVersionOutput(cmdCtx, 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
}
func fallbackSystemVersionInfo() systemVersionResponse {
return launcherBuildInfoForVersion()
}
func fallbackSystemVersionInfoFromConfig() systemVersionResponse {
buildTime, goVer := config.FormatBuildInfo()
return systemVersionResponse{
Version: config.GetVersion(),
GitCommit: config.GitCommit,
BuildTime: buildTime,
GoVersion: goVer,
}
}
// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher
// gateway start path when available, then falls back to launcher binary lookup.
// This keeps version probing aligned with the actual gateway startup behavior,
// so web and gateway do not drift onto different binaries.
func resolveGatewayBinaryForVersionInfo() string {
gateway.mu.Lock()
cmd := gateway.cmd
gateway.mu.Unlock()
if cmd != nil {
if execPath := strings.TrimSpace(cmd.Path); execPath != "" {
return execPath
}
}
return utils.FindPicoclawBinary()
}
func gatewayVersionState() (int, bool) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
if gateway.cmd == nil || gateway.cmd.Process == nil {
return 0, false
}
pid := gateway.cmd.Process.Pid
if pid <= 0 {
return 0, false
}
return pid, isCmdProcessAliveLocked(gateway.cmd)
}
func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) {
c.clearCurrentLocked()
}
if c.hasCurrent {
return c.current.value, true
}
return systemVersionResponse{}, false
}
func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) {
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
return false, false
}
c.mu.Lock()
if c.inflightCh == nil {
c.inflightCh = make(chan struct{})
c.mu.Unlock()
return true, true
}
waitCh := c.inflightCh
c.mu.Unlock()
select {
case <-waitCh:
return false, true
case <-ctx.Done():
return false, false
}
}
func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) {
c.mu.Lock()
if gatewayAlive && gatewayPID > 0 {
c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
c.hasCurrent = true
} else {
c.clearCurrentLocked()
}
inflightCh := c.inflightCh
c.inflightCh = nil
c.mu.Unlock()
if inflightCh != nil {
close(inflightCh)
}
}
func (c *systemVersionCache) clearCurrentLocked() {
c.hasCurrent = false
c.current = cachedSystemVersion{}
}
func (c *systemVersionCache) resetForTest() {
c.mu.Lock()
defer c.mu.Unlock()
c.current = cachedSystemVersion{}
c.hasCurrent = false
if c.inflightCh != nil {
close(c.inflightCh)
c.inflightCh = nil
}
}
// executePicoclawVersion runs the version subcommand against the
// discovered picoclaw executable.
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
cmd := exec.CommandContext(ctx, execPath, "version")
applyLauncherProcAttrs(cmd)
out, err := cmd.CombinedOutput()
if err == nil {
return string(out), 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 {
candidateVersion := strings.TrimSpace(match[1])
if !isLikelyVersionValue(candidateVersion) {
continue
}
result.Version = candidateVersion
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 err := scanner.Err(); err != nil {
return systemVersionResponse{}, false
}
if result.Version == "" {
return systemVersionResponse{}, false
}
return result, true
}
func isLikelyVersionValue(value string) bool {
v := strings.TrimSpace(strings.ToLower(value))
if v == "" {
return false
}
if v == "dev" {
return true
}
// Accept git-like short/long hashes even when they contain only letters (a-f).
if len(v) >= 7 && len(v) <= 40 {
allHex := true
for _, ch := range v {
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
continue
}
allHex = false
break
}
if allHex {
return true
}
}
for _, ch := range v {
if ch >= '0' && ch <= '9' {
return true
}
}
return false
}