polish webui and add desktop gateway service support

This commit is contained in:
lpf
2026-03-10 21:25:01 +08:00
parent 74a10ed4e3
commit cfab4cd1cc
22 changed files with 712 additions and 364 deletions

View File

@@ -3,6 +3,7 @@ package main
import (
"context"
"fmt"
"io"
"net/url"
"os"
"os/exec"
@@ -578,6 +579,12 @@ func applyMaximumPermissionPolicy(cfg *config.Config) {
}
func gatewayInstallServiceCmd() error {
switch runtime.GOOS {
case "darwin":
return gatewayInstallLaunchdService()
case "windows":
return gatewayInstallWindowsTask()
}
scope, unitPath, err := detectGatewayServiceScopeAndPath()
if err != nil {
return err
@@ -615,6 +622,12 @@ func gatewayInstallServiceCmd() error {
}
func gatewayServiceControlCmd(action string) error {
switch runtime.GOOS {
case "darwin":
return gatewayLaunchdControl(action)
case "windows":
return gatewayWindowsTaskControl(action)
}
scope, _, err := detectInstalledGatewayService()
if err != nil {
return err
@@ -631,8 +644,10 @@ func gatewayScopePreference() string {
}
func detectGatewayServiceScopeAndPath() (string, string, error) {
if runtime.GOOS != "linux" {
return "", "", fmt.Errorf("gateway service registration currently supports Linux systemd only")
switch runtime.GOOS {
case "linux":
default:
return "", "", fmt.Errorf("unsupported service manager for %s", runtime.GOOS)
}
switch gatewayScopePreference() {
case "user":
@@ -655,6 +670,12 @@ func userGatewayUnitPath() (string, string, error) {
}
func detectInstalledGatewayService() (string, string, error) {
switch runtime.GOOS {
case "darwin":
return detectInstalledLaunchdService()
case "windows":
return detectInstalledWindowsTask()
}
systemPath := "/etc/systemd/system/" + gatewayServiceName
userScope, userPath, err := userGatewayUnitPath()
if err != nil {
@@ -754,6 +775,271 @@ func runSystemctl(scope string, args ...string) error {
return nil
}
func gatewayLaunchdLabel() string { return "ai.clawgo.gateway" }
func gatewayWindowsTaskName() string { return "ClawGo Gateway" }
func detectLaunchdScopeAndPath() (string, string, error) {
label := gatewayLaunchdLabel() + ".plist"
switch gatewayScopePreference() {
case "system":
return "system", filepath.Join("/Library/LaunchDaemons", label), nil
case "user":
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("resolve user home failed: %w", err)
}
return "user", filepath.Join(home, "Library", "LaunchAgents", label), nil
}
if os.Geteuid() == 0 {
return "system", filepath.Join("/Library/LaunchDaemons", label), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", "", fmt.Errorf("resolve user home failed: %w", err)
}
return "user", filepath.Join(home, "Library", "LaunchAgents", label), nil
}
func detectInstalledLaunchdService() (string, string, error) {
userScope, userPath, err := detectLaunchdScopeAndPath()
if err != nil && gatewayScopePreference() == "user" {
return "", "", err
}
systemPath := filepath.Join("/Library/LaunchDaemons", gatewayLaunchdLabel()+".plist")
systemExists := fileExists(systemPath)
userExists := fileExists(userPath)
switch gatewayScopePreference() {
case "system":
if systemExists {
return "system", systemPath, nil
}
return "", "", fmt.Errorf("launchd plist not found in system scope: %s", systemPath)
case "user":
if userExists {
return userScope, userPath, nil
}
return "", "", fmt.Errorf("launchd plist not found in user scope: %s", userPath)
}
if os.Geteuid() == 0 {
if systemExists {
return "system", systemPath, nil
}
if userExists {
return userScope, userPath, nil
}
} else {
if userExists {
return userScope, userPath, nil
}
if systemExists {
return "system", systemPath, nil
}
}
return "", "", fmt.Errorf("gateway service not registered. Run: clawgo gateway")
}
func gatewayInstallLaunchdService() error {
scope, plistPath, err := detectLaunchdScopeAndPath()
if err != nil {
return err
}
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve executable path failed: %w", err)
}
exePath, _ = filepath.Abs(exePath)
configPath := getConfigPath()
workDir := filepath.Dir(exePath)
if err := os.MkdirAll(filepath.Dir(plistPath), 0755); err != nil {
return fmt.Errorf("create launchd directory failed: %w", err)
}
content := buildGatewayLaunchdPlist(exePath, configPath, workDir)
if err := os.WriteFile(plistPath, []byte(content), 0644); err != nil {
return fmt.Errorf("write launchd plist failed: %w", err)
}
_ = runLaunchctl(scope, "bootout", launchdDomainTarget(scope), plistPath)
if err := runLaunchctl(scope, "bootstrap", launchdDomainTarget(scope), plistPath); err != nil {
return err
}
if err := runLaunchctl(scope, "kickstart", "-k", launchdServiceTarget(scope)); err != nil {
return err
}
fmt.Printf("✓ Gateway service registered: %s (%s)\n", gatewayLaunchdLabel(), scope)
fmt.Printf(" Launchd plist: %s\n", plistPath)
fmt.Println(" Start service: clawgo gateway start")
fmt.Println(" Restart service: clawgo gateway restart")
fmt.Println(" Stop service: clawgo gateway stop")
return nil
}
func gatewayLaunchdControl(action string) error {
scope, plistPath, err := detectInstalledLaunchdService()
if err != nil {
return err
}
switch action {
case "start":
_ = runLaunchctl(scope, "bootstrap", launchdDomainTarget(scope), plistPath)
return runLaunchctl(scope, "kickstart", "-k", launchdServiceTarget(scope))
case "stop":
return runLaunchctl(scope, "bootout", launchdDomainTarget(scope), plistPath)
case "restart":
_ = runLaunchctl(scope, "bootout", launchdDomainTarget(scope), plistPath)
if err := runLaunchctl(scope, "bootstrap", launchdDomainTarget(scope), plistPath); err != nil {
return err
}
return runLaunchctl(scope, "kickstart", "-k", launchdServiceTarget(scope))
case "status":
return runLaunchctl(scope, "print", launchdServiceTarget(scope))
default:
return fmt.Errorf("unsupported action: %s", action)
}
}
func buildGatewayLaunchdPlist(exePath, configPath, workDir string) string {
return fmt.Sprintf(`<?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>Label</key>
<string>%s</string>
<key>ProgramArguments</key>
<array>
<string>%s</string>
<string>gateway</string>
<string>run</string>
<string>--config</string>
<string>%s</string>
</array>
<key>WorkingDirectory</key>
<string>%s</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>%s</string>
<key>StandardErrorPath</key>
<string>%s</string>
</dict>
</plist>
`, gatewayLaunchdLabel(), exePath, configPath, workDir, filepath.Join(filepath.Dir(configPath), "gateway.launchd.out.log"), filepath.Join(filepath.Dir(configPath), "gateway.launchd.err.log"))
}
func launchdDomainTarget(scope string) string {
if scope == "system" {
return "system"
}
return fmt.Sprintf("gui/%d", os.Getuid())
}
func launchdServiceTarget(scope string) string {
return launchdDomainTarget(scope) + "/" + gatewayLaunchdLabel()
}
func runLaunchctl(scope string, args ...string) error {
cmd := exec.Command("launchctl", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("launchctl %s failed: %w", strings.Join(args, " "), err)
}
return nil
}
func gatewayInstallWindowsTask() error {
exePath, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve executable path failed: %w", err)
}
exePath, _ = filepath.Abs(exePath)
configPath := getConfigPath()
taskName := gatewayWindowsTaskName()
command := fmt.Sprintf(`"%s" gateway run --config "%s"`, exePath, configPath)
_ = runSCHTASKS("/Delete", "/TN", taskName, "/F")
if err := runSCHTASKS("/Create", "/TN", taskName, "/SC", "ONLOGON", "/TR", command, "/F"); err != nil {
return err
}
fmt.Printf("✓ Gateway service registered: %s (windows task)\n", taskName)
fmt.Println(" Start service: clawgo gateway start")
fmt.Println(" Restart service: clawgo gateway restart")
fmt.Println(" Stop service: clawgo gateway stop")
return nil
}
func gatewayWindowsTaskControl(action string) error {
_, _, err := detectInstalledWindowsTask()
if err != nil {
return err
}
taskName := gatewayWindowsTaskName()
switch action {
case "start":
return runSCHTASKS("/Run", "/TN", taskName)
case "stop":
return stopGatewayProcessByPIDFile()
case "restart":
_ = stopGatewayProcessByPIDFile()
return runSCHTASKS("/Run", "/TN", taskName)
case "status":
return runSCHTASKS("/Query", "/TN", taskName, "/V", "/FO", "LIST")
default:
return fmt.Errorf("unsupported action: %s", action)
}
}
func detectInstalledWindowsTask() (string, string, error) {
taskName := gatewayWindowsTaskName()
if err := runSCHTASKSQuiet("/Query", "/TN", taskName); err != nil {
return "", "", fmt.Errorf("gateway service not registered. Run: clawgo gateway")
}
return "user", taskName, nil
}
func runSCHTASKS(args ...string) error {
cmd := exec.Command("schtasks", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("schtasks %s failed: %w", strings.Join(args, " "), err)
}
return nil
}
func runSCHTASKSQuiet(args ...string) error {
cmd := exec.Command("schtasks", args...)
cmd.Stdout = io.Discard
cmd.Stderr = io.Discard
return cmd.Run()
}
func stopGatewayProcessByPIDFile() error {
pidPath := filepath.Join(filepath.Dir(getConfigPath()), "gateway.pid")
data, err := os.ReadFile(pidPath)
if err != nil {
return fmt.Errorf("gateway pid file not found: %w", err)
}
pid := strings.TrimSpace(string(data))
if pid == "" {
return fmt.Errorf("gateway pid file is empty")
}
cmd := exec.Command("taskkill", "/PID", pid, "/T", "/F")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("taskkill /PID %s failed: %w", pid, err)
}
return nil
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func buildGatewayRuntime(ctx context.Context, cfg *config.Config, msgBus *bus.MessageBus, cronService *cron.CronService) (*agent.AgentLoop, *channels.Manager, error) {
provider, err := providers.CreateProvider(cfg)
if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
)
func uninstallCmd() {
@@ -52,6 +53,28 @@ func uninstallCmd() {
}
func uninstallGatewayService() error {
switch runtime.GOOS {
case "darwin":
scope, plistPath, err := detectInstalledLaunchdService()
if err != nil {
return nil
}
_ = runLaunchctl(scope, "bootout", launchdDomainTarget(scope), plistPath)
if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove launchd plist failed: %w", err)
}
return nil
case "windows":
_, taskName, err := detectInstalledWindowsTask()
if err != nil {
return nil
}
_ = stopGatewayProcessByPIDFile()
if err := runSCHTASKS("/Delete", "/TN", taskName, "/F"); err != nil {
return err
}
return nil
}
scope, unitPath, err := detectInstalledGatewayService()
if err != nil {
return nil