mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat(web): implement macOS app feature and file logger (#1723)
This commit is contained in:
+99
-39
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user