mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-04 11:05:53 +00:00
move closer to rust server format
This commit is contained in:
parent
d7dd436b2e
commit
f40c4bc16f
13 changed files with 834 additions and 522 deletions
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
389
linux/pkg/api/websocket.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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, " "),
|
||||
|
|
|
|||
Loading…
Reference in a new issue