Files
picoclaw/cmd/picoclaw-launcher/internal/server/server.go
T
Guoguo 5e028a847c feat: add picoclaw-launcher with web UI for configuration and gateway management (#904)
A standalone web-based tool for managing picoclaw configuration, OAuth
authentication providers, and gateway process lifecycle. Features include
a sidebar layout with i18n (en/zh) and theme support, real-time gateway
log streaming, startup prerequisites checks, and Windows icon embedding.

Co-authored-by: wj-xiao <meetwenjie@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:38:38 +08:00

197 lines
5.7 KiB
Go

package server
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
const DefaultPort = "18800"
// providerStatus represents the auth status of a single provider in API responses.
type providerStatus struct {
Provider string `json:"provider"`
AuthMethod string `json:"auth_method"`
Status string `json:"status"`
AccountID string `json:"account_id,omitempty"`
Email string `json:"email,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// ── Route registration ───────────────────────────────────────────
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
// GET /api/config — read config
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(absPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"config": cfg,
"path": absPath,
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(resp); err != nil {
log.Printf("Failed to encode response: %v", err)
}
})
// PUT /api/config — save config
mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var cfg config.Config
if err := json.Unmarshal(body, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if err := config.SaveConfig(absPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
}
func RegisterAuthAPI(mux *http.ServeMux, absPath string) {
// GET /api/auth/status — all authenticated providers + pending login state
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
store, err := auth.LoadStore()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
return
}
result := []providerStatus{}
for name, cred := range store.Credentials {
status := "active"
if cred.IsExpired() {
status = "expired"
} else if cred.NeedsRefresh() {
status = "needs_refresh"
}
ps := providerStatus{
Provider: name,
AuthMethod: cred.AuthMethod,
Status: status,
AccountID: cred.AccountID,
Email: cred.Email,
ProjectID: cred.ProjectID,
}
if !cred.ExpiresAt.IsZero() {
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
}
result = append(result, ps)
}
// Include pending device code state
var pendingDevice map[string]any
activeDeviceSessionMu.Lock()
if activeDeviceSession != nil {
activeDeviceSession.mu.Lock()
pendingDevice = map[string]any{
"provider": activeDeviceSession.Provider,
"status": activeDeviceSession.Status,
"device_url": activeDeviceSession.Info.VerifyURL,
"user_code": activeDeviceSession.Info.UserCode,
}
if activeDeviceSession.Error != "" {
pendingDevice["error"] = activeDeviceSession.Error
}
if activeDeviceSession.Done {
activeDeviceSession.mu.Unlock()
activeDeviceSession = nil
} else {
activeDeviceSession.mu.Unlock()
}
}
activeDeviceSessionMu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"providers": result,
"pending_device": pendingDevice,
})
})
// POST /api/auth/login — initiate provider login
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
Token string `json:"token,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
switch req.Provider {
case "openai":
handleOpenAILogin(w, absPath)
case "anthropic":
handleAnthropicLogin(w, req.Token, absPath)
case "google-antigravity", "antigravity":
handleGoogleAntigravityLogin(w, r, absPath)
default:
http.Error(
w,
fmt.Sprintf(
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
req.Provider,
),
http.StatusBadRequest,
)
}
})
// POST /api/auth/logout — logout a provider
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Provider == "" {
if err := auth.DeleteAllCredentials(); err != nil {
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
return
}
clearAllAuthMethodsInConfig(absPath)
} else {
if err := auth.DeleteCredential(req.Provider); err != nil {
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
return
}
clearAuthMethodInConfig(absPath, req.Provider)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// GET /auth/callback — OAuth browser callback for Google Antigravity
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
}