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
key = "OS_ACTIVITY_MODE"
value = "disable"
isEnabled = "YES">
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>

View file

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

View file

@ -166,10 +166,7 @@ final class RustServer: ServerProtocol {
// Use bind address from ServerManager to control server accessibility
let bindAddress = ServerManager.shared.bindAddress
// Get the VibeTunnel executable path
let vibeTunnelPath = Bundle.main.executablePath ?? ""
var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --vibetunnel-path \"\(vibeTunnelPath)\" --serve \(bindAddress):\(port)"
var ttyFwdCommand = "\"\(binaryPath)\" --static-path \"\(staticPath)\" --serve \(bindAddress):\(port)"
// Add password flag if password protection is enabled
// 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 workingDir: String?
let term: String?
let spawn_terminal: Bool?
}
let sessionRequest = try JSONDecoder().decode(CreateSessionRequest.self, from: requestData)
@ -723,6 +724,116 @@ public final class TunnelServer {
if sessionRequest.command.isEmpty {
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 cwd = resolvePath(sessionRequest.workingDir ?? "", fallback: FileManager.default.currentDirectoryPath)

View file

@ -36,4 +36,4 @@
<key>NSAppleEventsUsageDescription</key>
<string>VibeTunnel needs to control terminal applications to create new terminal sessions from the dashboard.</string>
</dict>
</plist>
</plist>

View file

@ -31,11 +31,11 @@ struct TerminalLaunchConfig {
}
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
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\\\\\"")
.replacingOccurrences(of: "'", with: "\\'")
.replacingOccurrences(of: "\"", with: "\\\"")
}
}
@ -44,18 +44,21 @@ enum TerminalLaunchMethod {
case appleScript(script: String)
case processWithArgs(args: [String])
case processWithTyping(delaySeconds: Double = 0.5)
case urlScheme(url: String)
}
/// Supported terminal applications.
///
/// Represents terminal emulators that VibeTunnel can launch
/// 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 {
case terminal = "Terminal"
case iTerm2 = "iTerm2"
case ghostty = "Ghostty"
case warp = "Warp"
case tabby = "Tabby"
case alacritty = "Alacritty"
case hyper = "Hyper"
case wezterm = "WezTerm"
@ -70,8 +73,6 @@ enum Terminal: String, CaseIterable {
"com.mitchellh.ghostty"
case .warp:
"dev.warp.Warp-Stable"
case .tabby:
"org.tabby"
case .alacritty:
"org.alacritty"
case .hyper:
@ -91,7 +92,6 @@ enum Terminal: String, CaseIterable {
case .alacritty: return 70 // Popular among power users
case .wezterm: return 60 // Less common but powerful
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)
}
/// Generate AppleScript for terminals that use keyboard input
func keystrokeAppleScript(for config: TerminalLaunchConfig) -> String {
"""
/// Generate unified AppleScript for all terminals
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)"
activate
tell application "System Events"
keystroke "n" using {command down}
end tell
delay 0.2
delay 3
tell application "System Events"
keystroke "\(config.keystrokeEscapedCommand)"
key code 36
@ -135,72 +171,36 @@ enum Terminal: String, CaseIterable {
}
/// 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 {
switch self {
case .terminal:
// Terminal.app has very limited CLI support, must use AppleScript
return .appleScript(script: """
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
""")
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
case .iTerm2:
// iTerm2 supports URL schemes for command execution
if let encoded = config.fullCommand.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) {
// 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
""")
}
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
case .ghostty:
// Ghostty requires AppleScript for command execution
return .appleScript(script: keystrokeAppleScript(for: config))
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
case .alacritty:
var args = ["--args", "-e", config.command]
if let workingDirectory = config.workingDirectory {
args = ["--args", "--working-directory", workingDirectory, "-e", config.command]
}
return .processWithArgs(args: args)
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
case .warp:
// Warp requires AppleScript for command execution
return .appleScript(script: keystrokeAppleScript(for: config))
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
case .hyper:
// Hyper requires AppleScript for command execution
return .appleScript(script: keystrokeAppleScript(for: config))
case .tabby:
// Tabby has limited CLI support
return .processWithTyping()
// Use unified AppleScript approach
return .appleScript(script: unifiedAppleScript(for: config))
case .wezterm:
// WezTerm has excellent CLI support with the 'start' subcommand
var args = ["--args", "start"]
if let workingDirectory = config.workingDirectory {
args += ["--cwd", workingDirectory]
}
args += ["--", "sh", "-c", config.command]
return .processWithArgs(args: args)
// Use unified AppleScript approach for consistency
return .appleScript(script: unifiedAppleScript(for: config))
}
}
@ -211,7 +211,6 @@ enum Terminal: String, CaseIterable {
case .iTerm2: return "iTerm"
case .ghostty: return "Ghostty"
case .warp: return "Warp"
case .tabby: return "Tabby"
case .alacritty: return "Alacritty"
case .hyper: return "Hyper"
case .wezterm: return "WezTerm"
@ -220,18 +219,8 @@ enum Terminal: String, CaseIterable {
/// Whether this terminal requires keystroke-based input (needs Accessibility permission)
var requiresKeystrokeInput: Bool {
switch self {
case .terminal:
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
}
// All terminals now use keystroke-based input
return true
}
}
@ -319,6 +308,7 @@ final class TerminalLauncher {
let config = TerminalLaunchConfig(command: command, workingDirectory: nil, terminal: terminal)
try launchWithConfig(config)
}
func verifyPreferredTerminal() {
let terminal = Terminal(rawValue: preferredTerminal) ?? .terminal
@ -382,10 +372,15 @@ final class TerminalLauncher {
}
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)
switch method {
case .appleScript(let script):
logger.debug("Generated AppleScript:\n\(script)")
try executeAppleScript(script)
case .processWithArgs(let args):
@ -398,7 +393,31 @@ final class TerminalLauncher {
Thread.sleep(forTimeInterval: delay)
// 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
try AppleScriptExecutor.shared.execute(script, timeout: 15.0)
} 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,
let code = errorCode,
(code == -25211 || code == -1719) {
// These error codes indicate accessibility permission issues
throw TerminalLauncherError.accessibilityPermissionDenied
let code = errorCode {
switch code {
case -25211, -1719:
// 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
throw error.toTerminalLauncherError()
@ -445,14 +472,19 @@ final class TerminalLauncher {
// MARK: - Terminal Session Launching
func launchTerminalSession(workingDirectory: String, command: String, sessionId: String) throws {
// Find tty-fwd binary path
let ttyFwdPath = findTTYFwdBinary()
// Expand tilde in working directory path
let expandedWorkingDir = (workingDirectory as NSString).expandingTildeInPath
// 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
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
let terminal = getValidTerminal()
@ -466,6 +498,33 @@ final class TerminalLauncher {
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 {
// Look for bundled tty-fwd binary (shipped with the app)
if let bundledTTYFwd = Bundle.main.path(forResource: "tty-fwd", ofType: nil) {
@ -476,4 +535,4 @@ final class TerminalLauncher {
logger.error("No tty-fwd binary found in app bundle, command will fail")
return "echo 'VibeTunnel: tty-fwd binary not found in app bundle'; false"
}
}
}

View file

@ -11,69 +11,7 @@ struct VibeTunnelApp: App {
@State private var serverMonitor = ServerMonitor.shared
init() {
// Check if launched with spawn-terminal command
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)
}
// No special initialization needed
}
var body: some Scene {
@ -182,6 +120,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
object: nil
)
// Start the terminal spawn service
TerminalSpawnService.shared.start()
// Initialize and start HTTP server using ServerManager
Task {
do {
@ -276,6 +217,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
// Stop session monitoring
sessionMonitor.stopMonitoring()
// Stop terminal spawn service
TerminalSpawnService.shared.stop()
// Stop HTTP server
Task {

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ mod http_server;
mod protocol;
mod sessions;
mod term;
mod term_socket;
mod tty_spawn;
use std::env;
@ -31,7 +32,6 @@ fn main() -> Result<(), anyhow::Error> {
let mut serve_address = None::<String>;
let mut static_path = None::<String>;
let mut password = None::<String>;
let mut vibetunnel_path = None::<String>;
let mut cmdline = Vec::<OsString>::new();
while let Some(param) = parser.param()? {
@ -95,9 +95,6 @@ fn main() -> Result<(), anyhow::Error> {
p if p.is_long("password") => {
password = Some(parser.value()?);
}
p if p.is_long("vibetunnel-path") => {
vibetunnel_path = Some(parser.value()?);
}
p if p.is_pos() => {
cmdline.push(parser.value()?);
}
@ -128,7 +125,6 @@ fn main() -> Result<(), anyhow::Error> {
" --static-path <path> Path to static files directory for HTTP server"
);
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!(" --help Show this help message");
return Ok(());
@ -207,7 +203,6 @@ fn main() -> Result<(), anyhow::Error> {
control_path,
static_path,
password,
vibetunnel_path,
);
}

View file

@ -1,150 +1,25 @@
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
/// on macOS 15 (Sequoia) and later. Instead, it directly invokes the VibeTunnel app
/// with command-line arguments.
///
/// # Command Format
///
/// The VibeTunnel app is invoked with:
/// ```
/// VibeTunnel spawn-terminal '{"command": [...], "workingDir": "...", "sessionId": "..."}'
/// ```
/// This approach uses a Unix domain socket at `/tmp/vibetunnel-terminal.sock` to
/// communicate with the running VibeTunnel application, which handles the actual
/// terminal spawning.
///
/// # Arguments
///
/// * `command` - Array of command arguments to execute
/// * `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 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(
command: &[String],
working_dir: Option<&str>,
vibetunnel_path: Option<&str>,
_vibetunnel_path: Option<&str>, // Kept for API compatibility, no longer used
) -> Result<String> {
let session_id = Uuid::new_v4().to_string();
// 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"
))
}
// Use the socket approach to communicate with VibeTunnel
crate::term_socket::spawn_terminal_via_socket(command, working_dir)
}

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 successMessage = '';
@state() private sessions: Session[] = [];
@state() private loading = false;
@state() private currentView: 'list' | 'session' = 'list';
@ -53,10 +54,22 @@ export class VibeTunnelApp extends LitElement {
}, 5000);
}
private showSuccess(message: string) {
this.successMessage = message;
// Clear success after 5 seconds
setTimeout(() => {
this.successMessage = '';
}, 5000);
}
private clearError() {
this.errorMessage = '';
}
private clearSuccess() {
this.successMessage = '';
}
private async loadSessions() {
this.loading = true;
try {
@ -86,6 +99,7 @@ export class VibeTunnelApp extends LitElement {
private async handleSessionCreated(e: CustomEvent) {
const sessionId = e.detail.sessionId;
const message = e.detail.message;
if (!sessionId) {
this.showError('Session created but ID not found in response');
@ -94,6 +108,13 @@ export class VibeTunnelApp extends LitElement {
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
await this.waitForSessionAndSwitch(sessionId);
}
@ -251,6 +272,18 @@ export class VibeTunnelApp extends LitElement {
</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 -->
${this.currentView === 'session' && this.selectedSessionId

View file

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