mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-05 11:15:57 +00:00
328 lines
8.4 KiB
Go
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
|
|
}
|