98e000e3be
Resolve GitHub issue #2 by aligning the Go module identity with the repository path so works. Changes made: - Updated go.mod module path from github.com/rcarmo/webterm-go-port to github.com/rcarmo/webterm. - Updated all internal import references to the new module path. - Updated version ldflags in Makefile and Dockerfile to use github.com/rcarmo/webterm/webterm.Version. - Added README quick-install section documenting the command. Validation: - Ran make check successfully after the rename/import updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
241 lines
4.7 KiB
Go
241 lines
4.7 KiB
Go
package webterm
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"github.com/creack/pty"
|
|
"github.com/google/shlex"
|
|
"github.com/rcarmo/webterm/internal/terminalstate"
|
|
)
|
|
|
|
type TerminalSession struct {
|
|
sessionID string
|
|
command string
|
|
|
|
mu sync.RWMutex
|
|
connector SessionConnector
|
|
cmd *exec.Cmd
|
|
ptyFile *os.File
|
|
tracker *terminalstate.Tracker
|
|
replay *ReplayBuffer
|
|
escapeBuffer []byte
|
|
width int
|
|
height int
|
|
started bool
|
|
running bool
|
|
done chan struct{}
|
|
doneOnce sync.Once
|
|
waitErr error
|
|
writeMu sync.Mutex
|
|
}
|
|
|
|
func NewTerminalSession(sessionID string, command string) *TerminalSession {
|
|
return &TerminalSession{
|
|
sessionID: sessionID,
|
|
command: command,
|
|
connector: noopConnector{},
|
|
replay: NewReplayBuffer(replayBufferSize),
|
|
done: make(chan struct{}),
|
|
width: DefaultTerminalWidth,
|
|
height: DefaultTerminalHeight,
|
|
}
|
|
}
|
|
|
|
func (s *TerminalSession) Open(width, height int) error {
|
|
if width <= 0 {
|
|
width = 80
|
|
}
|
|
if height <= 0 {
|
|
height = 24
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.running {
|
|
return nil
|
|
}
|
|
command := strings.TrimSpace(s.command)
|
|
if command == "" {
|
|
command = os.Getenv("SHELL")
|
|
}
|
|
if command == "" {
|
|
command = "/bin/sh"
|
|
}
|
|
argv, err := shlex.Split(command)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(argv) == 0 {
|
|
return errors.New("empty command")
|
|
}
|
|
cmd := exec.Command(argv[0], argv[1:]...)
|
|
cmd.Env = append(os.Environ(), "TERM_PROGRAM=webterm-go", "TERM_PROGRAM_VERSION="+Version)
|
|
file, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.cmd = cmd
|
|
s.ptyFile = file
|
|
s.tracker = terminalstate.NewTracker(width, height)
|
|
s.width = width
|
|
s.height = height
|
|
s.running = true
|
|
return nil
|
|
}
|
|
|
|
func (s *TerminalSession) Start(connector SessionConnector) error {
|
|
s.mu.Lock()
|
|
if connector != nil {
|
|
s.connector = connector
|
|
}
|
|
if s.started {
|
|
s.mu.Unlock()
|
|
return nil
|
|
}
|
|
if s.ptyFile == nil {
|
|
s.mu.Unlock()
|
|
return errors.New("session not open")
|
|
}
|
|
s.started = true
|
|
file := s.ptyFile
|
|
s.mu.Unlock()
|
|
go s.readLoop(file)
|
|
return nil
|
|
}
|
|
|
|
func (s *TerminalSession) readLoop(file *os.File) {
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
n, err := file.Read(buf)
|
|
if n > 0 {
|
|
s.handleOutput(buf[:n])
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
if s.cmd != nil {
|
|
s.waitErr = s.cmd.Wait()
|
|
}
|
|
s.running = false
|
|
connector := s.connector
|
|
s.mu.Unlock()
|
|
connector.OnClose()
|
|
s.doneOnce.Do(func() { close(s.done) })
|
|
}
|
|
|
|
func (s *TerminalSession) handleOutput(data []byte) {
|
|
s.mu.Lock()
|
|
filtered, escapeBuffer := FilterDASequences(data, s.escapeBuffer)
|
|
s.escapeBuffer = escapeBuffer
|
|
tracker := s.tracker
|
|
connector := s.connector
|
|
s.mu.Unlock()
|
|
dispatchSessionOutput(filtered, tracker, s.replay, connector)
|
|
}
|
|
|
|
func (s *TerminalSession) Close() error {
|
|
s.mu.Lock()
|
|
file := s.ptyFile
|
|
cmd := s.cmd
|
|
s.ptyFile = nil
|
|
s.running = false
|
|
s.mu.Unlock()
|
|
|
|
if cmd != nil && cmd.Process != nil {
|
|
_ = cmd.Process.Signal(syscall.SIGHUP)
|
|
}
|
|
if file != nil {
|
|
_ = file.Close()
|
|
}
|
|
s.doneOnce.Do(func() { close(s.done) })
|
|
return nil
|
|
}
|
|
|
|
func (s *TerminalSession) Wait() error {
|
|
<-s.done
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.waitErr
|
|
}
|
|
|
|
func (s *TerminalSession) SetTerminalSize(width, height int) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.ptyFile == nil {
|
|
return errors.New("session closed")
|
|
}
|
|
if width <= 0 {
|
|
width = 1
|
|
}
|
|
if height <= 0 {
|
|
height = 1
|
|
}
|
|
if err := pty.Setsize(s.ptyFile, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)}); err != nil {
|
|
return err
|
|
}
|
|
s.width = width
|
|
s.height = height
|
|
if s.tracker != nil {
|
|
s.tracker.Resize(width, height)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *TerminalSession) ForceRedraw() error {
|
|
s.mu.RLock()
|
|
width := s.width
|
|
height := s.height
|
|
s.mu.RUnlock()
|
|
return s.SetTerminalSize(width, height)
|
|
}
|
|
|
|
func (s *TerminalSession) SendBytes(data []byte) bool {
|
|
s.mu.RLock()
|
|
file := s.ptyFile
|
|
s.mu.RUnlock()
|
|
if file == nil {
|
|
return false
|
|
}
|
|
s.writeMu.Lock()
|
|
defer s.writeMu.Unlock()
|
|
_, err := file.Write(data)
|
|
return err == nil
|
|
}
|
|
|
|
func (s *TerminalSession) SendMeta(_ map[string]any) bool {
|
|
return true
|
|
}
|
|
|
|
func (s *TerminalSession) IsRunning() bool {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.running
|
|
}
|
|
|
|
func (s *TerminalSession) GetReplayBuffer() []byte {
|
|
return s.replay.Bytes()
|
|
}
|
|
|
|
func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot {
|
|
s.mu.RLock()
|
|
tracker := s.tracker
|
|
width, height := s.width, s.height
|
|
s.mu.RUnlock()
|
|
return snapshotFromTracker(tracker, width, height)
|
|
}
|
|
|
|
func (s *TerminalSession) UpdateConnector(connector SessionConnector) {
|
|
if connector == nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.connector = connector
|
|
}
|