Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ Thumbs.db
# Log files
mud-output-*.log
tui-content-*.log
telnet-debug-*.log

# Web session directories
.websessions/
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
Expand Down
43 changes: 41 additions & 2 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import (
"fmt"
"log"
"net/http"
"path/filepath"

"github.com/google/uuid"
)

// Server represents the web server
Expand Down Expand Up @@ -34,8 +37,12 @@ func Start(port int) error {
func StartWithLogging(port int, enableLogs bool) error {
server := NewServerWithLogging(port, enableLogs)

// Serve static files from web/static directory
http.Handle("/", http.FileServer(http.Dir("web/static")))
// Handle root with session management
http.HandleFunc("/", server.handleRoot)

// Serve static files (CSS, JS)
http.Handle("/styles.css", http.FileServer(http.Dir("web/static")))
http.Handle("/app.js", http.FileServer(http.Dir("web/static")))

// WebSocket endpoint
http.HandleFunc("/ws", server.handler.HandleWebSocket)
Expand All @@ -44,3 +51,35 @@ func StartWithLogging(port int, enableLogs bool) error {
log.Printf("Starting web server on %s", addr)
return http.ListenAndServe(addr, nil)
}

// handleRoot serves the main page and handles session management
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
// Check if session ID is provided
sessionID := r.URL.Query().Get("id")

if sessionID == "" {
// No session ID - generate a new GUID and redirect
newSessionID := uuid.New().String()
redirectURL := fmt.Sprintf("/?id=%s", newSessionID)
http.Redirect(w, r, redirectURL, http.StatusFound)
log.Printf("New session created: %s", newSessionID)
return
}

// Validate session ID is a valid UUID
if _, err := uuid.Parse(sessionID); err != nil {
// Invalid UUID - generate a new one and redirect
newSessionID := uuid.New().String()
redirectURL := fmt.Sprintf("/?id=%s", newSessionID)
http.Redirect(w, r, redirectURL, http.StatusFound)
log.Printf("Invalid session ID, created new session: %s", newSessionID)
return
}

// Store session ID for the handler to use
s.handler.SetSessionID(sessionID)

// Serve the static index.html file
http.ServeFile(w, r, filepath.Join("web", "static", "index.html"))
}

116 changes: 108 additions & 8 deletions internal/web/websocket.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"sync"
"unicode/utf8"

Expand All @@ -26,9 +27,11 @@ var upgrader = websocket.Upgrader{

// WebSocketHandler handles WebSocket connections
type WebSocketHandler struct {
sessions map[*websocket.Conn]*Session
mu sync.RWMutex
enableLogs bool // Whether to enable logging for spawned TUI instances
sessions map[*websocket.Conn]*Session
mu sync.RWMutex
enableLogs bool // Whether to enable logging for spawned TUI instances
currentSessID string // Current session ID to use for new connections
sessionIDMu sync.RWMutex
}

// Session represents a WebSocket session with a PTY running the TUI
Expand All @@ -39,6 +42,7 @@ type Session struct {
mu sync.Mutex
closed bool
utf8Buffer []byte // Buffer for incomplete UTF-8 sequences at PTY read boundaries
sessionID string // Session ID for this connection
}

// ConnectMessage represents the connection request from client
Expand Down Expand Up @@ -70,6 +74,20 @@ func NewWebSocketHandlerWithLogging(enableLogs bool) *WebSocketHandler {
}
}

// SetSessionID sets the current session ID for the next connection
func (h *WebSocketHandler) SetSessionID(sessionID string) {
h.sessionIDMu.Lock()
defer h.sessionIDMu.Unlock()
h.currentSessID = sessionID
}

// GetSessionID gets the current session ID
func (h *WebSocketHandler) GetSessionID() string {
h.sessionIDMu.RLock()
defer h.sessionIDMu.RUnlock()
return h.currentSessID
}

// HandleWebSocket handles WebSocket connections
func (h *WebSocketHandler) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
ws, err := upgrader.Upgrade(w, r, nil)
Expand All @@ -81,9 +99,17 @@ func (h *WebSocketHandler) HandleWebSocket(w http.ResponseWriter, r *http.Reques

log.Printf("New WebSocket connection from %s", r.RemoteAddr)

// Get session ID
sessionID := h.GetSessionID()
if sessionID == "" {
log.Printf("Warning: No session ID set for WebSocket connection")
sessionID = "default"
}

// Create a new session
session := &Session{
ws: ws,
ws: ws,
sessionID: sessionID,
}

h.mu.Lock()
Expand All @@ -97,6 +123,9 @@ func (h *WebSocketHandler) HandleWebSocket(w http.ResponseWriter, r *http.Reques
session.cleanup()
}()

// Auto-start the TUI client immediately (no connection parameters needed)
h.autoStartTUI(session)

// Handle incoming messages
for {
messageType, message, err := ws.ReadMessage()
Expand All @@ -113,9 +142,6 @@ func (h *WebSocketHandler) HandleWebSocket(w http.ResponseWriter, r *http.Reques
if err := json.Unmarshal(message, &msg); err == nil {
if msgType, ok := msg["type"].(string); ok {
switch msgType {
case "connect":
h.handleConnect(session, message)
continue
case "resize":
h.handleResize(session, message)
continue
Expand All @@ -137,14 +163,85 @@ func (h *WebSocketHandler) HandleWebSocket(w http.ResponseWriter, r *http.Reques
}
}

// handleConnect starts the TUI client in a PTY
// autoStartTUI automatically starts the TUI client in a PTY with no arguments
func (h *WebSocketHandler) autoStartTUI(session *Session) {
// Create session directory
sessionDir := filepath.Join(".websessions", session.sessionID)
if err := os.MkdirAll(sessionDir, 0755); err != nil {
session.sendError(fmt.Sprintf("Failed to create session directory: %v", err))
return
}

// Get the path to the dikuclient binary
dikuclientPath, err := exec.LookPath("dikuclient")
if err != nil {
// Try relative path from current working directory
cwd, _ := os.Getwd()
dikuclientPath = filepath.Join(cwd, "dikuclient")
// Check if it exists
if _, err := os.Stat(dikuclientPath); err != nil {
dikuclientPath = "./dikuclient"
}
}

// Build command arguments - no host/port, just optional logging
args := []string{}
if h.enableLogs {
args = append(args, "--log-all")
}

// Start the TUI client
cmd := exec.Command(dikuclientPath, args...)

// Get absolute path for session directory
absSessionDir, err := filepath.Abs(sessionDir)
if err != nil {
session.sendError(fmt.Sprintf("Failed to get absolute path: %v", err))
return
}

// Set working directory to session directory
cmd.Dir = absSessionDir

// Start the command with a PTY
ptmx, err := pty.Start(cmd)
if err != nil {
session.sendError(fmt.Sprintf("Failed to start TUI: %v", err))
return
}

// Set initial PTY size (will be updated by client)
pty.Setsize(ptmx, &pty.Winsize{
Rows: 24,
Cols: 80,
})

session.mu.Lock()
session.ptmx = ptmx
session.cmd = cmd
session.mu.Unlock()

// Start forwarding PTY output to WebSocket
go h.forwardPTYOutput(session)

log.Printf("Started TUI session for %s (no host/port - interactive mode)", session.sessionID)
}

// handleConnect starts the TUI client in a PTY (deprecated - kept for compatibility)
func (h *WebSocketHandler) handleConnect(session *Session, message []byte) {
var connectMsg ConnectMessage
if err := json.Unmarshal(message, &connectMsg); err != nil {
session.sendError(fmt.Sprintf("Invalid connect message: %v", err))
return
}

// Create session directory
sessionDir := filepath.Join(".websessions", session.sessionID)
if err := os.MkdirAll(sessionDir, 0755); err != nil {
session.sendError(fmt.Sprintf("Failed to create session directory: %v", err))
return
}

// Get the path to the dikuclient binary
// In production, this should be configurable
dikuclientPath, err := exec.LookPath("dikuclient")
Expand All @@ -161,6 +258,9 @@ func (h *WebSocketHandler) handleConnect(session *Session, message []byte) {

// Start the TUI client
cmd := exec.Command(dikuclientPath, args...)

// Set working directory to session directory
cmd.Dir = sessionDir

// Start the command with a PTY
ptmx, err := pty.Start(cmd)
Expand Down
Loading