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:
b4iterdev 2026-03-10 12:43:10 +07:00
parent 2312553286
commit 8fdb980025
7 changed files with 552 additions and 12 deletions

View 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
}

View 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)
}
}

View file

@ -11,6 +11,7 @@ import (
"github.com/rivo/tview" "github.com/rivo/tview"
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config" 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" picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
) )
@ -27,6 +28,7 @@ type appState struct {
backupPath string backupPath string
dirty bool dirty bool
logPath string logPath string
daemonRestartRequired bool
} }
func Run() error { func Run() error {
@ -127,6 +129,8 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
refreshModelMenuFromState(menu, s) refreshModelMenuFromState(menu, s)
case "channel": case "channel":
refreshChannelMenuFromState(menu, s) refreshChannelMenuFromState(menu, s)
case "daemon":
refreshDaemonMenuFromState(menu, s)
} }
} }
@ -233,6 +237,14 @@ func refreshMainMenu(menu *Menu, s *appState) {
s.viewGatewayLog() s.viewGatewayLog()
}, },
}, },
{
Label: "Daemon Manager",
Description: daemonMenuDescription(),
Action: func() {
s.push("daemon", s.daemonMenu())
},
Disabled: !daemon.Supported(),
},
{ {
Label: "Exit", Label: "Exit",
Description: "Exit the TUI", Description: "Exit the TUI",
@ -353,6 +365,13 @@ func rootChannelLabel(valid bool) string {
return "Channel" return "Channel"
} }
func daemonMenuDescription() string {
if !daemon.Supported() {
return "Linux only"
}
return "Manage systemd user service"
}
func (s *appState) startTalk() { func (s *appState) startTalk() {
if !s.isActiveModelValid() { if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting talk") s.showMessage("Model required", "Select a valid model before starting talk")

View 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")
}

View file

@ -0,0 +1,5 @@
package ui
func shouldBlockEnableDaemon(restartRequired bool) bool {
return restartRequired
}

View 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")
}
}

View 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()
},
},
})
}