mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-26 15:07:39 +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
|
||||
key = "OS_ACTIVITY_MODE"
|
||||
value = "disable"
|
||||
isEnabled = "YES">
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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 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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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",
|
||||
]
|
||||
|
||||
[[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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
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 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue