mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
2861fd90ab
* 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.
348 lines
8.3 KiB
Go
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
|
|
}
|