From 0a5ec810e3c0f1dc42800d9e51b65f13eb6a754a Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 18 Mar 2026 10:58:41 +0800 Subject: [PATCH] feat(web): implement macOS app feature and file logger --- Makefile | 12 +++ pkg/logger/logger.go | 28 +++--- scripts/build-macos-app.sh | 108 +++++++++++++++++++++ scripts/icon.icns | Bin 0 -> 16192 bytes scripts/setup.iss | 65 +++++++++++++ web/Makefile | 9 +- web/backend/api/gateway.go | 136 +++++++++++++++++++-------- web/backend/api/oauth.go | 6 +- web/backend/api/router.go | 5 +- web/backend/app_runtime.go | 22 ++++- web/backend/embed.go | 12 ++- web/backend/main.go | 94 ++++++++++++++---- web/backend/middleware/middleware.go | 8 +- web/backend/systray.go | 7 +- web/backend/utils/runtime.go | 20 ++-- 15 files changed, 437 insertions(+), 95 deletions(-) create mode 100755 scripts/build-macos-app.sh create mode 100644 scripts/icon.icns create mode 100644 scripts/setup.iss diff --git a/Makefile b/Makefile index 1c6b73591b..411cd9dc54 100644 --- a/Makefile +++ b/Makefile @@ -297,6 +297,18 @@ docker-clean: docker compose -f docker/docker-compose.full.yml down -v docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true + +## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window) +build-macos-app: + @echo "Building macOS .app bundle..." + @if [ "$(UNAME_S)" != "Darwin" ]; then \ + echo "Error: This target is only available on macOS"; \ + exit 1; \ + fi + @cd web && $(MAKE) build && cd .. + @./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH) + @echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app" + ## help: Show this help message help: @echo "picoclaw Makefile" diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 95af83ef1d..c5a1f895a8 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -51,7 +51,7 @@ func init() { FormatFieldValue: formatFieldValue, } - logger = zerolog.New(consoleWriter).With().Timestamp().Logger() + logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger() fileLogger = zerolog.Logger{} }) } @@ -94,6 +94,12 @@ func SetLevel(level LogLevel) { zerolog.SetGlobalLevel(level) } +func SetConsoleLevel(level LogLevel) { + mu.Lock() + defer mu.Unlock() + logger = logger.Level(level) +} + func GetLevel() LogLevel { mu.RLock() defer mu.RUnlock() @@ -134,9 +140,9 @@ func DisableFileLogging() { fileLogger = zerolog.Logger{} } -func getCallerInfo() (string, int, string) { +func getCallerSkip() int { for i := 2; i < 15; i++ { - pc, file, line, ok := runtime.Caller(i) + pc, file, _, ok := runtime.Caller(i) if !ok { continue } @@ -158,10 +164,10 @@ func getCallerInfo() (string, int, string) { continue } - return filepath.Base(file), line, filepath.Base(funcName) + return i - 1 } - return "???", 0, "???" + return 3 } //nolint:zerologlint @@ -187,19 +193,16 @@ func logMessage(level LogLevel, component string, message string, fields map[str return } - callerFile, callerLine, callerFunc := getCallerInfo() + skip := getCallerSkip() event := getEvent(logger, level) - // Build combined field with component and caller if component != "" { - event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc)) - } else { - event.Str("caller", fmt.Sprintf(" %s:%d (%s)", callerFile, callerLine, callerFunc)) + event.Str("component", component) } appendFields(event, fields) - event.Msg(message) + event.CallerSkipFrame(skip).Msg(message) // Also log to file if enabled if fileLogger.GetLevel() != zerolog.NoLevel { @@ -208,9 +211,10 @@ func logMessage(level LogLevel, component string, message string, fields map[str if component != "" { fileEvent.Str("component", component) } + // fileEvent.Str("caller", fmt.Sprintf("%s:%d (%s)", callerFile, callerLine, callerFunc)) appendFields(fileEvent, fields) - fileEvent.Msg(message) + fileEvent.CallerSkipFrame(skip).Msg(message) } if level == FATAL { diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh new file mode 100755 index 0000000000..093360ab76 --- /dev/null +++ b/scripts/build-macos-app.sh @@ -0,0 +1,108 @@ +#!/bin/bash +# Build macOS .app bundle for PicoClaw Launcher + +set -e + +EXECUTABLE=$1 + +if [ -z "$EXECUTABLE" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "executable: $EXECUTABLE" + +APP_NAME="PicoClaw Launcher" +APP_PATH="./build/${APP_NAME}.app" +APP_CONTENTS="${APP_PATH}/Contents" +APP_MACOS="${APP_CONTENTS}/MacOS" +APP_RESOURCES="${APP_CONTENTS}/Resources" +APP_EXECUTABLE="picoclaw-launcher" +ICON_SOURCE="./scripts/icon.icns" + +# Clean up existing .app +if [ -d "$APP_PATH" ]; then + echo "Removing existing ${APP_PATH}" + rm -rf "$APP_PATH" +fi + +# Create directory structure +echo "Creating .app bundle structure..." +mkdir -p "$APP_MACOS" +mkdir -p "$APP_RESOURCES" + +# Copy executable +echo "Copying executable..." +if [ -f "./web/build/${APP_EXECUTABLE}" ]; then + cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/" +else + echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first." + echo "Run: make build in web dir" + exit 1 +fi +if [ -f "./build/picoclaw" ]; then + cp "./build/picoclaw" "${APP_MACOS}/" +else + echo "Error: ./build/picoclaw not found. Please build the main file first." + echo "Run: make build" + exit 1 +fi +chmod +x "${APP_MACOS}/"* + +# Create Info.plist +echo "Creating Info.plist..." +cat > "${APP_CONTENTS}/Info.plist" << 'EOF' + + + + + CFBundleExecutable + picoclaw-launcher + CFBundleIdentifier + com.picoclaw.launcher + CFBundleName + PicoClaw Launcher + CFBundleDisplayName + PicoClaw Launcher + CFBundleIconFile + icon.icns + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + LSRequiresCarbon + + LSUIElement + 1 + NSHighResolutionCapable + + + +EOF + +#sips -z 128 128 "$ICON_SOURCE" --out "${ICONSET_PATH}/icon_128x128.png" > /dev/null 2>&1 +# +## Create icns file +#iconutil -c icns "$ICONSET_PATH" -o "$ICON_OUTPUT" 2>/dev/null || { +# echo "Warning: iconutil failed" +#} + +cp $ICON_SOURCE "${APP_RESOURCES}/icon.icns" + +echo "" +echo "==========================================" +echo "Successfully created: ${APP_PATH}" +echo "==========================================" +echo "" +echo "To launch PicoClaw:" +echo " 1. Double-click ${APP_NAME}.app in Finder" +echo " 2. Or use: open ${APP_PATH}" +echo "" +echo "Note: The app will run in the menu bar (systray) without a terminal window." +echo "" diff --git a/scripts/icon.icns b/scripts/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..bcf9adcd737e21ddd54b4b9c1a23aaaa95afb26f GIT binary patch literal 16192 zcmbumWmH^E&@MUygWKQ)cXtxp-QC>@?rwu?&;<7&!6hNMySuw5xZ5B%@BPj?_x?Vo z*Q$Q1yKC3l)jxVw?_H0rg_An~AR=mO!OjB!2tCKBD$AfD6Cwiu05mySN%emS`A-nx z|Mh*sW@G;V=&mj!4yc(V{`+qrX{9S?t)v8C{HG%VfKj#p(0?ZXgz%pL0I&r>0PH^m z{x??u^S@Ui1+f30{-2?20?7N{8yLz-ifQ@)&x{ZPv=(09H?B6vHq8=6MZsSg{G*Gs zMW?%BwUhL8Dm|y{?)WOI8yoE$PXOO`dX+YqPAQ^c!PFsjSi_`j&Kq~{Z^jG!eAB zQT84bP04qjJe)<}Vy?+4rdPmeCscGER6(EJ>}aF`hxSs zr>DKAD3oELe>j#>?nL_`qoFu2#X$*xtEgsJeW?mug?Hz9AUf$#o4?PEO?kl(P;=25625&U=8{rw(Uub%ZPt$CG%J<+oLkarkN#Zu%(QD_jjzpYcnvWS}60^|B zm})Y(svs93WcXXbTRV^qi|39dah4Z2(06@57v=LY4CeUx(uY%lpQu7!jm7kiA@f_O zx*l6x$;T@Xeu~~(^a8|**{z)hY2VaDSeuWc=i@h~l1UG!951^xyP|5(WL^I7QfK;+e*j1i^-c?hb3Ixv6qN@`lKoCuQ7K>hW=zI;$07Q zu0t@^HmWybk?6|3c*QxeQ39mU&ss`s#0$#dkv}!oRCpS zIp_Ll^5HoLX<=UIMbAF>y$T6n-riLXCp%lRfCgk%XodCiLsS7y;AdeUU6hFXAgxWW->H0Bu^#IK8)=T)J45_J6hWz?G}XmhLVJ2 zL0yfL!IL0Bd$LRlZYJ?p9Hj0xd?CZnS);Jl)3qa5Xk_@2mNjf`?Fu{GZxB{#8*ldK zwLne#zf$B08^!b+nsYzOgzZkclU0o+yZCDmlGco(5MuT(l#D7xp~A!&4N!+_i;Xyg z6F54LF04RUvK+^VmMIx^EFd1c)+j`h+{3L6lE8yY&7E%ij(2ch!* zK_dj$cKpxHD#;`ljwhtd7aen%6+}HuJEzrxUW%Q& zLrkjGE4^Qn9kv1OdwGgk&Uul4(%_(HB4!|7TMB({N#bezEge^LL~_`MhYUzbQ>x?cJ!agO|*GgasXc4i8)X#PQ$ESo3w9jIUMX=vdaU|AkD8@ z-aw}-ld(8zTz4ySw(kdDva0yd;VD&6s+}4JT>~!Nn}7)@^j4{Ym+%=dSP3P4L0oUd z7?k|@EeM3WKoe306Bb7z8c3RWMW&z2n*$SR-r7t$VWo9^@ejJbz2&5@(?Mf15A)Y) zYA~PqsdLg5K*wzMMzu|ST9s|=&`ke2*qY3o-N z&UO@OEJZ`42pFdg{5_Pc~E9g^rSxVx8Nja%r@g zz+j+xqVq>T8K#YBEKvf}ROuJS35uDHQm4s9MW)O>59I6n(9#iXVJ_lWKj+myEjKLr z&!SI*HcTg3S=y9SZ=$&KPiKyiMl;$P6PcwPHbur39<6p0CX3rs*DSLa4W8mn60W%) z_E3+jw3ptL)6m^mE@rp!5dw}Q9vXXN-9e0ovJHQD=J&WHkvfxui-!l`5ti@zr_{ofVwQ1GooVmk}_wDPi zsHO3>4Y%D90|r6kjl`2|yL%Q~PR*wdSzeo;kC(Yg`pu>{d)568!nAJZnevl|j@Z(u zPjY@b=RCfaKKy3YA?Bd^0(r15&Jfz%9;o(Pp+6|s+VTqZ%1c>z2pCJbxR|HSG=7`& z{J6dEz^`g3kFseXeG$-^*C1e}?q*KxX}c9Ejs8cBjCD(L!PsQ0*df?>tq}hr4d)e^ z9^%0AYmh#}e1G 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⁢khI2_ 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/dev/null || echo "dev") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") @@ -57,7 +60,7 @@ endif # Run both frontend and backend dev servers dev: - @if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \ + @if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \ echo "Build artifacts not found, building..."; \ $(MAKE) build; \ fi @@ -75,7 +78,7 @@ dev-backend: # Build frontend and embed into Go binary build: cd frontend && pnpm build:backend - cd backend && ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o picoclaw-web . + ${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/ # Run all tests test: @@ -89,5 +92,5 @@ lint: # Clean build artifacts clean: - rm -rf frontend/dist backend/dist backend/picoclaw-web + rm -rf frontend/dist backend/dist $(BUILD_DIR)/* mkdir -p backend/dist && touch backend/dist/.gitkeep diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go index 16b793427a..098e2babe7 100644 --- a/web/backend/api/gateway.go +++ b/web/backend/api/gateway.go @@ -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, + logger.InfoC( + "gateway", + fmt.Sprintf( + "Detected new gateway PID (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", - healthResp.Pid, - err, + // 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 } } diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go index 919b47fbca..4edabb9ab5 100644 --- a/web/backend/api/oauth.go +++ b/web/backend/api/oauth.go @@ -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 } diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 028a476f26..e4df86ed9c 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -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() +} diff --git a/web/backend/app_runtime.go b/web/backend/app_runtime.go index cf54e18a19..e3a9ec64f5 100644 --- a/web/backend/app_runtime.go +++ b/web/backend/app_runtime.go @@ -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) diff --git a/web/backend/embed.go b/web/backend/embed.go index 2b28f84b95..cf0c76bce7 100644 --- a/web/backend/embed.go +++ b/web/backend/embed.go @@ -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 diff --git a/web/backend/main.go b/web/backend/main.go index ec4e2832d7..922dc2f6dc 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -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() + } } diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go index e15da577bf..5e0dfeb904 100644 --- a/web/backend/middleware/middleware.go +++ b/web/backend/middleware/middleware.go @@ -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) } }() diff --git a/web/backend/systray.go b/web/backend/systray.go index 2ae4434bb9..fde2e115e4 100644 --- a/web/backend/systray.go +++ b/web/backend/systray.go @@ -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 diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go index 4e6c32c56e..425f25c08c 100644 --- a/web/backend/utils/runtime.go +++ b/web/backend/utils/runtime.go @@ -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.