move closer to rust server format

This commit is contained in:
Peter Steinberger 2025-06-20 07:17:15 +02:00
parent d7dd436b2e
commit f40c4bc16f
13 changed files with 834 additions and 522 deletions

View file

@ -5,7 +5,7 @@
# Variables
APP_NAME := vibetunnel
VERSION := 1.0.0
VERSION := 1.0.3
BUILD_DIR := build
WEB_DIR := ../web
DIST_DIR := $(WEB_DIR)/dist
@ -56,12 +56,18 @@ dev: build ## Build and run in development mode
install: build ## Install to /usr/local/bin
@echo "Installing $(APP_NAME) to /usr/local/bin..."
sudo cp $(BUILD_DIR)/$(APP_NAME) /usr/local/bin/
@echo "Installing vt command..."
sudo cp cmd/vt/vt /usr/local/bin/
sudo chmod +x /usr/local/bin/vt
@echo "Installation complete. Run 'vibetunnel --help' to get started."
install-user: build ## Install to ~/bin
@echo "Installing $(APP_NAME) to ~/bin..."
@mkdir -p ~/bin
cp $(BUILD_DIR)/$(APP_NAME) ~/bin/
@echo "Installing vt command..."
cp cmd/vt/vt ~/bin/
chmod +x ~/bin/vt
@echo "Installation complete. Make sure ~/bin is in your PATH."
@echo "Run 'vibetunnel --help' to get started."

View file

@ -1,33 +1,28 @@
#!/bin/bash
set -e
# Build universal vt binary for macOS
# Copy vt bash script for macOS app bundle
echo "Building vt universal binary..."
echo "Preparing vt bash script..."
# Get the directory where this script is located
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "$SCRIPT_DIR"
# Build for x86_64
echo "Building vt for x86_64..."
GOOS=darwin GOARCH=amd64 go build -o vt-x86_64 ./cmd/vt
# Build for arm64
echo "Building vt for arm64..."
GOOS=darwin GOARCH=arm64 go build -o vt-arm64 ./cmd/vt
# Create universal binary
echo "Creating universal binary..."
lipo -create -output vt vt-x86_64 vt-arm64
# Clean up architecture-specific binaries
rm vt-x86_64 vt-arm64
# Copy the bash script
echo "Copying vt bash script..."
cp cmd/vt/vt vt
# Make it executable
chmod +x vt
echo "vt universal binary built successfully at: $SCRIPT_DIR/vt"
# Sign it for macOS if codesign is available
if command -v codesign >/dev/null 2>&1; then
echo "Signing vt script..."
codesign --force --sign - vt
fi
echo "vt script prepared successfully at: $SCRIPT_DIR/vt"
# Copy to target location if provided
if [ -n "$1" ]; then

View file

@ -16,6 +16,9 @@ import (
)
var (
// Version injected at build time
version = "dev"
// Session management flags
controlPath string
sessionName string
@ -71,10 +74,8 @@ var rootCmd = &cobra.Command{
Long: `VibeTunnel allows you to access your Linux terminal from any web browser.
This is the Linux implementation compatible with the macOS VibeTunnel app.`,
RunE: run,
// Allow passing through unknown flags to the command being executed
FParseErrWhitelist: cobra.FParseErrWhitelist{
UnknownFlags: true,
},
// Allow positional arguments after flags (for command execution)
Args: cobra.ArbitraryArgs,
}
func init() {
@ -135,7 +136,7 @@ func init() {
Use: "version",
Short: "Show version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("VibeTunnel Linux v1.0.2")
fmt.Printf("VibeTunnel Linux v%s\n", version)
fmt.Println("Compatible with VibeTunnel macOS app")
},
})
@ -461,63 +462,103 @@ func main() {
if len(args) > 0 && (args[0] == "version" || args[0] == "config") {
// This is a subcommand, let Cobra handle it normally
} else {
// Check if any args look like VibeTunnel flags
hasVibeTunnelFlags := false
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
// Check if this is one of our known flags
flag := strings.TrimLeft(arg, "-")
flag = strings.Split(flag, "=")[0] // Handle --flag=value format
knownFlags := []string{
"serve", "port", "p", "bind", "localhost", "network",
"password", "password-enabled", "tls", "tls-port", "tls-domain",
"tls-self-signed", "tls-cert", "tls-key", "tls-redirect",
"ngrok", "ngrok-token", "debug", "cleanup-startup",
"server-mode", "update-channel", "config", "c",
"control-path", "session-name", "list-sessions",
"send-key", "send-text", "signal", "stop", "kill",
"cleanup-exited", "detached-session", "static-path", "help", "h",
}
for _, known := range knownFlags {
if flag == known {
hasVibeTunnelFlags = true
break
}
}
if hasVibeTunnelFlags {
break
}
// Check if we have a -- separator (everything after it is the command)
dashDashIndex := -1
for i, arg := range args {
if arg == "--" {
dashDashIndex = i
break
}
}
// If no VibeTunnel flags found, treat everything as a command
if !hasVibeTunnelFlags && len(args) > 0 {
homeDir, _ := os.UserHomeDir()
defaultControlPath := filepath.Join(homeDir, ".vibetunnel", "control")
cfg := config.LoadConfig(filepath.Join(homeDir, ".vibetunnel", "config.yaml"))
if cfg.ControlPath != "" {
defaultControlPath = cfg.ControlPath
if dashDashIndex >= 0 {
// We have a -- separator, everything after it is the command to execute
cmdArgs := args[dashDashIndex+1:]
if len(cmdArgs) > 0 {
homeDir, _ := os.UserHomeDir()
defaultControlPath := filepath.Join(homeDir, ".vibetunnel", "control")
cfg := config.LoadConfig(filepath.Join(homeDir, ".vibetunnel", "config.yaml"))
if cfg.ControlPath != "" {
defaultControlPath = cfg.ControlPath
}
manager := session.NewManager(defaultControlPath)
sess, err := manager.CreateSession(session.Config{
Name: "",
Cmdline: cmdArgs,
Cwd: ".",
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Attach to the session
if err := sess.Attach(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
} else {
// No -- separator, check if any args look like VibeTunnel flags
hasVibeTunnelFlags := false
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
// Check if this is one of our known flags
flag := strings.TrimLeft(arg, "-")
flag = strings.Split(flag, "=")[0] // Handle --flag=value format
knownFlags := []string{
"serve", "port", "p", "bind", "localhost", "network",
"password", "password-enabled", "tls", "tls-port", "tls-domain",
"tls-self-signed", "tls-cert", "tls-key", "tls-redirect",
"ngrok", "ngrok-token", "debug", "cleanup-startup",
"server-mode", "update-channel", "config", "c",
"control-path", "session-name", "list-sessions",
"send-key", "send-text", "signal", "stop", "kill",
"cleanup-exited", "detached-session", "static-path", "help", "h",
}
for _, known := range knownFlags {
if flag == known {
hasVibeTunnelFlags = true
break
}
}
if hasVibeTunnelFlags {
break
}
}
}
manager := session.NewManager(defaultControlPath)
sess, err := manager.CreateSession(session.Config{
Name: "",
Cmdline: args,
Cwd: ".",
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
// If no VibeTunnel flags found, treat everything as a command
if !hasVibeTunnelFlags && len(args) > 0 {
homeDir, _ := os.UserHomeDir()
defaultControlPath := filepath.Join(homeDir, ".vibetunnel", "control")
cfg := config.LoadConfig(filepath.Join(homeDir, ".vibetunnel", "config.yaml"))
if cfg.ControlPath != "" {
defaultControlPath = cfg.ControlPath
}
manager := session.NewManager(defaultControlPath)
sess, err := manager.CreateSession(session.Config{
Name: "",
Cmdline: args,
Cwd: ".",
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// Attach to the session
if err := sess.Attach(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
// Attach to the session
if err := sess.Attach(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
}
}

View file

@ -1,277 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
)
const Version = "1.0.2"
type Config struct {
Server string `json:"server,omitempty"`
}
func main() {
// Debug incoming args
if os.Getenv("VT_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "VT Debug: args = %v\n", os.Args)
}
// Handle version flag only if it's the only argument
if len(os.Args) == 2 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
fmt.Printf("vt version %s\n", Version)
os.Exit(0)
}
// Get preferred server
server := getPreferredServer()
// Forward to appropriate server
var err error
switch server {
case "rust":
err = forwardToRustServer(os.Args[1:])
case "go":
err = forwardToGoServer(os.Args[1:])
default:
err = forwardToGoServer(os.Args[1:])
}
if err != nil {
// If the command exited with a specific code, preserve it
if exitErr, ok := err.(*exec.ExitError); ok {
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
os.Exit(status.ExitStatus())
}
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "vt: %v\n", err)
os.Exit(1)
}
}
func getPreferredServer() string {
// Check environment variable first
if server := os.Getenv("VT_SERVER"); server != "" {
if server == "rust" || server == "go" {
return server
}
}
// Read from config file
configPath := filepath.Join(os.Getenv("HOME"), ".vibetunnel", "config.json")
data, err := os.ReadFile(configPath)
if err != nil {
return "go" // default
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return "go" // default on parse error
}
if config.Server == "rust" || config.Server == "go" {
return config.Server
}
return "go" // default for invalid values
}
func forwardToGoServer(args []string) error {
// Find vibetunnel binary
vibetunnelPath := findVibetunnelBinary()
if vibetunnelPath == "" {
return fmt.Errorf("vibetunnel binary not found")
}
// Check if this is a special VT command
var finalArgs []string
if len(args) > 0 && isVTSpecialCommand(args[0]) {
// Translate special VT commands
finalArgs = translateVTToGoArgs(args)
} else {
// For regular commands, just prepend -- to tell vibetunnel to stop parsing
finalArgs = append([]string{"--"}, args...)
}
// Debug: print what we're executing
if os.Getenv("VT_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "VT Debug: executing %s with args: %v\n", vibetunnelPath, finalArgs)
}
// Create command
cmd := exec.Command(vibetunnelPath, finalArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Run and return
return cmd.Run()
}
func isVTSpecialCommand(arg string) bool {
switch arg {
case "--claude", "--claude-yolo", "--shell", "-i",
"--no-shell-wrap", "-S", "--show-session-info", "--show-session-id":
return true
}
return false
}
func forwardToRustServer(args []string) error {
// Find tty-fwd binary
ttyFwdPath := findTtyFwdBinary()
if ttyFwdPath == "" {
return fmt.Errorf("tty-fwd binary not found")
}
// Create command with original args (tty-fwd already understands vt args)
cmd := exec.Command(ttyFwdPath, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Run and return
return cmd.Run()
}
func translateVTToGoArgs(args []string) []string {
if len(args) == 0 {
return args
}
// Check for special vt-only flags
switch args[0] {
case "--claude":
// Find Claude and run it with any additional arguments
claudePath := findClaude()
if claudePath != "" {
// Pass all remaining args to claude
result := []string{"--", claudePath}
if len(args) > 1 {
result = append(result, args[1:]...)
}
return result
}
// Fallback
result := []string{"--", "claude"}
if len(args) > 1 {
result = append(result, args[1:]...)
}
return result
case "--claude-yolo":
// Find Claude and run with permissions skip
claudePath := findClaude()
if claudePath != "" {
return []string{"--", claudePath, "--dangerously-skip-permissions"}
}
return []string{"--", "claude", "--dangerously-skip-permissions"}
case "--shell", "-i":
// Launch interactive shell
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/bash"
}
return []string{"--", shell, "-i"}
case "--no-shell-wrap", "-S":
// Direct execution without shell - skip the flag and pass rest
if len(args) > 1 {
return append([]string{"--"}, args[1:]...)
}
return []string{}
case "--show-session-info":
return []string{"--list-sessions"}
case "--show-session-id":
// This needs special handling - just pass through for now
return args
default:
// This shouldn't happen since we check isVTSpecialCommand first
return args
}
}
func findVibetunnelBinary() string {
// Check common locations
paths := []string{
// App bundle location
"/Applications/VibeTunnel.app/Contents/Resources/vibetunnel",
// Development locations
"./vibetunnel",
"../vibetunnel",
"../../linux/vibetunnel",
// Installed location
"/usr/local/bin/vibetunnel",
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return path
}
}
// Try to find in PATH
if path, err := exec.LookPath("vibetunnel"); err == nil {
return path
}
return ""
}
func findTtyFwdBinary() string {
// Check common locations
paths := []string{
// App bundle location
"/Applications/VibeTunnel.app/Contents/Resources/tty-fwd",
// Development locations
"./tty-fwd",
"../tty-fwd",
"../../tty-fwd/target/release/tty-fwd",
// Installed location
"/usr/local/bin/tty-fwd",
}
for _, path := range paths {
if _, err := os.Stat(path); err == nil {
return path
}
}
// Try to find in PATH
if path, err := exec.LookPath("tty-fwd"); err == nil {
return path
}
return ""
}
func findClaude() string {
// Check common Claude installation paths
claudePaths := []string{
filepath.Join(os.Getenv("HOME"), ".claude", "local", "claude"),
"/opt/homebrew/bin/claude",
"/usr/local/bin/claude",
"/usr/bin/claude",
}
for _, path := range claudePaths {
if _, err := os.Stat(path); err == nil {
return path
}
}
// Try PATH
if path, err := exec.LookPath("claude"); err == nil {
return path
}
return ""
}

View file

@ -10,6 +10,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/gorilla/websocket v1.5.3
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
golang.ngrok.com/ngrok v1.13.0

View file

@ -18,6 +18,8 @@ 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/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
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/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ=
github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible h1:VryeOTiaZfAzwx8xBcID1KlJCeoWSIpsNbSk+/D2LNk=

View file

@ -101,18 +101,39 @@ func (m *MultiSSEStreamer) streamSession(sessionID string) {
}
func (m *MultiSSEStreamer) sendEvent(sessionID string, event *protocol.StreamEvent) error {
data := map[string]interface{}{
"session_id": sessionID,
"event": event,
}
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
if _, err := fmt.Fprintf(m.w, "data: %s\n\n", jsonData); err != nil {
return err // Client disconnected
// Match Rust format: send raw arrays for terminal events
if event.Type == "event" && event.Event != nil {
// For terminal events, send as raw array
data := []interface{}{
event.Event.Time,
string(event.Event.Type),
event.Event.Data,
}
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
// Match Rust multistream format: sessionID:event_json
prefixedEvent := fmt.Sprintf("%s:%s", sessionID, jsonData)
if _, err := fmt.Fprintf(m.w, "data: %s\n\n", prefixedEvent); err != nil {
return err // Client disconnected
}
} else {
// For other event types, serialize the event
jsonData, err := json.Marshal(event)
if err != nil {
return err
}
// Match Rust multistream format: sessionID:event_json
prefixedEvent := fmt.Sprintf("%s:%s", sessionID, jsonData)
if _, err := fmt.Fprintf(m.w, "data: %s\n\n", prefixedEvent); err != nil {
return err // Client disconnected
}
}
if m.flusher != nil {

View file

@ -75,8 +75,6 @@ func (s *Server) Start(addr string) error {
}
}
// Stop the manager's background tasks
s.manager.Stop()
// Shutdown HTTP server
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -116,6 +114,15 @@ func (s *Server) createHandler() http.Handler {
api.HandleFunc("/ngrok/stop", s.handleNgrokStop).Methods("POST")
api.HandleFunc("/ngrok/status", s.handleNgrokStatus).Methods("GET")
// WebSocket endpoint for binary terminal streaming
bufferHandler := NewBufferWebSocketHandler(s.manager)
// Apply authentication middleware if password is set
if s.password != "" {
r.Handle("/buffers", s.basicAuthMiddleware(bufferHandler))
} else {
r.Handle("/buffers", bufferHandler)
}
if s.staticPath != "" {
// Serve static files with index.html fallback for directories
r.PathPrefix("/").HandlerFunc(s.serveStaticWithIndex)
@ -224,24 +231,24 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
return
}
// Convert to Rust-compatible format
type RustSessionInfo struct {
// Convert to API response format
type APISessionInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Command string `json:"command"` // Changed from cmdline array to command string
WorkingDir string `json:"workingDir"` // Changed from cwd to workingDir
Command string `json:"command"`
WorkingDir string `json:"workingDir"`
Pid *int `json:"pid,omitempty"`
Status string `json:"status"`
ExitCode *int `json:"exitCode,omitempty"` // Changed from exit_code to exitCode
StartedAt time.Time `json:"startedAt"` // Changed from started_at to startedAt
ExitCode *int `json:"exitCode,omitempty"`
StartedAt time.Time `json:"startedAt"`
Term string `json:"term"`
Width int `json:"width"`
Height int `json:"height"`
Env map[string]string `json:"env,omitempty"`
LastModified time.Time `json:"lastModified"` // Added to match Rust
LastModified time.Time `json:"lastModified"`
}
rustSessions := make([]RustSessionInfo, len(sessions))
apiSessions := make([]APISessionInfo, len(sessions))
for i, s := range sessions {
// Convert PID to pointer for omitempty behavior
var pid *int
@ -249,7 +256,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
pid = &s.Pid
}
rustSessions[i] = RustSessionInfo{
apiSessions[i] = APISessionInfo{
ID: s.ID,
Name: s.Name,
Command: s.Cmdline, // Already a string
@ -267,7 +274,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(rustSessions)
json.NewEncoder(w).Encode(apiSessions)
}
func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) {
@ -493,10 +500,62 @@ func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) {
return
}
// Return current session info without blocking on status update
// Status will be eventually consistent through background updates
// Get session info and convert to Rust-compatible format
info := sess.GetInfo()
if info == nil {
http.Error(w, "Session info not available", http.StatusInternalServerError)
return
}
// Update status on-demand
sess.UpdateStatus()
// Convert to Rust-compatible format like in handleListSessions
rustInfo := session.RustSessionInfo{
ID: info.ID,
Name: info.Name,
Cmdline: info.Args,
Cwd: info.Cwd,
Status: info.Status,
ExitCode: info.ExitCode,
Term: info.Term,
SpawnType: "pty",
Cols: &info.Width,
Rows: &info.Height,
Env: info.Env,
}
if info.Pid > 0 {
rustInfo.Pid = &info.Pid
}
if !info.StartedAt.IsZero() {
rustInfo.StartedAt = &info.StartedAt
}
// Convert to API response format with camelCase like Rust
response := map[string]interface{}{
"id": rustInfo.ID,
"name": rustInfo.Name,
"command": strings.Join(rustInfo.Cmdline, " "),
"workingDir": rustInfo.Cwd,
"pid": rustInfo.Pid,
"status": rustInfo.Status,
"exitCode": rustInfo.ExitCode,
"startedAt": rustInfo.StartedAt,
"term": rustInfo.Term,
"width": rustInfo.Cols,
"height": rustInfo.Rows,
"env": rustInfo.Env,
}
// Add lastModified like Rust does
if stat, err := os.Stat(sess.Path()); err == nil {
response["lastModified"] = stat.ModTime()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(sess)
json.NewEncoder(w).Encode(response)
}
func (s *Server) handleStreamSession(w http.ResponseWriter, r *http.Request) {
@ -635,7 +694,7 @@ func (s *Server) handleCleanupSession(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleCleanupExited(w http.ResponseWriter, r *http.Request) {
if err := s.manager.CleanupExitedSessions(); err != nil {
if err := s.manager.RemoveExitedSessions(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View file

@ -227,25 +227,37 @@ func (s *SSEStreamer) sendEvent(event *protocol.StreamEvent) error {
}
func (s *SSEStreamer) sendRawEvent(event *protocol.StreamEvent) error {
var data interface{}
// Match Rust behavior exactly - send raw arrays for terminal events
if event.Type == "header" {
// For header events, we can skip them since the frontend might not expect them
// Or send them in a compatible format if needed
// Skip headers like Rust does
return nil
} else if event.Type == "event" && event.Event != nil {
// Convert to asciinema format: [timestamp, type, data]
data = []interface{}{
// Send raw array directly like Rust: [timestamp, type, data]
data := []interface{}{
event.Event.Time,
string(event.Event.Type),
event.Event.Data,
}
} else {
// For other event types, use the original format
data = event
jsonData, err := json.Marshal(data)
if err != nil {
return err
}
// Send as SSE data
if _, err := fmt.Fprintf(s.w, "data: %s\n\n", jsonData); err != nil {
return err // Client disconnected
}
if s.flusher != nil {
s.flusher.Flush()
}
return nil
}
jsonData, err := json.Marshal(data)
// For other event types (error, end), send without wrapping
jsonData, err := json.Marshal(event)
if err != nil {
return err
}

389
linux/pkg/api/websocket.go Normal file
View file

@ -0,0 +1,389 @@
package api
import (
"encoding/binary"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/gorilla/websocket"
"github.com/vibetunnel/linux/pkg/protocol"
"github.com/vibetunnel/linux/pkg/session"
)
const (
// Magic byte for binary messages
BufferMagicByte = 0xbf
// WebSocket timeouts
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 512 * 1024 // 512KB
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
// Allow all origins for now
return true
},
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
type BufferWebSocketHandler struct {
manager *session.Manager
clients sync.Map // sessionID -> *websocket.Conn
}
func NewBufferWebSocketHandler(manager *session.Manager) *BufferWebSocketHandler {
return &BufferWebSocketHandler{
manager: manager,
}
}
func (h *BufferWebSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[WebSocket] Failed to upgrade connection: %v", err)
return
}
defer conn.Close()
// Set up connection parameters
conn.SetReadLimit(maxMessageSize)
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// Start ping ticker
ticker := time.NewTicker(pingPeriod)
defer ticker.Stop()
// Channel for writing messages
send := make(chan []byte, 256)
done := make(chan struct{})
// Start writer goroutine
go h.writer(conn, send, ticker, done)
// Handle incoming messages
for {
select {
case <-done:
return
default:
messageType, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("[WebSocket] Error: %v", err)
}
close(done)
return
}
if messageType == websocket.TextMessage {
h.handleTextMessage(conn, message, send, done)
}
}
}
}
func (h *BufferWebSocketHandler) handleTextMessage(conn *websocket.Conn, message []byte, send chan []byte, done chan struct{}) {
var msg map[string]interface{}
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("[WebSocket] Failed to parse message: %v", err)
return
}
msgType, ok := msg["type"].(string)
if !ok {
return
}
switch msgType {
case "ping":
// Send pong response
pong, _ := json.Marshal(map[string]string{"type": "pong"})
select {
case send <- pong:
case <-done:
return
}
case "subscribe":
sessionID, ok := msg["sessionId"].(string)
if !ok {
return
}
// Start streaming session data
go h.streamSession(sessionID, send, done)
case "unsubscribe":
// Currently we just close the connection when unsubscribing
close(done)
}
}
func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byte, done chan struct{}) {
sess, err := h.manager.GetSession(sessionID)
if err != nil {
log.Printf("[WebSocket] Session not found: %v", err)
errorMsg, _ := json.Marshal(map[string]string{
"type": "error",
"message": fmt.Sprintf("Session not found: %v", err),
})
select {
case send <- errorMsg:
case <-done:
}
return
}
streamPath := sess.StreamOutPath()
// Create file watcher
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Printf("[WebSocket] Failed to create watcher: %v", err)
return
}
defer watcher.Close()
// Add the stream file to the watcher
err = watcher.Add(streamPath)
if err != nil {
log.Printf("[WebSocket] Failed to watch file: %v", err)
return
}
headerSent := false
seenBytes := int64(0)
// Send initial content
h.processAndSendContent(sessionID, streamPath, &headerSent, &seenBytes, send, done)
// Watch for changes
for {
select {
case <-done:
return
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Write == fsnotify.Write {
h.processAndSendContent(sessionID, streamPath, &headerSent, &seenBytes, send, done)
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Printf("[WebSocket] Watcher error: %v", err)
case <-time.After(1 * time.Second):
// Check if session is still alive
if !sess.IsAlive() {
// Send exit event
exitMsg := h.createBinaryMessage(sessionID, []byte(`{"type":"exit","code":0}`))
select {
case send <- exitMsg:
case <-done:
}
return
}
}
}
}
func (h *BufferWebSocketHandler) processAndSendContent(sessionID, streamPath string, headerSent *bool, seenBytes *int64, send chan []byte, done chan struct{}) {
file, err := os.Open(streamPath)
if err != nil {
log.Printf("[WebSocket] Failed to open stream file: %v", err)
return
}
defer file.Close()
// Get current file size
fileInfo, err := file.Stat()
if err != nil {
return
}
currentSize := fileInfo.Size()
if currentSize <= *seenBytes {
return
}
// Seek to last position
if _, err := file.Seek(*seenBytes, 0); err != nil {
return
}
// Read new content
newContentSize := currentSize - *seenBytes
newContent := make([]byte, newContentSize)
bytesRead, err := file.Read(newContent)
if err != nil {
return
}
*seenBytes = currentSize
// Process content line by line
content := string(newContent[:bytesRead])
lines := strings.Split(content, "\n")
// Handle incomplete last line
endIndex := len(lines)
if !strings.HasSuffix(content, "\n") && len(lines) > 0 {
incompleteLineBytes := int64(len(lines[len(lines)-1]))
*seenBytes -= incompleteLineBytes
endIndex = len(lines) - 1
}
// Process complete lines
for i := 0; i < endIndex; i++ {
line := lines[i]
if line == "" {
continue
}
// Try to parse as header first
if !*headerSent {
var header protocol.AsciinemaHeader
if err := json.Unmarshal([]byte(line), &header); err == nil && header.Version > 0 {
*headerSent = true
// Send header as binary message
headerData, _ := json.Marshal(map[string]interface{}{
"type": "header",
"width": header.Width,
"height": header.Height,
})
msg := h.createBinaryMessage(sessionID, headerData)
select {
case send <- msg:
case <-done:
return
}
continue
}
}
// Try to parse as event array [timestamp, type, data]
var eventArray []interface{}
if err := json.Unmarshal([]byte(line), &eventArray); err == nil && len(eventArray) == 3 {
timestamp, ok1 := eventArray[0].(float64)
eventType, ok2 := eventArray[1].(string)
data, ok3 := eventArray[2].(string)
if ok1 && ok2 && ok3 && eventType == "o" {
// Create terminal output message
outputData, _ := json.Marshal(map[string]interface{}{
"type": "output",
"timestamp": timestamp,
"data": data,
})
msg := h.createBinaryMessage(sessionID, outputData)
select {
case send <- msg:
case <-done:
return
}
} else if ok1 && ok2 && ok3 && eventType == "r" {
// Create resize message
resizeData, _ := json.Marshal(map[string]interface{}{
"type": "resize",
"timestamp": timestamp,
"dimensions": data,
})
msg := h.createBinaryMessage(sessionID, resizeData)
select {
case send <- msg:
case <-done:
return
}
}
}
}
}
func (h *BufferWebSocketHandler) createBinaryMessage(sessionID string, data []byte) []byte {
// Binary message format:
// [magic byte (1)] [session ID length (4, little endian)] [session ID] [data]
sessionIDBytes := []byte(sessionID)
totalLen := 1 + 4 + len(sessionIDBytes) + len(data)
msg := make([]byte, totalLen)
offset := 0
// Magic byte
msg[offset] = BufferMagicByte
offset++
// Session ID length (little endian)
binary.LittleEndian.PutUint32(msg[offset:], uint32(len(sessionIDBytes)))
offset += 4
// Session ID
copy(msg[offset:], sessionIDBytes)
offset += len(sessionIDBytes)
// Data
copy(msg[offset:], data)
return msg
}
func (h *BufferWebSocketHandler) writer(conn *websocket.Conn, send chan []byte, ticker *time.Ticker, done chan struct{}) {
defer close(send)
for {
select {
case message, ok := <-send:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
// Check if it's a text message (JSON) or binary
if len(message) > 0 && message[0] == '{' {
// Text message
if err := conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
} else {
// Binary message
if err := conn.WriteMessage(websocket.BinaryMessage, message); err != nil {
return
}
}
case <-ticker.C:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-done:
return
}
}
}

View file

@ -4,35 +4,26 @@ import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
type Manager struct {
controlPath string
runningSessions map[string]*Session
mutex sync.RWMutex
stopChan chan struct{}
cleanupInterval time.Duration
}
func NewManager(controlPath string) *Manager {
m := &Manager{
return &Manager{
controlPath: controlPath,
runningSessions: make(map[string]*Session),
stopChan: make(chan struct{}),
cleanupInterval: 30 * time.Second, // Clean up every 30 seconds
}
// Start background cleanup goroutine
// Disabled automatic cleanup to match Rust behavior
// go m.backgroundCleanup()
return m
}
func (m *Manager) CreateSession(config Config) (*Session, error) {
@ -169,38 +160,23 @@ func (m *Manager) RemoveExitedSessions() error {
// No PID recorded, consider it exited
shouldRemove = true
} else {
// First check if it's a zombie process
statPath := fmt.Sprintf("/proc/%d/stat", info.Pid)
if data, err := os.ReadFile(statPath); err == nil {
statStr := string(data)
if lastParen := strings.LastIndex(statStr, ")"); lastParen != -1 {
fields := strings.Fields(statStr[lastParen+1:])
if len(fields) > 0 && fields[0] == "Z" {
// It's a zombie, should remove
shouldRemove = true
// Try to reap the zombie
var status syscall.WaitStatus
syscall.Wait4(info.Pid, &status, syscall.WNOHANG, nil)
}
}
} else {
// Can't read stat, process doesn't exist
shouldRemove = true
}
// Use ps command to check process status (portable across Unix systems)
cmd := exec.Command("ps", "-p", strconv.Itoa(info.Pid), "-o", "stat=")
output, err := cmd.Output()
// If not already marked for removal, check if process is alive
if !shouldRemove {
proc, err := os.FindProcess(info.Pid)
if err != nil {
if err != nil {
// Process doesn't exist
shouldRemove = true
} else {
// Check if it's a zombie process (status starts with 'Z')
stat := strings.TrimSpace(string(output))
if strings.HasPrefix(stat, "Z") {
// It's a zombie, should remove
shouldRemove = true
} else {
// Signal 0 just checks if process exists without actually sending a signal
err = proc.Signal(syscall.Signal(0))
if err != nil {
// Process doesn't exist
shouldRemove = true
}
// Try to reap the zombie
var status syscall.WaitStatus
syscall.Wait4(info.Pid, &status, syscall.WNOHANG, nil)
}
}
}
@ -222,26 +198,6 @@ func (m *Manager) RemoveExitedSessions() error {
return nil
}
// backgroundCleanup runs periodic cleanup of dead sessions
func (m *Manager) backgroundCleanup() {
ticker := time.NewTicker(m.cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Update session statuses and clean up dead ones
if err := m.UpdateAllSessionStatuses(); err != nil {
fmt.Printf("Background cleanup error: %v\n", err)
}
if err := m.CleanupExitedSessions(); err != nil {
fmt.Printf("Background cleanup error: %v\n", err)
}
case <-m.stopChan:
return
}
}
}
// UpdateAllSessionStatuses updates the status of all sessions
func (m *Manager) UpdateAllSessionStatuses() error {
@ -259,10 +215,6 @@ func (m *Manager) UpdateAllSessionStatuses() error {
return nil
}
// Stop stops the background cleanup goroutine
func (m *Manager) Stop() {
close(m.stopChan)
}
func (m *Manager) RemoveSession(id string) error {
// Remove from running sessions registry

View file

@ -46,15 +46,6 @@ func NewPTY(session *Session) (*PTY, error) {
debugLog("[DEBUG] NewPTY: Initial cmdline: %v", cmdline)
// For shells, force interactive mode to prevent immediate exit
if len(cmdline) == 1 && (strings.HasSuffix(cmdline[0], "bash") || strings.HasSuffix(cmdline[0], "zsh") || strings.HasSuffix(cmdline[0], "sh")) {
cmdline = append(cmdline, "-i")
// Update session info to reflect the actual command being run
session.info.Args = cmdline
session.info.Cmdline = strings.Join(cmdline, " ")
debugLog("[DEBUG] NewPTY: Added -i flag, cmdline now: %v", cmdline)
}
cmd := exec.Command(cmdline[0], cmdline[1:]...)
// Set working directory, ensuring it's valid
@ -68,10 +59,43 @@ func NewPTY(session *Session) (*PTY, error) {
debugLog("[DEBUG] NewPTY: Set working directory to: %s", session.info.Cwd)
}
// Set up environment with proper terminal settings
env := os.Environ()
env = append(env, "TERM="+session.info.Term)
env = append(env, "SHELL="+cmdline[0])
// Set up environment with filtered variables like Rust implementation
// Only pass safe environment variables
safeEnvVars := []string{"TERM", "SHELL", "LANG", "LC_ALL", "PATH", "USER", "HOME"}
env := make([]string, 0)
// Copy only safe environment variables from parent
for _, v := range os.Environ() {
parts := strings.SplitN(v, "=", 2)
if len(parts) == 2 {
for _, safe := range safeEnvVars {
if parts[0] == safe {
env = append(env, v)
break
}
}
}
}
// Ensure TERM and SHELL are set
hasTermVar := false
hasShellVar := false
for _, v := range env {
if strings.HasPrefix(v, "TERM=") {
hasTermVar = true
}
if strings.HasPrefix(v, "SHELL=") {
hasShellVar = true
}
}
if !hasTermVar {
env = append(env, "TERM="+session.info.Term)
}
if !hasShellVar {
env = append(env, "SHELL="+cmdline[0])
}
cmd.Env = env
ptmx, err := pty.Start(cmd)
@ -81,6 +105,10 @@ func NewPTY(session *Session) (*PTY, error) {
}
debugLog("[DEBUG] NewPTY: PTY started successfully, PID: %d", cmd.Process.Pid)
// Log the actual command being executed
debugLog("[DEBUG] NewPTY: Executing command: %v in directory: %s", cmdline, cmd.Dir)
debugLog("[DEBUG] NewPTY: Environment has %d variables", len(cmd.Env))
if err := pty.Setsize(ptmx, &pty.Winsize{
Rows: uint16(session.info.Height),
@ -92,6 +120,22 @@ func NewPTY(session *Session) (*PTY, error) {
return nil, fmt.Errorf("failed to set PTY size: %w", err)
}
// Set terminal flags to match Rust implementation
// Get the current terminal attributes
oldState, err := term.MakeRaw(int(ptmx.Fd()))
if err != nil {
debugLog("[DEBUG] NewPTY: Failed to get terminal attributes: %v", err)
} else {
// Restore the terminal but with specific flags enabled
// We don't want raw mode, we want interactive mode with ISIG, ICANON, and ECHO
term.Restore(int(ptmx.Fd()), oldState)
// The creack/pty library should have already set up the terminal properly
// for interactive use. The key is that the PTY slave (not master) needs
// these settings, and they're typically inherited from the process.
debugLog("[DEBUG] NewPTY: Terminal configured for interactive mode")
}
streamOut, err := os.Create(session.StreamOutPath())
if err != nil {
log.Printf("[ERROR] NewPTY: Failed to create stream-out: %v", err)

View file

@ -1,14 +1,18 @@
package session
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/google/uuid"
@ -249,7 +253,11 @@ func (s *Session) sendInput(data []byte) error {
stdinPath := s.StdinPath()
pipe, err := os.OpenFile(stdinPath, os.O_WRONLY, 0)
if err != nil {
return fmt.Errorf("failed to open stdin pipe: %w", err)
// If pipe fails, try Node.js proxy fallback like Rust
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Failed to open stdin pipe, trying Node.js proxy fallback: %v", err)
}
return s.proxyInputToNodeJS(data)
}
s.stdinPipe = pipe
}
@ -259,11 +267,58 @@ func (s *Session) sendInput(data []byte) error {
// If write fails, close and reset the pipe for next attempt
s.stdinPipe.Close()
s.stdinPipe = nil
return fmt.Errorf("failed to write to stdin pipe: %w", err)
// Try Node.js proxy fallback like Rust
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Failed to write to stdin pipe, trying Node.js proxy fallback: %v", err)
}
return s.proxyInputToNodeJS(data)
}
return nil
}
// proxyInputToNodeJS sends input via Node.js server fallback (like Rust implementation)
func (s *Session) proxyInputToNodeJS(data []byte) error {
client := &http.Client{
Timeout: 5 * time.Second,
}
url := fmt.Sprintf("http://localhost:3000/api/sessions/%s/input", s.ID)
payload := map[string]interface{}{
"data": string(data),
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal input data: %w", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create proxy request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("Node.js proxy fallback failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("Node.js proxy returned status %d: %s", resp.StatusCode, string(body))
}
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] Successfully sent input via Node.js proxy for session %s", s.ID[:8])
}
return nil
}
func (s *Session) Signal(sig string) error {
if s.info.Pid == 0 {
return fmt.Errorf("no process to signal")
@ -364,59 +419,53 @@ func (s *Session) Resize(width, height int) error {
func (s *Session) IsAlive() bool {
if s.info.Pid == 0 {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: PID is 0 for session %s", s.ID[:8])
}
return false
}
// Check if process exists and is not a zombie
if isZombie(s.info.Pid) {
return false
}
proc, err := os.FindProcess(s.info.Pid)
// Use ps command to check process status (portable across Unix systems)
// This matches the Rust implementation
cmd := exec.Command("ps", "-p", strconv.Itoa(s.info.Pid), "-o", "stat=")
output, err := cmd.Output()
if err != nil {
// Process doesn't exist
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: ps command failed for PID %d: %v", s.info.Pid, err)
}
return false
}
err = proc.Signal(syscall.Signal(0))
return err == nil
// Check if it's a zombie process (status starts with 'Z')
stat := strings.TrimSpace(string(output))
if strings.HasPrefix(stat, "Z") {
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: Process %d is zombie (stat=%s) for session %s", s.info.Pid, stat, s.ID[:8])
}
return false
}
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] IsAlive: Process %d is alive (stat=%s) for session %s", s.info.Pid, stat, s.ID[:8])
}
return true
}
// isZombie checks if a process is in zombie state
func isZombie(pid int) bool {
// Read process status from /proc/[pid]/stat
statPath := fmt.Sprintf("/proc/%d/stat", pid)
data, err := os.ReadFile(statPath)
if err != nil {
// If we can't read the stat file, process doesn't exist
return true
}
// The process state is the third field after the command name in parentheses
// Find the last ')' to handle processes with ')' in their names
statStr := string(data)
lastParen := strings.LastIndex(statStr, ")")
if lastParen == -1 {
return true
}
// Parse fields after the command name
fields := strings.Fields(statStr[lastParen+1:])
if len(fields) < 1 {
return true
}
// State is the first field after the command
// Z = zombie
state := fields[0]
return state == "Z"
}
func (s *Session) UpdateStatus() error {
if s.info.Status == string(StatusExited) {
return nil
}
if !s.IsAlive() {
alive := s.IsAlive()
if os.Getenv("VIBETUNNEL_DEBUG") != "" {
log.Printf("[DEBUG] UpdateStatus for session %s: PID=%d, alive=%v", s.ID[:8], s.info.Pid, alive)
}
if !alive {
s.info.Status = string(StatusExited)
exitCode := 0
s.info.ExitCode = &exitCode
@ -434,7 +483,32 @@ func (s *Session) GetInfo() *Info {
}
func (i *Info) Save(sessionPath string) error {
data, err := json.MarshalIndent(i, "", " ")
// Convert to Rust format for saving
rustInfo := RustSessionInfo{
ID: i.ID,
Name: i.Name,
Cmdline: i.Args, // Use Args array instead of Cmdline string
Cwd: i.Cwd,
Status: i.Status,
ExitCode: i.ExitCode,
Term: i.Term,
SpawnType: "pty", // Default spawn type
Cols: &i.Width,
Rows: &i.Height,
Env: i.Env,
}
// Only include Pid if non-zero
if i.Pid > 0 {
rustInfo.Pid = &i.Pid
}
// Only include StartedAt if not zero time
if !i.StartedAt.IsZero() {
rustInfo.StartedAt = &i.StartedAt
}
data, err := json.MarshalIndent(rustInfo, "", " ")
if err != nil {
return err
}
@ -465,21 +539,14 @@ func LoadInfo(sessionPath string) (*Info, error) {
return nil, err
}
// First try to unmarshal as Go format
var info Info
if err := json.Unmarshal(data, &info); err == nil {
// Successfully parsed as Go format
return &info, nil
}
// If that fails, try Rust format
// Load Rust format (the only format we support)
var rustInfo RustSessionInfo
if err := json.Unmarshal(data, &rustInfo); err != nil {
return nil, fmt.Errorf("failed to parse session.json: %w", err)
}
// Convert Rust format to Go format
info = Info{
// Convert Rust format to internal Info format
info := Info{
ID: rustInfo.ID,
Name: rustInfo.Name,
Cmdline: strings.Join(rustInfo.Cmdline, " "),