mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-05 04:13:50 +00:00
- GetGatewayURL/IsGatewayReady: use 127.0.0.1 to avoid WebKit resolving localhost to ::1 (IPv6) when gateway only binds IPv4, causing "Load failed" - Skip CORS warning for lite edition (localhost-only, no production exposure) - Skip server IP detection for lite edition (no multi-tenant risk, avoids leaking user's public IP in logs and unnecessary network calls)
343 lines
8.7 KiB
Go
343 lines
8.7 KiB
Go
//go:build sqliteonly
|
|
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/cmd"
|
|
"github.com/nextlevelbuilder/goclaw/internal/updater"
|
|
wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
|
)
|
|
|
|
// App holds Wails application state and embedded gateway lifecycle.
|
|
type App struct {
|
|
ctx context.Context
|
|
cancelGw context.CancelFunc
|
|
gatewayToken string
|
|
gatewayPort int
|
|
lastUpdate *updater.UpdateInfo // cached update info from last check
|
|
}
|
|
|
|
// NewApp creates a new App instance with default port.
|
|
func NewApp() *App {
|
|
return &App{
|
|
gatewayPort: 18790,
|
|
}
|
|
}
|
|
|
|
// startup is called by Wails when the application starts.
|
|
func (a *App) startup(ctx context.Context) {
|
|
a.ctx = ctx
|
|
|
|
// Resolve port from env or use default.
|
|
if p := os.Getenv("GOCLAW_PORT"); p != "" {
|
|
fmt.Sscanf(p, "%d", &a.gatewayPort)
|
|
}
|
|
|
|
// Ensure secrets (encryption key + gateway token) via OS keyring or file fallback.
|
|
encKey, gwToken, err := EnsureSecrets()
|
|
if err != nil {
|
|
slog.Error("failed to setup secrets", "error", err)
|
|
return
|
|
}
|
|
a.gatewayToken = gwToken
|
|
|
|
// Set env vars consumed by the embedded gateway.
|
|
os.Setenv("GOCLAW_ENCRYPTION_KEY", encKey)
|
|
os.Setenv("GOCLAW_GATEWAY_TOKEN", gwToken)
|
|
os.Setenv("GOCLAW_STORAGE_BACKEND", "sqlite")
|
|
os.Setenv("GOCLAW_DESKTOP", "1")
|
|
// Bind to localhost only — desktop has no reason to expose on LAN.
|
|
if os.Getenv("GOCLAW_HOST") == "" {
|
|
os.Setenv("GOCLAW_HOST", "127.0.0.1")
|
|
}
|
|
slog.Info("desktop secrets configured", "token_len", len(gwToken), "token_prefix", gwToken[:min(8, len(gwToken))])
|
|
|
|
// Ensure data directory exists.
|
|
dataDir := os.Getenv("GOCLAW_DATA_DIR")
|
|
if dataDir == "" {
|
|
home, _ := os.UserHomeDir()
|
|
dataDir = home + "/.goclaw/data"
|
|
os.Setenv("GOCLAW_DATA_DIR", dataDir)
|
|
}
|
|
os.MkdirAll(dataDir, 0755)
|
|
|
|
// Start gateway in background.
|
|
a.startGateway()
|
|
|
|
a.waitForGateway()
|
|
|
|
// Check for updates after gateway is ready, then every 6 hours.
|
|
go a.updateLoop()
|
|
}
|
|
|
|
// waitForGateway polls the health endpoint until the gateway is ready or times out.
|
|
func (a *App) waitForGateway() {
|
|
url := fmt.Sprintf("http://127.0.0.1:%d/health", a.gatewayPort)
|
|
for i := 0; i < 30; i++ {
|
|
resp, err := http.Get(url)
|
|
if err == nil && resp.StatusCode == 200 {
|
|
resp.Body.Close()
|
|
slog.Info("gateway ready", "port", a.gatewayPort)
|
|
return
|
|
}
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
slog.Warn("gateway not responding after 15s")
|
|
}
|
|
|
|
// shutdown is called by Wails when the application is closing.
|
|
func (a *App) shutdown(ctx context.Context) {
|
|
if a.cancelGw != nil {
|
|
a.cancelGw()
|
|
}
|
|
}
|
|
|
|
// GetGatewayURL returns the base URL of the embedded gateway.
|
|
func (a *App) GetGatewayURL() string {
|
|
return fmt.Sprintf("http://127.0.0.1:%d", a.gatewayPort)
|
|
}
|
|
|
|
// GetGatewayToken returns the token for WebSocket authentication.
|
|
func (a *App) GetGatewayToken() string {
|
|
return a.gatewayToken
|
|
}
|
|
|
|
// GetGatewayPort returns the gateway port number.
|
|
func (a *App) GetGatewayPort() int {
|
|
return a.gatewayPort
|
|
}
|
|
|
|
// IsGatewayReady checks if the gateway health endpoint is responding.
|
|
func (a *App) IsGatewayReady() bool {
|
|
url := fmt.Sprintf("http://127.0.0.1:%d/health", a.gatewayPort)
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
resp.Body.Close()
|
|
return resp.StatusCode == 200
|
|
}
|
|
|
|
// GetVersion returns the embedded gateway version string.
|
|
func (a *App) GetVersion() string {
|
|
return cmd.Version
|
|
}
|
|
|
|
// OpenFile opens a local file with the system's default application.
|
|
func (a *App) OpenFile(path string) error {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
return exec.Command("open", path).Start()
|
|
case "linux":
|
|
return exec.Command("xdg-open", path).Start()
|
|
default: // windows
|
|
return exec.Command("cmd", "/c", "start", "", path).Start()
|
|
}
|
|
}
|
|
|
|
// SaveFile opens a Save As dialog and copies the source file to the chosen location.
|
|
func (a *App) SaveFile(srcPath string) error {
|
|
dest, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{
|
|
DefaultFilename: filepath.Base(srcPath),
|
|
Title: "Save File",
|
|
})
|
|
if err != nil || dest == "" {
|
|
return err
|
|
}
|
|
src, err := os.Open(srcPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer src.Close()
|
|
dst, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dst.Close()
|
|
_, err = io.Copy(dst, src)
|
|
return err
|
|
}
|
|
|
|
// CheckForUpdate queries GitHub for a newer desktop release.
|
|
func (a *App) CheckForUpdate() (*updater.UpdateInfo, error) {
|
|
info, err := updater.CheckForUpdate(cmd.Version)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if info.Available {
|
|
a.lastUpdate = info // cache for ApplyUpdate
|
|
}
|
|
return info, nil
|
|
}
|
|
|
|
// ApplyUpdate downloads and installs the cached update.
|
|
// Uses server-side cached UpdateInfo — does NOT accept URL from frontend (security).
|
|
func (a *App) ApplyUpdate() error {
|
|
if a.lastUpdate == nil || !a.lastUpdate.Available {
|
|
return fmt.Errorf("no update available")
|
|
}
|
|
appPath, err := updater.ResolveAppPath()
|
|
if err != nil {
|
|
return fmt.Errorf("resolve app path: %w", err)
|
|
}
|
|
if err := updater.DownloadAndApply(a.lastUpdate, appPath); err != nil {
|
|
return fmt.Errorf("apply update: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RestartApp gracefully shuts down the gateway, relaunches the app, and exits.
|
|
func (a *App) RestartApp() error {
|
|
appPath, err := updater.ResolveAppPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Graceful gateway shutdown before exit
|
|
if a.cancelGw != nil {
|
|
a.cancelGw()
|
|
time.Sleep(500 * time.Millisecond) // brief wait for WAL checkpoint
|
|
}
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
exec.Command("open", "-n", appPath).Start()
|
|
case "windows":
|
|
exec.Command(appPath).Start()
|
|
}
|
|
os.Exit(0)
|
|
return nil
|
|
}
|
|
|
|
// updateLoop checks for updates on startup (5s delay) then every 6 hours.
|
|
// Respects app context for clean shutdown.
|
|
func (a *App) updateLoop() {
|
|
timer := time.NewTimer(5 * time.Second)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case <-timer.C:
|
|
a.checkAndEmitUpdate()
|
|
case <-a.ctx.Done():
|
|
return
|
|
}
|
|
|
|
ticker := time.NewTicker(6 * time.Hour)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
a.checkAndEmitUpdate()
|
|
case <-a.ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *App) checkAndEmitUpdate() {
|
|
info, err := updater.CheckForUpdate(cmd.Version)
|
|
if err != nil {
|
|
slog.Debug("update check failed", "error", err)
|
|
return
|
|
}
|
|
if info.Available {
|
|
a.lastUpdate = info
|
|
slog.Info("update available", "version", info.Version)
|
|
wailsRuntime.EventsEmit(a.ctx, "update:available", info)
|
|
}
|
|
}
|
|
|
|
// GetDataDir returns the path to the data directory containing the SQLite database.
|
|
func (a *App) GetDataDir() string {
|
|
dir := os.Getenv("GOCLAW_DATA_DIR")
|
|
if dir == "" {
|
|
home, _ := os.UserHomeDir()
|
|
dir = home + "/.goclaw/data"
|
|
}
|
|
return dir
|
|
}
|
|
|
|
// startGateway launches the embedded gateway in a background goroutine.
|
|
func (a *App) startGateway() {
|
|
gwCtx, cancel := context.WithCancel(context.Background())
|
|
a.cancelGw = cancel
|
|
|
|
go func() {
|
|
go func() {
|
|
<-gwCtx.Done()
|
|
p, _ := os.FindProcess(os.Getpid())
|
|
p.Signal(os.Interrupt)
|
|
}()
|
|
slog.Info("starting embedded gateway", "port", a.gatewayPort)
|
|
cmd.RunGateway()
|
|
}()
|
|
}
|
|
|
|
// ResetDatabase deletes the SQLite DB files and restarts the entire app.
|
|
// On macOS/Linux, os.Remove works on open files (unlinks inode).
|
|
// The gateway holds the DB open and cancelGw sends SIGINT to the process,
|
|
// so we delete first, then restart the app cleanly.
|
|
func (a *App) ResetDatabase() error {
|
|
dataDir := a.GetDataDir()
|
|
dbPath := filepath.Join(dataDir, "goclaw.db")
|
|
|
|
// Delete DB + WAL/SHM (works on Unix even while file is open).
|
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
p := dbPath + suffix
|
|
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
|
slog.Warn("database reset: failed to remove", "path", p, "error", err)
|
|
}
|
|
}
|
|
slog.Info("database reset: files deleted, restarting app", "path", dbPath)
|
|
|
|
return a.RestartApp()
|
|
}
|
|
|
|
// DownloadURL fetches a URL with Bearer auth and opens a Save As dialog.
|
|
func (a *App) DownloadURL(url, defaultFilename string) error {
|
|
req, err := http.NewRequest("GET", url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Use gateway token for local API calls
|
|
if strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1") {
|
|
req.Header.Set("Authorization", "Bearer "+a.gatewayToken)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
dest, err := wailsRuntime.SaveFileDialog(a.ctx, wailsRuntime.SaveDialogOptions{
|
|
DefaultFilename: defaultFilename,
|
|
Title: "Save File",
|
|
})
|
|
if err != nil || dest == "" {
|
|
return err
|
|
}
|
|
f, err := os.Create(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
_, err = io.Copy(f, resp.Body)
|
|
return err
|
|
}
|