sshkeeper/internal/session/tmux.go

195 lines
5.4 KiB
Go

package session
import (
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
)
var dedicatedWorkspace = "sshkeeper"
type Window struct {
ID string
Index int
Name string
ServerAlias string
Active bool
StartedAt time.Time
}
func Available() bool {
if runtime.GOOS == "windows" {
return false
}
_, err := exec.LookPath("tmux")
return err == nil
}
func workspaceTarget() (string, bool, error) {
if !Available() {
return "", false, fmt.Errorf("tmux is unavailable")
}
if os.Getenv("TMUX") == "" {
return dedicatedWorkspace, false, nil
}
out, err := exec.Command("tmux", "display-message", "-p", "#{session_name}").Output()
if err != nil {
return "", true, fmt.Errorf("resolve current tmux session: %w", err)
}
name := strings.TrimSpace(string(out))
if name == "" {
return "", true, fmt.Errorf("current tmux session has no name")
}
return name, true, nil
}
func sessionExists(target string) bool {
cmd := exec.Command("tmux", "has-session", "-t", target)
return cmd.Run() == nil
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func Open(serverAlias string) (string, bool, error) {
executable, err := os.Executable()
if err != nil {
return "", false, fmt.Errorf("resolve sshkeeper executable: %w", err)
}
command := shellQuote(executable) + " __session-connect " + shellQuote(serverAlias)
return openWindow(serverAlias, command)
}
func openWindow(serverAlias, command string) (string, bool, error) {
target, insideTmux, err := workspaceTarget()
if err != nil {
return "", insideTmux, err
}
name := sanitizeWindowName(serverAlias)
var args []string
if !insideTmux && !sessionExists(target) {
args = []string{"new-session", "-d", "-P", "-F", "#{window_id}", "-s", target, "-n", name, command}
} else {
args = []string{"new-window", "-d", "-P", "-F", "#{window_id}", "-t", target, "-n", name, command}
}
out, err := exec.Command("tmux", args...).CombinedOutput()
if err != nil {
return "", insideTmux, fmt.Errorf("create tmux session window: %s: %w", strings.TrimSpace(string(out)), err)
}
windowID := strings.TrimSpace(string(out))
if windowID == "" {
return "", insideTmux, fmt.Errorf("tmux did not return a window id")
}
if err := setWindowMetadata(windowID, serverAlias, time.Now()); err != nil {
return "", insideTmux, err
}
return windowID, insideTmux, nil
}
func sanitizeWindowName(alias string) string {
name := strings.TrimSpace(alias)
if name == "" {
return "ssh"
}
name = strings.ReplaceAll(name, ":", "-")
name = strings.ReplaceAll(name, " ", "-")
if len(name) > 40 {
name = name[:40]
}
return name
}
func setWindowMetadata(windowID, alias string, started time.Time) error {
pairs := [][2]string{
{"@sshkeeper_server", alias},
{"@sshkeeper_started", strconv.FormatInt(started.Unix(), 10)},
}
for _, pair := range pairs {
out, err := exec.Command("tmux", "set-window-option", "-t", windowID, pair[0], pair[1]).CombinedOutput()
if err != nil {
return fmt.Errorf("set tmux metadata %s: %s: %w", pair[0], strings.TrimSpace(string(out)), err)
}
}
return nil
}
func List() ([]Window, error) {
if !Available() {
return nil, nil
}
target, _, err := workspaceTarget()
if err != nil {
return nil, err
}
if !sessionExists(target) {
return nil, nil
}
format := "#{window_id}\t#{window_index}\t#{window_name}\t#{window_active}\t#{@sshkeeper_server}\t#{@sshkeeper_started}"
out, err := exec.Command("tmux", "list-windows", "-t", target, "-F", format).CombinedOutput()
if err != nil {
return nil, fmt.Errorf("list tmux windows: %s: %w", strings.TrimSpace(string(out)), err)
}
var result []Window
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if strings.TrimSpace(line) == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) < 6 || strings.TrimSpace(parts[4]) == "" {
continue
}
index, _ := strconv.Atoi(parts[1])
startedUnix, _ := strconv.ParseInt(parts[5], 10, 64)
window := Window{ID: parts[0], Index: index, Name: parts[2], Active: parts[3] == "1", ServerAlias: parts[4]}
if startedUnix > 0 {
window.StartedAt = time.Unix(startedUnix, 0)
}
result = append(result, window)
}
return result, nil
}
func Attach(windowID string) error {
if !Available() {
return fmt.Errorf("tmux is unavailable")
}
if strings.TrimSpace(windowID) == "" {
return fmt.Errorf("tmux window id is required")
}
if os.Getenv("TMUX") != "" {
out, err := exec.Command("tmux", "select-window", "-t", windowID).CombinedOutput()
if err != nil {
return fmt.Errorf("select tmux window: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
target, _, err := workspaceTarget()
if err != nil {
return err
}
if out, err := exec.Command("tmux", "select-window", "-t", windowID).CombinedOutput(); err != nil {
return fmt.Errorf("select tmux window: %s: %w", strings.TrimSpace(string(out)), err)
}
cmd := exec.Command("tmux", "attach-session", "-t", target)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("attach tmux session: %w", err)
}
return nil
}
func Close(windowID string) error {
if !Available() {
return fmt.Errorf("tmux is unavailable")
}
out, err := exec.Command("tmux", "kill-window", "-t", windowID).CombinedOutput()
if err != nil {
return fmt.Errorf("close tmux window: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}