feat(web): implement macOS app feature and file logger (#1723)
This commit is contained in:
parent
f79469c19d
commit
e6ebeaed13
15 changed files with 438 additions and 96 deletions
12
Makefile
12
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"
|
||||
|
|
|
|||
|
|
@ -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("<none> %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 {
|
||||
|
|
|
|||
108
scripts/build-macos-app.sh
Executable file
108
scripts/build-macos-app.sh
Executable file
|
|
@ -0,0 +1,108 @@
|
|||
#!/bin/bash
|
||||
# Build macOS .app bundle for PicoClaw Launcher
|
||||
|
||||
set -e
|
||||
|
||||
EXECUTABLE=$1
|
||||
|
||||
if [ -z "$EXECUTABLE" ]; then
|
||||
echo "Usage: $0 <executable>"
|
||||
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'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>picoclaw-launcher</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.picoclaw.launcher</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>PicoClaw Launcher</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>PicoClaw Launcher</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>LSRequiresCarbon</key>
|
||||
<true/>
|
||||
<key>LSUIElement</key>
|
||||
<string>1</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
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 ""
|
||||
BIN
scripts/icon.icns
Normal file
BIN
scripts/icon.icns
Normal file
Binary file not shown.
65
scripts/setup.iss
Normal file
65
scripts/setup.iss
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue