Spawn new Terminal

This commit is contained in:
Peter Steinberger 2025-06-18 04:47:43 +02:00
parent e3c6a6ea4a
commit e7480c3f59
18 changed files with 846 additions and 299 deletions

View file

@ -78,7 +78,7 @@
<EnvironmentVariable <EnvironmentVariable
key = "OS_ACTIVITY_MODE" key = "OS_ACTIVITY_MODE"
value = "disable" value = "disable"
isEnabled = "YES"> isEnabled = "NO">
</EnvironmentVariable> </EnvironmentVariable>
</EnvironmentVariables> </EnvironmentVariables>
</LaunchAction> </LaunchAction>

View file

@ -252,6 +252,8 @@ enum AppleScriptError: LocalizedError {
return "The application is not running or cannot be controlled." return "The application is not running or cannot be controlled."
case -1708: case -1708:
return "The event was not handled by the target application." return "The event was not handled by the target application."
case -2741:
return "AppleScript syntax error - check for unescaped quotes or invalid identifiers."
default: default:
return nil return nil
} }

View file

@ -166,10 +166,7 @@ final class RustServer: ServerProtocol {
// Use bind address from ServerManager to control server accessibility // Use bind address from ServerManager to control server accessibility
let bindAddress = ServerManager.shared.bindAddress let bindAddress = ServerManager.shared.bindAddress
// Get the VibeTunnel executable path var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --serve \(bindAddress):\(port)"
let vibeTunnelPath = Bundle.main.executablePath ?? ""
var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --vibetunnel-path \"\(vibeTunnelPath)\" --serve \(bindAddress):\(port)"
// Add password flag if password protection is enabled // Add password flag if password protection is enabled
// Only check if password exists, don't retrieve it yet // Only check if password exists, don't retrieve it yet

View file

@ -0,0 +1,216 @@
import Foundation
import os.log
/// Service that listens for terminal spawn requests via Unix domain socket using POSIX APIs
final class TerminalSpawnService: @unchecked Sendable {
static let shared = TerminalSpawnService()
private let logger = Logger(subsystem: "sh.vibetunnel.VibeTunnel", category: "TerminalSpawnService")
private let socketPath = "/tmp/vibetunnel-terminal.sock"
private let lock = NSLock()
private var serverSocket: Int32 = -1
private var listenQueue: DispatchQueue?
private var shouldStop = false
private init() {}
/// Start listening for terminal spawn requests
func start() {
lock.lock()
defer { lock.unlock() }
// Clean up any existing socket
unlink(socketPath)
// Create socket
serverSocket = socket(AF_UNIX, SOCK_STREAM, 0)
guard serverSocket >= 0 else {
logger.error("Failed to create socket: \(String(cString: strerror(errno)))")
return
}
// Set socket options
var reuseAddr: Int32 = 1
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &reuseAddr, socklen_t(MemoryLayout<Int32>.size))
// Bind to socket path
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
// Copy socket path to sun_path
socketPath.withCString { pathCString in
withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in
guard let baseAddress = sunPathPtr.baseAddress?.assumingMemoryBound(to: CChar.self) else { return }
strncpy(baseAddress, pathCString, sunPathPtr.count - 1)
}
}
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard bindResult == 0 else {
logger.error("Failed to bind socket: \(String(cString: strerror(errno)))")
close(serverSocket)
serverSocket = -1
return
}
// Listen for connections
guard listen(serverSocket, 5) == 0 else {
logger.error("Failed to listen on socket: \(String(cString: strerror(errno)))")
close(serverSocket)
serverSocket = -1
return
}
logger.info("Terminal spawn service listening on \(self.socketPath)")
// Start accepting connections on background queue
shouldStop = false
listenQueue = DispatchQueue(label: "sh.vibetunnel.terminal-spawn", qos: .userInitiated)
listenQueue?.async { [weak self] in
self?.acceptConnectionsAsync()
}
}
/// Stop the service
func stop() {
lock.lock()
defer { lock.unlock() }
logger.info("Stopping terminal spawn service")
shouldStop = true
if serverSocket >= 0 {
close(serverSocket)
serverSocket = -1
}
unlink(socketPath)
listenQueue = nil
}
private func acceptConnectionsAsync() {
while true {
lock.lock()
let shouldContinue = !shouldStop && serverSocket >= 0
let socket = serverSocket
lock.unlock()
if !shouldContinue {
break
}
var clientAddr = sockaddr_un()
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
accept(socket, sockaddrPtr, &clientAddrLen)
}
}
if clientSocket < 0 {
if errno != EINTR {
lock.lock()
let stopped = shouldStop
lock.unlock()
if !stopped {
logger.error("Failed to accept connection: \(String(cString: strerror(errno)))")
}
}
continue
}
// Handle connection on separate queue
handleConnectionAsync(clientSocket)
}
}
private func handleConnectionAsync(_ clientSocket: Int32) {
defer { close(clientSocket) }
// Read request data
var buffer = [UInt8](repeating: 0, count: 65536)
let bytesRead = recv(clientSocket, &buffer, buffer.count, 0)
guard bytesRead > 0 else {
logger.error("Failed to read from client socket")
return
}
let requestData = Data(bytes: buffer, count: bytesRead)
// Parse and handle the request
let responseData = handleRequestSync(requestData)
sendResponse(responseData, to: clientSocket)
}
private func handleRequestSync(_ data: Data) -> Data {
struct SpawnRequest: Codable {
let ttyFwdPath: String? // Optional: if provided, use this path instead of bundled one
let workingDir: String
let sessionId: String
let command: String // Already properly formatted command (not array)
let terminal: String? // Optional: preferred terminal (e.g. "ghostty", "terminal")
}
struct SpawnResponse: Codable {
let success: Bool
let error: String?
let sessionId: String?
}
do {
let request = try JSONDecoder().decode(SpawnRequest.self, from: data)
logger.info("Received spawn request for session \(request.sessionId)")
// Use DispatchQueue.main.sync to call TerminalLauncher on main thread
var launchError: Error?
DispatchQueue.main.sync {
do {
// If a specific terminal is requested, temporarily set it
var originalTerminal: String?
if let requestedTerminal = request.terminal {
originalTerminal = UserDefaults.standard.string(forKey: "preferredTerminal")
UserDefaults.standard.set(requestedTerminal, forKey: "preferredTerminal")
}
defer {
// Restore original terminal preference if we changed it
if let original = originalTerminal {
UserDefaults.standard.set(original, forKey: "preferredTerminal")
}
}
try TerminalLauncher.shared.launchOptimizedTerminalSession(
workingDirectory: request.workingDir,
command: request.command,
sessionId: request.sessionId,
ttyFwdPath: request.ttyFwdPath
)
} catch {
launchError = error
}
}
if let error = launchError {
throw error
}
let response = SpawnResponse(success: true, error: nil, sessionId: request.sessionId)
return try JSONEncoder().encode(response)
} catch {
logger.error("Failed to handle spawn request: \(error)")
let response = SpawnResponse(success: false, error: error.localizedDescription, sessionId: nil)
return (try? JSONEncoder().encode(response)) ?? Data()
}
}
private func sendResponse(_ data: Data, to clientSocket: Int32) {
data.withUnsafeBytes { bytes in
_ = send(clientSocket, bytes.baseAddress, data.count, 0)
}
}
}

View file

@ -716,6 +716,7 @@ public final class TunnelServer {
let command: [String] let command: [String]
let workingDir: String? let workingDir: String?
let term: String? let term: String?
let spawn_terminal: Bool?
} }
let sessionRequest = try JSONDecoder().decode(CreateSessionRequest.self, from: requestData) let sessionRequest = try JSONDecoder().decode(CreateSessionRequest.self, from: requestData)
@ -724,6 +725,116 @@ public final class TunnelServer {
return errorResponse(message: "Command array is required and cannot be empty", status: .badRequest) return errorResponse(message: "Command array is required and cannot be empty", status: .badRequest)
} }
// Handle terminal spawning if requested
if sessionRequest.spawn_terminal ?? false {
logger.info("Spawn terminal requested for command: \(sessionRequest.command.joined(separator: " "))")
let sessionId = UUID().uuidString
let workingDir = resolvePath(sessionRequest.workingDir ?? "~/", fallback: FileManager.default.homeDirectoryForCurrentUser.path)
_ = sessionRequest.command.joined(separator: " ")
// Connect to the terminal spawn service via Unix socket
let socketPath = "/tmp/vibetunnel-terminal.sock"
let socketFd = socket(AF_UNIX, SOCK_STREAM, 0)
guard socketFd >= 0 else {
logger.error("Failed to create socket")
return errorResponse(message: "Failed to create socket for terminal spawning", status: .internalServerError)
}
defer { close(socketFd) }
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path.0) { dst in
_ = strcpy(dst, ptr)
}
}
let connectResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
connect(socketFd, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard connectResult == 0 else {
logger.error("Failed to connect to terminal spawn service: \(String(cString: strerror(errno)))")
return errorResponse(message: "Terminal spawn service not available", status: .serviceUnavailable)
}
// Send the spawn request
struct SpawnRequest: Codable {
let command: [String]
let workingDir: String
let sessionId: String
}
let spawnRequest = SpawnRequest(
command: sessionRequest.command,
workingDir: workingDir,
sessionId: sessionId
)
do {
let requestData = try JSONEncoder().encode(spawnRequest)
let sendResult = send(socketFd, requestData.withUnsafeBytes { $0.baseAddress }, requestData.count, 0)
guard sendResult == requestData.count else {
throw NSError(domain: "TerminalSpawn", code: 1, userInfo: [NSLocalizedDescriptionKey: "Failed to send complete request"])
}
// Read the response
var responseBuffer = [UInt8](repeating: 0, count: 4096)
let bytesRead = recv(socketFd, &responseBuffer, responseBuffer.count, 0)
guard bytesRead > 0 else {
throw NSError(domain: "TerminalSpawn", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to read response"])
}
let responseData = Data(bytes: responseBuffer, count: bytesRead)
struct SpawnResponse: Codable {
let success: Bool
let error: String?
let sessionId: String?
}
let spawnResponse = try JSONDecoder().decode(SpawnResponse.self, from: responseData)
if spawnResponse.success {
logger.info("Terminal spawned successfully with session ID: \(sessionId)")
struct CreateSessionResponse: Encodable {
let sessionId: String
let message: String
}
let response = CreateSessionResponse(
sessionId: sessionId,
message: "Terminal spawned successfully"
)
let data = try JSONEncoder().encode(response)
var buffer = ByteBuffer()
buffer.writeData(data)
return Response(
status: .ok,
headers: [.contentType: "application/json"],
body: ResponseBody(byteBuffer: buffer)
)
} else {
let errorMsg = spawnResponse.error ?? "Unknown error"
logger.error("Failed to spawn terminal: \(errorMsg)")
return errorResponse(message: "Failed to spawn terminal: \(errorMsg)", status: .internalServerError)
}
} catch {
logger.error("Failed to communicate with terminal spawn service: \(error)")
return errorResponse(message: "Failed to spawn terminal: \(error.localizedDescription)", status: .internalServerError)
}
}
let sessionName = "session_\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(9))" let sessionName = "session_\(Int(Date().timeIntervalSince1970))_\(UUID().uuidString.prefix(9))"
let cwd = resolvePath(sessionRequest.workingDir ?? "", fallback: FileManager.default.currentDirectoryPath) let cwd = resolvePath(sessionRequest.workingDir ?? "", fallback: FileManager.default.currentDirectoryPath)
let term = sessionRequest.term ?? "xterm-256color" let term = sessionRequest.term ?? "xterm-256color"

View file

@ -31,11 +31,11 @@ struct TerminalLaunchConfig {
} }
var keystrokeEscapedCommand: String { var keystrokeEscapedCommand: String {
// For keystroke commands, we need to escape quotes differently // For keystroke commands, we need to escape backslashes and quotes
// AppleScript keystroke requires double-escaping for quotes
fullCommand fullCommand
.replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\\\\\"") .replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "'", with: "\\'")
} }
} }
@ -44,18 +44,21 @@ enum TerminalLaunchMethod {
case appleScript(script: String) case appleScript(script: String)
case processWithArgs(args: [String]) case processWithArgs(args: [String])
case processWithTyping(delaySeconds: Double = 0.5) case processWithTyping(delaySeconds: Double = 0.5)
case urlScheme(url: String)
} }
/// Supported terminal applications. /// Supported terminal applications.
/// ///
/// Represents terminal emulators that VibeTunnel can launch /// Represents terminal emulators that VibeTunnel can launch
/// with commands, including detection of installed terminals. /// with commands, including detection of installed terminals.
///
/// Note: Tabby is not included as it shows a startup screen
/// which makes it difficult to support automated command execution.
enum Terminal: String, CaseIterable { enum Terminal: String, CaseIterable {
case terminal = "Terminal" case terminal = "Terminal"
case iTerm2 = "iTerm2" case iTerm2 = "iTerm2"
case ghostty = "Ghostty" case ghostty = "Ghostty"
case warp = "Warp" case warp = "Warp"
case tabby = "Tabby"
case alacritty = "Alacritty" case alacritty = "Alacritty"
case hyper = "Hyper" case hyper = "Hyper"
case wezterm = "WezTerm" case wezterm = "WezTerm"
@ -70,8 +73,6 @@ enum Terminal: String, CaseIterable {
"com.mitchellh.ghostty" "com.mitchellh.ghostty"
case .warp: case .warp:
"dev.warp.Warp-Stable" "dev.warp.Warp-Stable"
case .tabby:
"org.tabby"
case .alacritty: case .alacritty:
"org.alacritty" "org.alacritty"
case .hyper: case .hyper:
@ -91,7 +92,6 @@ enum Terminal: String, CaseIterable {
case .alacritty: return 70 // Popular among power users case .alacritty: return 70 // Popular among power users
case .wezterm: return 60 // Less common but powerful case .wezterm: return 60 // Less common but powerful
case .hyper: return 50 // Less popular Electron-based case .hyper: return 50 // Less popular Electron-based
case .tabby: return 40 // Least popular
} }
} }
@ -117,15 +117,51 @@ enum Terminal: String, CaseIterable {
allCases.filter(\.isInstalled) allCases.filter(\.isInstalled)
} }
/// Generate AppleScript for terminals that use keyboard input /// Generate unified AppleScript for all terminals
func keystrokeAppleScript(for config: TerminalLaunchConfig) -> String { func unifiedAppleScript(for config: TerminalLaunchConfig) -> String {
""" // Terminal.app supports 'do script' which handles complex commands better
if self == .terminal {
return """
tell application "Terminal"
activate
do script "\(config.appleScriptEscapedCommand)"
end tell
"""
}
// Warp has issues with key code 36 (Enter), so we use a special approach
if self == .warp {
return """
tell application "\(processName)"
activate
tell application "System Events"
keystroke "n" using {command down}
end tell
delay 3
tell application "System Events"
-- Warp needs special handling for command execution
-- First, type the command
keystroke "\(config.keystrokeEscapedCommand)"
delay 0.1
-- Try multiple approaches to execute the command
-- Option 1: Ctrl+J (line feed)
keystroke "j" using {control down}
delay 0.1
-- Option 2: If that didn't work, try regular Enter
key code 36
end tell
end tell
"""
}
// Standard approach for other terminals
return """
tell application "\(processName)" tell application "\(processName)"
activate activate
tell application "System Events" tell application "System Events"
keystroke "n" using {command down} keystroke "n" using {command down}
end tell end tell
delay 0.2 delay 3
tell application "System Events" tell application "System Events"
keystroke "\(config.keystrokeEscapedCommand)" keystroke "\(config.keystrokeEscapedCommand)"
key code 36 key code 36
@ -135,72 +171,36 @@ enum Terminal: String, CaseIterable {
} }
/// Determine the launch method for this terminal /// Determine the launch method for this terminal
/// The idea is that we optimize this later to use sth faster than AppleScript if available
func launchMethod(for config: TerminalLaunchConfig) -> TerminalLaunchMethod { func launchMethod(for config: TerminalLaunchConfig) -> TerminalLaunchMethod {
switch self { switch self {
case .terminal: case .terminal:
// Terminal.app has very limited CLI support, must use AppleScript // Use unified AppleScript approach for consistency
return .appleScript(script: """ return .appleScript(script: unifiedAppleScript(for: config))
tell application "Terminal"
activate
tell application "System Events"
keystroke "n" using {command down}
end tell
delay 0.1
do script "\(config.escapedCommand)" in front window
end tell
""")
case .iTerm2: case .iTerm2:
// iTerm2 supports URL schemes for command execution // Use unified AppleScript approach for consistency
if let encoded = config.fullCommand.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { return .appleScript(script: unifiedAppleScript(for: config))
// Use iTerm2's URL scheme instead of AppleScript
// Note: URL must be opened with 'open' command, not as an argument
let urlString = "iterm2://profile=default?cmd=\(encoded)"
return .processWithArgs(args: [urlString])
} else {
// Fallback to AppleScript if encoding fails
return .appleScript(script: """
tell application "iTerm"
activate
create window with default profile
tell current session of current window
write text "\(config.escapedCommand)"
end tell
end tell
""")
}
case .ghostty: case .ghostty:
// Ghostty requires AppleScript for command execution // Use unified AppleScript approach
return .appleScript(script: keystrokeAppleScript(for: config)) return .appleScript(script: unifiedAppleScript(for: config))
case .alacritty: case .alacritty:
var args = ["--args", "-e", config.command] // Use unified AppleScript approach for consistency
if let workingDirectory = config.workingDirectory { return .appleScript(script: unifiedAppleScript(for: config))
args = ["--args", "--working-directory", workingDirectory, "-e", config.command]
}
return .processWithArgs(args: args)
case .warp: case .warp:
// Warp requires AppleScript for command execution // Use unified AppleScript approach
return .appleScript(script: keystrokeAppleScript(for: config)) return .appleScript(script: unifiedAppleScript(for: config))
case .hyper: case .hyper:
// Hyper requires AppleScript for command execution // Use unified AppleScript approach
return .appleScript(script: keystrokeAppleScript(for: config)) return .appleScript(script: unifiedAppleScript(for: config))
case .tabby:
// Tabby has limited CLI support
return .processWithTyping()
case .wezterm: case .wezterm:
// WezTerm has excellent CLI support with the 'start' subcommand // Use unified AppleScript approach for consistency
var args = ["--args", "start"] return .appleScript(script: unifiedAppleScript(for: config))
if let workingDirectory = config.workingDirectory {
args += ["--cwd", workingDirectory]
}
args += ["--", "sh", "-c", config.command]
return .processWithArgs(args: args)
} }
} }
@ -211,7 +211,6 @@ enum Terminal: String, CaseIterable {
case .iTerm2: return "iTerm" case .iTerm2: return "iTerm"
case .ghostty: return "Ghostty" case .ghostty: return "Ghostty"
case .warp: return "Warp" case .warp: return "Warp"
case .tabby: return "Tabby"
case .alacritty: return "Alacritty" case .alacritty: return "Alacritty"
case .hyper: return "Hyper" case .hyper: return "Hyper"
case .wezterm: return "WezTerm" case .wezterm: return "WezTerm"
@ -220,18 +219,8 @@ enum Terminal: String, CaseIterable {
/// Whether this terminal requires keystroke-based input (needs Accessibility permission) /// Whether this terminal requires keystroke-based input (needs Accessibility permission)
var requiresKeystrokeInput: Bool { var requiresKeystrokeInput: Bool {
switch self { // All terminals now use keystroke-based input
case .terminal: return true
return false // Uses 'do script' command, not keystrokes
case .iTerm2:
return false // Uses URL scheme or 'write text' command
case .ghostty, .warp, .hyper:
return true // Uses keystroke-based input
case .tabby:
return true // Uses processWithTyping which requires keystrokes
case .alacritty, .wezterm:
return false // Uses command line arguments
}
} }
} }
@ -320,6 +309,7 @@ final class TerminalLauncher {
try launchWithConfig(config) try launchWithConfig(config)
} }
func verifyPreferredTerminal() { func verifyPreferredTerminal() {
let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal
if !terminal.isInstalled { if !terminal.isInstalled {
@ -382,10 +372,15 @@ final class TerminalLauncher {
} }
private func launchWithConfig(_ config: TerminalLaunchConfig) throws { private func launchWithConfig(_ config: TerminalLaunchConfig) throws {
logger.debug("Launch config - command: \(config.command)")
logger.debug("Launch config - fullCommand: \(config.fullCommand)")
logger.debug("Launch config - keystrokeEscapedCommand: \(config.keystrokeEscapedCommand)")
let method = config.terminal.launchMethod(for: config) let method = config.terminal.launchMethod(for: config)
switch method { switch method {
case .appleScript(let script): case .appleScript(let script):
logger.debug("Generated AppleScript:\n\(script)")
try executeAppleScript(script) try executeAppleScript(script)
case .processWithArgs(let args): case .processWithArgs(let args):
@ -398,7 +393,31 @@ final class TerminalLauncher {
Thread.sleep(forTimeInterval: delay) Thread.sleep(forTimeInterval: delay)
// Use the same keystroke pattern as other terminals // Use the same keystroke pattern as other terminals
try executeAppleScript(config.terminal.keystrokeAppleScript(for: config)) try executeAppleScript(config.terminal.unifiedAppleScript(for: config))
case .urlScheme(let url):
// Open URL schemes using NSWorkspace
guard let nsUrl = URL(string: url) else {
throw TerminalLauncherError.processLaunchFailed("Invalid URL: \(url)")
}
if !NSWorkspace.shared.open(nsUrl) {
// Fallback to using 'open' command if NSWorkspace fails
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = [url]
do {
try process.run()
process.waitUntilExit()
if process.terminationStatus != 0 {
throw TerminalLauncherError.processLaunchFailed("Failed to open URL scheme")
}
} catch {
throw TerminalLauncherError.processLaunchFailed(error.localizedDescription)
}
}
} }
} }
@ -426,12 +445,20 @@ final class TerminalLauncher {
// as some terminals (like Ghostty) can take longer to start up // as some terminals (like Ghostty) can take longer to start up
try AppleScriptExecutor.shared.execute(script, timeout: 15.0) try AppleScriptExecutor.shared.execute(script, timeout: 15.0)
} catch let error as AppleScriptError { } catch let error as AppleScriptError {
// Check if this is a keystroke permission error // Check if this is a permission error
if case .executionFailed(_, let errorCode) = error, if case .executionFailed(_, let errorCode) = error,
let code = errorCode, let code = errorCode {
(code == -25211 || code == -1719) { switch code {
// These error codes indicate accessibility permission issues case -25211, -1719:
throw TerminalLauncherError.accessibilityPermissionDenied // These error codes indicate accessibility permission issues
throw TerminalLauncherError.accessibilityPermissionDenied
case -2741:
// This is a syntax error: "Expected end of line but found identifier"
// It usually means the AppleScript has unescaped quotes or other syntax issues
throw TerminalLauncherError.appleScriptExecutionFailed("AppleScript syntax error - likely unescaped quotes in command", errorCode: code)
default:
break
}
} }
// Convert AppleScriptError to TerminalLauncherError // Convert AppleScriptError to TerminalLauncherError
throw error.toTerminalLauncherError() throw error.toTerminalLauncherError()
@ -445,14 +472,19 @@ final class TerminalLauncher {
// MARK: - Terminal Session Launching // MARK: - Terminal Session Launching
func launchTerminalSession(workingDirectory: String, command: String, sessionId: String) throws { func launchTerminalSession(workingDirectory: String, command: String, sessionId: String) throws {
// Find tty-fwd binary path // Find tty-fwd binary path
let ttyFwdPath = findTTYFwdBinary() let ttyFwdPath = findTTYFwdBinary()
// Expand tilde in working directory path
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
// Escape the working directory for shell // Escape the working directory for shell
let escapedWorkingDir = workingDirectory.replacingOccurrences(of: "\"", with: "\\\"") let escapedWorkingDir = expandedWorkingDir.replacingOccurrences(of: "\"", with: "\\\"")
// Construct the full command with cd && tty-fwd && exit pattern // Construct the full command with cd && tty-fwd && exit pattern
let fullCommand = "cd \"\(escapedWorkingDir)\" && \(ttyFwdPath) --session-id=\"\(sessionId)\" -- \(command) && exit" // tty-fwd will use TTY_SESSION_ID from environment or generate one
let fullCommand = "cd \"\(escapedWorkingDir)\" && TTY_SESSION_ID=\"\(sessionId)\" \(ttyFwdPath) -- \(command) && exit"
// Get the preferred terminal or fallback // Get the preferred terminal or fallback
let terminal = getValidTerminal() let terminal = getValidTerminal()
@ -466,6 +498,33 @@ final class TerminalLauncher {
try launchWithConfig(config) try launchWithConfig(config)
} }
/// Optimized terminal session launching that receives pre-formatted command from Rust
func launchOptimizedTerminalSession(workingDirectory: String, command: String, sessionId: String, ttyFwdPath: String? = nil) throws {
// Expand tilde in working directory path
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
// Use provided tty-fwd path or find bundled one
let ttyFwd = ttyFwdPath ?? findTTYFwdBinary()
// The command comes pre-formatted from Rust, just launch it
// This avoids double escaping issues
// Properly escape the directory path for shell
let escapedDir = expandedWorkingDir.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let fullCommand = "cd \"\(escapedDir)\" && \(command)"
// Get the preferred terminal or fallback
let terminal = getValidTerminal()
// Launch with configuration
let config = TerminalLaunchConfig(
command: fullCommand,
workingDirectory: nil,
terminal: terminal
)
try launchWithConfig(config)
}
private func findTTYFwdBinary() -> String { private func findTTYFwdBinary() -> String {
// Look for bundled tty-fwd binary (shipped with the app) // Look for bundled tty-fwd binary (shipped with the app)
if let bundledTTYFwd = Bundle.main.path(forResource: "tty-fwd", ofType: nil) { if let bundledTTYFwd = Bundle.main.path(forResource: "tty-fwd", ofType: nil) {

View file

@ -11,69 +11,7 @@ struct VibeTunnelApp: App {
@State private var serverMonitor = ServerMonitor.shared @State private var serverMonitor = ServerMonitor.shared
init() { init() {
// Check if launched with spawn-terminal command // No special initialization needed
let args = CommandLine.arguments
if args.count >= 3 && args[1] == "spawn-terminal" {
handleSpawnTerminalCommand(args[2])
exit(0)
}
}
private func handleSpawnTerminalCommand(_ jsonString: String) {
guard let data = jsonString.data(using: .utf8) else {
print("Error: Invalid JSON string encoding")
exit(1)
}
struct SpawnTerminalParams: Codable {
let command: [String]
let workingDir: String
let sessionId: String
}
do {
let params = try JSONDecoder().decode(SpawnTerminalParams.self, from: data)
// Initialize the app environment minimally for CLI usage
NSApplication.shared.setActivationPolicy(.accessory)
// Use async approach with run loop to handle CLI invocation
let semaphore = DispatchSemaphore(value: 0)
var launchError: Error?
DispatchQueue.main.async {
do {
try TerminalLauncher.shared.launchTerminalSession(
workingDirectory: params.workingDir,
command: params.command.joined(separator: " "),
sessionId: params.sessionId
)
print("Terminal spawned successfully for session: \(params.sessionId)")
} catch {
launchError = error
print("Error spawning terminal: \(error)")
}
semaphore.signal()
}
// Wait for completion with timeout
let timeout = DispatchTime.now() + .seconds(5)
let result = semaphore.wait(timeout: timeout)
if result == .timedOut {
print("Warning: Terminal spawn operation timed out")
exit(0) // Still exit successfully as the terminal may have been spawned
}
if launchError != nil {
exit(1)
} else {
exit(0) // Exit successfully after spawning terminal
}
} catch {
print("Error parsing spawn-terminal parameters: \(error)")
exit(1)
}
} }
var body: some Scene { var body: some Scene {
@ -182,6 +120,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
object: nil object: nil
) )
// Start the terminal spawn service
TerminalSpawnService.shared.start()
// Initialize and start HTTP server using ServerManager // Initialize and start HTTP server using ServerManager
Task { Task {
do { do {
@ -277,6 +218,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Stop session monitoring // Stop session monitoring
sessionMonitor.stopMonitoring() sessionMonitor.stopMonitoring()
// Stop terminal spawn service
TerminalSpawnService.shared.stop()
// Stop HTTP server // Stop HTTP server
Task { Task {
await serverManager.stop() await serverManager.stop()

232
docs/swift-rust-comm.md Normal file
View file

@ -0,0 +1,232 @@
# Swift-Rust Communication Architecture
This document describes the inter-process communication (IPC) architecture between the Swift VibeTunnel macOS application and the Rust tty-fwd terminal multiplexer.
## Overview
VibeTunnel uses a Unix domain socket for communication between the Swift app and Rust components. This approach avoids UI spawning issues and provides reliable, bidirectional communication.
## Architecture Components
### 1. Terminal Spawn Service (Swift)
**File**: `VibeTunnel/Core/Services/TerminalSpawnService.swift`
The `TerminalSpawnService` listens on a Unix domain socket at `/tmp/vibetunnel-terminal.sock` and handles requests to spawn terminal windows.
Key features:
- Uses POSIX socket APIs (socket, bind, listen, accept) for reliable Unix domain socket communication
- Runs on a dedicated queue with `.userInitiated` QoS
- Automatically cleans up the socket on startup and shutdown
- Handles JSON-encoded spawn requests and responses
- Non-blocking accept loop with proper error handling
**Lifecycle**:
- Started in `AppDelegate.applicationDidFinishLaunching`
- Stopped in `AppDelegate.applicationWillTerminate`
### 2. Socket Client (Rust)
**File**: `tty-fwd/src/term_socket.rs`
The Rust client connects to the Unix socket to request terminal spawning:
```rust
pub fn spawn_terminal_via_socket(
command: &[String],
working_dir: Option<&str>,
) -> Result<String>
```
**Communication Protocol**:
Request format (optimized):
```json
{
"command": "tty-fwd --session-id=\"uuid\" -- zsh && exit",
"workingDir": "/Users/example",
"sessionId": "uuid-here",
"ttyFwdPath": "/path/to/tty-fwd",
"terminal": "ghostty" // optional
}
```
Response format:
```json
{
"success": true,
"error": null,
"sessionId": "uuid-here"
}
```
Key optimizations:
- Command is pre-formatted in Rust to avoid double-escaping issues
- ttyFwdPath is provided to avoid path discovery
- Terminal preference can be specified per-request
- Working directory handling is simplified
### 3. Integration Points
#### Swift Server (Hummingbird)
**File**: `VibeTunnel/Core/Services/TunnelServer.swift`
When `spawn_terminal: true` is received in a session creation request:
1. Connects to the Unix socket using low-level socket APIs
2. Sends the spawn request
3. Reads the response
4. Returns appropriate HTTP response to the web UI
#### Rust API Server
**File**: `tty-fwd/src/api_server.rs`
The API server handles HTTP requests and uses `spawn_terminal_command` when the `spawn_terminal` flag is set.
## Communication Flow
```
Web UI → HTTP POST /api/sessions (spawn_terminal: true)
API Server (Swift or Rust)
Unix Socket Client
/tmp/vibetunnel-terminal.sock
TerminalSpawnService (Swift)
TerminalLauncher
AppleScript execution
Terminal.app/iTerm2/etc opens with command
```
## Benefits of This Architecture
1. **No UI Spawning**: The main VibeTunnel app handles all terminal spawning, avoiding macOS restrictions on spawning UI apps from background processes.
2. **Process Isolation**: tty-fwd doesn't need to know about VibeTunnel's location or how to invoke it.
3. **Reliable Communication**: Unix domain sockets provide fast, reliable local IPC.
4. **Clean Separation**: Terminal spawning logic stays in the Swift app where it belongs.
5. **Fallback Support**: If the socket is unavailable, appropriate error messages guide the user.
## Error Handling
Common error scenarios:
1. **Socket Unavailable**:
- Error: "Terminal spawn service not available at /tmp/vibetunnel-terminal.sock"
- Cause: VibeTunnel app not running or service not started
- Solution: Ensure VibeTunnel is running
2. **Permission Denied**:
- Error: "Failed to spawn terminal: Accessibility permission denied"
- Cause: macOS security restrictions on AppleScript
- Solution: Grant accessibility permissions to VibeTunnel
3. **Terminal Not Found**:
- Error: "Selected terminal application not found"
- Cause: Configured terminal app not installed
- Solution: Install the terminal or change preferences
## Implementation Notes
### Socket Path
The socket path `/tmp/vibetunnel-terminal.sock` was chosen because:
- `/tmp` is accessible to all processes
- Automatically cleaned up on system restart
- No permission issues between different processes
### JSON Protocol
JSON was chosen for the protocol because:
- Easy to parse in both Swift and Rust
- Human-readable for debugging
- Extensible for future features
### Performance Optimizations
1. **Pre-formatted Commands**: Rust formats the complete command string, avoiding complex escaping logic in Swift
2. **Path Discovery**: tty-fwd path is passed in the request to avoid repeated file system lookups
3. **Direct Terminal Selection**: Terminal preference can be specified per-request without changing global settings
4. **Simplified Escaping**: Using shell-words crate in Rust for proper command escaping
5. **Reduced Payload Size**: Command is a single string instead of an array
### Security Considerations
- The socket is created with default permissions (user-only access)
- No authentication is required as it's local-only communication
- The socket is cleaned up on app termination
- Commands are properly escaped using shell-words to prevent injection
## Adding New IPC Features
To add new IPC commands:
1. Define the request/response structures in both Swift and Rust
2. Add a new handler in `TerminalSpawnService.handleRequest`
3. Create a corresponding client function in Rust
4. Update error handling for the new command
Example:
```swift
struct NewCommand: Codable {
let action: String
let parameters: [String: String]
}
```
```rust
#[derive(serde::Serialize)]
struct NewCommand {
action: String,
parameters: HashMap<String, String>,
}
```
## Debugging
To debug socket communication:
1. Check if the socket exists: `ls -la /tmp/vibetunnel-terminal.sock`
2. Monitor Swift logs: Look for `TerminalSpawnService` category
3. Check Rust debug output when running tty-fwd with verbose logging
4. Use `netstat -an | grep vibetunnel` to see socket connections
## Implementation Details
### POSIX Socket Implementation
The service uses low-level POSIX socket APIs for maximum compatibility:
```swift
// Socket creation
serverSocket = socket(AF_UNIX, SOCK_STREAM, 0)
// Binding to path
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
bind(serverSocket, &addr, socklen_t(MemoryLayout<sockaddr_un>.size))
// Accept connections
let clientSocket = accept(serverSocket, &clientAddr, &clientAddrLen)
```
This approach avoids the Network framework's limitations with Unix domain sockets and provides reliable, cross-platform compatible IPC.
## Historical Context
Previously, tty-fwd would spawn VibeTunnel as a subprocess with CLI arguments. This approach had several issues:
- macOS security restrictions on spawning UI apps
- Duplicate instance detection conflicts
- Complex error handling
- Path discovery problems
The Unix socket approach would resolve these issues while providing a cleaner architecture, but needs to be implemented using lower-level APIs due to Network framework limitations.

7
tty-fwd/Cargo.lock generated
View file

@ -512,6 +512,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "shell-words"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
[[package]] [[package]]
name = "signal-hook" name = "signal-hook"
version = "0.3.18" version = "0.3.18"
@ -575,6 +581,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"shell-words",
"signal-hook", "signal-hook",
"tempfile", "tempfile",
"uuid", "uuid",

View file

@ -27,6 +27,7 @@ signal-hook = { version = "0.3.14", default-features = false, features = ["itera
tempfile = "3.20.0" tempfile = "3.20.0"
uuid = { version = "1.17.0", features = ["v4"], default-features = false } uuid = { version = "1.17.0", features = ["v4"], default-features = false }
bytes = "1.0" bytes = "1.0"
shell-words = "1.1"
http = "1.0" http = "1.0"
regex = "1.10" regex = "1.10"
ctrlc = "3.4.2" ctrlc = "3.4.2"

View file

@ -258,7 +258,6 @@ pub fn start_server(
control_path: PathBuf, control_path: PathBuf,
static_path: Option<String>, static_path: Option<String>,
password: Option<String>, password: Option<String>,
vibetunnel_path: Option<String>,
) -> Result<()> { ) -> Result<()> {
fs::create_dir_all(&control_path)?; fs::create_dir_all(&control_path)?;
@ -280,7 +279,6 @@ pub fn start_server(
let control_path = control_path.clone(); let control_path = control_path.clone();
let static_path = static_path.clone(); let static_path = static_path.clone();
let auth_password = auth_password.clone(); let auth_password = auth_password.clone();
let vibetunnel_path = vibetunnel_path.clone();
thread::spawn(move || { thread::spawn(move || {
let mut req = match req { let mut req = match req {
@ -330,7 +328,7 @@ pub fn start_server(
(&Method::GET, "/api/health") => handle_health(), (&Method::GET, "/api/health") => handle_health(),
(&Method::GET, "/api/sessions") => handle_list_sessions(&control_path), (&Method::GET, "/api/sessions") => handle_list_sessions(&control_path),
(&Method::POST, "/api/sessions") => { (&Method::POST, "/api/sessions") => {
handle_create_session(&control_path, &req, vibetunnel_path.as_deref()) handle_create_session(&control_path, &req)
} }
(&Method::POST, "/api/cleanup-exited") => handle_cleanup_exited(&control_path), (&Method::POST, "/api/cleanup-exited") => handle_cleanup_exited(&control_path),
(&Method::POST, "/api/mkdir") => handle_mkdir(&req), (&Method::POST, "/api/mkdir") => handle_mkdir(&req),
@ -450,7 +448,6 @@ fn handle_list_sessions(control_path: &Path) -> Response<String> {
fn handle_create_session( fn handle_create_session(
control_path: &Path, control_path: &Path,
req: &crate::http_server::HttpRequest, req: &crate::http_server::HttpRequest,
vibetunnel_path: Option<&str>,
) -> Response<String> { ) -> Response<String> {
// Read the request body // Read the request body
let body_bytes = req.body(); let body_bytes = req.body();
@ -483,7 +480,7 @@ fn handle_create_session(
match crate::term::spawn_terminal_command( match crate::term::spawn_terminal_command(
&create_request.command, &create_request.command,
create_request.working_dir.as_deref(), create_request.working_dir.as_deref(),
vibetunnel_path, None,
) { ) {
Ok(terminal_session_id) => { Ok(terminal_session_id) => {
println!("Terminal spawned with session ID: {}", terminal_session_id); println!("Terminal spawned with session ID: {}", terminal_session_id);

View file

@ -3,6 +3,7 @@ mod http_server;
mod protocol; mod protocol;
mod sessions; mod sessions;
mod term; mod term;
mod term_socket;
mod tty_spawn; mod tty_spawn;
use std::env; use std::env;
@ -31,7 +32,6 @@ fn main() -> Result<(), anyhow::Error> {
let mut serve_address = None::<String>; let mut serve_address = None::<String>;
let mut static_path = None::<String>; let mut static_path = None::<String>;
let mut password = None::<String>; let mut password = None::<String>;
let mut vibetunnel_path = None::<String>;
let mut cmdline = Vec::<OsString>::new(); let mut cmdline = Vec::<OsString>::new();
while let Some(param) = parser.param()? { while let Some(param) = parser.param()? {
@ -95,9 +95,6 @@ fn main() -> Result<(), anyhow::Error> {
p if p.is_long("password") => { p if p.is_long("password") => {
password = Some(parser.value()?); password = Some(parser.value()?);
} }
p if p.is_long("vibetunnel-path") => {
vibetunnel_path = Some(parser.value()?);
}
p if p.is_pos() => { p if p.is_pos() => {
cmdline.push(parser.value()?); cmdline.push(parser.value()?);
} }
@ -128,7 +125,6 @@ fn main() -> Result<(), anyhow::Error> {
" --static-path <path> Path to static files directory for HTTP server" " --static-path <path> Path to static files directory for HTTP server"
); );
println!(" --password <password> Enable basic auth with random username and specified password"); println!(" --password <password> Enable basic auth with random username and specified password");
println!(" --vibetunnel-path <path> Path to VibeTunnel executable (for terminal spawning)");
println!(" --spawn-terminal <app> Spawn command in a new terminal window (supports Terminal.app, Ghostty.app)"); println!(" --spawn-terminal <app> Spawn command in a new terminal window (supports Terminal.app, Ghostty.app)");
println!(" --help Show this help message"); println!(" --help Show this help message");
return Ok(()); return Ok(());
@ -207,7 +203,6 @@ fn main() -> Result<(), anyhow::Error> {
control_path, control_path,
static_path, static_path,
password, password,
vibetunnel_path,
); );
} }

View file

@ -1,150 +1,25 @@
use anyhow::Result; use anyhow::Result;
use serde_json::json;
use std::process::Command;
use uuid::Uuid;
/// Spawns a terminal command by invoking the VibeTunnel app with CLI arguments. /// Spawns a terminal command by communicating with VibeTunnel via Unix domain socket.
/// ///
/// This approach bypasses the distributed notification system which has restrictions /// This approach uses a Unix domain socket at `/tmp/vibetunnel-terminal.sock` to
/// on macOS 15 (Sequoia) and later. Instead, it directly invokes the VibeTunnel app /// communicate with the running VibeTunnel application, which handles the actual
/// with command-line arguments. /// terminal spawning.
///
/// # Command Format
///
/// The VibeTunnel app is invoked with:
/// ```
/// VibeTunnel spawn-terminal '{"command": [...], "workingDir": "...", "sessionId": "..."}'
/// ```
/// ///
/// # Arguments /// # Arguments
/// ///
/// * `command` - Array of command arguments to execute /// * `command` - Array of command arguments to execute
/// * `working_dir` - Optional working directory path /// * `working_dir` - Optional working directory path
/// * `vibetunnel_path` - Optional path to the VibeTunnel executable /// * `_vibetunnel_path` - Kept for API compatibility but no longer used
/// ///
/// # Returns /// # Returns
/// ///
/// Returns the session ID on success, or an error if the invocation fails /// Returns the session ID on success, or an error if the socket communication fails
pub fn spawn_terminal_command( pub fn spawn_terminal_command(
command: &[String], command: &[String],
working_dir: Option<&str>, working_dir: Option<&str>,
vibetunnel_path: Option<&str>, _vibetunnel_path: Option<&str>, // Kept for API compatibility, no longer used
) -> Result<String> { ) -> Result<String> {
let session_id = Uuid::new_v4().to_string(); // Use the socket approach to communicate with VibeTunnel
crate::term_socket::spawn_terminal_via_socket(command, working_dir)
// Construct the JSON payload
let mut payload = json!({
"command": command,
"sessionId": session_id
});
if let Some(wd) = working_dir {
payload["workingDir"] = json!(wd);
}
let json_string = serde_json::to_string(&payload)?;
// Use provided path or find VibeTunnel app path
let vibetunnel_executable = if let Some(path) = vibetunnel_path {
// Validate that the provided path exists
if !std::path::Path::new(path).exists() {
return Err(anyhow::anyhow!(
"Provided VibeTunnel path does not exist: {}",
path
));
}
path.to_string()
} else {
find_vibetunnel_app()?
};
println!(
"Spawning terminal session {} via CLI invocation",
session_id
);
println!("VibeTunnel path: {}", vibetunnel_executable);
println!("Payload: {}", json_string);
// Invoke VibeTunnel with spawn-terminal command
let output = Command::new(&vibetunnel_executable)
.arg("spawn-terminal")
.arg(&json_string)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!("Failed to spawn terminal: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
if stdout.contains("successfully") {
println!("Terminal spawned successfully for session: {}", session_id);
}
Ok(session_id)
}
/// Finds the path to the VibeTunnel app executable
fn find_vibetunnel_app() -> Result<String> {
let home = std::env::var("HOME").unwrap_or_default();
// Try common locations for macOS apps
let user_apps_path = format!(
"{}/Applications/VibeTunnel.app/Contents/MacOS/VibeTunnel",
home
);
let derived_data_debug = format!("{}/Library/Developer/Xcode/DerivedData/VibeTunnel-*/Build/Products/Debug/VibeTunnel.app/Contents/MacOS/VibeTunnel", home);
let derived_data_release = format!("{}/Library/Developer/Xcode/DerivedData/VibeTunnel-*/Build/Products/Release/VibeTunnel.app/Contents/MacOS/VibeTunnel", home);
let possible_paths = vec![
// Check if VibeTunnel is in PATH (e.g., via symlink)
"VibeTunnel",
// Standard Applications folder
"/Applications/VibeTunnel.app/Contents/MacOS/VibeTunnel",
// User Applications folder
&user_apps_path,
// Development build location (relative to tty-fwd)
"../VibeTunnel/build/Debug/VibeTunnel.app/Contents/MacOS/VibeTunnel",
"../VibeTunnel/build/Release/VibeTunnel.app/Contents/MacOS/VibeTunnel",
// Xcode DerivedData (common development location)
&derived_data_debug,
&derived_data_release,
];
// First try to find it in PATH
if let Ok(output) = Command::new("which").arg("VibeTunnel").output() {
if output.status.success() {
if let Ok(path) = String::from_utf8(output.stdout) {
let path = path.trim();
if !path.is_empty() {
return Ok(path.to_string());
}
}
}
}
// Try each possible path
for path in &possible_paths {
// Handle glob patterns for DerivedData
if path.contains("*") {
if let Ok(entries) = glob::glob(path) {
for entry in entries.filter_map(Result::ok) {
if entry.exists() {
return Ok(entry.to_string_lossy().to_string());
}
}
}
} else if std::path::Path::new(path).exists() {
// For non-glob paths, check directly
if *path == "VibeTunnel" {
// If just "VibeTunnel", use full path resolution
return Ok("VibeTunnel".to_string());
}
return Ok(path.to_string());
}
}
Err(anyhow::anyhow!(
"VibeTunnel app not found. Please ensure VibeTunnel is installed in /Applications or add it to your PATH"
))
} }

View file

@ -0,0 +1,76 @@
use anyhow::Result;
use serde_json::json;
use std::io::{Read, Write};
use std::os::unix::net::UnixStream;
use uuid::Uuid;
use std::env;
/// Spawn a terminal session by communicating with VibeTunnel via Unix socket
pub fn spawn_terminal_via_socket(
command: &[String],
working_dir: Option<&str>,
) -> Result<String> {
let session_id = Uuid::new_v4().to_string();
let socket_path = "/tmp/vibetunnel-terminal.sock";
// Try to connect to the Unix socket
let mut stream = match UnixStream::connect(socket_path) {
Ok(stream) => stream,
Err(e) => {
return Err(anyhow::anyhow!("Terminal spawn service not available at {}: {}", socket_path, e));
}
};
// Get the current tty-fwd binary path
let tty_fwd_path = env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "tty-fwd".to_string());
// Pre-format the command with proper escaping
// This reduces complexity in Swift and avoids double-escaping issues
// tty-fwd reads session ID from TTY_SESSION_ID environment variable
let formatted_command = format!(
"TTY_SESSION_ID=\"{}\" {} -- {}",
session_id,
tty_fwd_path,
shell_words::join(command)
);
// Construct the spawn request with optimized format
let request = json!({
"command": formatted_command,
"workingDir": working_dir.unwrap_or("~/"),
"sessionId": session_id,
"ttyFwdPath": tty_fwd_path,
"terminal": std::env::var("VIBETUNNEL_TERMINAL").ok()
});
let request_data = serde_json::to_vec(&request)?;
// Send the request
stream.write_all(&request_data)?;
stream.flush()?;
// Read the response
let mut response_data = Vec::new();
stream.read_to_end(&mut response_data)?;
// Parse the response
#[derive(serde::Deserialize)]
struct SpawnResponse {
success: bool,
error: Option<String>,
#[serde(rename = "sessionId")]
#[allow(dead_code)]
session_id: Option<String>,
}
let response: SpawnResponse = serde_json::from_slice(&response_data)?;
if response.success {
Ok(session_id)
} else {
let error_msg = response.error.unwrap_or_else(|| "Unknown error".to_string());
Err(anyhow::anyhow!("Failed to spawn terminal: {}", error_msg))
}
}

View file

@ -19,6 +19,7 @@ export class VibeTunnelApp extends LitElement {
} }
@state() private errorMessage = ''; @state() private errorMessage = '';
@state() private successMessage = '';
@state() private sessions: Session[] = []; @state() private sessions: Session[] = [];
@state() private loading = false; @state() private loading = false;
@state() private currentView: 'list' | 'session' = 'list'; @state() private currentView: 'list' | 'session' = 'list';
@ -53,10 +54,22 @@ export class VibeTunnelApp extends LitElement {
}, 5000); }, 5000);
} }
private showSuccess(message: string) {
this.successMessage = message;
// Clear success after 5 seconds
setTimeout(() => {
this.successMessage = '';
}, 5000);
}
private clearError() { private clearError() {
this.errorMessage = ''; this.errorMessage = '';
} }
private clearSuccess() {
this.successMessage = '';
}
private async loadSessions() { private async loadSessions() {
this.loading = true; this.loading = true;
try { try {
@ -86,6 +99,7 @@ export class VibeTunnelApp extends LitElement {
private async handleSessionCreated(e: CustomEvent) { private async handleSessionCreated(e: CustomEvent) {
const sessionId = e.detail.sessionId; const sessionId = e.detail.sessionId;
const message = e.detail.message;
if (!sessionId) { if (!sessionId) {
this.showError('Session created but ID not found in response'); this.showError('Session created but ID not found in response');
@ -94,6 +108,13 @@ export class VibeTunnelApp extends LitElement {
this.showCreateModal = false; this.showCreateModal = false;
// Check if this was a terminal spawn (not a web session)
if (message && message.includes('Terminal spawned successfully')) {
// Don't try to switch to the session - it's running in a terminal window
this.showSuccess('Terminal window opened successfully');
return;
}
// Wait for session to appear in the list and then switch to it // Wait for session to appear in the list and then switch to it
await this.waitForSessionAndSwitch(sessionId); await this.waitForSessionAndSwitch(sessionId);
} }
@ -251,6 +272,18 @@ export class VibeTunnelApp extends LitElement {
</div> </div>
` `
: ''} : ''}
${this.successMessage
? html`
<div class="fixed top-4 right-4 z-50">
<div class="bg-vs-link text-vs-bg px-4 py-2 rounded shadow-lg font-mono text-sm">
${this.successMessage}
<button @click=${this.clearSuccess} class="ml-2 text-vs-bg hover:text-vs-muted">
</button>
</div>
</div>
`
: ''}
<!-- Main content --> <!-- Main content -->
${this.currentView === 'session' && this.selectedSessionId ${this.currentView === 'session' && this.selectedSessionId

View file

@ -6,6 +6,7 @@ export interface SessionCreateData {
command: string[]; command: string[];
workingDir: string; workingDir: string;
name?: string; name?: string;
spawn_terminal?: boolean;
} }
@customElement('session-create-form') @customElement('session-create-form')
@ -130,6 +131,7 @@ export class SessionCreateForm extends LitElement {
const sessionData: SessionCreateData = { const sessionData: SessionCreateData = {
command: this.parseCommand(this.command.trim()), command: this.parseCommand(this.command.trim()),
workingDir: this.workingDir.trim(), workingDir: this.workingDir.trim(),
spawn_terminal: true,
}; };
// Add session name if provided // Add session name if provided