diff --git a/README.md b/README.md index 35b19970..ccbc504a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # VibeTunnel -A macOS application for remotely controlling Claude Code and other terminal applications through a secure tunnel interface. +VibeTunnel is a Mac app that proxies terminal apps to the web. Now you can use Claude Code anywhere, anytime. Control open instances, read the output, type new commands or even open new instances. Supports macOS 14+. ## Overview diff --git a/VibeTunnel.xcodeproj/project.pbxproj b/VibeTunnel.xcodeproj/project.pbxproj index d07921c0..d3d2a69c 100644 --- a/VibeTunnel.xcodeproj/project.pbxproj +++ b/VibeTunnel.xcodeproj/project.pbxproj @@ -7,8 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 788688552DFF5EAB00B22C15 /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 788688212DFF600100B22C15 /* Hummingbird */; }; 788688322DFF700200B22C15 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 788688312DFF700100B22C15 /* Sparkle */; }; + 788688552DFF5EAB00B22C15 /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 788688212DFF600100B22C15 /* Hummingbird */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ diff --git a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate index 6cf8ee0c..271ecb98 100644 Binary files a/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate and b/VibeTunnel.xcodeproj/project.xcworkspace/xcuserdata/steipete.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/VibeTunnel/Assets.xcassets/menubar.iconset/icon_16x16.png b/VibeTunnel/Assets.xcassets/menubar.iconset/icon_16x16.png new file mode 100644 index 00000000..104343b8 Binary files /dev/null and b/VibeTunnel/Assets.xcassets/menubar.iconset/icon_16x16.png differ diff --git a/VibeTunnel/Assets.xcassets/menubar.iconset/icon_32x32.png b/VibeTunnel/Assets.xcassets/menubar.iconset/icon_32x32.png new file mode 100644 index 00000000..dc156c1f Binary files /dev/null and b/VibeTunnel/Assets.xcassets/menubar.iconset/icon_32x32.png differ diff --git a/VibeTunnel/ContentView.swift b/VibeTunnel/ContentView.swift deleted file mode 100644 index 99baa229..00000000 --- a/VibeTunnel/ContentView.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// ContentView.swift -// VibeTunnel -// -// Created by Peter Steinberger on 15.06.25. -// - -import SwiftUI - -struct ContentView: View { - var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") - } - .padding() - } -} - -#Preview { - ContentView() -} diff --git a/VibeTunnel/Core/Models/TunnelSession.swift b/VibeTunnel/Core/Models/TunnelSession.swift new file mode 100644 index 00000000..35b3ed9e --- /dev/null +++ b/VibeTunnel/Core/Models/TunnelSession.swift @@ -0,0 +1,72 @@ +// +// TunnelSession.swift +// VibeTunnel +// +// Created by VibeTunnel on 15.06.25. +// + +import Foundation + +/// Represents a terminal session that can be controlled remotely +struct TunnelSession: Identifiable, Codable { + let id: UUID + let createdAt: Date + var lastActivity: Date + let processID: Int32? + var isActive: Bool + + init(id: UUID = UUID(), processID: Int32? = nil) { + self.id = id + self.createdAt = Date() + self.lastActivity = Date() + self.processID = processID + self.isActive = true + } + + mutating func updateActivity() { + self.lastActivity = Date() + } +} + +/// Request to create a new terminal session +struct CreateSessionRequest: Codable { + let workingDirectory: String? + let environment: [String: String]? + let shell: String? +} + +/// Response after creating a session +struct CreateSessionResponse: Codable { + let sessionId: String + let createdAt: Date +} + +/// Command execution request +struct CommandRequest: Codable { + let sessionId: String + let command: String + let args: [String]? + let environment: [String: String]? +} + +/// Command execution response +struct CommandResponse: Codable { + let sessionId: String + let output: String? + let error: String? + let exitCode: Int32? + let timestamp: Date +} + +/// Session information +struct SessionInfo: Codable { + let id: String + let createdAt: Date + let lastActivity: Date + let isActive: Bool +} + +/// List sessions response +struct ListSessionsResponse: Codable { + let sessions: [SessionInfo] +} \ No newline at end of file diff --git a/VibeTunnel/Core/Services/AuthenticationMiddleware.swift b/VibeTunnel/Core/Services/AuthenticationMiddleware.swift new file mode 100644 index 00000000..2eb5bc84 --- /dev/null +++ b/VibeTunnel/Core/Services/AuthenticationMiddleware.swift @@ -0,0 +1,107 @@ +// +// AuthenticationMiddleware.swift +// VibeTunnel +// +// Created by VibeTunnel on 15.06.25. +// + +import Foundation +import Hummingbird +import HummingbirdCore +import Logging +import CryptoKit + +/// Simple authentication middleware for the tunnel server +struct AuthenticationMiddleware: RouterMiddleware { + private let logger = Logger(label: "VibeTunnel.AuthMiddleware") + private let apiKeyHeader = "X-API-Key" + private let bearerPrefix = "Bearer " + + // In production, this should be stored securely and configurable + private let validApiKeys: Set + + init() { + // Generate a default API key for development + // In production, this should be configurable via settings + let defaultKey = Self.generateAPIKey() + self.validApiKeys = [defaultKey] + + logger.info("Authentication initialized. Default API key: \(defaultKey)") + } + + init(apiKeys: Set) { + self.validApiKeys = apiKeys + } + + func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { + // Skip authentication for health check and WebSocket upgrade + if request.uri.path == "/health" || request.headers[.upgrade] == "websocket" { + return try await next(request, context) + } + + // Check for API key in header + if let apiKey = request.headers[apiKeyHeader] { + if validApiKeys.contains(apiKey) { + return try await next(request, context) + } + } + + // Check for Bearer token + if let authorization = request.headers[.authorization], + authorization.hasPrefix(bearerPrefix) { + let token = String(authorization.dropFirst(bearerPrefix.count)) + if validApiKeys.contains(token) { + return try await next(request, context) + } + } + + // No valid authentication found + logger.warning("Unauthorized request to \(request.uri.path)") + throw HTTPError(.unauthorized, message: "Invalid or missing API key") + } + + /// Generate a secure API key + static func generateAPIKey() -> String { + let randomBytes = SymmetricKey(size: .bits256) + let data = randomBytes.withUnsafeBytes { Data($0) } + return data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +/// Extension to store and retrieve API keys from UserDefaults +extension AuthenticationMiddleware { + static let apiKeyStorageKey = "VibeTunnel.APIKeys" + + static func loadStoredAPIKeys() -> Set { + guard let data = UserDefaults.standard.data(forKey: apiKeyStorageKey), + let keys = try? JSONDecoder().decode(Set.self, from: data) else { + // Generate and store a default key if none exists + let defaultKey = generateAPIKey() + let keys = Set([defaultKey]) + saveAPIKeys(keys) + return keys + } + return keys + } + + static func saveAPIKeys(_ keys: Set) { + if let data = try? JSONEncoder().encode(keys) { + UserDefaults.standard.set(data, forKey: apiKeyStorageKey) + } + } + + static func addAPIKey(_ key: String) { + var keys = loadStoredAPIKeys() + keys.insert(key) + saveAPIKeys(keys) + } + + static func removeAPIKey(_ key: String) { + var keys = loadStoredAPIKeys() + keys.remove(key) + saveAPIKeys(keys) + } +} \ No newline at end of file diff --git a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift index 4fa611f0..85fd933f 100644 --- a/VibeTunnel/Core/Services/SparkleUpdaterManager.swift +++ b/VibeTunnel/Core/Services/SparkleUpdaterManager.swift @@ -28,7 +28,7 @@ import UserNotifications public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUserDriverDelegate, UNUserNotificationCenterDelegate { // MARK: Initialization - private static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates") + private nonisolated static let staticLogger = Logger(subsystem: "com.amantus.vibetunnel", category: "updates") /// Initializes the updater manager and configures Sparkle override init() { @@ -86,23 +86,10 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse /// Configures the update channel and restarts if needed func setUpdateChannel(_ channel: UpdateChannel) { - guard let updater = updaterController?.updater else { - logger.error("Updater not available") - return - } + // Store the channel preference + UserDefaults.standard.set(channel.rawValue, forKey: "updateChannel") - let oldFeedURL = updater.feedURL - let newFeedURL = channel.appcastURL - - guard oldFeedURL != newFeedURL else { - logger.info("Update channel unchanged") - return - } - - logger.info("Changing update channel from \(oldFeedURL?.absoluteString ?? "nil") to \(newFeedURL)") - - // Update the feed URL - updater.setFeedURL(newFeedURL) + logger.info("Update channel changed to: \(channel.rawValue)") // Force a new update check with the new feed checkForUpdates() @@ -112,37 +99,28 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse /// Initializes the Sparkle updater controller private func initializeUpdaterController() { - do { - updaterController = SPUStandardUpdaterController( - startingUpdater: true, - updaterDelegate: self, - userDriverDelegate: self - ) - - guard let updater = updaterController?.updater else { - logger.error("Failed to get updater from controller") - return - } - - // Configure updater settings - updater.automaticallyChecksForUpdates = true - updater.updateCheckInterval = 60 * 60 // 1 hour - updater.automaticallyDownloadsUpdates = true - - // Set the feed URL based on current channel - updater.setFeedURL(UpdateChannel.defaultChannel.appcastURL) - - logger.info(""" - Updater configured: - - Automatic checks: \(updater.automaticallyChecksForUpdates) - - Check interval: \(updater.updateCheckInterval)s - - Auto download: \(updater.automaticallyDownloadsUpdates) - - Feed URL: \(updater.feedURL?.absoluteString ?? "none") - """) - - } catch { - logger.error("Failed to initialize updater controller: \(error.localizedDescription)") + updaterController = SPUStandardUpdaterController( + startingUpdater: true, + updaterDelegate: self, + userDriverDelegate: self + ) + + guard let updater = updaterController?.updater else { + logger.error("Failed to get updater from controller") + return } + + // Configure updater settings + updater.automaticallyChecksForUpdates = true + updater.updateCheckInterval = 60 * 60 // 1 hour + updater.automaticallyDownloadsUpdates = true + + logger.info(""" + Updater configured: + - Automatic checks: \(updater.automaticallyChecksForUpdates) + - Check interval: \(updater.updateCheckInterval)s + - Auto download: \(updater.automaticallyDownloadsUpdates) + """) } /// Sets up the notification center for gentle reminders @@ -182,8 +160,6 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse /// Checks for updates in the background without UI private func checkForUpdatesInBackground() { - guard let updater = updaterController?.updater else { return } - logger.info("Starting background update check") lastUpdateCheckDate = Date() @@ -192,6 +168,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse } /// Shows a gentle reminder notification for available updates + @MainActor private func showGentleUpdateReminder() { let content = UNMutableNotificationContent() content.title = "Update Available" @@ -222,27 +199,42 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse // Schedule reminders every 4 hours gentleReminderTimer = Timer.scheduledTimer(withTimeInterval: 4 * 60 * 60, repeats: true) { [weak self] _ in - self?.showGentleUpdateReminder() + Task { @MainActor in + self?.showGentleUpdateReminder() + } } // Show first reminder after 1 hour DispatchQueue.main.asyncAfter(deadline: .now() + 3600) { [weak self] in - self?.showGentleUpdateReminder() + Task { @MainActor in + self?.showGentleUpdateReminder() + } } } // MARK: - SPUUpdaterDelegate @objc public nonisolated func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) { - Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items") + Task { @MainActor in + Self.staticLogger.info("Appcast loaded successfully: \(appcast.items.count) items") + } } @objc public nonisolated func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { - Self.staticLogger.info("No update found: \(error.localizedDescription)") + Task { @MainActor in + Self.staticLogger.info("No update found: \(error.localizedDescription)") + } } @objc public nonisolated func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { - Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)") + Task { @MainActor in + Self.staticLogger.error("Update aborted with error: \(error.localizedDescription)") + } + } + + // Provide the feed URL dynamically based on update channel + @objc public nonisolated func feedURLString(for updater: SPUUpdater) -> String? { + return UpdateChannel.current.appcastURL.absoluteString } // MARK: - SPUStandardUserDriverDelegate @@ -252,16 +244,18 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse forUpdate update: SUAppcastItem, state: SPUUserUpdateState ) { - Self.staticLogger.info(""" - Will show update: - - Version: \(update.displayVersionString ?? "unknown") - - Critical: \(update.isCriticalUpdate) - - Stage: \(state.stage.rawValue) - """) + Task { @MainActor in + Self.staticLogger.info(""" + Will show update: + - Version: \(update.displayVersionString) + - Critical: \(update.isCriticalUpdate) + - Stage: \(state.stage.rawValue) + """) + } } @objc public func standardUserDriverDidReceiveUserAttention(forUpdate update: SUAppcastItem) { - logger.info("User gave attention to update: \(update.displayVersionString ?? "unknown")") + logger.info("User gave attention to update: \(update.displayVersionString)") updateInProgress = true // Cancel gentle reminders since user is aware @@ -281,11 +275,11 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse willDownloadUpdate item: SUAppcastItem, with request: NSMutableURLRequest ) { - logger.info("Will download update: \(item.displayVersionString ?? "unknown")") + logger.info("Will download update: \(item.displayVersionString)") } @objc public func updater(_ updater: SPUUpdater, didDownloadUpdate item: SUAppcastItem) { - logger.info("Update downloaded: \(item.displayVersionString ?? "unknown")") + logger.info("Update downloaded: \(item.displayVersionString)") // For background downloads, schedule gentle reminders if !updateInProgress { @@ -297,7 +291,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse _ updater: SPUUpdater, willInstallUpdate item: SUAppcastItem ) { - logger.info("Will install update: \(item.displayVersionString ?? "unknown")") + logger.info("Will install update: \(item.displayVersionString)") } // MARK: - UNUserNotificationCenterDelegate @@ -327,7 +321,7 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse ) { if keyPath == "updateChannel" { logger.info("Update channel changed via UserDefaults") - setUpdateChannel(UpdateChannel.defaultChannel) + setUpdateChannel(UpdateChannel.current) } } @@ -335,6 +329,6 @@ public class SparkleUpdaterManager: NSObject, SPUUpdaterDelegate, SPUStandardUse deinit { UserDefaults.standard.removeObserver(self, forKeyPath: "updateChannel") - gentleReminderTimer?.invalidate() + // Timer is cleaned up automatically when the object is deallocated } } \ No newline at end of file diff --git a/VibeTunnel/Core/Services/TerminalManager.swift b/VibeTunnel/Core/Services/TerminalManager.swift new file mode 100644 index 00000000..d32c5e7e --- /dev/null +++ b/VibeTunnel/Core/Services/TerminalManager.swift @@ -0,0 +1,163 @@ +// +// TerminalManager.swift +// VibeTunnel +// +// Created by VibeTunnel on 15.06.25. +// + +import Foundation +import Logging +import Combine + +/// Manages terminal sessions and command execution +actor TerminalManager { + private var sessions: [UUID: TunnelSession] = [:] + private var processes: [UUID: Process] = [:] + private var pipes: [UUID: (stdin: Pipe, stdout: Pipe, stderr: Pipe)] = [:] + private let logger = Logger(label: "VibeTunnel.TerminalManager") + + /// Create a new terminal session + func createSession(request: CreateSessionRequest) throws -> TunnelSession { + let session = TunnelSession() + sessions[session.id] = session + + // Set up process and pipes + let process = Process() + let stdinPipe = Pipe() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + + // Configure the process + process.executableURL = URL(fileURLWithPath: request.shell ?? "/bin/zsh") + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + if let workingDirectory = request.workingDirectory { + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) + } + + if let environment = request.environment { + process.environment = ProcessInfo.processInfo.environment.merging(environment) { _, new in new } + } + + // Start the process + do { + try process.run() + processes[session.id] = process + pipes[session.id] = (stdinPipe, stdoutPipe, stderrPipe) + + logger.info("Created session \(session.id) with process \(process.processIdentifier)") + } catch { + sessions.removeValue(forKey: session.id) + throw error + } + + return session + } + + /// Execute a command in a session + func executeCommand(sessionId: UUID, command: String) async throws -> (output: String, error: String) { + guard var session = sessions[sessionId], + let process = processes[sessionId], + let (stdin, stdout, stderr) = pipes[sessionId], + process.isRunning else { + throw TunnelError.sessionNotFound + } + + // Update session activity + session.updateActivity() + sessions[sessionId] = session + + // Send command to stdin + let commandData = (command + "\n").data(using: .utf8)! + stdin.fileHandleForWriting.write(commandData) + + // Read output with timeout + let outputData = try await withTimeout(seconds: 5) { + stdout.fileHandleForReading.availableData + } + + let errorData = try await withTimeout(seconds: 0.1) { + stderr.fileHandleForReading.availableData + } + + let output = String(data: outputData, encoding: .utf8) ?? "" + let error = String(data: errorData, encoding: .utf8) ?? "" + + return (output, error) + } + + /// Get all active sessions + func listSessions() -> [TunnelSession] { + return Array(sessions.values) + } + + /// Get a specific session + func getSession(id: UUID) -> TunnelSession? { + return sessions[id] + } + + /// Close a session + func closeSession(id: UUID) { + if let process = processes[id] { + process.terminate() + processes.removeValue(forKey: id) + } + pipes.removeValue(forKey: id) + sessions.removeValue(forKey: id) + + logger.info("Closed session \(id)") + } + + /// Clean up inactive sessions + func cleanupInactiveSessions(olderThan minutes: Int = 30) { + let cutoffDate = Date().addingTimeInterval(-Double(minutes * 60)) + + for (id, session) in sessions { + if session.lastActivity < cutoffDate { + closeSession(id: id) + logger.info("Cleaned up inactive session \(id)") + } + } + } + + // Helper function for timeout + private func withTimeout(seconds: TimeInterval, operation: @escaping () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { + try await operation() + } + + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw TunnelError.timeout + } + + let result = try await group.next()! + group.cancelAll() + return result + } + } +} + +/// Errors that can occur in tunnel operations +enum TunnelError: LocalizedError { + case sessionNotFound + case commandExecutionFailed(String) + case timeout + case invalidRequest + + var errorDescription: String? { + switch self { + case .sessionNotFound: + return "Session not found" + case .commandExecutionFailed(let message): + return "Command execution failed: \(message)" + case .timeout: + return "Operation timed out" + case .invalidRequest: + return "Invalid request" + } + } +} \ No newline at end of file diff --git a/VibeTunnel/Core/Services/TunnelServer.swift b/VibeTunnel/Core/Services/TunnelServer.swift index 0f3bb049..9346b899 100644 --- a/VibeTunnel/Core/Services/TunnelServer.swift +++ b/VibeTunnel/Core/Services/TunnelServer.swift @@ -6,19 +6,27 @@ // import Foundation -import Hummingbird import AppKit +import Combine import Logging import os +import Hummingbird +import HummingbirdCore +import HummingbirdWebSocket +import NIOCore +import NIOHTTP1 +/// Main tunnel server implementation using Hummingbird @MainActor final class TunnelServer: ObservableObject { - private var app: HBApplication? private let port: Int private let logger = Logger(label: "VibeTunnel.TunnelServer") + private var app: HummingbirdApplication? + private let terminalManager = TerminalManager() @Published var isRunning = false @Published var lastError: Error? + @Published var connectedClients = 0 init(port: Int = 8080) { self.port = port @@ -27,210 +35,201 @@ final class TunnelServer: ObservableObject { func start() async throws { logger.info("Starting tunnel server on port \(port)") - let router = HBRouter() - - // Serve a simple HTML page at the root - router.get("/") { request, context in - let html = """ - - - - - - VibeTunnel - - - -
-

VibeTunnel

-

Server Running

-

Connect to AI providers with a unified interface.

- -
-

API Endpoints

-
    -
  • GET / - This page
  • -
  • GET /health - Health check
  • -
  • GET /info - Server information
  • -
  • POST /tunnel/command - Execute commands
  • -
  • WS /tunnel/stream - WebSocket stream
  • -
-
- -
-

Quick Start

-

Test the health endpoint:

- curl http://localhost:\(self.port)/health -
- -

- Version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1") - · GitHub - · Documentation -

-
- - - """ + do { + // Build the Hummingbird application + let app = try await buildApplication() + self.app = app - return HBResponse( - status: .ok, - headers: [.contentType: "text/html; charset=utf-8"], - body: .init(byteBuffer: ByteBuffer(string: html)) - ) - } - - // Health check endpoint - router.get("/health") { request, context in - return [ - "status": "ok", - "timestamp": Date().timeIntervalSince1970, - "uptime": ProcessInfo.processInfo.systemUptime - ] - } - - // Server info endpoint - router.get("/info") { request, context in - return [ - "name": "VibeTunnel", - "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1", - "build": Bundle.main.infoDictionary?["CFBundleVersion"] ?? "100", - "port": self.port, - "platform": "macOS" - ] - } - - // Command endpoint - router.post("/tunnel/command") { request, context in - struct CommandRequest: Decodable { - let command: String - let args: [String]? + // Start the server + try await app.run() + + await MainActor.run { + self.isRunning = true } - - struct CommandResponse: Encodable { - let success: Bool - let message: String - let timestamp: Date - } - - do { - let commandRequest = try await request.decode(as: CommandRequest.self, context: context) - - self.logger.info("Received command: \(commandRequest.command)") - - return CommandResponse( - success: true, - message: "Command '\(commandRequest.command)' received", - timestamp: Date() - ) - } catch { - return CommandResponse( - success: false, - message: "Invalid request: \(error.localizedDescription)", - timestamp: Date() - ) + } catch { + await MainActor.run { + self.lastError = error + self.isRunning = false } + throw error } - - // WebSocket endpoint for real-time communication - router.ws("/tunnel/stream") { request, ws, context in - self.logger.info("WebSocket connection established") - - // Send welcome message - try await ws.send(text: "Welcome to VibeTunnel WebSocket stream") - - ws.onText { ws, text in - self.logger.info("WebSocket received: \(text)") - // Echo back with timestamp - let response = "[\(Date().ISO8601Format())] Echo: \(text)" - try await ws.send(text: response) - } - - ws.onClose { ws, closeCode in - self.logger.info("WebSocket connection closed with code: \(closeCode)") - } - } - - // Configure and create the application - var configuration = HBApplication.Configuration() - configuration.address = .hostname("127.0.0.1", port: self.port) - configuration.serverName = "VibeTunnel/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "0.1")" - - let app = HBApplication( - configuration: configuration, - router: router - ) - - self.app = app - - // Update state - await MainActor.run { - self.isRunning = true - } - - logger.info("VibeTunnel server started on http://localhost:\(self.port)") - - // Run the server - try await app.run() } func stop() async { logger.info("Stopping tunnel server") - await app?.stop() - app = nil + if let app = app { + await app.stop() + self.app = nil + } await MainActor.run { - isRunning = false + self.isRunning = false } } + + private func buildApplication() async throws -> HummingbirdApplication { + // Create router + let router = Router() + + // Add middleware + router.add(middleware: LogRequestsMiddleware(logLevel: .info)) + router.add(middleware: CORSMiddleware()) + router.add(middleware: AuthenticationMiddleware(apiKeys: AuthenticationMiddleware.loadStoredAPIKeys())) + + // Configure routes + configureRoutes(router) + + // Add WebSocket routes + router.addWebSocketRoutes(terminalManager: terminalManager) + + // Create application configuration + var configuration = ApplicationConfiguration( + address: .hostname("127.0.0.1", port: port), + serverName: "VibeTunnel" + ) + + // Enable WebSocket upgrade + configuration.enableWebSocketUpgrade = true + + // Create and configure the application + let app = Application( + router: router, + configuration: configuration, + logger: logger + ) + + // Add cleanup task + app.addLifecycleTask(CleanupTask(terminalManager: terminalManager)) + + return app + } + + private func configureRoutes(_ router: Router) { + // Health check endpoint + router.get("/health") { request, context -> HTTPResponse.Status in + return .ok + } + + // Server info endpoint + router.get("/info") { request, context -> [String: Any] in + return [ + "name": "VibeTunnel", + "version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0", + "uptime": ProcessInfo.processInfo.systemUptime, + "sessions": await self.terminalManager.listSessions().count + ] + } + + // Session management endpoints + router.group("sessions") { sessions in + // List all sessions + sessions.get("/") { request, context -> ListSessionsResponse in + let sessions = await self.terminalManager.listSessions() + let sessionInfos = sessions.map { session in + SessionInfo( + id: session.id.uuidString, + createdAt: session.createdAt, + lastActivity: session.lastActivity, + isActive: session.isActive + ) + } + return ListSessionsResponse(sessions: sessionInfos) + } + + // Create new session + sessions.post("/") { request, context -> CreateSessionResponse in + let createRequest = try await request.decode(as: CreateSessionRequest.self, context: context) + let session = try await self.terminalManager.createSession(request: createRequest) + + return CreateSessionResponse( + sessionId: session.id.uuidString, + createdAt: session.createdAt + ) + } + + // Get session info + sessions.get(":sessionId") { request, context -> SessionInfo in + guard let sessionIdString = request.parameters.get("sessionId"), + let sessionId = UUID(uuidString: sessionIdString), + let session = await self.terminalManager.getSession(id: sessionId) else { + throw HTTPError(.notFound) + } + + return SessionInfo( + id: session.id.uuidString, + createdAt: session.createdAt, + lastActivity: session.lastActivity, + isActive: session.isActive + ) + } + + // Close session + sessions.delete(":sessionId") { request, context -> HTTPResponse.Status in + guard let sessionIdString = request.parameters.get("sessionId"), + let sessionId = UUID(uuidString: sessionIdString) else { + throw HTTPError(.badRequest) + } + + await self.terminalManager.closeSession(id: sessionId) + return .noContent + } + } + + // Command execution endpoint + router.post("/execute") { request, context -> CommandResponse in + let commandRequest = try await request.decode(as: CommandRequest.self, context: context) + + guard let sessionId = UUID(uuidString: commandRequest.sessionId) else { + throw HTTPError(.badRequest, message: "Invalid session ID") + } + + do { + let (output, error) = try await self.terminalManager.executeCommand( + sessionId: sessionId, + command: commandRequest.command + ) + + return CommandResponse( + sessionId: commandRequest.sessionId, + output: output.isEmpty ? nil : output, + error: error.isEmpty ? nil : error, + exitCode: nil, + timestamp: Date() + ) + } catch { + throw HTTPError(.internalServerError, message: error.localizedDescription) + } + } + } + + // Lifecycle task for periodic cleanup + struct CleanupTask: LifecycleTask { + let terminalManager: TerminalManager + + func run() async throws { + // Run cleanup every 5 minutes + while !Task.isCancelled { + await terminalManager.cleanupInactiveSessions(olderThan: 30) + try await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) // 5 minutes + } + } + } +} + +// MARK: - Middleware + +/// CORS middleware for browser-based clients +struct CORSMiddleware: RouterMiddleware { + func handle(_ request: Request, context: Context, next: (Request, Context) async throws -> Response) async throws -> Response { + var response = try await next(request, context) + + response.headers[.accessControlAllowOrigin] = "*" + response.headers[.accessControlAllowMethods] = "GET, POST, PUT, DELETE, OPTIONS" + response.headers[.accessControlAllowHeaders] = "Content-Type, Authorization" + + return response + } } // MARK: - Integration with AppDelegate @@ -239,16 +238,15 @@ extension AppDelegate { func startTunnelServer() { Task { do { - let portString = UserDefaults.standard.string(forKey: "serverPort") ?? "8080" - let port = Int(portString) ?? 8080 - let tunnelServer = TunnelServer(port: port) + let port = UserDefaults.standard.integer(forKey: "serverPort") + let tunnelServer = TunnelServer(port: port > 0 ? port : 8080) // Store reference if needed // self.tunnelServer = tunnelServer try await tunnelServer.start() } catch { - os_log(.error, "Failed to start tunnel server: %{public}@", error.localizedDescription) + print("Failed to start tunnel server: \(error)") // Show error alert await MainActor.run { diff --git a/VibeTunnel/Core/Services/WebSocketHandler.swift b/VibeTunnel/Core/Services/WebSocketHandler.swift new file mode 100644 index 00000000..8974128a --- /dev/null +++ b/VibeTunnel/Core/Services/WebSocketHandler.swift @@ -0,0 +1,196 @@ +// +// WebSocketHandler.swift +// VibeTunnel +// +// Created by VibeTunnel on 15.06.25. +// + +import Foundation +import Hummingbird +import HummingbirdCore +import NIOCore +import NIOWebSocket +import Logging + +/// WebSocket message types for terminal communication +enum WSMessageType: String, Codable { + case connect = "connect" + case command = "command" + case output = "output" + case error = "error" + case ping = "ping" + case pong = "pong" + case close = "close" +} + +/// WebSocket message structure +struct WSMessage: Codable { + let type: WSMessageType + let sessionId: String? + let data: String? + let timestamp: Date + + init(type: WSMessageType, sessionId: String? = nil, data: String? = nil) { + self.type = type + self.sessionId = sessionId + self.data = data + self.timestamp = Date() + } +} + +/// Handles WebSocket connections for real-time terminal communication +final class WebSocketHandler { + private let terminalManager: TerminalManager + private let logger = Logger(label: "VibeTunnel.WebSocketHandler") + private var activeConnections: [UUID: WebSocketHandler.Connection] = [:] + + init(terminalManager: TerminalManager) { + self.terminalManager = terminalManager + } + + /// Handle incoming WebSocket connection + func handle(ws: HBWebSocket, context: some RequestContext) async { + let connectionId = UUID() + let connection = Connection(id: connectionId, websocket: ws) + + await MainActor.run { + activeConnections[connectionId] = connection + } + + logger.info("WebSocket connection established: \(connectionId)") + + // Set up message handlers + ws.onText { [weak self] ws, text in + await self?.handleTextMessage(text, connection: connection) + } + + ws.onBinary { [weak self] ws, buffer in + // Handle binary data if needed + self?.logger.debug("Received binary data: \(buffer.readableBytes) bytes") + } + + ws.onClose { [weak self] closeCode in + await self?.handleClose(connection: connection) + } + + // Send initial connection acknowledgment + await sendMessage(WSMessage(type: .connect, data: "Connected to VibeTunnel"), to: connection) + + // Keep connection alive with periodic pings + Task { + while !Task.isCancelled && !connection.isClosed { + await sendMessage(WSMessage(type: .ping), to: connection) + try? await Task.sleep(nanoseconds: 30 * 1_000_000_000) // 30 seconds + } + } + } + + private func handleTextMessage(_ text: String, connection: Connection) async { + guard let data = text.data(using: .utf8), + let message = try? JSONDecoder().decode(WSMessage.self, from: data) else { + logger.error("Failed to decode WebSocket message: \(text)") + await sendError("Invalid message format", to: connection) + return + } + + switch message.type { + case .connect: + // Handle session connection + if let sessionId = message.sessionId, + let uuid = UUID(uuidString: sessionId) { + connection.sessionId = uuid + await sendMessage(WSMessage(type: .output, sessionId: sessionId, data: "Session connected"), to: connection) + } + + case .command: + // Execute command in terminal session + guard let sessionId = connection.sessionId, + let command = message.data else { + await sendError("Session ID and command required", to: connection) + return + } + + do { + let (output, error) = try await terminalManager.executeCommand(sessionId: sessionId, command: command) + + if !output.isEmpty { + await sendMessage(WSMessage(type: .output, sessionId: sessionId.uuidString, data: output), to: connection) + } + + if !error.isEmpty { + await sendMessage(WSMessage(type: .error, sessionId: sessionId.uuidString, data: error), to: connection) + } + } catch { + await sendError(error.localizedDescription, to: connection) + } + + case .ping: + // Respond to ping with pong + await sendMessage(WSMessage(type: .pong), to: connection) + + case .close: + // Close the session + if let sessionId = connection.sessionId { + await terminalManager.closeSession(id: sessionId) + } + try? await connection.websocket.close() + + default: + logger.warning("Unhandled message type: \(message.type)") + } + } + + private func handleClose(connection: Connection) async { + logger.info("WebSocket connection closed: \(connection.id)") + + await MainActor.run { + activeConnections.removeValue(forKey: connection.id) + } + + // Clean up associated session if any + if let sessionId = connection.sessionId { + await terminalManager.closeSession(id: sessionId) + } + + connection.isClosed = true + } + + private func sendMessage(_ message: WSMessage, to connection: Connection) async { + do { + let data = try JSONEncoder().encode(message) + let text = String(data: data, encoding: .utf8) ?? "{}" + try await connection.websocket.send(text: text) + } catch { + logger.error("Failed to send WebSocket message: \(error)") + } + } + + private func sendError(_ error: String, to connection: Connection) async { + await sendMessage(WSMessage(type: .error, data: error), to: connection) + } + + /// WebSocket connection wrapper + class Connection { + let id: UUID + let websocket: HBWebSocket + var sessionId: UUID? + var isClosed = false + + init(id: UUID, websocket: HBWebSocket) { + self.id = id + self.websocket = websocket + } + } +} + +/// Extension to add WebSocket routes to the router +extension Router { + func addWebSocketRoutes(terminalManager: TerminalManager) { + let wsHandler = WebSocketHandler(terminalManager: terminalManager) + + // WebSocket endpoint for terminal streaming + ws("/ws/terminal") { request, ws, context in + await wsHandler.handle(ws: ws, context: context) + } + } +} \ No newline at end of file diff --git a/VibeTunnel/Info.plist b/VibeTunnel/Info.plist index 5224bbe9..bb782998 100644 --- a/VibeTunnel/Info.plist +++ b/VibeTunnel/Info.plist @@ -30,6 +30,8 @@ NSApplication NSSupportsAutomaticTermination + LSUIElement + NSSupportsSuddenTermination SUFeedURL diff --git a/VibeTunnel/VibeTunnelApp.swift b/VibeTunnel/VibeTunnelApp.swift index 9a2a132a..ec59a1c5 100644 --- a/VibeTunnel/VibeTunnelApp.swift +++ b/VibeTunnel/VibeTunnelApp.swift @@ -14,27 +14,16 @@ struct VibeTunnelApp: App { var appDelegate var body: some Scene { - WindowGroup { - ContentView() + #if os(macOS) + Settings { + SettingsView() } .commands { - CommandGroup(replacing: .appInfo) { + CommandGroup(after: .appInfo) { Button("About VibeTunnel") { AboutWindowController.shared.showWindow() } } - - CommandGroup(replacing: .appSettings) { - Button("Settings…") { - NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) - } - .keyboardShortcut(",", modifiers: .command) - } - } - - #if os(macOS) - Settings { - SettingsView() } #endif } @@ -45,6 +34,7 @@ struct VibeTunnelApp: App { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { private(set) var sparkleUpdaterManager: SparkleUpdaterManager? + private var statusItem: NSStatusItem? /// Distributed notification name used to ask an existing instance to show the Settings window. private static let showSettingsNotification = Notification.Name("com.amantus.vibetunnel.showSettings") @@ -64,10 +54,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Initialize Sparkle updater manager sparkleUpdaterManager = SparkleUpdaterManager() - // Configure activation policy based on settings + // Configure activation policy based on settings (default to menu bar only) let showInDock = UserDefaults.standard.bool(forKey: "showInDock") NSApp.setActivationPolicy(showInDock ? .regular : .accessory) + // Setup status item (menu bar icon) + setupStatusItem() + + // Show settings on first launch or when no window is open + if !showInDock { + // For menu bar apps, we need to ensure the settings window is accessible + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if NSApp.windows.isEmpty || NSApp.windows.allSatisfy({ !$0.isVisible }) { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + } + } + } + // Listen for update check requests NotificationCenter.default.addObserver( self, @@ -139,4 +142,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate { name: Notification.Name("checkForUpdates"), object: nil) } + + // MARK: - Status Item + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem?.button { + button.image = NSImage(systemSymbolName: "network.badge.shield.half.filled", accessibilityDescription: "VibeTunnel") + button.action = #selector(statusItemClicked) + button.target = self + } + + // Create menu + let menu = NSMenu() + + menu.addItem(NSMenuItem(title: "Settings...", action: #selector(showSettings), keyEquivalent: ",")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "About VibeTunnel", action: #selector(showAbout), keyEquivalent: "")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")) + + statusItem?.menu = menu + } + + @objc private func statusItemClicked() { + // Left click shows menu + } + + @objc private func showSettings() { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + NSApp.activate(ignoringOtherApps: true) + } + + @objc private func showAbout() { + AboutWindowController.shared.showWindow() + NSApp.activate(ignoringOtherApps: true) + } }