From 761a3a122bee613dc7cd5dd13258d0f57e61caeb Mon Sep 17 00:00:00 2001 From: Stefan Friese Date: Tue, 11 Feb 2025 14:11:22 +0000 Subject: [PATCH] added interactive-mode --- go.mod | 5 ++ go.sum | 4 + main.go | 56 +++++++++++- static/start-interactive.js | 169 ++++++++++++++++++++++++++++++++++++ static/stylesheet.css | 13 +++ templates/index.html | 2 + 6 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 go.sum create mode 100644 static/start-interactive.js diff --git a/go.mod b/go.mod index dbdbdbc..4096831 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module gommand go 1.23.5 + +require ( + github.com/creack/pty v1.1.24 + github.com/gorilla/websocket v1.5.3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a15fd45 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/main.go b/main.go index d7a3e43..0680fbe 100644 --- a/main.go +++ b/main.go @@ -8,6 +8,7 @@ import ( "fmt" "html/template" "io" + "log" "net/http" "net/url" "os" @@ -16,6 +17,9 @@ import ( "path/filepath" "regexp" "strings" + + "github.com/creack/pty" + "github.com/gorilla/websocket" ) type PageData struct { @@ -176,6 +180,7 @@ func executeCommandTree(node *CommandNode, currentDir string) (string, error) { } return executeCommandTree(node.Right, currentDir) + // TODO: or operator does not work correctly, fix it case "||": leftOutput, err := executeCommandTree(node.Left, currentDir) if err == nil && leftOutput != "" { @@ -442,8 +447,6 @@ func parseCommandWithQuotes(command string) []string { func handler(w http.ResponseWriter, r *http.Request) { currentDir, _ := os.Getwd() displayDir := filepath.Base(currentDir) - // displayDirList := strings.Split(currentDir, "/") - // displayDir := displayDirList[len(displayDirList)-1] // get the last element of the path currentUser, _ := user.Current() currentUsername := currentUser.Username hostname, _ := os.Hostname() @@ -485,7 +488,56 @@ func main() { http.HandleFunc("/", handler) http.HandleFunc("/upload", fileUploadHandler) http.HandleFunc("/download", fileDownloadHandler) + http.HandleFunc("/terminal", terminalHandler) http.Handle("/static/", http.FileServer(http.FS(staticFiles))) fmt.Println("Starting server on :8080") http.ListenAndServe(":8080", nil) } + +var upgrader = websocket.Upgrader { + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func terminalHandler (w http.ResponseWriter, r *http.Request) { + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Websocket upgrade error: %v", err) + return + } + defer ws.Close() + + cmd := exec.Command("bash") + ptmx, err := pty.Start(cmd) + if err != nil { + log.Printf("Error starting PTY: %v", err) + return + } + defer func() { _ = ptmx.Close() }() + ptmx.Write([]byte("stty raw -echo\n")) + // ptmx.Write([]byte("export SHELL=bash 1>&2 2>/dev/null; export TERM=xterm-256color 1>&2 2>/dev/null")) + + go func() { + buf := make([]byte, 1024) + for { + n, err := ptmx.Read(buf) + if err != nil { + break + } + if err := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { + break + } + } + }() + + for { + _, message, err := ws.ReadMessage() + if err != nil { + break + } + if _, err := ptmx.Write(message); err != nil { + break + } + } +} diff --git a/static/start-interactive.js b/static/start-interactive.js new file mode 100644 index 0000000..024de9b --- /dev/null +++ b/static/start-interactive.js @@ -0,0 +1,169 @@ +document.addEventListener("DOMContentLoaded", function () { + const input = document.getElementById("command-input"); + const terminal = document.getElementById("terminal"); + let interactiveWS = null; + let interactiveMode = false; + let lastKeySentTime = 0; + const throttleInterval = 50; // milliseconds + + // Holds the user’s input on the current line. + let currentLineBuffer = ""; + let currentLineElem = null; + const ansi_up = new AnsiUp; + + input.addEventListener("keydown", function (event) { + if (!interactiveMode && event.key === "Enter") { + const command = input.value.trim(); + if (command === "start-interactive") { + startInteractiveSession(); + input.value = ""; + event.preventDefault(); + return; + } + // Otherwise, normal HTTP submission… + } + }); + + function startInteractiveSession() { + interactiveWS = new WebSocket("ws://" + location.host + "/terminal"); + interactiveMode = true; + terminal.insertAdjacentHTML('beforeend', "\n--- Interactive session started ---\n"); + input.style.display = "none"; // Hide normal input. + currentLineBuffer = ""; + createCurrentLineElem(); + + interactiveWS.binaryType = "arraybuffer"; + + interactiveWS.onmessage = function (event) { + // When the shell returns output (for example, after Enter), process it. + let text = new TextDecoder("utf-8").decode(event.data); + text = removeOSCTitle(text); + processPTYOutput(text); + }; + + interactiveWS.onclose = function () { + interactiveMode = false; + terminal.insertAdjacentHTML('beforeend', "\n--- Interactive session ended ---\n"); + input.style.display = "block"; + input.focus(); + document.removeEventListener("keydown", handleInteractiveKey, true); + }; + + interactiveWS.onerror = function (err) { + terminal.insertAdjacentHTML('beforeend', "\n--- Error in interactive session ---\n"); + console.error("Interactive WS error:", err); + interactiveMode = false; + input.style.display = "block"; + document.removeEventListener("keydown", handleInteractiveKey, true); + }; + + // Listen for all key events while in interactive mode. + document.addEventListener("keydown", handleInteractiveKey, true); + } + + // Process output received from the PTY. + // We expect output to include completed lines (with newline) as command results. + function processPTYOutput(text) { + // Split output into lines. + const parts = text.split("\n"); + for (let i = 0; i < parts.length; i++) { + const processedPart = processBackspaces(parts[i]); + if (i < parts.length - 1) { + // Completed line. + currentLineBuffer += processedPart; + const finishedLine = document.createElement("div"); + finishedLine.innerHTML = ansi_up.ansi_to_html(currentLineBuffer); + terminal.appendChild(finishedLine); + currentLineBuffer = ""; + createCurrentLineElem(); + } else { + // Incomplete line. + currentLineBuffer += processedPart; + } + } + updateCurrentLine(); + } + + // Update the current line element with the current buffer and a blinking cursor. + function updateCurrentLine() { + if (currentLineElem) { + currentLineElem.innerHTML = ansi_up.ansi_to_html(currentLineBuffer); + updateCursor(); + } + terminal.scrollTop = terminal.scrollHeight; + } + + // Create (or recreate) the current line element. + function createCurrentLineElem() { + if (currentLineElem && currentLineElem.parentNode) { + currentLineElem.parentNode.removeChild(currentLineElem); + } + currentLineElem = document.createElement("span"); + currentLineElem.id = "current-line"; + // Ensure the current line element is inline so the cursor stays on the same line. + currentLineElem.style.display = "inline"; + terminal.appendChild(currentLineElem); + } + + // Append a blinking cursor to the current line. + function updateCursor() { + const existingCursor = currentLineElem.querySelector(".cursor"); + if (existingCursor) { + existingCursor.parentNode.removeChild(existingCursor); + } + currentLineElem.insertAdjacentHTML('beforeend', ''); + } + + // Throttled key handler that updates the local current line buffer and sends keys to the PTY. + function handleInteractiveKey(event) { + if (!interactiveMode || !interactiveWS || interactiveWS.readyState !== WebSocket.OPEN) + return; + const now = Date.now(); + if (now - lastKeySentTime < throttleInterval) { + event.preventDefault(); + return; + } + lastKeySentTime = now; + let data = ""; + if (event.key === "Enter") { + data = "\n"; + // Append newline to the local buffer and clear it + currentLineBuffer += "\n"; + updateCurrentLine(); + currentLineBuffer = ""; + } else if (event.key === "Backspace") { + data = "\x7f"; // DEL + // Update the local buffer: remove the last character. + currentLineBuffer = currentLineBuffer.slice(0, -1); + updateCurrentLine(); + } else if (event.key.length === 1) { + data = event.key; + currentLineBuffer += event.key; + updateCurrentLine(); + } else { + return; + } + interactiveWS.send(data); + event.preventDefault(); + } + + // Remove OSC sequences (for terminal title updates). + function removeOSCTitle(text) { + const oscRegex = /\x1b\]0;.*?(?:\x07|\x1b\\)/g; + return text.replace(oscRegex, ""); + } + + // Process backspace characters in a string (for PTY output). + function processBackspaces(text) { + let result = ""; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "\x7f" || ch === "\b") { + result = result.slice(0, -1); + } else { + result += ch; + } + } + return result; + } +}); diff --git a/static/stylesheet.css b/static/stylesheet.css index 0b91b15..7908782 100644 --- a/static/stylesheet.css +++ b/static/stylesheet.css @@ -41,6 +41,10 @@ body { /* overflow: hidden; */ /* text-overflow: ellipsis; */ } +/* current-line is for interactive mode */ +#current-line { + display: inline; +} input { flex-grow: 1; font-size: 14pt; @@ -54,6 +58,15 @@ input { input:focus { outline: none; } +span.cursor { + display: inline-block; + width: 10px; + animation: blink 1s step-end infinite; + vertical-align: bottom; +} +@keyframes blink { + 50% { opacity: 0; } +} span.command { color: var(--command-color); } diff --git a/templates/index.html b/templates/index.html index 0543aae..6a50930 100644 --- a/templates/index.html +++ b/templates/index.html @@ -7,6 +7,8 @@ + +