mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Spawn new Terminal
This commit is contained in:
parent
e3c6a6ea4a
commit
e7480c3f59
18 changed files with 846 additions and 299 deletions
Binary file not shown.
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
216
VibeTunnel/Core/Services/TerminalSpawnService.swift
Normal file
216
VibeTunnel/Core/Services/TerminalSpawnService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
232
docs/swift-rust-comm.md
Normal 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
7
tty-fwd/Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
76
tty-fwd/src/term_socket.rs
Normal file
76
tty-fwd/src/term_socket.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue