diff --git a/src/cmd/clear.go b/src/cmd/clear.go index 3435cc6..2381224 100644 --- a/src/cmd/clear.go +++ b/src/cmd/clear.go @@ -10,6 +10,11 @@ import ( ) func clearCommand(args []string) error { + if ui.ClearConsole() { + ui.ApplicationBanner() + return nil + } + clearCmd, err := getClearCommand() if err != nil { return fmt.Errorf("failed to determine clear command: %w", err) diff --git a/src/main.go b/src/main.go index 92e34d8..b1d4934 100644 --- a/src/main.go +++ b/src/main.go @@ -1,35 +1,14 @@ package main import ( - "bufio" - "fmt" - "os" - "strings" + "log" "servercommander/src/cmd" "servercommander/src/ui" - "servercommander/src/utils" ) func main() { - ui.ApplicationBanner() - reader := bufio.NewReader(os.Stdin) - - for { - fmt.Print(utils.Yellow, "\n>> ", utils.Reset) - input, err := reader.ReadString('\n') - if err != nil { - fmt.Println(utils.Red, "Failed to read input:", err, utils.Reset) - continue - } - - command := strings.TrimSpace(input) - if command == "" { - continue - } - - if err := cmd.Execute(command); err != nil { - fmt.Println(utils.Red, err, utils.Reset) - } + if err := ui.RunStandaloneConsole(cmd.Execute); err != nil { + log.Fatal(err) } } diff --git a/src/ui/console_server.go b/src/ui/console_server.go new file mode 100644 index 0000000..eff78e7 --- /dev/null +++ b/src/ui/console_server.go @@ -0,0 +1,557 @@ +package ui + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "regexp" + "runtime" + "strings" + "sync" + + "servercommander/src/utils" +) + +type commandExecutor func(string) error + +type eventPayload struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +type ConsoleServer struct { + executor commandExecutor + listener net.Listener + clients map[int]chan eventPayload + clientsMu sync.Mutex + history []eventPayload + historyMu sync.Mutex + errCh chan error + + stdoutPipe *os.File + stderrPipe *os.File + origStdout *os.File + origStderr *os.File +} + +var ( + consoleInstance *ConsoleServer + consoleMu sync.RWMutex +) + +const ( + controlClearEvent = "clear" + appendEvent = "append" +) + +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +func RunStandaloneConsole(executor func(string) error) error { + server, err := newConsoleServer(executor) + if err != nil { + return err + } + + consoleMu.Lock() + consoleInstance = server + consoleMu.Unlock() + + return server.run() +} + +func ClearConsole() bool { + consoleMu.RLock() + server := consoleInstance + consoleMu.RUnlock() + if server == nil { + return false + } + server.clear() + return true +} + +func newConsoleServer(executor commandExecutor) (*ConsoleServer, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("failed to allocate listener: %w", err) + } + + server := &ConsoleServer{ + executor: executor, + listener: listener, + clients: make(map[int]chan eventPayload), + errCh: make(chan error, 1), + } + + if err := server.redirectStandardStreams(); err != nil { + listener.Close() + return nil, err + } + + return server, nil +} + +func (c *ConsoleServer) run() error { + mux := http.NewServeMux() + mux.HandleFunc("/", c.handleIndex) + mux.HandleFunc("/execute", c.handleExecute) + mux.HandleFunc("/events", c.handleEvents) + + go func() { + c.errCh <- http.Serve(c.listener, mux) + }() + + url := "http://" + c.listener.Addr().String() + "/" + if err := openBrowser(url); err != nil { + fmt.Println(utils.Yellow, "Unable to automatically open the interface. Visit", url, "in your browser.", utils.Reset) + } else { + fmt.Println(utils.Green, "ServerCommander interface started at", url, utils.Reset) + } + + ApplicationBanner() + + err := <-c.errCh + if err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} + +func (c *ConsoleServer) redirectStandardStreams() error { + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + return fmt.Errorf("failed to redirect stdout: %w", err) + } + + stderrReader, stderrWriter, err := os.Pipe() + if err != nil { + stdoutReader.Close() + stdoutWriter.Close() + return fmt.Errorf("failed to redirect stderr: %w", err) + } + + c.stdoutPipe = stdoutWriter + c.stderrPipe = stderrWriter + c.origStdout = os.Stdout + c.origStderr = os.Stderr + + os.Stdout = stdoutWriter + os.Stderr = stderrWriter + + go c.readPipe(stdoutReader, c.origStdout) + go c.readPipe(stderrReader, c.origStderr) + + return nil +} + +func (c *ConsoleServer) readPipe(pipe *os.File, mirror *os.File) { + reader := bufio.NewReader(pipe) + buffer := make([]byte, 1024) + var pending strings.Builder + + for { + n, err := reader.Read(buffer) + if n > 0 { + chunk := buffer[:n] + if mirror != nil { + _, _ = mirror.Write(chunk) + } + + pending.Write(chunk) + emit, remainder := splitANSISequences(pending.String()) + if emit != "" { + c.broadcastAppend(emit) + } + + pending.Reset() + if remainder != "" { + pending.WriteString(remainder) + } + } + + if err != nil { + if pending.Len() > 0 { + c.broadcastAppend(pending.String()) + } + return + } + } +} + +func splitANSISequences(input string) (string, string) { + last := strings.LastIndexByte(input, 0x1b) + if last == -1 { + return input, "" + } + + fragment := input[last:] + if fragment == "" { + return input, "" + } + + if ansiSequenceComplete(fragment) { + return input, "" + } + + return input[:last], fragment +} + +func ansiSequenceComplete(fragment string) bool { + if len(fragment) < 2 { + return false + } + + if fragment[0] != 0x1b { + return true + } + + if fragment[1] != '[' { + return true + } + + for i := 2; i < len(fragment); i++ { + b := fragment[i] + if (b >= '0' && b <= '9') || b == ';' { + continue + } + if b >= '@' && b <= '~' { + return true + } + return false + } + + return false +} + +func (c *ConsoleServer) handleIndex(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, consoleHTML) +} + +func (c *ConsoleServer) handleExecute(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var payload struct { + Command string `json:"command"` + } + + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + command := strings.TrimSpace(payload.Command) + if command == "" { + w.WriteHeader(http.StatusNoContent) + return + } + + c.broadcastAppend(">> " + command + "\n") + go c.executeCommand(command) + w.WriteHeader(http.StatusAccepted) +} + +func (c *ConsoleServer) executeCommand(command string) { + if c.executor == nil { + return + } + + if err := c.executor(command); err != nil { + fmt.Println(utils.Red, err.Error(), utils.Reset) + } +} + +func (c *ConsoleServer) handleEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + clientChan := make(chan eventPayload, 16) + clientID := c.addClient(clientChan) + defer c.removeClient(clientID) + + history := c.snapshotHistory() + for _, event := range history { + if err := writeSSE(w, event); err != nil { + return + } + } + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + case event := <-clientChan: + if err := writeSSE(w, event); err != nil { + return + } + flusher.Flush() + } + } +} + +func (c *ConsoleServer) addClient(ch chan eventPayload) int { + c.clientsMu.Lock() + defer c.clientsMu.Unlock() + + id := len(c.clients) + 1 + c.clients[id] = ch + return id +} + +func (c *ConsoleServer) removeClient(id int) { + c.clientsMu.Lock() + defer c.clientsMu.Unlock() + + if ch, exists := c.clients[id]; exists { + close(ch) + delete(c.clients, id) + } +} + +func (c *ConsoleServer) snapshotHistory() []eventPayload { + c.historyMu.Lock() + defer c.historyMu.Unlock() + + snapshot := make([]eventPayload, len(c.history)) + copy(snapshot, c.history) + return snapshot +} + +func (c *ConsoleServer) broadcastAppend(text string) { + cleaned := sanitizeText(text) + if cleaned == "" { + return + } + + event := eventPayload{Type: appendEvent, Text: cleaned} + + c.historyMu.Lock() + c.history = append(c.history, event) + c.historyMu.Unlock() + + c.dispatch(event) +} + +func (c *ConsoleServer) clear() { + c.historyMu.Lock() + c.history = nil + c.historyMu.Unlock() + + c.dispatch(eventPayload{Type: controlClearEvent}) +} + +func (c *ConsoleServer) dispatch(event eventPayload) { + c.clientsMu.Lock() + defer c.clientsMu.Unlock() + + for id, ch := range c.clients { + select { + case ch <- event: + default: + go func(target chan eventPayload, identifier int) { + target <- event + }(ch, id) + } + } +} + +func sanitizeText(text string) string { + if text == "" { + return "" + } + + cleaned := ansiPattern.ReplaceAllString(text, "") + cleaned = strings.ReplaceAll(cleaned, "\r\n", "\n") + cleaned = strings.ReplaceAll(cleaned, "\r", "\n") + return cleaned +} + +func writeSSE(w http.ResponseWriter, event eventPayload) error { + data, err := json.Marshal(event) + if err != nil { + return err + } + + if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { + return err + } + return nil +} + +const consoleHTML = ` + +
+ +