diff --git a/cmd/picoclaw-launcher-tui/internal/daemon/systemd.go b/cmd/picoclaw-launcher-tui/internal/daemon/systemd.go new file mode 100644 index 000000000..0f11596f7 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/daemon/systemd.go @@ -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 +} diff --git a/cmd/picoclaw-launcher-tui/internal/daemon/systemd_test.go b/cmd/picoclaw-launcher-tui/internal/daemon/systemd_test.go new file mode 100644 index 000000000..aceb86fdb --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/daemon/systemd_test.go @@ -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) + } +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go index a2ccddf70..1708f1e58 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/app.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go @@ -11,22 +11,24 @@ 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" ) type appState struct { - app *tview.Application - pages *tview.Pages - stack []string - config *picoclawconfig.Config - configPath string - gatewayCmd *exec.Cmd - menus map[string]*Menu - original []byte - hasOriginal bool - backupPath string - dirty bool - logPath string + app *tview.Application + pages *tview.Pages + stack []string + config *picoclawconfig.Config + configPath string + gatewayCmd *exec.Cmd + menus map[string]*Menu + original []byte + hasOriginal bool + 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") diff --git a/cmd/picoclaw-launcher-tui/internal/ui/daemon_linux.go b/cmd/picoclaw-launcher-tui/internal/ui/daemon_linux.go new file mode 100644 index 000000000..5c3467738 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/daemon_linux.go @@ -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") +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/daemon_policy.go b/cmd/picoclaw-launcher-tui/internal/ui/daemon_policy.go new file mode 100644 index 000000000..b305476c2 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/daemon_policy.go @@ -0,0 +1,5 @@ +package ui + +func shouldBlockEnableDaemon(restartRequired bool) bool { + return restartRequired +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/daemon_policy_test.go b/cmd/picoclaw-launcher-tui/internal/ui/daemon_policy_test.go new file mode 100644 index 000000000..75f7f9063 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/daemon_policy_test.go @@ -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") + } +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/daemon_unsupported.go b/cmd/picoclaw-launcher-tui/internal/ui/daemon_unsupported.go new file mode 100644 index 000000000..b7c0ff58f --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/daemon_unsupported.go @@ -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() + }, + }, + }) +}