vibetunnel/linux/pkg/server/services/terminal_manager.go
2025-06-21 02:49:38 +02:00

328 lines
8.4 KiB
Go

package services
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sync"
"github.com/vibetunnel/linux/pkg/session"
"github.com/vibetunnel/linux/pkg/terminal"
"github.com/vibetunnel/linux/pkg/termsocket"
)
// TerminalManager handles terminal session management
type TerminalManager struct {
sessionManager *session.Manager
noSpawn bool
doNotAllowColumnSet bool
bufferCallbacks map[string][]func([]byte)
mu sync.RWMutex
}
// NewTerminalManager creates a new terminal manager service
func NewTerminalManager(sessionManager *session.Manager) *TerminalManager {
return &TerminalManager{
sessionManager: sessionManager,
bufferCallbacks: make(map[string][]func([]byte)),
}
}
// SetNoSpawn configures whether terminal spawning is allowed
func (tm *TerminalManager) SetNoSpawn(noSpawn bool) {
tm.noSpawn = noSpawn
}
// SetDoNotAllowColumnSet configures whether terminal resizing is allowed
func (tm *TerminalManager) SetDoNotAllowColumnSet(doNotAllowColumnSet bool) {
tm.doNotAllowColumnSet = doNotAllowColumnSet
tm.sessionManager.SetDoNotAllowColumnSet(doNotAllowColumnSet)
}
// CreateSession creates a new terminal session
func (tm *TerminalManager) CreateSession(config SessionConfig) (*session.Session, error) {
sessionConfig := session.Config{
Name: config.Name,
Cmdline: config.Command,
Cwd: config.WorkingDir,
Width: config.Cols,
Height: config.Rows,
IsSpawned: config.SpawnTerminal,
}
// Process working directory
cwd := tm.processWorkingDirectory(config.WorkingDir)
sessionConfig.Cwd = cwd
// Set default dimensions if not provided
if sessionConfig.Width <= 0 {
sessionConfig.Width = 120
}
if sessionConfig.Height <= 0 {
sessionConfig.Height = 30
}
if config.SpawnTerminal && !tm.noSpawn {
return tm.createSpawnedSession(sessionConfig, config.Term)
}
// Create regular (detached) session
sess, err := tm.sessionManager.CreateSession(sessionConfig)
if err != nil {
return nil, err
}
// Add buffer change callback
sess.AddBufferChangeCallback(func(sessionID string) {
tm.NotifyBufferChange(sessionID)
})
return sess, nil
}
// createSpawnedSession creates a session that will be spawned in a terminal
func (tm *TerminalManager) createSpawnedSession(config session.Config, terminalType string) (*session.Session, error) {
// Try to use the Mac app's terminal spawn service first
if conn, err := termsocket.TryConnect(""); err == nil {
defer conn.Close()
sessionID := session.GenerateID()
vtPath := tm.findVTBinary()
if vtPath == "" {
return nil, fmt.Errorf("vibetunnel binary not found")
}
// Format spawn request for Mac app
spawnReq := &termsocket.SpawnRequest{
Command: termsocket.FormatCommand(sessionID, vtPath, config.Cmdline),
WorkingDir: config.Cwd,
SessionID: sessionID,
TTYFwdPath: vtPath,
Terminal: terminalType,
}
// Create session with specific ID
sess, err := tm.sessionManager.CreateSessionWithID(sessionID, config)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
// Send spawn request to Mac app
resp, err := termsocket.SendSpawnRequest(conn, spawnReq)
if err != nil {
tm.sessionManager.RemoveSession(sess.ID)
return nil, fmt.Errorf("failed to send terminal spawn request: %w", err)
}
if !resp.Success {
tm.sessionManager.RemoveSession(sess.ID)
errorMsg := resp.Error
if errorMsg == "" {
errorMsg = "Unknown error"
}
return nil, fmt.Errorf("terminal spawn failed: %s", errorMsg)
}
log.Printf("[INFO] Successfully spawned terminal session via Mac app: %s", sessionID)
// Add buffer change callback
sess.AddBufferChangeCallback(func(sessionID string) {
tm.NotifyBufferChange(sessionID)
})
return sess, nil
}
// Fallback to native terminal spawning
log.Printf("[INFO] Mac app socket not available, falling back to native terminal spawn")
sess, err := tm.sessionManager.CreateSession(config)
if err != nil {
return nil, err
}
// Add buffer change callback
sess.AddBufferChangeCallback(func(sessionID string) {
tm.NotifyBufferChange(sessionID)
})
vtPath := tm.findVTBinary()
if vtPath == "" {
tm.sessionManager.RemoveSession(sess.ID)
return nil, fmt.Errorf("vibetunnel binary not found")
}
// Spawn terminal using native method
if err := terminal.SpawnInTerminal(sess.ID, vtPath, config.Cmdline, config.Cwd); err != nil {
tm.sessionManager.RemoveSession(sess.ID)
return nil, fmt.Errorf("failed to spawn terminal: %w", err)
}
log.Printf("[INFO] Successfully spawned terminal session natively: %s", sess.ID)
return sess, nil
}
// processWorkingDirectory handles working directory expansion and validation
func (tm *TerminalManager) processWorkingDirectory(cwd string) string {
if cwd == "" {
homeDir, _ := os.UserHomeDir()
return homeDir
}
// Expand ~ in working directory
if cwd[0] == '~' {
if cwd == "~" || len(cwd) >= 2 && cwd[:2] == "~/" {
homeDir, err := os.UserHomeDir()
if err == nil {
if cwd == "~" {
cwd = homeDir
} else {
cwd = filepath.Join(homeDir, cwd[2:])
}
}
}
}
// Validate the working directory exists
if _, err := os.Stat(cwd); err != nil {
log.Printf("[WARN] Working directory '%s' not accessible: %v. Using home directory instead.", cwd, err)
homeDir, err := os.UserHomeDir()
if err != nil {
log.Printf("[ERROR] Failed to get home directory: %v", err)
return ""
}
return homeDir
}
return cwd
}
// findVTBinary locates the vibetunnel Go binary
func (tm *TerminalManager) findVTBinary() string {
// Get the directory of the current executable
execPath, err := os.Executable()
if err == nil {
return execPath
}
// Check common locations
paths := []string{
"/Applications/VibeTunnel.app/Contents/Resources/vibetunnel",
"./linux/cmd/vibetunnel/vibetunnel",
"../linux/cmd/vibetunnel/vibetunnel",
"../../linux/cmd/vibetunnel/vibetunnel",
"./vibetunnel",
"../vibetunnel",
"/usr/local/bin/vibetunnel",
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
absPath, _ := filepath.Abs(path)
return absPath
}
}
// Try to find in PATH
if path, err := exec.LookPath("vibetunnel"); err == nil {
return path
}
return ""
}
// ResizeSession handles terminal resize requests
func (tm *TerminalManager) ResizeSession(sessionID string, cols, rows int) error {
if tm.doNotAllowColumnSet {
return fmt.Errorf("terminal resizing is disabled by server configuration")
}
sess, err := tm.sessionManager.GetSession(sessionID)
if err != nil {
return err
}
return sess.Resize(cols, rows)
}
// GetBufferSnapshot gets a snapshot of the terminal buffer for a session
func (tm *TerminalManager) GetBufferSnapshot(sessionID string) ([]byte, error) {
sess, err := tm.sessionManager.GetSession(sessionID)
if err != nil {
return nil, err
}
// Get terminal buffer from session
buffer := sess.GetTerminalBuffer()
if buffer == nil {
return nil, fmt.Errorf("no terminal buffer available")
}
// Get snapshot and encode it
snapshot := buffer.GetSnapshot()
return snapshot.SerializeToBinary(), nil
}
// SubscribeToBufferChanges subscribes to buffer changes for a session
func (tm *TerminalManager) SubscribeToBufferChanges(sessionID string, callback func([]byte)) func() {
tm.mu.Lock()
defer tm.mu.Unlock()
// Add callback to list
tm.bufferCallbacks[sessionID] = append(tm.bufferCallbacks[sessionID], callback)
// Return unsubscribe function
return func() {
tm.mu.Lock()
defer tm.mu.Unlock()
callbacks := tm.bufferCallbacks[sessionID]
newCallbacks := []func([]byte){}
for _, cb := range callbacks {
if &cb != &callback {
newCallbacks = append(newCallbacks, cb)
}
}
tm.bufferCallbacks[sessionID] = newCallbacks
if len(newCallbacks) == 0 {
delete(tm.bufferCallbacks, sessionID)
}
}
}
// NotifyBufferChange notifies all subscribers of a buffer change
func (tm *TerminalManager) NotifyBufferChange(sessionID string) {
tm.mu.RLock()
callbacks := tm.bufferCallbacks[sessionID]
tm.mu.RUnlock()
if len(callbacks) == 0 {
return
}
// Get encoded buffer snapshot
buffer, err := tm.GetBufferSnapshot(sessionID)
if err != nil {
return
}
// Notify all callbacks
for _, callback := range callbacks {
callback(buffer)
}
}
// SessionConfig represents configuration for creating a session
type SessionConfig struct {
Name string
Command []string
WorkingDir string
Cols int
Rows int
SpawnTerminal bool
Term string
}