feat(launcher-tui): add linux systemd user daemon manager
Integrate daemon controls into launcher TUI with Linux-only systemd user service management, enforce -no-browser execution, and require explicit restart to apply public mode changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2312553286
commit
8fdb980025
7 changed files with 552 additions and 12 deletions
174
cmd/picoclaw-launcher-tui/internal/daemon/systemd.go
Normal file
174
cmd/picoclaw-launcher-tui/internal/daemon/systemd.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
serviceName = "picoclaw-launcher.service"
|
||||
serviceFileName = serviceName
|
||||
)
|
||||
|
||||
type Status struct {
|
||||
Enabled bool
|
||||
Active bool
|
||||
}
|
||||
|
||||
func Supported() bool {
|
||||
return runtime.GOOS == "linux"
|
||||
}
|
||||
|
||||
func userUnitPath(home string) string {
|
||||
return filepath.Join(home, ".config", "systemd", "user", serviceFileName)
|
||||
}
|
||||
|
||||
func buildExecStart(exePath, configPath string, public bool) string {
|
||||
parts := []string{quoteArg(exePath), "-no-browser"}
|
||||
if public {
|
||||
parts = append(parts, "-public")
|
||||
}
|
||||
parts = append(parts, quoteArg(configPath))
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func quoteArg(v string) string {
|
||||
if v == "" {
|
||||
return "\"\""
|
||||
}
|
||||
if !strings.ContainsAny(v, " \t\n\"\\") {
|
||||
return v
|
||||
}
|
||||
return fmt.Sprintf("%q", v)
|
||||
}
|
||||
|
||||
func buildUnitContent(exePath, configPath string, public bool) string {
|
||||
execStart := buildExecStart(exePath, configPath, public)
|
||||
return strings.Join([]string{
|
||||
"[Unit]",
|
||||
"Description=PicoClaw Launcher (no browser)",
|
||||
"After=network.target",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=simple",
|
||||
"ExecStart=" + execStart,
|
||||
"Restart=on-failure",
|
||||
"RestartSec=2",
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=default.target",
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func resolveLauncherExecutable() (string, error) {
|
||||
path, err := exec.LookPath("picoclaw-launcher")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to find picoclaw-launcher in PATH: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func writeUnit(configPath string, public bool) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unitPath := userUnitPath(home)
|
||||
if err := os.MkdirAll(filepath.Dir(unitPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
exePath, err := resolveLauncherExecutable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
content := buildUnitContent(exePath, configPath, public)
|
||||
return os.WriteFile(unitPath, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func Enable(configPath string, public bool) error {
|
||||
if !Supported() {
|
||||
return fmt.Errorf("systemd user service is only supported on linux")
|
||||
}
|
||||
if err := writeUnit(configPath, public); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runSystemctl("daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
return runSystemctl("enable", "--now", serviceName)
|
||||
}
|
||||
|
||||
func Disable() error {
|
||||
if !Supported() {
|
||||
return fmt.Errorf("systemd user service is only supported on linux")
|
||||
}
|
||||
return runSystemctl("disable", "--now", serviceName)
|
||||
}
|
||||
|
||||
func Restart(configPath string, public bool) error {
|
||||
if !Supported() {
|
||||
return fmt.Errorf("systemd user service is only supported on linux")
|
||||
}
|
||||
if err := writeUnit(configPath, public); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := runSystemctl("daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
return runSystemctl("restart", serviceName)
|
||||
}
|
||||
|
||||
func GetStatus() (Status, error) {
|
||||
if !Supported() {
|
||||
return Status{}, fmt.Errorf("systemd user service is only supported on linux")
|
||||
}
|
||||
enabled, err := systemctlState("is-enabled", serviceName)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
active, err := systemctlState("is-active", serviceName)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return Status{Enabled: enabled == "enabled", Active: active == "active"}, nil
|
||||
}
|
||||
|
||||
func systemctlState(args ...string) (string, error) {
|
||||
cmd := exec.Command("systemctl", append([]string{"--user"}, args...)...)
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
s := strings.TrimSpace(out.String())
|
||||
if s == "disabled" || s == "inactive" || s == "failed" || s == "not-found" {
|
||||
return s, nil
|
||||
}
|
||||
se := strings.TrimSpace(stderr.String())
|
||||
if se == "" {
|
||||
se = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("systemctl %s failed: %s", strings.Join(args, " "), se)
|
||||
}
|
||||
return strings.TrimSpace(out.String()), nil
|
||||
}
|
||||
|
||||
func runSystemctl(args ...string) error {
|
||||
cmd := exec.Command("systemctl", append([]string{"--user"}, args...)...)
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return fmt.Errorf("systemctl %s failed: %s", strings.Join(args, " "), msg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
68
cmd/picoclaw-launcher-tui/internal/daemon/systemd_test.go
Normal file
68
cmd/picoclaw-launcher-tui/internal/daemon/systemd_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
package daemon
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildExecStart_DefaultIncludesNoBrowser(t *testing.T) {
|
||||
execPath := "/usr/local/bin/picoclaw-launcher"
|
||||
configPath := "/home/user/.picoclaw/config.json"
|
||||
|
||||
line := buildExecStart(execPath, configPath, false)
|
||||
|
||||
if !strings.Contains(line, "-no-browser") {
|
||||
t.Fatalf("expected -no-browser in ExecStart, got %q", line)
|
||||
}
|
||||
if strings.Contains(line, " -public") {
|
||||
t.Fatalf("did not expect -public in ExecStart when public=false, got %q", line)
|
||||
}
|
||||
if !strings.Contains(line, configPath) {
|
||||
t.Fatalf("expected config path in ExecStart, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExecStart_PublicIncludesPublicFlag(t *testing.T) {
|
||||
execPath := "/usr/local/bin/picoclaw-launcher"
|
||||
configPath := "/home/user/.picoclaw/config.json"
|
||||
|
||||
line := buildExecStart(execPath, configPath, true)
|
||||
|
||||
if !strings.Contains(line, " -public") {
|
||||
t.Fatalf("expected -public in ExecStart, got %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildUnitContent(t *testing.T) {
|
||||
unit := buildUnitContent("/usr/local/bin/picoclaw-launcher", "/tmp/config.json", true)
|
||||
|
||||
checks := []string{
|
||||
"[Unit]",
|
||||
"[Service]",
|
||||
"[Install]",
|
||||
"Restart=on-failure",
|
||||
"ExecStart=",
|
||||
"-no-browser",
|
||||
"-public",
|
||||
}
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(unit, check) {
|
||||
t.Fatalf("expected unit content to include %q, got:\n%s", check, unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserUnitPath(t *testing.T) {
|
||||
home := "/home/example"
|
||||
if runtime.GOOS == "windows" {
|
||||
home = `C:\\Users\\example`
|
||||
}
|
||||
|
||||
got := userUnitPath(home)
|
||||
want := filepath.Join(home, ".config", "systemd", "user", serviceFileName)
|
||||
if got != want {
|
||||
t.Fatalf("unexpected unit path, want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/rivo/tview"
|
||||
|
||||
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/daemon"
|
||||
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
|
||||
)
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ type appState struct {
|
|||
backupPath string
|
||||
dirty bool
|
||||
logPath string
|
||||
daemonRestartRequired bool
|
||||
}
|
||||
|
||||
func Run() error {
|
||||
|
|
@ -127,6 +129,8 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
|
|||
refreshModelMenuFromState(menu, s)
|
||||
case "channel":
|
||||
refreshChannelMenuFromState(menu, s)
|
||||
case "daemon":
|
||||
refreshDaemonMenuFromState(menu, s)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +237,14 @@ func refreshMainMenu(menu *Menu, s *appState) {
|
|||
s.viewGatewayLog()
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Daemon Manager",
|
||||
Description: daemonMenuDescription(),
|
||||
Action: func() {
|
||||
s.push("daemon", s.daemonMenu())
|
||||
},
|
||||
Disabled: !daemon.Supported(),
|
||||
},
|
||||
{
|
||||
Label: "Exit",
|
||||
Description: "Exit the TUI",
|
||||
|
|
@ -353,6 +365,13 @@ func rootChannelLabel(valid bool) string {
|
|||
return "Channel"
|
||||
}
|
||||
|
||||
func daemonMenuDescription() string {
|
||||
if !daemon.Supported() {
|
||||
return "Linux only"
|
||||
}
|
||||
return "Manage systemd user service"
|
||||
}
|
||||
|
||||
func (s *appState) startTalk() {
|
||||
if !s.isActiveModelValid() {
|
||||
s.showMessage("Model required", "Select a valid model before starting talk")
|
||||
|
|
|
|||
214
cmd/picoclaw-launcher-tui/internal/ui/daemon_linux.go
Normal file
214
cmd/picoclaw-launcher-tui/internal/ui/daemon_linux.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/daemon"
|
||||
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
||||
)
|
||||
|
||||
func (s *appState) daemonMenu() tview.Primitive {
|
||||
menu := NewMenu("Daemon Manager", s.buildDaemonMenuItems())
|
||||
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyEsc {
|
||||
s.pop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return menu
|
||||
}
|
||||
|
||||
func refreshDaemonMenuFromState(menu *Menu, s *appState) {
|
||||
menu.applyItems(s.buildDaemonMenuItems())
|
||||
}
|
||||
|
||||
func (s *appState) buildDaemonMenuItems() []MenuItem {
|
||||
status, statusErr := daemon.GetStatus()
|
||||
cfg, cfgErr := s.loadLauncherConfig()
|
||||
|
||||
publicDesc := "Failed to load launcher config"
|
||||
if cfgErr == nil {
|
||||
if cfg.Public {
|
||||
publicDesc = "Current: enabled (restart required to apply changes)"
|
||||
} else {
|
||||
publicDesc = "Current: disabled (restart required to apply changes)"
|
||||
}
|
||||
}
|
||||
|
||||
restartDesc := "Apply latest public setting to daemon"
|
||||
if s.daemonRestartRequired {
|
||||
restartDesc = "Restart required to apply public setting"
|
||||
}
|
||||
|
||||
statusText := "Status unavailable"
|
||||
if statusErr == nil {
|
||||
enabledText := "disabled"
|
||||
if status.Enabled {
|
||||
enabledText = "enabled"
|
||||
}
|
||||
activeText := "inactive"
|
||||
if status.Active {
|
||||
activeText = "active"
|
||||
}
|
||||
statusText = fmt.Sprintf("enabled=%s, active=%s", enabledText, activeText)
|
||||
}
|
||||
|
||||
enableDesc := "systemctl --user enable --now picoclaw-launcher.service"
|
||||
if shouldBlockEnableDaemon(s.daemonRestartRequired) {
|
||||
enableDesc = "Restart daemon first to apply pending public change"
|
||||
}
|
||||
|
||||
items := []MenuItem{
|
||||
{
|
||||
Label: "Service Status",
|
||||
Description: statusText,
|
||||
Action: func() {
|
||||
if statusErr != nil {
|
||||
s.showMessage("Status unavailable", statusErr.Error())
|
||||
return
|
||||
}
|
||||
s.showMessage("Daemon Status", statusText)
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Toggle Public",
|
||||
Description: publicDesc,
|
||||
Action: func() {
|
||||
s.toggleDaemonPublic()
|
||||
refreshMainMenuIfPresent(s)
|
||||
if menu, ok := s.menus["daemon"]; ok {
|
||||
refreshDaemonMenuFromState(menu, s)
|
||||
}
|
||||
},
|
||||
Disabled: cfgErr != nil,
|
||||
},
|
||||
{
|
||||
Label: "Enable Daemon",
|
||||
Description: enableDesc,
|
||||
Action: func() {
|
||||
s.enableDaemon()
|
||||
if menu, ok := s.menus["daemon"]; ok {
|
||||
refreshDaemonMenuFromState(menu, s)
|
||||
}
|
||||
},
|
||||
Disabled: cfgErr != nil || shouldBlockEnableDaemon(s.daemonRestartRequired),
|
||||
},
|
||||
{
|
||||
Label: "Disable Daemon",
|
||||
Description: "systemctl --user disable --now picoclaw-launcher.service",
|
||||
Action: func() {
|
||||
s.disableDaemon()
|
||||
if menu, ok := s.menus["daemon"]; ok {
|
||||
refreshDaemonMenuFromState(menu, s)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Restart Daemon",
|
||||
Description: restartDesc,
|
||||
Action: func() {
|
||||
s.restartDaemon()
|
||||
if menu, ok := s.menus["daemon"]; ok {
|
||||
refreshDaemonMenuFromState(menu, s)
|
||||
}
|
||||
},
|
||||
Disabled: cfgErr != nil,
|
||||
},
|
||||
{
|
||||
Label: "View Daemon Logs",
|
||||
Description: "journalctl --user -u picoclaw-launcher.service -n 100 --no-pager",
|
||||
Action: func() {
|
||||
s.showMessage(
|
||||
"Daemon Logs",
|
||||
"Run: journalctl --user -u picoclaw-launcher.service -n 100 --no-pager",
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Back",
|
||||
Description: "Return to main menu",
|
||||
Action: func() {
|
||||
s.pop()
|
||||
},
|
||||
},
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (s *appState) launcherConfigPath() string {
|
||||
return launcherconfig.PathForAppConfig(s.configPath)
|
||||
}
|
||||
|
||||
func (s *appState) loadLauncherConfig() (launcherconfig.Config, error) {
|
||||
return launcherconfig.Load(s.launcherConfigPath(), launcherconfig.Default())
|
||||
}
|
||||
|
||||
func (s *appState) saveLauncherConfig(cfg launcherconfig.Config) error {
|
||||
return launcherconfig.Save(s.launcherConfigPath(), cfg)
|
||||
}
|
||||
|
||||
func (s *appState) toggleDaemonPublic() {
|
||||
cfg, err := s.loadLauncherConfig()
|
||||
if err != nil {
|
||||
s.showMessage("Failed to load launcher config", err.Error())
|
||||
return
|
||||
}
|
||||
cfg.Public = !cfg.Public
|
||||
if err := s.saveLauncherConfig(cfg); err != nil {
|
||||
s.showMessage("Failed to save launcher config", err.Error())
|
||||
return
|
||||
}
|
||||
s.daemonRestartRequired = true
|
||||
if cfg.Public {
|
||||
s.showMessage("Public enabled", "Public mode enabled. Restart daemon to apply.")
|
||||
return
|
||||
}
|
||||
s.showMessage("Public disabled", "Public mode disabled. Restart daemon to apply.")
|
||||
}
|
||||
|
||||
func (s *appState) enableDaemon() {
|
||||
if shouldBlockEnableDaemon(s.daemonRestartRequired) {
|
||||
s.showMessage("Restart required", "Restart daemon first to apply pending public change")
|
||||
return
|
||||
}
|
||||
cfg, err := s.loadLauncherConfig()
|
||||
if err != nil {
|
||||
s.showMessage("Failed to load launcher config", err.Error())
|
||||
return
|
||||
}
|
||||
if err := daemon.Enable(s.configPath, cfg.Public); err != nil {
|
||||
s.showMessage("Enable daemon failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.daemonRestartRequired = false
|
||||
s.showMessage("Daemon enabled", "User service is enabled and started")
|
||||
}
|
||||
|
||||
func (s *appState) disableDaemon() {
|
||||
if err := daemon.Disable(); err != nil {
|
||||
s.showMessage("Disable daemon failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.showMessage("Daemon disabled", "User service is disabled and stopped")
|
||||
}
|
||||
|
||||
func (s *appState) restartDaemon() {
|
||||
cfg, err := s.loadLauncherConfig()
|
||||
if err != nil {
|
||||
s.showMessage("Failed to load launcher config", err.Error())
|
||||
return
|
||||
}
|
||||
if err := daemon.Restart(s.configPath, cfg.Public); err != nil {
|
||||
s.showMessage("Restart daemon failed", err.Error())
|
||||
return
|
||||
}
|
||||
s.daemonRestartRequired = false
|
||||
s.showMessage("Daemon restarted", "User service restarted with latest settings")
|
||||
}
|
||||
5
cmd/picoclaw-launcher-tui/internal/ui/daemon_policy.go
Normal file
5
cmd/picoclaw-launcher-tui/internal/ui/daemon_policy.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package ui
|
||||
|
||||
func shouldBlockEnableDaemon(restartRequired bool) bool {
|
||||
return restartRequired
|
||||
}
|
||||
15
cmd/picoclaw-launcher-tui/internal/ui/daemon_policy_test.go
Normal file
15
cmd/picoclaw-launcher-tui/internal/ui/daemon_policy_test.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package ui
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShouldBlockEnableDaemonWhenRestartRequired(t *testing.T) {
|
||||
if !shouldBlockEnableDaemon(true) {
|
||||
t.Fatalf("expected enable to be blocked when restart is required")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldBlockEnableDaemonWhenNoRestartRequired(t *testing.T) {
|
||||
if shouldBlockEnableDaemon(false) {
|
||||
t.Fatalf("expected enable not to be blocked when restart is not required")
|
||||
}
|
||||
}
|
||||
45
cmd/picoclaw-launcher-tui/internal/ui/daemon_unsupported.go
Normal file
45
cmd/picoclaw-launcher-tui/internal/ui/daemon_unsupported.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//go:build !linux
|
||||
// +build !linux
|
||||
|
||||
package ui
|
||||
|
||||
import "github.com/rivo/tview"
|
||||
|
||||
func (s *appState) daemonMenu() tview.Primitive {
|
||||
menu := NewMenu("Daemon Manager", []MenuItem{
|
||||
{
|
||||
Label: "Unsupported",
|
||||
Description: "Linux only",
|
||||
Action: func() {
|
||||
s.showMessage("Unsupported", "Daemon manager is only available on Linux")
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Back",
|
||||
Description: "Return to main menu",
|
||||
Action: func() {
|
||||
s.pop()
|
||||
},
|
||||
},
|
||||
})
|
||||
return menu
|
||||
}
|
||||
|
||||
func refreshDaemonMenuFromState(menu *Menu, s *appState) {
|
||||
menu.applyItems([]MenuItem{
|
||||
{
|
||||
Label: "Unsupported",
|
||||
Description: "Linux only",
|
||||
Action: func() {
|
||||
s.showMessage("Unsupported", "Daemon manager is only available on Linux")
|
||||
},
|
||||
},
|
||||
{
|
||||
Label: "Back",
|
||||
Description: "Return to main menu",
|
||||
Action: func() {
|
||||
s.pop()
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue