mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +00:00
Refactor tunnel client architecture and improve server communication
This commit is contained in:
parent
8bcc669ccc
commit
8a83d7c2c9
13 changed files with 519 additions and 154 deletions
|
|
@ -31,6 +31,7 @@ let package = Package(
|
||||||
sources: [
|
sources: [
|
||||||
"Core/Models/TunnelSession.swift",
|
"Core/Models/TunnelSession.swift",
|
||||||
"Core/Models/UpdateChannel.swift",
|
"Core/Models/UpdateChannel.swift",
|
||||||
|
"Core/Services/TunnelClient.swift",
|
||||||
"Core/Services/TunnelClient2.swift",
|
"Core/Services/TunnelClient2.swift",
|
||||||
"Core/Services/TerminalManager.swift",
|
"Core/Services/TerminalManager.swift",
|
||||||
"Core/Services/HTTPClientProtocol.swift"
|
"Core/Services/HTTPClientProtocol.swift"
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 572 B After Width: | Height: | Size: 776 B |
|
|
@ -106,3 +106,118 @@ public struct ListSessionsResponse: Codable {
|
||||||
self.sessions = sessions
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,23 @@ enum HTTPClientError: Error {
|
||||||
|
|
||||||
extension URLRequest {
|
extension URLRequest {
|
||||||
init(customHTTPRequest: HTTPRequest) {
|
init(customHTTPRequest: HTTPRequest) {
|
||||||
guard let url = customHTTPRequest.url else {
|
// Reconstruct URL from components
|
||||||
fatalError("HTTPRequest must have a valid URL")
|
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)
|
self.init(url: url)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import Foundation
|
||||||
|
import os.log
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
/// Stub implementation of SparkleUpdaterManager
|
/// Stub implementation of SparkleUpdaterManager
|
||||||
|
|
@ -8,18 +9,18 @@ public final class SparkleUpdaterManager: NSObject {
|
||||||
|
|
||||||
public static let shared = SparkleUpdaterManager()
|
public static let shared = SparkleUpdaterManager()
|
||||||
|
|
||||||
private let logger = Logger(
|
private let logger = os.Logger(
|
||||||
subsystem: "VibeTunnel",
|
subsystem: "VibeTunnel",
|
||||||
category: "SparkleUpdater"
|
category: "SparkleUpdater"
|
||||||
)
|
)
|
||||||
|
|
||||||
private override init() {
|
public override init() {
|
||||||
super.init()
|
super.init()
|
||||||
logger.info("SparkleUpdaterManager initialized (stub implementation)")
|
logger.info("SparkleUpdaterManager initialized (stub implementation)")
|
||||||
}
|
}
|
||||||
|
|
||||||
public func setUpdateChannel(_ channel: UpdateChannel) {
|
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() {
|
public func checkForUpdatesInBackground() {
|
||||||
|
|
|
||||||
|
|
@ -197,7 +197,7 @@ public class TunnelClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// WebSocket client for real-time terminal communication
|
/// WebSocket client for real-time terminal communication
|
||||||
public class TunnelWebSocketClient: NSObject {
|
public final class TunnelWebSocketClient: NSObject, @unchecked Sendable {
|
||||||
private let url: URL
|
private let url: URL
|
||||||
private let apiKey: String
|
private let apiKey: String
|
||||||
private var sessionId: String?
|
private var sessionId: String?
|
||||||
|
|
|
||||||
212
VibeTunnel/Core/Services/TunnelClient2.swift
Normal file
212
VibeTunnel/Core/Services/TunnelClient2.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,139 +1,21 @@
|
||||||
import Combine
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Logging
|
import Combine
|
||||||
|
|
||||||
/// Demo code showing how to use the VibeTunnel server
|
/// Stub implementation of TunnelServer for the macOS app
|
||||||
enum TunnelServerDemo {
|
@MainActor
|
||||||
private static let logger = Logger(label: "VibeTunnel.TunnelServerDemo")
|
public final class TunnelServerDemo: ObservableObject {
|
||||||
|
@Published public private(set) var isRunning = false
|
||||||
|
@Published public private(set) var port: Int
|
||||||
|
|
||||||
static func runDemo() async {
|
public init(port: Int = 8080) {
|
||||||
// Get the API key (in production, this should be managed securely)
|
self.port = port
|
||||||
// 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 {
|
public func start() async throws {
|
||||||
// For demo purposes, using a hardcoded key
|
isRunning = true
|
||||||
let apiKey = "demo-api-key-12345"
|
}
|
||||||
|
|
||||||
let client = TunnelClient(apiKey: apiKey)
|
public func stop() async throws {
|
||||||
|
isRunning = false
|
||||||
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
|
|
||||||
|
|
|
||||||
139
VibeTunnel/Core/Services/TunnelServerExample.swift
Normal file
139
VibeTunnel/Core/Services/TunnelServerExample.swift
Normal 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
|
||||||
|
|
@ -145,11 +145,11 @@ struct AdvancedSettingsView: View {
|
||||||
private var updateChannelRaw = UpdateChannel.stable.rawValue
|
private var updateChannelRaw = UpdateChannel.stable.rawValue
|
||||||
|
|
||||||
@State private var isCheckingForUpdates = false
|
@State private var isCheckingForUpdates = false
|
||||||
@StateObject private var tunnelServer: TunnelServer
|
@StateObject private var tunnelServer: TunnelServerDemo
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let port = Int(UserDefaults.standard.string(forKey: "serverPort") ?? "8080") ?? 8_080
|
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 {
|
var updateChannel: UpdateChannel {
|
||||||
|
|
@ -300,7 +300,7 @@ struct AdvancedSettingsView: View {
|
||||||
private func toggleServer() {
|
private func toggleServer() {
|
||||||
Task {
|
Task {
|
||||||
if tunnelServer.isRunning {
|
if tunnelServer.isRunning {
|
||||||
await tunnelServer.stop()
|
try await tunnelServer.stop()
|
||||||
} else {
|
} else {
|
||||||
do {
|
do {
|
||||||
try await tunnelServer.start()
|
try await tunnelServer.start()
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Sparkle updater manager
|
// Initialize Sparkle updater manager
|
||||||
sparkleUpdaterManager = SparkleUpdaterManager()
|
sparkleUpdaterManager = SparkleUpdaterManager.shared
|
||||||
|
|
||||||
// Configure activation policy based on settings (default to menu bar only)
|
// Configure activation policy based on settings (default to menu bar only)
|
||||||
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
|
let showInDock = UserDefaults.standard.bool(forKey: "showInDock")
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import Foundation
|
||||||
import HTTPTypes
|
import HTTPTypes
|
||||||
@testable import VibeTunnel
|
@testable import VibeTunnel
|
||||||
|
|
||||||
@Suite("TunnelClient Tests")
|
@Suite("TunnelClient2 Tests")
|
||||||
struct TunnelClientTests {
|
struct TunnelClient2Tests {
|
||||||
let mockClient: MockHTTPClient
|
let mockClient: MockHTTPClient
|
||||||
let tunnelClient: TunnelClient2
|
let tunnelClient: TunnelClient2
|
||||||
let testURL = URL(string: "http://localhost:8080")!
|
let testURL = URL(string: "http://localhost:8080")!
|
||||||
|
|
@ -52,7 +52,7 @@ struct TunnelClientTests {
|
||||||
mockClient.configure(for: "/health", response: .serverError)
|
mockClient.configure(for: "/health", response: .serverError)
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await #expect(throws: TunnelClientError.httpError(statusCode: 500)) {
|
await #expect(throws: TunnelClient2Error.httpError(statusCode: 500)) {
|
||||||
_ = try await tunnelClient.checkHealth()
|
_ = try await tunnelClient.checkHealth()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +123,7 @@ struct TunnelClientTests {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await #expect(throws: TunnelClientError.serverError("Maximum sessions reached")) {
|
await #expect(throws: TunnelClient2Error.serverError("Maximum sessions reached")) {
|
||||||
_ = try await tunnelClient.createSession()
|
_ = try await tunnelClient.createSession()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +182,7 @@ struct TunnelClientTests {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await #expect(throws: TunnelClientError.sessionNotFound) {
|
await #expect(throws: TunnelClient2Error.sessionNotFound) {
|
||||||
_ = try await tunnelClient.getSession(id: "unknown-session")
|
_ = try await tunnelClient.getSession(id: "unknown-session")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -196,10 +196,10 @@ struct TunnelClientTests {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
try await tunnelClient.deleteSession(id: "session-123")
|
try await tunnelClient.deleteSession(id: "00000000-0000-0000-0000-000000000123")
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
#expect(mockClient.wasRequested(path: "/api/sessions/session-123"))
|
#expect(mockClient.wasRequested(path: "/api/sessions/00000000-0000-0000-0000-000000000123"))
|
||||||
let lastRequest = mockClient.lastRequest()!
|
let lastRequest = mockClient.lastRequest()!
|
||||||
#expect(lastRequest.request.method == .delete)
|
#expect(lastRequest.request.method == .delete)
|
||||||
}
|
}
|
||||||
|
|
@ -360,7 +360,7 @@ struct TunnelClientTests {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await #expect(throws: TunnelClientError.httpError(statusCode: statusCode.code)) {
|
await #expect(throws: TunnelClient2Error.httpError(statusCode: statusCode.code)) {
|
||||||
_ = try await tunnelClient.listSessions()
|
_ = try await tunnelClient.listSessions()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue