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 0000000000..bcf9adcd73 Binary files /dev/null and b/scripts/icon.icns differ diff --git a/scripts/setup.iss b/scripts/setup.iss new file mode 100644 index 0000000000..c081d4dff2 --- /dev/null +++ b/scripts/setup.iss @@ -0,0 +1,65 @@ +; Script generated by the Inno Setup Script Wizard. +; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES! + +#define MyAppName "PicoClaw Launcher" +#define MyAppVersion "1.0" +#define MyAppPublisher "PicoClaw" +#define MyAppURL "https://github.com/sipeed/picoclaw" +#define MyAppExeName "picoclaw-launcher.exe" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. Do not use the same AppId value in installers for other applications. +; (To generate a new GUID, click Tools | Generate GUID inside the IDE.) +AppId={{C8A1B4E7-D5F9-4C2A-8A6E-5F4D3C2A1B0E} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +;AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppURL} +AppUpdatesURL={#MyAppURL} +DefaultDirName={autopf}\PicoClaw +DefaultGroupName={#MyAppName} +; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run +; on anything but x64 and Windows 11 on Arm. +ArchitecturesAllowed=x64compatible +; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the +; install be done in "64-bit mode" on x64 or Windows 11 on Arm, +; meaning it should use the native 64-bit Program Files directory and +; the 64-bit view of the registry. +ArchitecturesInstallIn64BitMode=x64compatible +DisableProgramGroupPage=yes +; Remove the following line to run in administrative install mode (install for all users.) +PrivilegesRequired=lowest +OutputDir=build +OutputBaseFilename=PicoClawSetup +Compression=lzma +SolidCompression=yes +WizardStyle=modern +; SourceDir=windows +SetupIconFile=icon.ico + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Dirs] + +[Files] +Source: "..\web\build\picoclaw-launcher.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion +Source: "..\build\picoclaw.exe"; DestDir: "{app}"; Flags: ignoreversion +Source: "..\web\backend\icon.ico"; DestDir: "{app}"; Flags: ignoreversion +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[UninstallDelete] + +[Icons] +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; IconFilename: "{app}\icon.ico" +Name: "{group}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}" +Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Tasks: desktopicon; IconFilename: "{app}\icon.ico" + +[Run] +Filename:"{app}\{#MyAppExeName}"; WorkingDir: "{app}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent + diff --git a/web/Makefile b/web/Makefile index 5943924f28..c631a974d6 100644 --- a/web/Makefile +++ b/web/Makefile @@ -5,6 +5,9 @@ GO?=CGO_ENABLED=0 go WEB_GO?=$(GO) GOFLAGS?=-v -tags stdjson +# Build variables +BUILD_DIR=build + # 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") @@ -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.