From 86b2596214428e8368b2b0a99604396aec66484c Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 14 May 2024 23:25:21 -0700 Subject: [PATCH] terminal working, not prod ready, but it works --- frontend/app/view/term.tsx | 39 +++++- pkg/blockcontroller/blockcommand.go | 92 ++++++++++++++ pkg/blockcontroller/blockcontroller.go | 158 ++++++++++++++++--------- pkg/shellexec/shellexec.go | 45 +++++++ 4 files changed, 276 insertions(+), 58 deletions(-) create mode 100644 pkg/blockcontroller/blockcommand.go diff --git a/frontend/app/view/term.tsx b/frontend/app/view/term.tsx index 693d24e5..2cc1f81c 100644 --- a/frontend/app/view/term.tsx +++ b/frontend/app/view/term.tsx @@ -44,7 +44,8 @@ function getThemeFromCSSVars(el: Element): ITheme { const TerminalView = ({ blockId }: { blockId: string }) => { const connectElemRef = React.useRef(null); - const [term, setTerm] = React.useState(null); + const [term, setTerm] = React.useState(null); + const [blockStarted, setBlockStarted] = React.useState(false); React.useEffect(() => { if (!connectElemRef.current) { @@ -58,16 +59,27 @@ const TerminalView = ({ blockId }: { blockId: string }) => { fontWeight: "normal", fontWeightBold: "bold", }); + setTerm(term); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(connectElemRef.current); fitAddon.fit(); term.write("Hello, world!\r\n"); - setTerm(term); + console.log(term); + term.onData((data) => { + const b64data = btoa(data); + const inputCmd = { command: "input", blockid: blockId, inputdata64: b64data }; + BlockService.SendCommand(blockId, inputCmd); + }); // resize observer const rszObs = new ResizeObserver(() => { + const oldRows = term.rows; + const oldCols = term.cols; fitAddon.fit(); + if (oldRows !== term.rows || oldCols !== term.cols) { + BlockService.SendCommand(blockId, { command: "input", termsize: { rows: term.rows, cols: term.cols } }); + } }); rszObs.observe(connectElemRef.current); @@ -88,7 +100,10 @@ const TerminalView = ({ blockId }: { blockId: string }) => { async function handleRunClick() { try { - await BlockService.StartBlock(blockId); + if (!blockStarted) { + await BlockService.StartBlock(blockId); + setBlockStarted(true); + } let termSize = { rows: term.rows, cols: term.cols }; await BlockService.SendCommand(blockId, { command: "run", cmdstr: "ls -l", termsize: termSize }); } catch (e) { @@ -96,12 +111,28 @@ const TerminalView = ({ blockId }: { blockId: string }) => { } } + async function handleStartTerminalClick() { + try { + if (!blockStarted) { + await BlockService.StartBlock(blockId); + setBlockStarted(true); + } + let termSize = { rows: term.rows, cols: term.cols }; + await BlockService.SendCommand(blockId, { command: "runshell", termsize: termSize }); + } catch (e) { + console.log("start terminal click error: ", e); + } + } + return (
Terminal
+
diff --git a/pkg/blockcontroller/blockcommand.go b/pkg/blockcontroller/blockcommand.go new file mode 100644 index 00000000..bdff3f0f --- /dev/null +++ b/pkg/blockcontroller/blockcommand.go @@ -0,0 +1,92 @@ +// Copyright 2024, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +package blockcontroller + +import ( + "encoding/json" + "fmt" + "reflect" + + "github.com/wavetermdev/thenextwave/pkg/shellexec" +) + +const CommandKey = "command" + +const ( + BlockCommand_Message = "message" + BlockCommand_Run = "run" + BlockCommand_Input = "input" + BlockCommand_RunShell = "runshell" +) + +var CommandToTypeMap = map[string]reflect.Type{ + BlockCommand_Message: reflect.TypeOf(MessageCommand{}), + BlockCommand_Run: reflect.TypeOf(RunCommand{}), + BlockCommand_Input: reflect.TypeOf(InputCommand{}), + BlockCommand_RunShell: reflect.TypeOf(RunShellCommand{}), +} + +type BlockCommand interface { + GetCommand() string +} + +func ParseCmdMap(cmdMap map[string]any) (BlockCommand, error) { + cmdType, ok := cmdMap[CommandKey].(string) + if !ok { + return nil, fmt.Errorf("no %s field in command map", CommandKey) + } + mapJson, err := json.Marshal(cmdMap) + if err != nil { + return nil, fmt.Errorf("error marshalling command map: %w", err) + } + rtype := CommandToTypeMap[cmdType] + if rtype == nil { + return nil, fmt.Errorf("unknown command type %q", cmdType) + } + cmd := reflect.New(rtype).Interface() + err = json.Unmarshal(mapJson, cmd) + if err != nil { + return nil, fmt.Errorf("error unmarshalling command: %w", err) + } + return cmd.(BlockCommand), nil +} + +type MessageCommand struct { + Command string `json:"command"` + Message string `json:"message"` +} + +func (mc *MessageCommand) GetCommand() string { + return BlockCommand_Message +} + +type RunCommand struct { + Command string `json:"command"` + CmdStr string `json:"cmdstr"` + TermSize shellexec.TermSize `json:"termsize"` +} + +func (rc *RunCommand) GetCommand() string { + return BlockCommand_Run +} + +type InputCommand struct { + Command string `json:"command"` + InputData64 string `json:"inputdata64"` + SigName string `json:"signame,omitempty"` + TermSize *shellexec.TermSize `json:"termsize,omitempty"` +} + +func (ic *InputCommand) GetCommand() string { + return BlockCommand_Input +} + +type RunShellCommand struct { + Command string `json:"command"` + TermSize shellexec.TermSize `json:"termsize"` +} + +func (rsc *RunShellCommand) GetCommand() string { + return BlockCommand_RunShell +} diff --git a/pkg/blockcontroller/blockcontroller.go b/pkg/blockcontroller/blockcontroller.go index 4e8de7c6..6bf657b8 100644 --- a/pkg/blockcontroller/blockcontroller.go +++ b/pkg/blockcontroller/blockcontroller.go @@ -5,12 +5,13 @@ package blockcontroller import ( "encoding/base64" - "encoding/json" "fmt" + "io" "log" "os/exec" "sync" + "github.com/creack/pty" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wavetermdev/thenextwave/pkg/eventbus" "github.com/wavetermdev/thenextwave/pkg/shellexec" @@ -20,64 +21,31 @@ import ( var globalLock = &sync.Mutex{} var blockControllerMap = make(map[string]*BlockController) -type BlockCommand interface { - GetCommand() string -} - -type MessageCommand struct { - Command string `json:"command"` - Message string `json:"message"` -} - -func (mc *MessageCommand) GetCommand() string { - return "message" -} - -type RunCommand struct { - Command string `json:"command"` - CmdStr string `json:"cmdstr"` - TermSize shellexec.TermSize `json:"termsize"` -} - -func (rc *RunCommand) GetCommand() string { - return "run" -} - type BlockController struct { - BlockId string - InputCh chan BlockCommand + Lock *sync.Mutex + BlockId string + InputCh chan BlockCommand + ShellProc *shellexec.ShellProc + ShellInputCh chan *InputCommand } -func ParseCmdMap(cmdMap map[string]any) (BlockCommand, error) { - cmdType, ok := cmdMap["command"].(string) - if !ok { - return nil, fmt.Errorf("no command field in command map") - } - mapJson, err := json.Marshal(cmdMap) - if err != nil { - return nil, fmt.Errorf("error marshalling command map: %w", err) - } - switch cmdType { - case "message": - var cmd MessageCommand - err := json.Unmarshal(mapJson, &cmd) - if err != nil { - return nil, fmt.Errorf("error unmarshalling message command: %w", err) - } - return &cmd, nil - case "run": - var cmd RunCommand - err := json.Unmarshal(mapJson, &cmd) - if err != nil { - return nil, fmt.Errorf("error unmarshalling run command: %w", err) - } - return &cmd, nil - default: - return nil, fmt.Errorf("unknown command type %q", cmdType) +func (bc *BlockController) setShellProc(shellProc *shellexec.ShellProc) error { + bc.Lock.Lock() + defer bc.Lock.Unlock() + if bc.ShellProc != nil { + return fmt.Errorf("shell process already running") } + bc.ShellProc = shellProc + return nil } -func (bc *BlockController) StartShellCommand(rc *RunCommand) error { +func (bc *BlockController) getShellProc() *shellexec.ShellProc { + bc.Lock.Lock() + defer bc.Lock.Unlock() + return bc.ShellProc +} + +func (bc *BlockController) DoRunCommand(rc *RunCommand) error { cmdStr := rc.CmdStr shellPath := shellutil.DetectLocalShellPath() ecmd := exec.Command(shellPath, "-c", cmdStr) @@ -104,6 +72,71 @@ func (bc *BlockController) StartShellCommand(rc *RunCommand) error { return nil } +func (bc *BlockController) DoRunShellCommand(rc *RunShellCommand) error { + if bc.getShellProc() != nil { + return nil + } + shellProc, err := shellexec.StartShellProc(rc.TermSize) + if err != nil { + return err + } + err = bc.setShellProc(shellProc) + if err != nil { + bc.ShellProc.Close() + return err + } + shellInputCh := make(chan *InputCommand) + bc.ShellInputCh = shellInputCh + go func() { + defer func() { + // needs synchronization + bc.ShellProc.Close() + close(bc.ShellInputCh) + bc.ShellProc = nil + bc.ShellInputCh = nil + }() + buf := make([]byte, 4096) + for { + nr, err := bc.ShellProc.Pty.Read(buf) + eventbus.SendEvent(application.WailsEvent{ + Name: "block:ptydata", + Data: map[string]any{ + "blockid": bc.BlockId, + "blockfile": "main", + "ptydata": base64.StdEncoding.EncodeToString(buf[:nr]), + }, + }) + if err == io.EOF { + break + } + if err != nil { + log.Printf("error reading from shell: %v\n", err) + break + } + } + }() + go func() { + for ic := range shellInputCh { + if ic.InputData64 != "" { + inputBuf := make([]byte, base64.StdEncoding.DecodedLen(len(ic.InputData64))) + nw, err := base64.StdEncoding.Decode(inputBuf, []byte(ic.InputData64)) + if err != nil { + log.Printf("error decoding input data: %v\n", err) + continue + } + bc.ShellProc.Pty.Write(inputBuf[:nw]) + } + if ic.TermSize != nil { + err := pty.Setsize(bc.ShellProc.Pty, &pty.Winsize{Rows: uint16(ic.TermSize.Rows), Cols: uint16(ic.TermSize.Cols)}) + if err != nil { + log.Printf("error setting term size: %v\n", err) + } + } + } + }() + return nil +} + func (bc *BlockController) Run() { defer func() { eventbus.SendEvent(application.WailsEvent{ @@ -132,12 +165,28 @@ func (bc *BlockController) Run() { case *RunCommand: fmt.Printf("RUN: %s | %q\n", bc.BlockId, cmd.CmdStr) go func() { - err := bc.StartShellCommand(cmd) + err := bc.DoRunCommand(cmd) if err != nil { log.Printf("error running shell command: %v\n", err) } }() + case *InputCommand: + fmt.Printf("INPUT: %s | %q\n", bc.BlockId, cmd.InputData64) + if bc.ShellInputCh != nil { + bc.ShellInputCh <- cmd + } + case *RunShellCommand: + fmt.Printf("RUNSHELL: %s\n", bc.BlockId) + if bc.ShellProc != nil { + continue + } + go func() { + err := bc.DoRunShellCommand(cmd) + if err != nil { + log.Printf("error running shell: %v\n", err) + } + }() default: fmt.Printf("unknown command type %T\n", cmd) } @@ -151,6 +200,7 @@ func StartBlockController(blockId string) *BlockController { return existingBC } bc := &BlockController{ + Lock: &sync.Mutex{}, BlockId: blockId, InputCh: make(chan BlockCommand), } diff --git a/pkg/shellexec/shellexec.go b/pkg/shellexec/shellexec.go index 8adab570..ef041327 100644 --- a/pkg/shellexec/shellexec.go +++ b/pkg/shellexec/shellexec.go @@ -20,6 +20,51 @@ type TermSize struct { Cols int `json:"cols"` } +type ShellProc struct { + Cmd *exec.Cmd + Pty *os.File +} + +func (sp *ShellProc) Close() { + sp.Cmd.Process.Kill() + go func() { + sp.Cmd.Process.Wait() + sp.Pty.Close() + }() +} + +func StartShellProc(termSize TermSize) (*ShellProc, error) { + shellPath := shellutil.DetectLocalShellPath() + ecmd := exec.Command(shellPath, "-i", "-l") + ecmd.Env = os.Environ() + shellutil.UpdateCmdEnv(ecmd, shellutil.WaveshellEnvVars(shellutil.DefaultTermType)) + cmdPty, cmdTty, err := pty.Open() + if err != nil { + return nil, fmt.Errorf("opening new pty: %w", err) + } + if termSize.Rows == 0 || termSize.Cols == 0 { + termSize.Rows = shellutil.DefaultTermRows + termSize.Cols = shellutil.DefaultTermCols + } + if termSize.Rows <= 0 || termSize.Cols <= 0 { + return nil, fmt.Errorf("invalid term size: %v", termSize) + } + pty.Setsize(cmdPty, &pty.Winsize{Rows: uint16(termSize.Rows), Cols: uint16(termSize.Cols)}) + ecmd.Stdin = cmdTty + ecmd.Stdout = cmdTty + ecmd.Stderr = cmdTty + ecmd.SysProcAttr = &syscall.SysProcAttr{} + ecmd.SysProcAttr.Setsid = true + ecmd.SysProcAttr.Setctty = true + err = ecmd.Start() + cmdTty.Close() + if err != nil { + cmdPty.Close() + return nil, err + } + return &ShellProc{Cmd: ecmd, Pty: cmdPty}, nil +} + func RunSimpleCmdInPty(ecmd *exec.Cmd, termSize TermSize) ([]byte, error) { ecmd.Env = os.Environ() shellutil.UpdateCmdEnv(ecmd, shellutil.WaveshellEnvVars(shellutil.DefaultTermType))