diff --git a/Makefile b/Makefile index 2f673d3b9..4f4a7a6cb 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config -LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w" +LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w # Go variables GO?=CGO_ENABLED=0 go @@ -107,7 +107,7 @@ generate: build: generate @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @@ -128,16 +128,16 @@ build-whatsapp-native: generate ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) - GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) - GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) - GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) - GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) -## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) +## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete" ## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @@ -145,21 +145,21 @@ build-whatsapp-native: generate build-linux-arm: generate @echo "Building for linux/arm (GOARM=7)..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm" ## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit) build-linux-arm64: generate @echo "Building for linux/arm64..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64" ## build-linux-mipsle: Build for Linux MIPS32 LE build-linux-mipsle: generate @echo "Building for linux/mipsle (softfloat)..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle" @@ -171,18 +171,18 @@ build-pi-zero: build-linux-arm build-linux-arm64 build-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) - GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) - GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) - GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) + GOOS=linux GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=loong64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + GOOS=linux GOARCH=riscv64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) - GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) - GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) - GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) - GOOS=netbsd GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR) - GOOS=netbsd GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) + GOOS=darwin GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + GOOS=windows GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) + GOOS=netbsd GOARCH=amd64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR) + GOOS=netbsd GOARCH=arm64 $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR) @echo "All builds complete" ## install: Install picoclaw to system and copy builtin skills @@ -223,7 +223,8 @@ vet: generate ## test: Test Go code test: generate - @$(GO) test ./... + @$(GO) test $$(go list ./... | grep -v github.com/sipeed/picoclaw/web/) + @cd web && make test ## fmt: Format Go code fmt: diff --git a/go.mod b/go.mod index 130db73ff..4442b28fe 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/sipeed/picoclaw go 1.25.7 require ( + fyne.io/systray v1.12.0 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 github.com/bwmarrin/discordgo v0.29.0 @@ -28,6 +29,7 @@ require ( github.com/tencent-connect/botgo v0.2.1 go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.36.0 + golang.org/x/term v0.40.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 @@ -43,6 +45,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect @@ -59,7 +62,6 @@ require ( go.mau.fi/libsignal v0.2.1 // indirect go.mau.fi/util v0.9.6 // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect - golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect @@ -90,7 +92,7 @@ require ( github.com/valyala/fastjson v1.6.10 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/arch v0.24.0 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.48.0 golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/go.sum b/go.sum index a4d8ed3d0..f0e3fc132 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= @@ -64,6 +66,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78 github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index f8057bb2f..5a13c8f1b 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -190,7 +190,7 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: workspace, - Model: "test-model", + ModelName: "test-model", RestrictToWorkspace: true, }, }, diff --git a/web/Makefile b/web/Makefile index 559005956..653dd77e1 100644 --- a/web/Makefile +++ b/web/Makefile @@ -1,5 +1,59 @@ .PHONY: dev dev-frontend dev-backend build test lint clean +# Version +VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") +BUILD_TIME=$(shell date +%FT%T%z) +GO_VERSION=$(shell $(GO) version | awk '{print $$3}') +CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config +LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w + +# Go variables +GO?=CGO_ENABLED=0 go +GOFLAGS?=-v -tags stdjson + + +# OS detection +UNAME_S:=$(shell uname -s) +UNAME_M:=$(shell uname -m) + +# Platform-specific settings +ifeq ($(UNAME_S),Linux) + PLATFORM=linux + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),aarch64) + ARCH=arm64 + else ifeq ($(UNAME_M),armv81) + ARCH=arm64 + else ifeq ($(UNAME_M),loongarch64) + ARCH=loong64 + else ifeq ($(UNAME_M),riscv64) + ARCH=riscv64 + else ifeq ($(UNAME_M),mipsel) + ARCH=mipsle + else + ARCH=$(UNAME_M) + endif +else ifeq ($(UNAME_S),Darwin) + PLATFORM=darwin + GO=CGO_ENABLED=1 go + ifeq ($(UNAME_M),x86_64) + ARCH=amd64 + else ifeq ($(UNAME_M),arm64) + ARCH=arm64 + else + ARCH=$(UNAME_M) + endif +else ifeq ($(UNAME_S),Windows) + PLATFORM=windows + ARCH=$(UNAME_M) + LDFLAGS=-H=windowsgui $(LDFLAGS) +else + PLATFORM=$(UNAME_S) + ARCH=$(UNAME_M) +endif + # Run both frontend and backend dev servers dev: @if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \ @@ -15,21 +69,21 @@ dev-frontend: # Start backend dev server dev-backend: - cd backend && go run . + cd backend && ${GO} run -ldflags "$(LDFLAGS)" . # Build frontend and embed into Go binary build: cd frontend && pnpm build:backend - cd backend && go build -o picoclaw-web . + cd backend && ${GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o picoclaw-web . # Run all tests test: - cd backend && go test ./... + cd backend && ${GO} test ./... cd frontend && pnpm lint # Lint and format lint: - cd backend && go vet ./... + cd backend && ${GO} vet ./... cd frontend && pnpm check # Clean build artifacts diff --git a/web/backend/api/events.go b/web/backend/api/events.go index af44d1824..5c85b149a 100644 --- a/web/backend/api/events.go +++ b/web/backend/api/events.go @@ -40,9 +40,13 @@ func (b *EventBroadcaster) Subscribe() chan string { // Unsubscribe removes a listener channel and closes it. func (b *EventBroadcaster) Unsubscribe(ch chan string) { b.mu.Lock() - delete(b.clients, ch) - b.mu.Unlock() - close(ch) + defer b.mu.Unlock() + + // Check if the channel is still registered before closing + if _, exists := b.clients[ch]; exists { + delete(b.clients, ch) + close(ch) + } } // Broadcast sends a GatewayEvent to all connected SSE clients. @@ -63,3 +67,14 @@ func (b *EventBroadcaster) Broadcast(event GatewayEvent) { } } } + +// Shutdown closes all subscriber channels, notifying all SSE clients to disconnect. +// This should be called when the server is shutting down. +func (b *EventBroadcaster) Shutdown() { + // Close all channels to notify listeners + for ch := range b.clients { + b.Unsubscribe(ch) + } + // Clear the map + b.clients = make(map[chan string]struct{}) +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index f50f7609a..424b21e96 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -3,10 +3,10 @@ package api import ( "bufio" "encoding/json" + "errors" "fmt" "io" "log" - "net" "net/http" "os" "os/exec" @@ -18,6 +18,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/web/backend/utils" ) @@ -48,6 +49,27 @@ var gatewayHealthGet = func(url string, timeout time.Duration) (*http.Response, return client.Get(url) } +// getGatewayHealth checks the gateway health endpoint and returns the status response +// Returns (*health.StatusResponse, statusCode, error). If error is not nil, the other values are not valid. +func getGatewayHealth(port int, timeout time.Duration) (*health.StatusResponse, int, error) { + if port == 0 { + port = 18790 + } + url := fmt.Sprintf("http://127.0.0.1:%d/health", port) + resp, err := gatewayHealthGet(url, timeout) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + var healthResponse health.StatusResponse + if decErr := json.NewDecoder(resp.Body).Decode(&healthResponse); decErr != nil { + return nil, resp.StatusCode, decErr + } + + return &healthResponse, resp.StatusCode, nil +} + // registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux. func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) @@ -62,12 +84,35 @@ func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { // TryAutoStartGateway checks whether gateway start preconditions are met and // starts it when possible. Intended to be called by the backend at startup. func (h *Handler) TryAutoStartGateway() { + // Check if gateway is already running via health endpoint + cfg, cfgErr := config.LoadConfig(h.configPath) + if cfgErr == nil && cfg != nil { + healthResp, statusCode, err := getGatewayHealth(cfg.Gateway.Port, 2*time.Second) + if err == nil && statusCode == http.StatusOK { + // Gateway is already running, attach to the existing process + pid := healthResp.Pid + gateway.mu.Lock() + defer gateway.mu.Unlock() + ready, reason, err := h.gatewayStartReady() + if err != nil { + log.Printf("Skip auto-starting gateway: %v", err) + return + } + if !ready { + log.Printf("Skip auto-starting gateway: %s", reason) + return + } + _, err = h.startGatewayLocked("starting", pid) + if err != nil { + log.Printf("Failed to attach to running gateway (PID: %d): %v", pid, err) + } + return + } + } + gateway.mu.Lock() defer gateway.mu.Unlock() - if isGatewayProcessAliveLocked() { - return - } if gateway.cmd != nil && gateway.cmd.Process != nil { gateway.cmd = nil } @@ -82,7 +127,7 @@ func (h *Handler) TryAutoStartGateway() { return } - pid, err := h.startGatewayLocked("starting") + pid, err := h.startGatewayLocked("starting", 0) if err != nil { log.Printf("Failed to auto-start gateway: %v", err) return @@ -125,10 +170,6 @@ func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig return modelCfg } -func isGatewayProcessAliveLocked() bool { - return isCmdProcessAliveLocked(gateway.cmd) -} - func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { if cmd == nil || cmd.Process == nil { return false @@ -157,6 +198,28 @@ func setGatewayRuntimeStatusLocked(status string) { gateway.startupDeadline = time.Time{} } +// attachToGatewayProcess attaches to an existing gateway process by PID +// and updates the gateway state accordingly. +// Assumes gateway.mu is held by the caller. +func attachToGatewayProcessLocked(pid int, cfg *config.Config) error { + process, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("failed to find process for PID %d: %w", pid, err) + } + + gateway.cmd = &exec.Cmd{Process: process} + setGatewayRuntimeStatusLocked("running") + + // Update bootDefaultModel from config + if cfg != nil { + defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + gateway.bootDefaultModel = defaultModelName + } + + log.Printf("Attached to gateway process (PID: %d)", pid) + return nil +} + func gatewayStatusOnHealthFailureLocked() string { if gateway.runtimeStatus == "starting" || gateway.runtimeStatus == "restarting" { if gateway.startupDeadline.IsZero() || time.Now().Before(gateway.startupDeadline) { @@ -238,24 +301,41 @@ func stopGatewayProcessForRestart(cmd *exec.Cmd) error { return fmt.Errorf("existing gateway did not exit before restart") } -func gatewayRestartRequired(status, bootDefaultModel, configDefaultModel string) bool { - return status == "running" && - bootDefaultModel != "" && - configDefaultModel != "" && - bootDefaultModel != configDefaultModel -} - -func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { +func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int, error) { cfg, err := config.LoadConfig(h.configPath) if err != nil { return 0, fmt.Errorf("failed to load config: %w", err) } defaultModelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + var cmd *exec.Cmd + var pid int + + if existingPid > 0 { + // Attach to existing process + pid = existingPid + gateway.cmd = nil // Clear first to ensure clean state + if err = attachToGatewayProcessLocked(pid, cfg); err != nil { + return 0, err + } + + // Broadcast the attached state + gateway.events.Broadcast(GatewayEvent{ + Status: initialStatus, + PID: pid, + BootDefaultModel: defaultModelName, + ConfigDefaultModel: defaultModelName, + RestartRequired: false, + }) + + return pid, nil + } + + // Start new process // Locate the picoclaw executable execPath := utils.FindPicoclawBinary() - cmd := exec.Command(execPath, "gateway") + cmd = exec.Command(execPath, "gateway") cmd.Env = os.Environ() // Forward the launcher's config path via the environment variable that // GetConfigPath() already reads, so the gateway sub-process uses the same @@ -293,7 +373,7 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { gateway.cmd = cmd gateway.bootDefaultModel = defaultModelName setGatewayRuntimeStatusLocked(initialStatus) - pid := cmd.Process.Pid + pid = cmd.Process.Pid log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath) // Broadcast the launch state immediately so clients can reflect it without polling. @@ -351,30 +431,22 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { if err != nil { continue } - healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) - healthPort := cfg.Gateway.Port - if healthPort == 0 { - healthPort = 18790 - } - healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort))) - resp, err := gatewayHealthGet(healthURL, 1*time.Second) - if err == nil { - resp.Body.Close() - if resp.StatusCode == http.StatusOK { - gateway.mu.Lock() - if gateway.cmd == cmd { - setGatewayRuntimeStatusLocked("running") - } - gateway.mu.Unlock() - gateway.events.Broadcast(GatewayEvent{ - Status: "running", - PID: pid, - BootDefaultModel: defaultModelName, - ConfigDefaultModel: defaultModelName, - RestartRequired: false, - }) - return + healthResp, statusCode, err := getGatewayHealth(cfg.Gateway.Port, 1*time.Second) + if err == nil && statusCode == http.StatusOK && healthResp.Pid == pid { + // Verify the health endpoint returns the expected pid + gateway.mu.Lock() + if gateway.cmd == cmd { + setGatewayRuntimeStatusLocked("running") } + gateway.mu.Unlock() + gateway.events.Broadcast(GatewayEvent{ + Status: "running", + PID: pid, + BootDefaultModel: defaultModelName, + ConfigDefaultModel: defaultModelName, + RestartRequired: false, + }) + return } } }() @@ -386,19 +458,54 @@ func (h *Handler) startGatewayLocked(initialStatus string) (int, error) { // // POST /api/gateway/start func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { + // Prevent duplicate starts by checking health endpoint + cfg, cfgErr := config.LoadConfig(h.configPath) + if cfgErr == nil && cfg != nil { + healthResp, statusCode, err := getGatewayHealth(cfg.Gateway.Port, 2*time.Second) + if err == nil && statusCode == http.StatusOK { + // Gateway is already running, attach to the existing process + pid := healthResp.Pid + gateway.mu.Lock() + ready, reason, err := h.gatewayStartReady() + if err != nil { + gateway.mu.Unlock() + http.Error( + w, + fmt.Sprintf("Failed to validate gateway start conditions: %v", err), + http.StatusInternalServerError, + ) + return + } + if !ready { + gateway.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "precondition_failed", + "message": reason, + }) + return + } + _, err = h.startGatewayLocked("starting", pid) + gateway.mu.Unlock() + if err != nil { + log.Printf("Failed to attach to running gateway (PID: %d): %v", pid, err) + http.Error(w, fmt.Sprintf("Failed to attach to gateway: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) + return + } + } + gateway.mu.Lock() defer gateway.mu.Unlock() - // Prevent duplicate starts - if isGatewayProcessAliveLocked() { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - json.NewEncoder(w).Encode(map[string]any{ - "status": "already_running", - "pid": gateway.cmd.Process.Pid, - }) - return - } if gateway.cmd != nil && gateway.cmd.Process != nil { gateway.cmd = nil setGatewayRuntimeStatusLocked("stopped") @@ -423,7 +530,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { return } - pid, err := h.startGatewayLocked("starting") + pid, err := h.startGatewayLocked("starting", 0) if err != nil { http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) return @@ -475,27 +582,16 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { }) } -// handleGatewayRestart stops the gateway (if running) and starts a new instance. -// -// POST /api/gateway/restart -func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { +// RestartGateway restarts the gateway process. This is a non-blocking operation +// that stops the current gateway (if running) and starts a new one. +// Returns the PID of the new gateway process or an error. +func (h *Handler) RestartGateway() (int, error) { ready, reason, err := h.gatewayStartReady() if err != nil { - http.Error( - w, - fmt.Sprintf("Failed to validate gateway start conditions: %v", err), - http.StatusInternalServerError, - ) - return + return 0, fmt.Errorf("failed to validate gateway start conditions: %w", err) } if !ready { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(map[string]any{ - "status": "precondition_failed", - "message": reason, - }) - return + return 0, &preconditionFailedError{reason: reason} } gateway.mu.Lock() @@ -519,8 +615,7 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { } } gateway.mu.Unlock() - http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError) - return + return 0, fmt.Errorf("failed to stop gateway: %w", err) } gateway.mu.Lock() @@ -528,7 +623,7 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { gateway.cmd = nil gateway.bootDefaultModel = "" } - pid, err := h.startGatewayLocked("restarting") + pid, err := h.startGatewayLocked("restarting", 0) if err != nil { gateway.cmd = nil gateway.bootDefaultModel = "" @@ -536,6 +631,43 @@ func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { } gateway.mu.Unlock() if err != nil { + return 0, fmt.Errorf("failed to start gateway: %w", err) + } + + return pid, nil +} + +// preconditionFailedError is returned when gateway restart preconditions are not met +type preconditionFailedError struct { + reason string +} + +func (e *preconditionFailedError) Error() string { + return e.reason +} + +// IsBadRequest returns true if the error should result in a 400 Bad Request status +func (e *preconditionFailedError) IsBadRequest() bool { + return true +} + +// handleGatewayRestart stops the gateway (if running) and starts a new instance. +// +// POST /api/gateway/restart +func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { + pid, err := h.RestartGateway() + if err != nil { + // Check if it's a precondition failed error + var precondErr *preconditionFailedError + if errors.As(err, &precondErr) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "precondition_failed", + "message": precondErr.reason, + }) + return + } http.Error(w, fmt.Sprintf("Failed to restart gateway: %v", err), http.StatusInternalServerError) return } @@ -573,83 +705,74 @@ func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) gatewayStatusData() map[string]any { data := map[string]any{} cfg, cfgErr := config.LoadConfig(h.configPath) - configDefaultModel := "" if cfgErr == nil && cfg != nil { - configDefaultModel = strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + configDefaultModel := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) if configDefaultModel != "" { data["config_default_model"] = configDefaultModel } } - // Check process state - gateway.mu.Lock() - processAlive := isGatewayProcessAliveLocked() - bootDefaultModel := "" - if processAlive { - data["pid"] = gateway.cmd.Process.Pid - if gateway.bootDefaultModel != "" { - data["boot_default_model"] = gateway.bootDefaultModel - bootDefaultModel = gateway.bootDefaultModel - } + // Probe health endpoint to get pid and status + port := 0 + if cfgErr == nil && cfg != nil { + port = cfg.Gateway.Port } - gateway.mu.Unlock() - if !processAlive { + healthResp, statusCode, err := getGatewayHealth(port, 2*time.Second) + if err != nil { gateway.mu.Lock() - data["gateway_status"] = currentGatewayStatusLocked(false) + data["gateway_status"] = currentGatewayStatusLocked(true) gateway.mu.Unlock() + log.Printf("Gateway health check failed: %v", err) } else { - // Process is alive — probe its health endpoint - host := "127.0.0.1" - port := 18790 - if cfgErr == nil && cfg != nil { - host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) - if cfg.Gateway.Port != 0 { - port = cfg.Gateway.Port - } - } - - url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port))) - resp, err := gatewayHealthGet(url, 2*time.Second) - - if err != nil { + log.Printf("Gateway health status: %d", statusCode) + if statusCode != http.StatusOK { gateway.mu.Lock() - data["gateway_status"] = currentGatewayStatusLocked(true) + setGatewayRuntimeStatusLocked("error") gateway.mu.Unlock() + data["gateway_status"] = "error" + data["status_code"] = statusCode } else { - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("error") - gateway.mu.Unlock() - data["gateway_status"] = "error" - data["status_code"] = resp.StatusCode + gateway.mu.Lock() + // Check if this pid matches our tracked process + if gateway.cmd != nil && gateway.cmd.Process != nil && gateway.cmd.Process.Pid == healthResp.Pid { + setGatewayRuntimeStatusLocked("running") + bootDefaultModel := gateway.bootDefaultModel + if bootDefaultModel != "" { + data["boot_default_model"] = bootDefaultModel + } + data["gateway_status"] = "running" + data["pid"] = healthResp.Pid } else { - var healthData map[string]any - if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil { - gateway.mu.Lock() + // Health endpoint responded with a different pid + // This could be a manual restart, try to attach to the new process + oldPid := "none" + if gateway.cmd != nil && gateway.cmd.Process != nil { + oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid) + } + log.Printf("Detected new gateway PID (old: %s, new: %d), attempting to attach", oldPid, healthResp.Pid) + + if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil { + // Failed to find the process, treat as error setGatewayRuntimeStatusLocked("error") - gateway.mu.Unlock() data["gateway_status"] = "error" + data["pid"] = healthResp.Pid + log.Printf("Failed to attach to new gateway process (PID: %d): %v", healthResp.Pid, err) } else { - gateway.mu.Lock() - setGatewayRuntimeStatusLocked("running") - gateway.mu.Unlock() - for k, v := range healthData { - data[k] = v + // Successfully attached, update response data + bootDefaultModel := gateway.bootDefaultModel + if bootDefaultModel != "" { + data["boot_default_model"] = bootDefaultModel } data["gateway_status"] = "running" + data["pid"] = healthResp.Pid } } + gateway.mu.Unlock() } } - status, _ := data["gateway_status"].(string) - data["gateway_restart_required"] = gatewayRestartRequired( - status, - bootDefaultModel, - configDefaultModel, - ) + data["gateway_restart_required"] = false ready, reason, readyErr := h.gatewayStartReady() if readyErr != nil { diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go index 06803722d..fb4f7d943 100644 --- a/web/backend/api/gateway_test.go +++ b/web/backend/api/gateway_test.go @@ -494,60 +494,6 @@ func TestGatewayStatusReturnsRestartingDuringRestartGap(t *testing.T) { } } -func TestGatewayStatusIncludesRestartRequiredWhenModelsDiffer(t *testing.T) { - resetGatewayTestState(t) - - configPath := filepath.Join(t.TempDir(), "config.json") - cfg := config.DefaultConfig() - cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName - cfg.ModelList[0].APIKey = "test-key" - if err := config.SaveConfig(configPath, cfg); err != nil { - t.Fatalf("SaveConfig() error = %v", err) - } - - h := NewHandler(configPath) - mux := http.NewServeMux() - h.RegisterRoutes(mux) - - cmd := startLongRunningProcess(t) - t.Cleanup(func() { - if cmd.Process != nil { - _ = cmd.Process.Kill() - } - _ = cmd.Wait() - }) - - gateway.mu.Lock() - gateway.cmd = cmd - gateway.bootDefaultModel = "previous-model" - setGatewayRuntimeStatusLocked("running") - gateway.mu.Unlock() - - gatewayHealthGet = func(string, time.Duration) (*http.Response, error) { - rec := httptest.NewRecorder() - rec.WriteHeader(http.StatusOK) - _, _ = rec.WriteString(`{"ok":true}`) - return rec.Result(), nil - } - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) - mux.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) - } - - var body map[string]any - if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { - t.Fatalf("unmarshal response: %v", err) - } - - if got := body["gateway_restart_required"]; got != true { - t.Fatalf("gateway_restart_required = %#v, want true", got) - } -} - func TestGatewayRestartKeepsRunningProcessWhenPreconditionsFail(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") cfg := config.DefaultConfig() diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 5f081dee9..b56438784 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -70,3 +70,8 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Launcher service parameters (port/public) h.registerLauncherConfigRoutes(mux) } + +// Shutdown gracefully shuts down the handler, closing all SSE connections. +func (h *Handler) Shutdown() { + gateway.events.Shutdown() +} diff --git a/web/backend/i18n.go b/web/backend/i18n.go new file mode 100644 index 000000000..9cda9e5d5 --- /dev/null +++ b/web/backend/i18n.go @@ -0,0 +1,120 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// Language represents the supported languages +type Language string + +const ( + LanguageEnglish Language = "en" + LanguageChinese Language = "zh" +) + +// current language (default: English) +var currentLang Language = LanguageEnglish + +// TranslationKey represents a translation key used for i18n +type TranslationKey string + +const ( + AppTooltip TranslationKey = "AppTooltip" + MenuOpen TranslationKey = "MenuOpen" + MenuOpenTooltip TranslationKey = "MenuOpenTooltip" + MenuAbout TranslationKey = "MenuAbout" + MenuAboutTooltip TranslationKey = "MenuAboutTooltip" + MenuVersion TranslationKey = "MenuVersion" + MenuVersionTooltip TranslationKey = "MenuVersionTooltip" + MenuGitHub TranslationKey = "MenuGitHub" + MenuDocs TranslationKey = "MenuDocs" + MenuRestart TranslationKey = "MenuRestart" + MenuRestartTooltip TranslationKey = "MenuRestartTooltip" + MenuQuit TranslationKey = "MenuQuit" + MenuQuitTooltip TranslationKey = "MenuQuitTooltip" + Exiting TranslationKey = "Exiting" + DocUrl TranslationKey = "DocUrl" +) + +// Translation tables +// Chinese translations intentionally contain Han script +// +//nolint:gosmopolitan +var translations = map[Language]map[TranslationKey]string{ + LanguageEnglish: { + AppTooltip: "%s - Web Console", + MenuOpen: "Open Console", + MenuOpenTooltip: "Open PicoClaw console in browser", + MenuAbout: "About", + MenuAboutTooltip: "About PicoClaw", + MenuVersion: "Version: %s", + MenuVersionTooltip: "Current version number", + MenuGitHub: "GitHub", + MenuDocs: "Documentation", + MenuRestart: "Restart Service", + MenuRestartTooltip: "Restart Gateway service", + MenuQuit: "Quit", + MenuQuitTooltip: "Exit PicoClaw", + Exiting: "Exiting PicoClaw...", + DocUrl: "https://docs.picoclaw.io/docs/", + }, + LanguageChinese: { + AppTooltip: "%s - Web Console", + MenuOpen: "打开控制台", + MenuOpenTooltip: "在浏览器中打开 PicoClaw 控制台", + MenuAbout: "关于", + MenuAboutTooltip: "关于 PicoClaw", + MenuVersion: "版本: %s", + MenuVersionTooltip: "当前版本号", + MenuGitHub: "GitHub", + MenuDocs: "文档", + MenuRestart: "重启服务", + MenuRestartTooltip: "重启核心服务", + MenuQuit: "退出", + MenuQuitTooltip: "退出 PicoClaw", + Exiting: "正在退出 PicoClaw...", + DocUrl: "https://docs.picoclaw.io/zh-Hans/docs/", + }, +} + +// SetLanguage sets the current language +func SetLanguage(lang string) { + lang = strings.ToLower(strings.TrimSpace(lang)) + + // Extract language code before first underscore or dot + // e.g., "en_US.UTF-8" -> "en", "zh_CN" -> "zh" + if idx := strings.IndexAny(lang, "_."); idx > 0 { + lang = lang[:idx] + } + + if lang == "zh" || lang == "zh-cn" || lang == "chinese" { + currentLang = LanguageChinese + } else { + currentLang = LanguageEnglish + } +} + +// GetLanguage returns the current language +func GetLanguage() Language { + return currentLang +} + +// T translates a key to the current language +func T(key TranslationKey, args ...any) string { + if trans, ok := translations[currentLang][key]; ok { + if len(args) > 0 { + return fmt.Sprintf(trans, args...) + } + return trans + } + return string(key) +} + +// Initialize i18n from environment variable +func init() { + if lang := os.Getenv("LANG"); lang != "" { + SetLanguage(lang) + } +} diff --git a/web/backend/icon.png b/web/backend/icon.png new file mode 100644 index 000000000..e0b4aab9c Binary files /dev/null and b/web/backend/icon.png differ diff --git a/web/backend/main.go b/web/backend/main.go index 650540ea8..f2fe3de97 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -22,16 +22,34 @@ import ( "strconv" "time" + "fyne.io/systray" + + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/web/backend/api" "github.com/sipeed/picoclaw/web/backend/launcherconfig" "github.com/sipeed/picoclaw/web/backend/middleware" "github.com/sipeed/picoclaw/web/backend/utils" ) +const ( + appName = "PicoClaw" +) + +var ( + appVersion = config.Version + + server *http.Server + serverAddr string + apiHandler *api.Handler + + noBrowser *bool +) + func main() { port := flag.String("port", "18800", "Port to listen on") public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") - noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup") + noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup") + lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale") flag.Usage = func() { fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n") @@ -51,6 +69,11 @@ func main() { } flag.Parse() + // Set language from command line or auto-detect + if *lang != "" { + SetLanguage(*lang) + } + // Resolve config path configPath := utils.GetDefaultConfigPath() if flag.NArg() > 0 { @@ -113,7 +136,7 @@ func main() { mux := http.NewServeMux() // API Routes (e.g. /api/status) - apiHandler := api.NewHandler(absPath) + apiHandler = api.NewHandler(absPath) apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) apiHandler.RegisterRoutes(mux) @@ -145,16 +168,10 @@ func main() { } fmt.Println() - // Auto-open browser - if !*noBrowser { - go func() { - time.Sleep(500 * time.Millisecond) - url := "http://localhost:" + effectivePort - if err := utils.OpenBrowser(url); err != nil { - log.Printf("Warning: Failed to auto-open browser: %v", err) - } - }() - } + // Set server address for systray + serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort) + + // Auto-open browser will be handled by systray onReady // Auto-start gateway after backend starts listening. go func() { @@ -162,8 +179,15 @@ func main() { apiHandler.TryAutoStartGateway() }() - // Start the Server - if err := http.ListenAndServe(addr, handler); err != nil { - log.Fatalf("Server failed to start: %v", err) - } + // Start the Server in a goroutine + server = &http.Server{Addr: addr, Handler: handler} + go func() { + log.Printf("Server listening on %s", addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server failed to start: %v", err) + } + }() + + // Start system tray + systray.Run(onReady, onExit) } diff --git a/web/backend/systray.go b/web/backend/systray.go new file mode 100644 index 000000000..58ce4984f --- /dev/null +++ b/web/backend/systray.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "time" + + "fyne.io/systray" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +const ( + browserDelay = 500 * time.Millisecond + shutdownTimeout = 15 * time.Second +) + +// onReady is called when the system tray is ready +func onReady() { + // Set icon and tooltip + systray.SetIcon(getIcon()) + systray.SetTooltip(fmt.Sprintf(T(AppTooltip), appName)) + + // Create menu items + mOpen := systray.AddMenuItem(T(MenuOpen), T(MenuOpenTooltip)) + mAbout := systray.AddMenuItem(T(MenuAbout), T(MenuAboutTooltip)) + + // Add version info under About menu + mVersion := mAbout.AddSubMenuItem(fmt.Sprintf(T(MenuVersion), appVersion), T(MenuVersionTooltip)) + mVersion.Disable() + mRepo := mAbout.AddSubMenuItem(T(MenuGitHub), "") + mDocs := mAbout.AddSubMenuItem(T(MenuDocs), "") + + systray.AddSeparator() + + // Add restart option + mRestart := systray.AddMenuItem(T(MenuRestart), T(MenuRestartTooltip)) + + systray.AddSeparator() + + // Quit option + mQuit := systray.AddMenuItem(T(MenuQuit), T(MenuQuitTooltip)) + + // Handle menu clicks + go func() { + for { + select { + case <-mOpen.ClickedCh: + if err := openBrowser(); err != nil { + logger.Errorf("Failed to open browser: %v", err) + } + + case <-mVersion.ClickedCh: + // Version info - do nothing, just shows current version + + case <-mRepo.ClickedCh: + if err := utils.OpenBrowser("https://github.com/sipeed/picoclaw"); err != nil { + logger.Errorf("Failed to open GitHub: %v", err) + } + + case <-mDocs.ClickedCh: + if err := utils.OpenBrowser(T(DocUrl)); err != nil { + logger.Errorf("Failed to open docs: %v", err) + } + + case <-mRestart.ClickedCh: + fmt.Println("Restart request received...") + if apiHandler != nil { + if pid, err := apiHandler.RestartGateway(); err != nil { + logger.Errorf("Failed to restart gateway: %v", err) + } else { + logger.Infof("Gateway restarted (PID: %d)", pid) + } + } + + case <-mQuit.ClickedCh: + systray.Quit() + } + } + }() + + if !*noBrowser { + // Auto-open browser after systray is ready (if not disabled) + // Check no-browser flag via environment or pass as parameter if needed + if err := openBrowser(); err != nil { + logger.Errorf("Warning: Failed to auto-open browser: %v", err) + } + } +} + +// onExit is called when the system tray is exiting +func onExit() { + fmt.Println(T(Exiting)) + + // First, shutdown API handler to close all SSE connections + if apiHandler != nil { + apiHandler.Shutdown() + } + + if server != nil { + // Disable keep-alive to allow graceful shutdown + server.SetKeepAlivesEnabled(false) + + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := server.Shutdown(ctx); err != nil { + // Context deadline exceeded is expected if there are active connections + // This is not necessarily an error, so log it at info level + if err == context.DeadlineExceeded { + logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout) + } else { + logger.Errorf("Server shutdown error: %v", err) + } + } else { + logger.Infof("Server shutdown completed successfully") + } + } +} + +// openBrowser opens the PicoClaw web console in the default browser +func openBrowser() error { + if serverAddr == "" { + return fmt.Errorf("server address not set") + } + return utils.OpenBrowser(serverAddr) +} + +// getIcon returns the system tray icon +func getIcon() []byte { + return iconData +} diff --git a/web/backend/systray_unix.go b/web/backend/systray_unix.go new file mode 100644 index 000000000..0f9d2bb51 --- /dev/null +++ b/web/backend/systray_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package main + +import _ "embed" + +//go:embed icon.png +var iconData []byte diff --git a/web/backend/systray_windows.go b/web/backend/systray_windows.go new file mode 100644 index 000000000..cc1885155 --- /dev/null +++ b/web/backend/systray_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package main + +import _ "embed" + +//go:embed icon.ico +var iconData []byte