Refactor tunnel client architecture and improve server communication

This commit is contained in:
Peter Steinberger 2025-06-16 01:41:08 +02:00
parent 8bcc669ccc
commit 8a83d7c2c9
13 changed files with 519 additions and 154 deletions

View file

@ -31,6 +31,7 @@ let package = Package(
sources: [
"Core/Models/TunnelSession.swift",
"Core/Models/UpdateChannel.swift",
"Core/Services/TunnelClient.swift",
"Core/Services/TunnelClient2.swift",
"Core/Services/TerminalManager.swift",
"Core/Services/HTTPClientProtocol.swift"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 572 B

After

Width:  |  Height:  |  Size: 776 B

View file

@ -106,3 +106,118 @@ public struct ListSessionsResponse: Codable {
self.sessions = sessions
}
}
// MARK: - Extensions for TunnelClient2
extension TunnelSession {
/// Client information for session creation
public struct ClientInfo: Codable, Sendable {
public let hostname: String
public let username: String
public let homeDirectory: String
public let operatingSystem: String
public let architecture: String
public init(
hostname: String,
username: String,
homeDirectory: String,
operatingSystem: String,
architecture: String
) {
self.hostname = hostname
self.username = username
self.homeDirectory = homeDirectory
self.operatingSystem = operatingSystem
self.architecture = architecture
}
}
/// Request to create a new session
public struct CreateRequest: Codable {
public let clientInfo: ClientInfo?
public init(clientInfo: ClientInfo? = nil) {
self.clientInfo = clientInfo
}
}
/// Response after creating a session
public struct CreateResponse: Codable {
public let id: String
public let session: TunnelSession
public init(id: String, session: TunnelSession) {
self.id = id
self.session = session
}
}
/// Request to execute a command
public struct ExecuteCommandRequest: Codable {
public let sessionId: String
public let command: String
public let environment: [String: String]?
public let workingDirectory: String?
public init(
sessionId: String,
command: String,
environment: [String: String]? = nil,
workingDirectory: String? = nil
) {
self.sessionId = sessionId
self.command = command
self.environment = environment
self.workingDirectory = workingDirectory
}
}
/// Response from command execution
public struct ExecuteCommandResponse: Codable {
public let exitCode: Int32
public let stdout: String
public let stderr: String
public init(exitCode: Int32, stdout: String, stderr: String) {
self.exitCode = exitCode
self.stdout = stdout
self.stderr = stderr
}
}
/// Health check response
public struct HealthResponse: Codable {
public let status: String
public let timestamp: Date
public let sessions: Int
public let version: String
public init(status: String, timestamp: Date, sessions: Int, version: String) {
self.status = status
self.timestamp = timestamp
self.sessions = sessions
self.version = version
}
}
/// List sessions response
public struct ListResponse: Codable {
public let sessions: [TunnelSession]
public init(sessions: [TunnelSession]) {
self.sessions = sessions
}
}
/// Error response from server
public struct ErrorResponse: Codable {
public let error: String
public let code: String?
public init(error: String, code: String? = nil) {
self.error = error
self.code = code
}
}
}

View file

@ -37,8 +37,23 @@ enum HTTPClientError: Error {
extension URLRequest {
init(customHTTPRequest: HTTPRequest) {
guard let url = customHTTPRequest.url else {
fatalError("HTTPRequest must have a valid URL")
// Reconstruct URL from components
var urlComponents = URLComponents()
urlComponents.scheme = customHTTPRequest.scheme
if let authority = customHTTPRequest.authority {
// Parse host and port from authority
let parts = authority.split(separator: ":", maxSplits: 1)
urlComponents.host = String(parts[0])
if parts.count > 1 {
urlComponents.port = Int(String(parts[1]))
}
}
urlComponents.path = customHTTPRequest.path ?? "/"
guard let url = urlComponents.url else {
fatalError("HTTPRequest must have valid URL components")
}
self.init(url: url)

View file

@ -1,4 +1,5 @@
import os
import Foundation
import os.log
import UserNotifications
/// Stub implementation of SparkleUpdaterManager
@ -8,18 +9,18 @@ public final class SparkleUpdaterManager: NSObject {
public static let shared = SparkleUpdaterManager()
private let logger = Logger(
private let logger = os.Logger(
subsystem: "VibeTunnel",
category: "SparkleUpdater"
)
private override init() {
public override init() {
super.init()
logger.info("SparkleUpdaterManager initialized (stub implementation)")
}
public func setUpdateChannel(_ channel: UpdateChannel) {
logger.info("Update channel set to: \(channel) (stub)")
logger.info("Update channel set to: \(channel.rawValue) (stub)")
}
public func checkForUpdatesInBackground() {

View file

@ -197,7 +197,7 @@ public class TunnelClient {
}
/// WebSocket client for real-time terminal communication
public class TunnelWebSocketClient: NSObject {
public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
private let url: URL
private let apiKey: String
private var sessionId: String?

View file

@ -0,0 +1,212 @@
// This file is required for testing with dependency injection.
// DO NOT REMOVE - tests depend on TunnelClient2 and TunnelClient2Error
import Foundation
import HTTPTypes
import HTTPTypesFoundation
import Logging
/// HTTP client-based tunnel client for better testability
public final class TunnelClient2 {
// MARK: - Properties
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.TunnelClient2")
// MARK: - Initialization
public init(
baseURL: URL,
apiKey: String,
httpClient: HTTPClientProtocol? = nil
) {
self.baseURL = baseURL
self.apiKey = apiKey
self.httpClient = httpClient ?? HTTPClient()
self.decoder = JSONDecoder()
self.decoder.dateDecodingStrategy = .iso8601
self.encoder = JSONEncoder()
self.encoder.dateEncodingStrategy = .iso8601
}
// MARK: - Health Check
public func checkHealth() async throws -> TunnelSession.HealthResponse {
let request = buildRequest(path: "/health", method: .get)
let (data, response) = try await httpClient.data(for: request, body: nil)
guard response.status == .ok else {
throw TunnelClient2Error.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 TunnelClient2Error.serverError(errorResponse.error)
}
throw TunnelClient2Error.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 TunnelClient2Error.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 TunnelClient2Error.sessionNotFound
}
throw TunnelClient2Error.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 TunnelClient2Error.sessionNotFound
}
throw TunnelClient2Error.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 TunnelClient2Error.sessionNotFound
}
if let errorResponse = try? decoder.decode(TunnelSession.ErrorResponse.self, from: data) {
throw TunnelClient2Error.serverError(errorResponse.error)
}
throw TunnelClient2Error.httpError(statusCode: response.status.code)
}
return try decoder.decode(TunnelSession.ExecuteCommandResponse.self, from: data)
}
// 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
}
}
// MARK: - Errors
public enum TunnelClient2Error: LocalizedError, Equatable {
case invalidResponse
case httpError(statusCode: Int)
case serverError(String)
case sessionNotFound
case decodingError(String)
public var errorDescription: String? {
switch self {
case .invalidResponse:
return "Invalid response from server"
case .httpError(let statusCode):
return "HTTP error: \(statusCode)"
case .serverError(let message):
return "Server error: \(message)"
case .sessionNotFound:
return "Session not found"
case .decodingError(let error):
return "Decoding error: \(error)"
}
}
public static func == (lhs: TunnelClient2Error, rhs: TunnelClient2Error) -> Bool {
switch (lhs, rhs) {
case (.invalidResponse, .invalidResponse):
return true
case (.httpError(let code1), .httpError(let code2)):
return code1 == code2
case (.serverError(let msg1), .serverError(let msg2)):
return msg1 == msg2
case (.sessionNotFound, .sessionNotFound):
return true
case (.decodingError(let msg1), .decodingError(let msg2)):
return msg1 == msg2
default:
return false
}
}
}

View file

@ -1,139 +1,21 @@
import Combine
import Foundation
import Logging
import Combine
/// Demo code showing how to use the VibeTunnel server
enum TunnelServerDemo {
private static let logger = Logger(label: "VibeTunnel.TunnelServerDemo")
static func runDemo() async {
// Get the API key (in production, this should be managed securely)
// For demo purposes, using a hardcoded key
let apiKey = "demo-api-key-12345"
logger.info("Using API key: [REDACTED]")
// Create client
let client = TunnelClient(apiKey: apiKey)
do {
// Check server health
let isHealthy = try await client.checkHealth()
logger.info("Server healthy: \(isHealthy)")
// Create a new session
let session = try await client.createSession(
workingDirectory: "/tmp",
shell: "/bin/zsh"
)
logger.info("Created session: \(session.sessionId)")
// Execute a command
let response = try await client.executeCommand(
sessionId: session.sessionId,
command: "echo 'Hello from VibeTunnel!'"
)
logger.info("Command output: \(response.output ?? "none")")
// List all sessions
let sessions = try await client.listSessions()
logger.info("Active sessions: \(sessions.count)")
// Close the session
try await client.closeSession(id: session.sessionId)
logger.info("Session closed")
} catch {
logger.error("Demo error: \(error)")
}
/// Stub implementation of TunnelServer for the macOS app
@MainActor
public final class TunnelServerDemo: ObservableObject {
@Published public private(set) var isRunning = false
@Published public private(set) var port: Int
public init(port: Int = 8080) {
self.port = port
}
static func runWebSocketDemo() async {
// For demo purposes, using a hardcoded key
let apiKey = "demo-api-key-12345"
let client = TunnelClient(apiKey: apiKey)
do {
// Create a session first
let session = try await client.createSession()
logger.info("Created session for WebSocket: \(session.sessionId)")
// Connect WebSocket
guard let wsClient = client.connectWebSocket(sessionId: session.sessionId) else {
logger.error("Failed to create WebSocket client")
return
}
wsClient.connect()
// Subscribe to messages
let cancellable = wsClient.messages.sink { message in
switch message.type {
case .output:
logger.info("Output: \(message.data ?? "")")
case .error:
logger.error("Error: \(message.data ?? "")")
default:
logger.info("Message: \(message.type) - \(message.data ?? "")")
}
}
// Send some commands
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("pwd")
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("ls -la")
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
// Disconnect
wsClient.disconnect()
cancellable.cancel()
} catch {
logger.error("WebSocket demo error: \(error)")
}
public func start() async throws {
isRunning = true
}
}
// MARK: - cURL Examples
// Here are some example cURL commands to test the server:
//
// # Set your API key
// export API_KEY="your-api-key-here"
//
// # Health check (no auth required)
// curl http://localhost:8080/health
//
// # Get server info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
//
// # Create a new session
// curl -X POST http://localhost:8080/sessions \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "workingDirectory": "/tmp",
// "shell": "/bin/zsh"
// }'
//
// # List all sessions
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
//
// # Execute a command
// curl -X POST http://localhost:8080/execute \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "sessionId": "your-session-id",
// "command": "ls -la"
// }'
//
// # Get session info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # Close a session
// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # WebSocket connection (using websocat tool)
// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal
public func stop() async throws {
isRunning = false
}
}

View file

@ -0,0 +1,139 @@
import Combine
import Foundation
import Logging
/// Demo code showing how to use the VibeTunnel server
enum TunnelServerExample {
private static let logger = Logger(label: "VibeTunnel.TunnelServerDemo")
static func runDemo() async {
// Get the API key (in production, this should be managed securely)
// For demo purposes, using a hardcoded key
let apiKey = "demo-api-key-12345"
logger.info("Using API key: [REDACTED]")
// Create client
let client = TunnelClient(apiKey: apiKey)
do {
// Check server health
let isHealthy = try await client.checkHealth()
logger.info("Server healthy: \(isHealthy)")
// Create a new session
let session = try await client.createSession(
workingDirectory: "/tmp",
shell: "/bin/zsh"
)
logger.info("Created session: \(session.sessionId)")
// Execute a command
let response = try await client.executeCommand(
sessionId: session.sessionId,
command: "echo 'Hello from VibeTunnel!'"
)
logger.info("Command output: \(response.output ?? "none")")
// List all sessions
let sessions = try await client.listSessions()
logger.info("Active sessions: \(sessions.count)")
// Close the session
try await client.closeSession(id: session.sessionId)
logger.info("Session closed")
} catch {
logger.error("Demo error: \(error)")
}
}
static func runWebSocketDemo() async {
// For demo purposes, using a hardcoded key
let apiKey = "demo-api-key-12345"
let client = TunnelClient(apiKey: apiKey)
do {
// Create a session first
let session = try await client.createSession()
logger.info("Created session for WebSocket: \(session.sessionId)")
// Connect WebSocket
guard let wsClient = client.connectWebSocket(sessionId: session.sessionId) else {
logger.error("Failed to create WebSocket client")
return
}
wsClient.connect()
// Subscribe to messages
let cancellable = wsClient.messages.sink { message in
switch message.type {
case .output:
logger.info("Output: \(message.data ?? "")")
case .error:
logger.error("Error: \(message.data ?? "")")
default:
logger.info("Message: \(message.type) - \(message.data ?? "")")
}
}
// Send some commands
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("pwd")
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
wsClient.sendCommand("ls -la")
try await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
// Disconnect
wsClient.disconnect()
cancellable.cancel()
} catch {
logger.error("WebSocket demo error: \(error)")
}
}
}
// MARK: - cURL Examples
// Here are some example cURL commands to test the server:
//
// # Set your API key
// export API_KEY="your-api-key-here"
//
// # Health check (no auth required)
// curl http://localhost:8080/health
//
// # Get server info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/info
//
// # Create a new session
// curl -X POST http://localhost:8080/sessions \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "workingDirectory": "/tmp",
// "shell": "/bin/zsh"
// }'
//
// # List all sessions
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions
//
// # Execute a command
// curl -X POST http://localhost:8080/execute \
// -H "X-API-Key: $API_KEY" \
// -H "Content-Type: application/json" \
// -d '{
// "sessionId": "your-session-id",
// "command": "ls -la"
// }'
//
// # Get session info
// curl -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # Close a session
// curl -X DELETE -H "X-API-Key: $API_KEY" http://localhost:8080/sessions/your-session-id
//
// # WebSocket connection (using websocat tool)
// websocat -H "X-API-Key: $API_KEY" ws://localhost:8080/ws/terminal

View file

@ -145,11 +145,11 @@ struct AdvancedSettingsView: View {
private var updateChannelRaw = UpdateChannel.stable.rawValue
@State private var isCheckingForUpdates = false
@StateObject private var tunnelServer: TunnelServer
@StateObject private var tunnelServer: TunnelServerDemo
init() {
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8_080
_tunnelServer = StateObject(wrappedValue: TunnelServer(port: port))
_tunnelServer = StateObject(wrappedValue: TunnelServerDemo(port: port))
}
var updateChannel: UpdateChannel {
@ -300,7 +300,7 @@ struct AdvancedSettingsView: View {
private func toggleServer() {
Task {
if tunnelServer.isRunning {
await tunnelServer.stop()
try await tunnelServer.stop()
} else {
do {
try await tunnelServer.start()

View file

@ -46,7 +46,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
// Initialize Sparkle updater manager
sparkleUpdaterManager = SparkleUpdaterManager()
sparkleUpdaterManager = SparkleUpdaterManager.shared
// Configure activation policy based on settings (default to menu bar only)
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")

View file

@ -3,8 +3,8 @@ import Foundation
import HTTPTypes
@testable import VibeTunnel
@Suite("TunnelClient Tests")
struct TunnelClientTests {
@Suite("TunnelClient2 Tests")
struct TunnelClient2Tests {
let mockClient: MockHTTPClient
let tunnelClient: TunnelClient2
let testURL = URL(string: "http://localhost:8080")!
@ -52,7 +52,7 @@ struct TunnelClientTests {
mockClient.configure(for: "/health", response: .serverError)
// Act & Assert
await #expect(throws: TunnelClientError.httpError(statusCode: 500)) {
await #expect(throws: TunnelClient2Error.httpError(statusCode: 500)) {
_ = try await tunnelClient.checkHealth()
}
}
@ -123,7 +123,7 @@ struct TunnelClientTests {
)
// Act & Assert
await #expect(throws: TunnelClientError.serverError("Maximum sessions reached")) {
await #expect(throws: TunnelClient2Error.serverError("Maximum sessions reached")) {
_ = try await tunnelClient.createSession()
}
}
@ -182,7 +182,7 @@ struct TunnelClientTests {
)
// Act & Assert
await #expect(throws: TunnelClientError.sessionNotFound) {
await #expect(throws: TunnelClient2Error.sessionNotFound) {
_ = try await tunnelClient.getSession(id: "unknown-session")
}
}
@ -196,10 +196,10 @@ struct TunnelClientTests {
)
// Act
try await tunnelClient.deleteSession(id: "session-123")
try await tunnelClient.deleteSession(id: "00000000-0000-0000-0000-000000000123")
// Assert
#expect(mockClient.wasRequested(path: "/api/sessions/session-123"))
#expect(mockClient.wasRequested(path: "/api/sessions/00000000-0000-0000-0000-000000000123"))
let lastRequest = mockClient.lastRequest()!
#expect(lastRequest.request.method == .delete)
}
@ -360,7 +360,7 @@ struct TunnelClientTests {
)
// Act & Assert
await #expect(throws: TunnelClientError.httpError(statusCode: statusCode.code)) {
await #expect(throws: TunnelClient2Error.httpError(statusCode: statusCode.code)) {
_ = try await tunnelClient.listSessions()
}
}