feat(web): implement macOS app feature and file logger (#1723)

This commit is contained in:
Cytown
2026-03-18 14:43:58 +08:00
committed by GitHub
parent f79469c19d
commit e6ebeaed13
15 changed files with 438 additions and 96 deletions
+99 -39
View File
@@ -6,7 +6,6 @@ import (
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
@@ -20,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/utils"
)
@@ -27,6 +27,7 @@ import (
var gateway = struct {
mu sync.Mutex
cmd *exec.Cmd
owned bool // true if we started the process, false if we attached to an existing one
bootDefaultModel string
runtimeStatus string
startupDeadline time.Time
@@ -101,16 +102,16 @@ func (h *Handler) TryAutoStartGateway() {
defer gateway.mu.Unlock()
ready, reason, err := h.gatewayStartReady()
if err != nil {
log.Printf("Skip auto-starting gateway: %v", err)
logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err))
return
}
if !ready {
log.Printf("Skip auto-starting gateway: %s", reason)
logger.InfoC("gateway", fmt.Sprintf("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)
logger.ErrorC("gateway", fmt.Sprintf("Failed to attach to running gateway (PID: %d): %v", pid, err))
}
return
}
@@ -125,20 +126,20 @@ func (h *Handler) TryAutoStartGateway() {
ready, reason, err := h.gatewayStartReady()
if err != nil {
log.Printf("Skip auto-starting gateway: %v", err)
logger.ErrorC("gateway", fmt.Sprintf("Skip auto-starting gateway: %v", err))
return
}
if !ready {
log.Printf("Skip auto-starting gateway: %s", reason)
logger.InfoC("gateway", fmt.Sprintf("Skip auto-starting gateway: %s", reason))
return
}
pid, err := h.startGatewayLocked("starting", 0)
if err != nil {
log.Printf("Failed to auto-start gateway: %v", err)
logger.ErrorC("gateway", fmt.Sprintf("Failed to auto-start gateway: %v", err))
return
}
log.Printf("Gateway auto-started (PID: %d)", pid)
logger.InfoC("gateway", fmt.Sprintf("Gateway auto-started (PID: %d)", pid))
}
// gatewayStartReady validates whether current config can start the gateway.
@@ -224,6 +225,7 @@ func attachToGatewayProcessLocked(pid int, cfg *config.Config) error {
}
gateway.cmd = &exec.Cmd{Process: process}
gateway.owned = false // We didn't start this process
setGatewayRuntimeStatusLocked("running")
// Update bootDefaultModel from config
@@ -232,7 +234,7 @@ func attachToGatewayProcessLocked(pid int, cfg *config.Config) error {
gateway.bootDefaultModel = defaultModelName
}
log.Printf("Attached to gateway process (PID: %d)", pid)
logger.InfoC("gateway", fmt.Sprintf("Attached to gateway process (PID: %d)", pid))
return nil
}
@@ -269,6 +271,59 @@ func waitForGatewayProcessExit(cmd *exec.Cmd, timeout time.Duration) bool {
}
}
// StopGateway stops the gateway process if it was started by this handler.
// This method is called during application shutdown to ensure the gateway subprocess
// is properly terminated. It only stops processes that were started by this handler,
// not processes that were attached to from existing instances.
func (h *Handler) StopGateway() {
gateway.mu.Lock()
defer gateway.mu.Unlock()
// Only stop if we own the process (started it ourselves)
if !gateway.owned || gateway.cmd == nil || gateway.cmd.Process == nil {
return
}
pid, err := stopGatewayLocked()
if err != nil {
logger.ErrorC("gateway", fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, err))
return
}
logger.InfoC("gateway", fmt.Sprintf("Gateway stopped (PID: %d)", pid))
}
// stopGatewayLocked sends a stop signal to the gateway process.
// Assumes gateway.mu is held by the caller.
// Returns the PID of the stopped process and any error encountered.
func stopGatewayLocked() (int, error) {
if gateway.cmd == nil || gateway.cmd.Process == nil {
return 0, nil
}
pid := gateway.cmd.Process.Pid
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
if runtime.GOOS == "windows" {
sigErr = gateway.cmd.Process.Kill()
} else {
sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM)
}
if sigErr != nil {
return pid, sigErr
}
logger.InfoC("gateway", fmt.Sprintf("Sent stop signal to gateway (PID: %d)", pid))
gateway.cmd = nil
gateway.owned = false
gateway.bootDefaultModel = ""
setGatewayRuntimeStatusLocked("stopped")
return pid, nil
}
func stopGatewayProcessForRestart(cmd *exec.Cmd) error {
if cmd == nil || cmd.Process == nil || !isCmdProcessAliveLocked(cmd) {
return nil
@@ -353,7 +408,7 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
// Ensure Pico Channel is configured before starting gateway
if _, err := h.ensurePicoChannel(""); err != nil {
log.Printf("Warning: failed to ensure pico channel: %v", err)
logger.ErrorC("gateway", fmt.Sprintf("Warning: failed to ensure pico channel: %v", err))
// Non-fatal: gateway can still start without pico channel
}
@@ -362,10 +417,11 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
}
gateway.cmd = cmd
gateway.owned = true // We started this process
gateway.bootDefaultModel = defaultModelName
setGatewayRuntimeStatusLocked(initialStatus)
pid = cmd.Process.Pid
log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath)
logger.InfoC("gateway", fmt.Sprintf("Started picoclaw gateway (PID: %d) from %s", pid, execPath))
// Capture stdout/stderr in background
go scanPipe(stdoutPipe, gateway.logs)
@@ -374,9 +430,9 @@ func (h *Handler) startGatewayLocked(initialStatus string, existingPid int) (int
// Wait for exit in background and clean up
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("Gateway process exited: %v", err)
logger.ErrorC("gateway", fmt.Sprintf("Gateway process exited: %v", err))
} else {
log.Printf("Gateway process exited normally")
logger.InfoC("gateway", "Gateway process exited normally")
}
gateway.mu.Lock()
@@ -455,7 +511,7 @@ func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) {
_, err = h.startGatewayLocked("starting", pid)
gateway.mu.Unlock()
if err != nil {
log.Printf("Failed to attach to running gateway (PID: %d): %v", pid, err)
logger.ErrorC("gateway", fmt.Sprintf("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
}
@@ -524,23 +580,12 @@ func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) {
return
}
pid := gateway.cmd.Process.Pid
// Send SIGTERM for graceful shutdown (SIGKILL on Windows)
var sigErr error
if runtime.GOOS == "windows" {
sigErr = gateway.cmd.Process.Kill()
} else {
sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM)
}
if sigErr != nil {
http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, sigErr), http.StatusInternalServerError)
pid, err := stopGatewayLocked()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, err), http.StatusInternalServerError)
return
}
log.Printf("Sent stop signal to gateway (PID: %d)", pid)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
@@ -681,9 +726,9 @@ func (h *Handler) gatewayStatusData() map[string]any {
gateway.mu.Lock()
data["gateway_status"] = gatewayStatusWithoutHealthLocked()
gateway.mu.Unlock()
log.Printf("Gateway health check failed: %v", err)
logger.ErrorC("gateway", fmt.Sprintf("Gateway health check failed: %v", err))
} else {
log.Printf("Gateway health status: %d", statusCode)
logger.InfoC("gateway", fmt.Sprintf("Gateway health status: %d", statusCode))
if statusCode != http.StatusOK {
gateway.mu.Lock()
setGatewayRuntimeStatusLocked("error")
@@ -698,17 +743,32 @@ func (h *Handler) gatewayStatusData() map[string]any {
if gateway.cmd != nil && gateway.cmd.Process != nil {
oldPid = fmt.Sprintf("%d", gateway.cmd.Process.Pid)
}
log.Printf(
"Detected gateway PID from health (old: %s, new: %d), attempting to attach",
oldPid,
healthResp.Pid,
)
if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil {
log.Printf(
"Failed to attach to gateway process reported by health (PID: %d): %v",
logger.InfoC(
"gateway",
fmt.Sprintf(
"Detected new gateway PID (old: %s, new: %d), attempting to attach",
oldPid,
healthResp.Pid,
err,
),
)
if err := attachToGatewayProcessLocked(healthResp.Pid, cfg); err != nil {
// Failed to find the process, treat as error
setGatewayRuntimeStatusLocked("error")
data["gateway_status"] = "error"
data["pid"] = healthResp.Pid
logger.ErrorC(
"gateway",
fmt.Sprintf("Failed to attach to new gateway process (PID: %d): %v", healthResp.Pid, err),
)
} else {
// Successfully attached, update response data
bootDefaultModel := gateway.bootDefaultModel
if bootDefaultModel != "" {
data["boot_default_model"] = bootDefaultModel
}
data["gateway_status"] = "running"
data["pid"] = healthResp.Pid
}
}
+3 -3
View File
@@ -7,13 +7,13 @@ import (
"fmt"
"html"
"io"
"log"
"net/http"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
@@ -714,7 +714,7 @@ func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *
if cp.Email == "" {
email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken)
if err != nil {
log.Printf("oauth warning: could not fetch google email: %v", err)
logger.ErrorC("oauth", fmt.Sprintf("oauth warning: could not fetch google email: %v", err))
} else {
cp.Email = email
}
@@ -722,7 +722,7 @@ func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *
if cp.ProjectID == "" {
projectID, err := oauthFetchAntigravityProject(cp.AccessToken)
if err != nil {
log.Printf("oauth warning: could not fetch antigravity project id: %v", err)
logger.ErrorC("oauth", fmt.Sprintf("oauth warning: could not fetch antigravity project id: %v", err))
} else {
cp.ProjectID = projectID
}
+4 -1
View File
@@ -71,4 +71,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
h.registerLauncherConfigRoutes(mux)
}
func (h *Handler) Shutdown() {}
// Shutdown gracefully shuts down the handler, stopping the gateway if it was started by this handler.
func (h *Handler) Shutdown() {
h.StopGateway()
}
+19 -3
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"time"
@@ -14,20 +15,35 @@ const (
shutdownTimeout = 15 * time.Second
)
// shutdownApp gracefully shuts down all server components and resources.
// It performs the following shutdown sequence:
// - Shuts down the API handler to close all active SSE (Server-Sent Events) connections
// - Disables HTTP keep-alive to prevent new connections during shutdown
// - Attempts graceful HTTP server shutdown with timeout
// - Logs shutdown status at appropriate log levels
//
// The function handles timeout errors gracefully by logging them at info level
// since context.DeadlineExceeded is expected when there are active long-running
// connections (such as SSE streams).
//
// This function should be called during application termination to ensure
// clean resource cleanup and proper connection closure.
func shutdownApp() {
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 {
if err == context.DeadlineExceeded {
// Context deadline exceeded is expected if there are active connections
// This is not necessarily an error, so log it at info level
if errors.Is(err, context.DeadlineExceeded) {
logger.Infof("Server shutdown timeout after %v, forcing close", shutdownTimeout)
} else {
logger.Errorf("Server shutdown error: %v", err)
+7 -5
View File
@@ -2,12 +2,14 @@ package main
import (
"embed"
"fmt"
"io/fs"
"log"
"mime"
"net/http"
"path"
"strings"
"github.com/sipeed/picoclaw/pkg/logger"
)
//go:embed all:dist
@@ -19,16 +21,16 @@ func registerEmbedRoutes(mux *http.ServeMux) {
// Go's built-in mime.TypeByExtension returns "image/svg" which is incorrect
// The correct MIME type per RFC 6838 is "image/svg+xml"
if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil {
log.Printf("Warning: failed to register SVG MIME type: %v", err)
logger.ErrorC("web", fmt.Sprintf("Warning: failed to register SVG MIME type: %v", err))
}
// Attempt to get the subdirectory 'dist' where Vite usually builds
subFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
// Log a warning if dist doesn't exist yet (e.g., during development before a frontend build)
log.Printf(
"Warning: no 'dist' folder found in embedded frontend. " +
"Ensure you run `pnpm build:backend` in the frontend directory " +
logger.WarnC("web",
"Warning: no 'dist' folder found in embedded frontend. "+
"Ensure you run `pnpm build:backend` in the frontend directory "+
"before building the Go backend.",
)
return
+77 -17
View File
@@ -15,14 +15,16 @@ import (
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/web/backend/api"
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
"github.com/sipeed/picoclaw/web/backend/middleware"
@@ -48,6 +50,7 @@ func main() {
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")
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
console := flag.Bool("console", false, "Console mode, no GUI")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n")
@@ -67,6 +70,26 @@ func main() {
}
flag.Parse()
// Initialize logger
picoHome := utils.GetPicoclawHome()
// By default, detect terminal to decide console log behavior
// If -console-logs flag is explicitly set, it overrides the detection
enableConsole := *console
if !enableConsole {
// Disable console logging by setting level to Fatal (no output)
logger.SetConsoleLevel(logger.FATAL)
logPath := filepath.Join(picoHome, "logs", "web.log")
if err := logger.EnableFileLogging(logPath); err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize logger: %v\n", err)
os.Exit(1)
}
defer logger.DisableFileLogging()
}
logger.InfoC("web", "PicoClaw Launcher starting...")
logger.InfoC("web", fmt.Sprintf("PicoClaw Home: %s", picoHome))
// Set language from command line or auto-detect
if *lang != "" {
SetLanguage(*lang)
@@ -80,11 +103,11 @@ func main() {
absPath, err := filepath.Abs(configPath)
if err != nil {
log.Fatalf("Failed to resolve config path: %v", err)
logger.Fatalf("Failed to resolve config path: %v", err)
}
err = utils.EnsureOnboarded(absPath)
if err != nil {
log.Printf("Warning: Failed to initialize PicoClaw config automatically: %v", err)
logger.Errorf("Warning: Failed to initialize PicoClaw config automatically: %v", err)
}
var explicitPort bool
@@ -101,7 +124,7 @@ func main() {
launcherPath := launcherconfig.PathForAppConfig(absPath)
launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default())
if err != nil {
log.Printf("Warning: Failed to load %s: %v", launcherPath, err)
logger.ErrorC("web", fmt.Sprintf("Warning: Failed to load %s: %v", launcherPath, err))
launcherCfg = launcherconfig.Default()
}
@@ -119,7 +142,7 @@ func main() {
if err == nil {
err = errors.New("must be in range 1-65535")
}
log.Fatalf("Invalid port %q: %v", effectivePort, err)
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
}
// Determine listen address
@@ -143,7 +166,7 @@ func main() {
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
if err != nil {
log.Fatalf("Invalid allowed CIDR configuration: %v", err)
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
}
// Apply middleware stack
@@ -153,18 +176,28 @@ func main() {
),
)
// Print startup banner
fmt.Print(utils.Banner)
fmt.Println()
fmt.Println(" Open the following URL in your browser:")
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
// Print startup banner (only in console mode)
if enableConsole {
fmt.Print(utils.Banner)
fmt.Println()
fmt.Println(" Open the following URL in your browser:")
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", effectivePort)
if effectivePublic {
if ip := utils.GetLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
}
}
fmt.Println()
}
// Log startup info to file
logger.InfoC("web", fmt.Sprintf("Server will listen on http://localhost:%s", effectivePort))
if effectivePublic {
if ip := utils.GetLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort)
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s:%s", ip, effectivePort))
}
}
fmt.Println()
// Share the local URL with the launcher runtime.
serverAddr = fmt.Sprintf("http://localhost:%s", effectivePort)
@@ -180,11 +213,38 @@ func main() {
// Start the Server in a goroutine
server = &http.Server{Addr: addr, Handler: handler}
go func() {
log.Printf("Server listening on %s", addr)
logger.InfoC("web", fmt.Sprintf("Server listening on %s", addr))
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
logger.Fatalf("Server failed to start: %v", err)
}
}()
runTray()
defer shutdownApp()
// Start system tray or run in console mode
if enableConsole {
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)
}
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Main event loop - wait for signals or config changes
for {
select {
case <-sigChan:
logger.Info("Shutting down...")
return
}
}
} else {
// GUI mode: start system tray
runTray()
}
}
+5 -3
View File
@@ -1,10 +1,12 @@
package middleware
import (
"log"
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
)
// JSONContentType sets the Content-Type header to application/json for
@@ -48,7 +50,7 @@ func Logger(next http.Handler) http.Handler {
start := time.Now()
rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rec, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start))
logger.DebugC("http", fmt.Sprintf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start)))
})
}
@@ -58,7 +60,7 @@ func Recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("panic recovered: %v\n%s", err, debug.Stack())
logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack()))
http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError)
}
}()
+6 -1
View File
@@ -13,7 +13,7 @@ import (
)
func runTray() {
systray.Run(onReady, shutdownApp)
systray.Run(onReady, onExit)
}
// onReady is called when the system tray is ready
@@ -89,6 +89,11 @@ func onReady() {
}
}
// onExit is called when the system tray is exiting
func onExit() {
logger.Info(T(Exiting))
}
// getIcon returns the system tray icon
func getIcon() []byte {
return iconData
+11 -9
View File
@@ -9,19 +9,21 @@ import (
"runtime"
)
// GetDefaultConfigPath returns the default path to the picoclaw config file.
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
return home
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw")
}
func GetDefaultConfigPath() string {
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
return configPath
}
if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" {
return filepath.Join(picoclawHome, "config.json")
}
home, err := os.UserHomeDir()
if err != nil {
return "config.json"
}
return filepath.Join(home, ".picoclaw", "config.json")
return filepath.Join(GetPicoclawHome(), "config.json")
}
// FindPicoclawBinary locates the picoclaw executable.