vibetunnel/mac/VibeTunnel/Core/Services/TerminalSpawnService.swift
2025-06-20 19:09:04 +02:00

215 lines
7.6 KiB
Swift

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: 65_536)
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 vibetunnelPath: 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,
vibetunnelPath: request.vibetunnelPath
)
} 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)
}
}
}