first commit
This commit is contained in:
140
backend/internal/services/web_terminal.go
Normal file
140
backend/internal/services/web_terminal.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/Kodecable/crosspty"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type terminalWSMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data string `json:"data,omitempty"`
|
||||
Cols uint16 `json:"cols,omitempty"`
|
||||
Rows uint16 `json:"rows,omitempty"`
|
||||
}
|
||||
|
||||
// ServeWebTerminal attaches a PTY shell session to the websocket connection.
|
||||
func ServeWebTerminal(conn *websocket.Conn, workDir string) error {
|
||||
abs, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析目录失败: %w", err)
|
||||
}
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("目录不存在: %s", abs)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("不是有效目录: %s", abs)
|
||||
}
|
||||
|
||||
ptmx, err := crosspty.Start(crosspty.CommandConfig{
|
||||
Argv: defaultShellArgv(),
|
||||
Dir: abs,
|
||||
Env: os.Environ(),
|
||||
EnvInject: map[string]string{
|
||||
"TERM": "xterm-256color",
|
||||
"COLORTERM": "truecolor",
|
||||
},
|
||||
Size: crosspty.TermSize{Rows: 24, Cols: 80},
|
||||
})
|
||||
if err != nil {
|
||||
if err == crosspty.ErrConPTYNotSupported {
|
||||
return fmt.Errorf("当前 Windows 版本不支持 ConPTY,需 Windows 10 1809 或更高版本")
|
||||
}
|
||||
return fmt.Errorf("启动终端失败: %w", err)
|
||||
}
|
||||
defer ptmx.Close()
|
||||
|
||||
var writeMu sync.Mutex
|
||||
writeJSON := func(v any) error {
|
||||
writeMu.Lock()
|
||||
defer writeMu.Unlock()
|
||||
return conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, readErr := ptmx.Read(buf)
|
||||
if n > 0 {
|
||||
writeMu.Lock()
|
||||
werr := conn.WriteMessage(websocket.BinaryMessage, buf[:n])
|
||||
writeMu.Unlock()
|
||||
if werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
msgType, payload, readErr := conn.ReadMessage()
|
||||
if readErr != nil {
|
||||
if websocket.IsCloseError(readErr, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
|
||||
return nil
|
||||
}
|
||||
if readErr == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return readErr
|
||||
}
|
||||
|
||||
switch msgType {
|
||||
case websocket.BinaryMessage:
|
||||
if _, werr := ptmx.Write(payload); werr != nil {
|
||||
return werr
|
||||
}
|
||||
case websocket.TextMessage:
|
||||
var msg terminalWSMessage
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
if _, werr := ptmx.Write(payload); werr != nil {
|
||||
return werr
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch msg.Type {
|
||||
case "input":
|
||||
if _, werr := ptmx.Write([]byte(msg.Data)); werr != nil {
|
||||
return werr
|
||||
}
|
||||
case "resize":
|
||||
if msg.Cols > 0 && msg.Rows > 0 {
|
||||
_ = ptmx.Resize(crosspty.TermSize{Rows: msg.Rows, Cols: msg.Cols})
|
||||
}
|
||||
case "ping":
|
||||
if err := writeJSON(map[string]string{"type": "pong"}); err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
if msg.Data != "" {
|
||||
if _, werr := ptmx.Write([]byte(msg.Data)); werr != nil {
|
||||
return werr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func defaultShellArgv() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if comspec := os.Getenv("COMSPEC"); comspec != "" {
|
||||
return []string{comspec}
|
||||
}
|
||||
return []string{"cmd.exe"}
|
||||
}
|
||||
if shell := os.Getenv("SHELL"); shell != "" {
|
||||
return []string{shell, "-l"}
|
||||
}
|
||||
return []string{"/bin/bash", "-l"}
|
||||
}
|
||||
Reference in New Issue
Block a user