mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-24 14:47:39 +00:00
- Include CreditLink component directly in AboutView.swift - Fix Swift 6 concurrency issue with NSRunningApplication - Remove Windows build from Rust workflow (tty-fwd is Unix-only) - tty-fwd uses Unix-specific PTY features not available on Windows
399 lines
14 KiB
Swift
399 lines
14 KiB
Swift
import Foundation
|
|
import HTTPTypes
|
|
import Logging
|
|
|
|
/// WebSocket message types for terminal communication.
|
|
///
|
|
/// Defines the different types of messages that can be exchanged
|
|
/// between the client and server over WebSocket connections.
|
|
public enum WSMessageType: String, Codable {
|
|
case connect
|
|
case command
|
|
case output
|
|
case error
|
|
case ping
|
|
case pong
|
|
case close
|
|
}
|
|
|
|
/// WebSocket message structure.
|
|
///
|
|
/// Encapsulates data sent over WebSocket connections for terminal
|
|
/// communication, including message type, session information, and payload.
|
|
public struct WSMessage: Codable {
|
|
public let type: WSMessageType
|
|
public let sessionId: String?
|
|
public let data: String?
|
|
public let timestamp: Date
|
|
|
|
public init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) {
|
|
self.type = type
|
|
self.sessionId = sessionId
|
|
self.data = data
|
|
self.timestamp = Date()
|
|
}
|
|
}
|
|
|
|
/// Client SDK for interacting with the VibeTunnel server.
|
|
///
|
|
/// Provides a high-level interface for creating and managing terminal sessions
|
|
/// through the VibeTunnel HTTP API. Handles authentication, request/response
|
|
/// serialization, and error handling for all server operations.
|
|
public class TunnelClient {
|
|
private let baseURL: URL
|
|
private let apiKey: String
|
|
private let httpClient: HTTPClientProtocol
|
|
private let decoder = JSONDecoder()
|
|
private let encoder = JSONEncoder()
|
|
private let logger = Logger(label: "VibeTunnel.TunnelClient")
|
|
|
|
/// Default base URL for the tunnel server
|
|
private static let defaultBaseURL: URL = {
|
|
guard let url = URL(string: "http://127.0.0.1:8080") else {
|
|
fatalError("Invalid default base URL - this should never happen with a hardcoded URL")
|
|
}
|
|
return url
|
|
}()
|
|
|
|
public init(baseURL: URL? = nil, apiKey: String, httpClient: HTTPClientProtocol? = nil) {
|
|
// Use a static default URL that we know is valid
|
|
self.baseURL = baseURL ?? Self.defaultBaseURL
|
|
self.apiKey = apiKey
|
|
|
|
// Use injected client or create default with API key in session config
|
|
if let httpClient {
|
|
self.httpClient = httpClient
|
|
} else {
|
|
let config = URLSessionConfiguration.default
|
|
config.httpAdditionalHeaders = ["X-API-Key": apiKey]
|
|
self.httpClient = HTTPClient(session: URLSession(configuration: config))
|
|
}
|
|
|
|
decoder.dateDecodingStrategy = .iso8601
|
|
encoder.dateEncodingStrategy = .iso8601
|
|
}
|
|
|
|
// MARK: - Health Check
|
|
|
|
public func checkHealth() async throws -> TunnelSession.HealthResponse {
|
|
let request = buildRequest(path: "/api/health", method: .get)
|
|
let (data, response) = try await httpClient.data(for: request, body: nil)
|
|
|
|
guard response.status == .ok else {
|
|
throw TunnelClientError.httpError(statusCode: response.status.code)
|
|
}
|
|
|
|
return try decoder.decode(TunnelSession.HealthResponse.self, from: data)
|
|
}
|
|
|
|
// MARK: - Session Management
|
|
|
|
public func createSession(clientInfo: TunnelSession.ClientInfo? = nil) async throws -> TunnelSession
|
|
.CreateResponse
|
|
{
|
|
let requestBody = TunnelSession.CreateRequest(clientInfo: clientInfo)
|
|
let request = buildRequest(path: "/api/sessions", method: .post)
|
|
let body = try encoder.encode(requestBody)
|
|
|
|
let (data, response) = try await httpClient.data(for: request, body: body)
|
|
|
|
guard response.status == .created || response.status == .ok else {
|
|
if let errorResponse = try? decoder.decode(TunnelSession.ErrorResponse.self, from: data) {
|
|
throw TunnelClientError.serverError(errorResponse.error)
|
|
}
|
|
throw TunnelClientError.httpError(statusCode: response.status.code)
|
|
}
|
|
|
|
return try decoder.decode(TunnelSession.CreateResponse.self, from: data)
|
|
}
|
|
|
|
public func listSessions() async throws -> [TunnelSession] {
|
|
let request = buildRequest(path: "/api/sessions", method: .get)
|
|
let (data, response) = try await httpClient.data(for: request, body: nil)
|
|
|
|
guard response.status == .ok else {
|
|
throw TunnelClientError.httpError(statusCode: response.status.code)
|
|
}
|
|
|
|
let listResponse = try decoder.decode(TunnelSession.ListResponse.self, from: data)
|
|
return listResponse.sessions
|
|
}
|
|
|
|
public func getSession(id: String) async throws -> TunnelSession {
|
|
let request = buildRequest(path: "/api/sessions/\(id)", method: .get)
|
|
let (data, response) = try await httpClient.data(for: request, body: nil)
|
|
|
|
guard response.status == .ok else {
|
|
if response.status == .notFound {
|
|
throw TunnelClientError.sessionNotFound
|
|
}
|
|
throw TunnelClientError.httpError(statusCode: response.status.code)
|
|
}
|
|
|
|
return try decoder.decode(TunnelSession.self, from: data)
|
|
}
|
|
|
|
public func deleteSession(id: String) async throws {
|
|
let request = buildRequest(path: "/api/sessions/\(id)", method: .delete)
|
|
let (_, response) = try await httpClient.data(for: request, body: nil)
|
|
|
|
guard response.status == .noContent || response.status == .ok else {
|
|
if response.status == .notFound {
|
|
throw TunnelClientError.sessionNotFound
|
|
}
|
|
throw TunnelClientError.httpError(statusCode: response.status.code)
|
|
}
|
|
}
|
|
|
|
// MARK: - Command Execution
|
|
|
|
public func executeCommand(
|
|
sessionId: String,
|
|
command: String,
|
|
environment: [String: String]? = nil,
|
|
workingDirectory: String? = nil
|
|
)
|
|
async throws -> TunnelSession.ExecuteCommandResponse
|
|
{
|
|
let requestBody = TunnelSession.ExecuteCommandRequest(
|
|
sessionId: sessionId,
|
|
command: command,
|
|
environment: environment,
|
|
workingDirectory: workingDirectory
|
|
)
|
|
|
|
let request = buildRequest(path: "/api/sessions/\(sessionId)/execute", method: .post)
|
|
let body = try encoder.encode(requestBody)
|
|
|
|
let (data, response) = try await httpClient.data(for: request, body: body)
|
|
|
|
guard response.status == .ok else {
|
|
if response.status == .notFound {
|
|
throw TunnelClientError.sessionNotFound
|
|
}
|
|
if let errorResponse = try? decoder.decode(TunnelSession.ErrorResponse.self, from: data) {
|
|
throw TunnelClientError.serverError(errorResponse.error)
|
|
}
|
|
throw TunnelClientError.httpError(statusCode: response.status.code)
|
|
}
|
|
|
|
return try decoder.decode(TunnelSession.ExecuteCommandResponse.self, from: data)
|
|
}
|
|
|
|
// MARK: - WebSocket Connection
|
|
|
|
public func connectWebSocket(sessionId: String? = nil) -> TunnelWebSocketClient? {
|
|
guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
|
|
logger.error("Failed to create URL components from baseURL: \(baseURL)")
|
|
return nil
|
|
}
|
|
|
|
components.scheme = components.scheme == "https" ? "wss" : "ws"
|
|
components.path += "/ws/terminal"
|
|
|
|
guard let wsURL = components.url else {
|
|
logger.error("Failed to create WebSocket URL from components")
|
|
return nil
|
|
}
|
|
|
|
return TunnelWebSocketClient(url: wsURL, apiKey: apiKey, sessionId: sessionId)
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
private func buildRequest(path: String, method: HTTPRequest.Method) -> HTTPRequest {
|
|
let url = baseURL.appendingPathComponent(path)
|
|
|
|
// Use URLComponents to get scheme, host, and path
|
|
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
|
|
fatalError("Invalid URL")
|
|
}
|
|
|
|
var request = HTTPRequest(
|
|
method: method,
|
|
scheme: components.scheme,
|
|
authority: components.host.map { host in
|
|
components.port.map { "\(host):\($0)" } ?? host
|
|
},
|
|
path: components.path
|
|
)
|
|
|
|
// Add authentication
|
|
request.headerFields[.authorization] = "Bearer \(apiKey)"
|
|
|
|
// Add content type for POST/PUT requests
|
|
if method == .post || method == .put {
|
|
request.headerFields[.contentType] = "application/json"
|
|
}
|
|
|
|
return request
|
|
}
|
|
}
|
|
|
|
/// WebSocket client for real-time terminal communication.
|
|
///
|
|
/// Provides WebSocket connectivity for streaming terminal I/O and
|
|
/// receiving real-time updates from terminal sessions. Handles
|
|
/// authentication, message encoding/decoding, and connection lifecycle.
|
|
public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
|
|
private let url: URL
|
|
private let apiKey: String
|
|
private var sessionId: String?
|
|
private var webSocketTask: URLSessionWebSocketTask?
|
|
private var messageContinuation: AsyncStream<WSMessage>.Continuation?
|
|
private let logger = Logger(label: "VibeTunnel.TunnelWebSocketClient")
|
|
|
|
public var messages: AsyncStream<WSMessage> {
|
|
AsyncStream { continuation in
|
|
self.messageContinuation = continuation
|
|
}
|
|
}
|
|
|
|
public init(url: URL, apiKey: String, sessionId: String? = nil) {
|
|
self.url = url
|
|
self.apiKey = apiKey
|
|
self.sessionId = sessionId
|
|
super.init()
|
|
}
|
|
|
|
public func connect() {
|
|
let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
|
var request = URLRequest(url: url)
|
|
request.setValue(apiKey, forHTTPHeaderField: "X-API-Key")
|
|
|
|
webSocketTask = session.webSocketTask(with: request)
|
|
webSocketTask?.resume()
|
|
|
|
// Send initial connection message if session ID is provided
|
|
if let sessionId {
|
|
send(WSMessage(type: .connect, sessionId: sessionId))
|
|
}
|
|
|
|
// Start receiving messages
|
|
receiveMessage()
|
|
}
|
|
|
|
public func send(_ message: WSMessage) {
|
|
guard let webSocketTask else { return }
|
|
|
|
do {
|
|
let data = try JSONEncoder().encode(message)
|
|
let text = String(data: data, encoding: .utf8) ?? "{}"
|
|
let message = URLSessionWebSocketTask.Message.string(text)
|
|
|
|
webSocketTask.send(message) { error in
|
|
if let error {
|
|
self.logger.error("WebSocket send error: \(error)")
|
|
}
|
|
}
|
|
} catch {
|
|
logger.error("Failed to encode message: \(error)")
|
|
}
|
|
}
|
|
|
|
public func sendCommand(_ command: String) {
|
|
guard let sessionId else { return }
|
|
send(WSMessage(type: .command, sessionId: sessionId, data: command))
|
|
}
|
|
|
|
public func disconnect() {
|
|
webSocketTask?.cancel(with: .goingAway, reason: nil)
|
|
messageContinuation?.finish()
|
|
}
|
|
|
|
private func receiveMessage() {
|
|
webSocketTask?.receive { [weak self] result in
|
|
switch result {
|
|
case .success(let message):
|
|
switch message {
|
|
case .string(let text):
|
|
if let data = text.data(using: .utf8),
|
|
let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data)
|
|
{
|
|
self?.messageContinuation?.yield(wsMessage)
|
|
}
|
|
case .data(let data):
|
|
if let wsMessage = try? JSONDecoder().decode(WSMessage.self, from: data) {
|
|
self?.messageContinuation?.yield(wsMessage)
|
|
}
|
|
@unknown default:
|
|
break
|
|
}
|
|
|
|
// Continue receiving messages
|
|
self?.receiveMessage()
|
|
|
|
case .failure(let error):
|
|
self?.logger.error("WebSocket receive error: \(error)")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - URLSessionWebSocketDelegate
|
|
|
|
extension TunnelWebSocketClient: URLSessionWebSocketDelegate {
|
|
public func urlSession(
|
|
_ session: URLSession,
|
|
webSocketTask: URLSessionWebSocketTask,
|
|
didOpenWithProtocol protocol: String?
|
|
) {
|
|
logger.info("WebSocket connected")
|
|
}
|
|
|
|
public func urlSession(
|
|
_ session: URLSession,
|
|
webSocketTask: URLSessionWebSocketTask,
|
|
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
|
reason: Data?
|
|
) {
|
|
logger.info("WebSocket disconnected with code: \(closeCode)")
|
|
messageContinuation?.finish()
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
/// Errors that can occur when using the TunnelClient.
|
|
///
|
|
/// Represents various failure modes including network errors,
|
|
/// server errors, and data decoding issues.
|
|
public enum TunnelClientError: LocalizedError, Equatable {
|
|
case invalidResponse
|
|
case httpError(statusCode: Int)
|
|
case serverError(String)
|
|
case sessionNotFound
|
|
case decodingError(String)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidResponse:
|
|
"Invalid response from server"
|
|
case .httpError(let statusCode):
|
|
"HTTP error: \(statusCode)"
|
|
case .serverError(let message):
|
|
"Server error: \(message)"
|
|
case .sessionNotFound:
|
|
"Session not found"
|
|
case .decodingError(let error):
|
|
"Decoding error: \(error)"
|
|
}
|
|
}
|
|
|
|
public static func == (lhs: Self, rhs: Self) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.invalidResponse, .invalidResponse):
|
|
true
|
|
case (.httpError(let code1), .httpError(let code2)):
|
|
code1 == code2
|
|
case (.serverError(let msg1), .serverError(let msg2)):
|
|
msg1 == msg2
|
|
case (.sessionNotFound, .sessionNotFound):
|
|
true
|
|
case (.decodingError(let msg1), .decodingError(let msg2)):
|
|
msg1 == msg2
|
|
default:
|
|
false
|
|
}
|
|
}
|
|
}
|