Files
MuyueWorkspace/internal/api/terminal.go
Muyue fc7a5b9d87
All checks were successful
PR Check / check (pull_request) Successful in 57s
fix(terminal/windows): fallback to pipes when PTY unsupported (v0.7.1)
The terminal tab was unusable on Windows: creack/pty has no native
Windows ConPTY support, so pty.Start() returned "operating system not
supported" and the WebSocket closed immediately on any tab click —
even though the menu detection (wsl --list --quiet, pwsh, cmd) worked.

Introduce a termSession interface with two implementations selected at
runtime:
- ptySession (unix): unchanged behaviour, real PTY via creack/pty,
  resize works, vim/top behave normally.
- pipeSession (windows): plain stdin + merged stdout/stderr pipes,
  forwarded to the WebSocket. Resize is a no-op (no SIGWINCH without a
  TTY), so full-screen TUIs misbehave in this mode — but launching
  wsl.exe, pwsh, or cmd works for line-based interaction, which is
  what the menu shortcuts target.

handleTerminalWS now goes through startTermSession(cmd); the unix path
is unchanged, the windows fallback kicks in only when pty.Start would
have failed.

Bump v0.7.0 → v0.7.1; CHANGELOG entry added.
2026-04-27 11:56:40 +02:00

470 lines
11 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/muyue/muyue/internal/config"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
switch {
case strings.HasPrefix(origin, "http://127.0.0.1"),
strings.HasPrefix(origin, "http://localhost"),
strings.HasPrefix(origin, "http://[::1]"),
strings.HasPrefix(origin, "https://127.0.0.1"),
strings.HasPrefix(origin, "https://localhost"),
strings.HasPrefix(origin, "https://[::1]"):
return true
default:
return false
}
},
}
type wsMessage struct {
Type string `json:"type"`
Data string `json:"data"`
Rows uint16 `json:"rows,omitempty"`
Cols uint16 `json:"cols,omitempty"`
}
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
var initMsg wsMessage
_, raw, err := conn.ReadMessage()
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
return
}
if err := json.Unmarshal(raw, &initMsg); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
return
}
var cmd *exec.Cmd
if initMsg.Type == "ssh" && initMsg.Data != "" {
var sshConf struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Password string `json:"password"`
}
if err := json.Unmarshal([]byte(initMsg.Data), &sshConf); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid ssh config"})
return
}
if sshConf.Port == 0 {
sshConf.Port = 22
}
sshArgs := []string{
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
}
if sshConf.KeyPath != "" {
sshArgs = append(sshArgs, "-i", sshConf.KeyPath)
}
if sshConf.Port != 22 {
sshArgs = append(sshArgs, "-p", fmt.Sprintf("%d", sshConf.Port))
}
sshArgs = append(sshArgs, fmt.Sprintf("%s@%s", sshConf.User, sshConf.Host))
if sshConf.Password != "" {
sshpassPath, err := exec.LookPath("sshpass")
if err == nil {
args := append([]string{"-e"}, "ssh")
args = append(args, sshArgs...)
cmd = exec.Command(sshpassPath, args...)
cmd.Env = append(os.Environ(), "SSHPASS="+sshConf.Password)
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
cmd = exec.Command("ssh", sshArgs...)
}
} else {
shell := strings.TrimSpace(initMsg.Data)
if shell == "" {
shell = detectShell()
}
if shell == "" {
shell = "/bin/sh"
}
// Support "wsl -d <distro>" shell strings sent from the UI quick-access.
if extra, ok := parseWSLShell(shell); ok {
wslPath, err := exec.LookPath("wsl")
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: "wsl not found on this host"})
return
}
cmd = exec.Command(wslPath, extra...)
} else {
if path, err := exec.LookPath(shell); err == nil {
shell = path
}
if _, err := os.Stat(shell); err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
return
}
shellName := filepath.Base(shell)
switch shellName {
case "wsl":
cmd = exec.Command(shell, "--shell-type", "login")
case "powershell", "pwsh":
cmd = exec.Command(shell, "-NoLogo", "-NoProfile")
case "fish":
cmd = exec.Command(shell, "--login")
default:
cmd = exec.Command(shell)
}
}
}
if cmd.Env == nil {
cmd.Env = os.Environ()
}
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
session, err := startTermSession(cmd)
if err != nil {
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
return
}
var once sync.Once
cleanup := func() {
once.Do(func() {
session.Close()
session.Wait()
})
}
defer cleanup()
go func() {
buf := make([]byte, 4096)
for {
n, err := session.Read(buf)
if n > 0 {
if err := conn.WriteJSON(wsMessage{
Type: "output",
Data: string(buf[:n]),
}); err != nil {
cleanup()
return
}
}
if err != nil {
cleanup()
return
}
}
}()
conn.SetReadLimit(1 << 20)
conn.SetReadDeadline(time.Time{})
for {
_, raw, err := conn.ReadMessage()
if err != nil {
cleanup()
return
}
var msg wsMessage
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
switch msg.Type {
case "input":
if _, err := session.Write([]byte(msg.Data)); err != nil {
cleanup()
return
}
case "resize":
if msg.Rows > 0 && msg.Cols > 0 {
session.Resize(msg.Rows, msg.Cols)
}
}
}
}
func (s *Server) handleTerminalSessions(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
masked := make([]config.SSHConnection, len(s.config.Terminal.SSH))
for i, c := range s.config.Terminal.SSH {
masked[i] = c
if masked[i].Password != "" {
masked[i].Password = "***"
}
}
writeJSON(w, map[string]interface{}{
"ssh": masked,
"system": detectSystemTerminals(),
})
return
}
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
var body struct {
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
KeyPath string `json:"key_path"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Name == "" || body.Host == "" {
writeError(w, "name and host required", http.StatusBadRequest)
return
}
if body.Port == 0 {
body.Port = 22
}
for i, c := range s.config.Terminal.SSH {
if c.Name == body.Name {
password := body.Password
if password == "***" {
password = c.Password
}
s.config.Terminal.SSH[i] = config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: password,
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
return
}
}
conn := config.SSHConnection{
Name: body.Name,
Host: body.Host,
Port: body.Port,
User: body.User,
KeyPath: body.KeyPath,
Password: body.Password,
}
if s.config.Terminal.SSH == nil {
s.config.Terminal.SSH = []config.SSHConnection{}
}
s.config.Terminal.SSH = append(s.config.Terminal.SSH, conn)
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminalSessionsDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != "DELETE" {
writeError(w, "DELETE only", http.StatusMethodNotAllowed)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/terminal/sessions/")
if name == "" {
writeError(w, "name required", http.StatusBadRequest)
return
}
found := false
for i, c := range s.config.Terminal.SSH {
if c.Name == name {
s.config.Terminal.SSH = append(s.config.Terminal.SSH[:i], s.config.Terminal.SSH[i+1:]...)
found = true
break
}
}
if !found {
writeError(w, "not found", http.StatusNotFound)
return
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func detectShell() string {
shells := []string{"zsh", "bash", "fish", "pwsh", "powershell"}
for _, s := range shells {
if path, err := exec.LookPath(s); err == nil {
return path
}
}
return "/bin/sh"
}
// listWSLDistros returns the list of installed WSL distribution names.
// Windows hosts only — returns nil on other platforms or if WSL is unavailable.
func listWSLDistros() []string {
if runtime.GOOS != "windows" {
return nil
}
out, err := exec.Command("wsl", "--list", "--quiet").Output()
if err != nil {
return nil
}
// `wsl --list --quiet` outputs UTF-16LE on Windows. Strip BOM and decode best-effort.
raw := stripUTF16ToASCII(out)
var distros []string
seen := make(map[string]bool)
for _, line := range strings.Split(raw, "\n") {
name := strings.TrimSpace(line)
if name == "" || seen[name] {
continue
}
// Skip default-marker arrows or annotations.
name = strings.TrimSpace(strings.TrimPrefix(name, "*"))
if name == "" || !validWSLName.MatchString(name) {
continue
}
seen[name] = true
distros = append(distros, name)
}
return distros
}
var validWSLName = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// parseWSLShell recognises strings of the form "wsl -d <distro>" (and optionally
// "-u <user>") emitted by the Shell tab quick-access menu, returning the args
// to pass to the wsl binary. Returns ok=false otherwise.
func parseWSLShell(shell string) ([]string, bool) {
parts := strings.Fields(shell)
if len(parts) < 3 || parts[0] != "wsl" {
return nil, false
}
args := []string{}
i := 1
for i < len(parts) {
switch parts[i] {
case "-d", "--distribution":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-d", parts[i+1])
i += 2
case "-u", "--user":
if i+1 >= len(parts) || !validWSLName.MatchString(parts[i+1]) {
return nil, false
}
args = append(args, "-u", parts[i+1])
i += 2
default:
return nil, false
}
}
if len(args) == 0 {
return nil, false
}
return args, true
}
func stripUTF16ToASCII(b []byte) string {
// Best-effort: keep only printable bytes (drop high bytes from UTF-16LE pairs).
var out []byte
for i := 0; i < len(b); i++ {
c := b[i]
if c == 0 {
continue
}
if c >= 32 && c < 127 || c == '\n' || c == '\r' || c == '\t' {
out = append(out, c)
}
}
return string(out)
}
func detectSystemTerminals() []map[string]string {
var terminals []map[string]string
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Default Shell",
"shell": detectShell(),
})
if runtime.GOOS == "windows" {
if _, err := exec.LookPath("wsl"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL (default)",
"shell": "wsl",
})
for _, distro := range listWSLDistros() {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "WSL: " + distro,
"shell": "wsl -d " + distro,
})
}
}
if _, err := exec.LookPath("powershell"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell",
"shell": "powershell",
})
}
if _, err := exec.LookPath("pwsh"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "PowerShell Core",
"shell": "pwsh",
})
}
if _, err := exec.LookPath("cmd"); err == nil {
terminals = append(terminals, map[string]string{
"type": "local",
"name": "Command Prompt",
"shell": "cmd",
})
}
}
return terminals
}